fix: harden world travel presentation

This commit is contained in:
Bastian Wagner
2026-08-19 08:51:26 +02:00
parent 1e2bfe6e4b
commit 1a30eb3491
15 changed files with 238 additions and 37 deletions

View File

@@ -23,6 +23,15 @@ The reference screenshots were inspected but are not shipped or referenced by th
- `ContextPanelComponent` renders the current/selected location, description, recommended level, safe status, hunting status, and server artwork. - `ContextPanelComponent` renders the current/selected location, description, recommended level, safe status, hunting status, and server artwork.
- Seed paths now match the delivered assets and are covered by the API seed test. - Seed paths now match the delivered assets and are covered by the API seed test.
## Review-fix round
- Map-node geography is now anchored to the stable location keys (`south-gate` and `burned-road`). Current, reachable, and selected state only changes presentation; a regression test swaps the authoritative current location without moving either node.
- On a server `COMPLETED` response, the selected connection is cleared and the store stays loading until the authoritative character/location/travel reload completes. All travel actions are disabled in that interval; no local arrival or location update is performed.
- The moving countdown is a `<time role="timer">` without a broad live region. The selected and travelling mode headings remain labelled for assistive technology.
- The decision panel now occupies normal document flow below the map scene, reserving a non-overlapping zone at desktop and narrow breakpoints.
- The original delivered PNGs remain untouched. A deterministic local System.Drawing JPEG conversion (quality 86, high-quality bicubic) provides runtime derivatives: `map_ashen_realm-1440.jpg` (1440x810, 233,778 B), `Suedtor-960.jpg` (960x540, 94,286 B), and `Aschestrasse-960.jpg` (960x540, 93,277 B). The WorldPage and ContextPanel prefer these derivatives while retaining each server artwork path as the `<img>` fallback.
- The load error is exposed as an alert and its retry control is covered by a store-load regression test.
## Verification ## Verification
- RED confirmed: four new WorldPage contracts failed against the empty placeholder; seed artwork-path assertion failed against the former WebP paths. - RED confirmed: four new WorldPage contracts failed against the empty placeholder; seed artwork-path assertion failed against the former WebP paths.
@@ -30,6 +39,10 @@ The reference screenshots were inspected but are not shipped or referenced by th
- Full web tests: 18/18 pass. - Full web tests: 18/18 pass.
- Affected API seed tests: 2/2 pass. - Affected API seed tests: 2/2 pass.
- `npm run build:web` and `npm run build:api` pass. - `npm run build:web` and `npm run build:api` pass.
- Review focused World/Store/Context tests: 19 pass (4 files; 4 pre-existing skips).
- Review full web suite: 23/23 pass.
- Affected API seed test: 2/2 pass.
- Both review builds (`npm run build:web`, `npm run build:api`) pass.
## Remaining concern ## Remaining concern

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@@ -1,11 +1,13 @@
<section class="travel-panel" aria-live="polite"> <section class="travel-panel" aria-labelledby="travel-panel-title">
@if (travellingTravel; as travel) { @if (travellingTravel; as travel) {
<span class="travel-panel__eyebrow">REISE LÄUFT</span> <span class="travel-panel__eyebrow" role="status">REISE LÄUFT</span>
<h2>Reiseziel: {{ travel.targetLocation.name }}</h2> <h2 id="travel-panel-title">Reiseziel: {{ travel.targetLocation.name }}</h2>
<dl class="travel-panel__details"> <dl class="travel-panel__details">
<div> <div>
<dt>Ankunft in</dt> <dt>Ankunft in</dt>
<dd>{{ formatDuration(remainingSeconds ?? 0) }}</dd> <dd>
<time role="timer">{{ formatDuration(remainingSeconds ?? 0) }}</time>
</dd>
</div> </div>
<div> <div>
<dt>Ziel</dt> <dt>Ziel</dt>
@@ -15,7 +17,7 @@
<button type="button" data-travel-start disabled>Reise läuft</button> <button type="button" data-travel-start disabled>Reise läuft</button>
} @else if (selectedConnection; as connection) { } @else if (selectedConnection; as connection) {
<span class="travel-panel__eyebrow">REISEN</span> <span class="travel-panel__eyebrow">REISEN</span>
<h2>Zur {{ connection.targetLocation.name }} reisen?</h2> <h2 id="travel-panel-title">Zur {{ connection.targetLocation.name }} reisen?</h2>
<dl class="travel-panel__details"> <dl class="travel-panel__details">
<div> <div>
<dt>Ziel</dt> <dt>Ziel</dt>
@@ -37,6 +39,8 @@
</button> </button>
} @else { } @else {
<span class="travel-panel__eyebrow">REISEN</span> <span class="travel-panel__eyebrow">REISEN</span>
<p class="travel-panel__instruction">Wähle einen erreichbaren Ort auf der Karte.</p> <p id="travel-panel-title" class="travel-panel__instruction">
Wähle einen erreichbaren Ort auf der Karte.
</p>
} }
</section> </section>

View File

@@ -18,7 +18,7 @@
</svg> </svg>
<app-location-node <app-location-node
class="world-page__node world-page__node--current" [class]="'world-page__node world-page__node--' + location.key"
[location]="{ id: location.id, key: location.key, name: location.name }" [location]="{ id: location.id, key: location.key, name: location.name }"
[current]="true" [current]="true"
[disabled]="true" [disabled]="true"
@@ -26,25 +26,25 @@
@for (connection of location.connections; track connection.targetLocation.id) { @for (connection of location.connections; track connection.targetLocation.id) {
<app-location-node <app-location-node
class="world-page__node world-page__node--reachable" [class]="'world-page__node world-page__node--' + connection.targetLocation.key"
[location]="connection.targetLocation" [location]="connection.targetLocation"
[selected]=" [selected]="
worldStore.selectedConnection()?.targetLocation?.id === connection.targetLocation.id worldStore.selectedConnection()?.targetLocation?.id === connection.targetLocation.id
" "
[disabled]="worldStore.currentTravel()?.status === 'TRAVELLING'" [disabled]="worldStore.loading() || worldStore.currentTravel()?.status !== 'IDLE'"
(choose)="selectConnection(connection)" (choose)="selectConnection(connection)"
/> />
} }
<app-travel-panel
class="world-page__travel-panel"
[selectedConnection]="worldStore.selectedConnection()"
[currentTravel]="worldStore.currentTravel()"
[remainingSeconds]="worldStore.remainingSeconds()"
[busy]="worldStore.loading()"
(travelStart)="worldStore.startTravel()"
/>
</section> </section>
<app-travel-panel
class="world-page__travel-panel"
[selectedConnection]="worldStore.selectedConnection()"
[currentTravel]="worldStore.currentTravel()"
[remainingSeconds]="worldStore.remainingSeconds()"
[busy]="worldStore.loading()"
(travelStart)="worldStore.startTravel()"
/>
} @else { } @else {
<section class="world-page__empty" aria-live="polite"> <section class="world-page__empty" aria-live="polite">
<p>Weltkarte wird vorbereitet.</p> <p>Weltkarte wird vorbereitet.</p>
@@ -58,7 +58,7 @@
@if (worldStore.error(); as error) { @if (worldStore.error(); as error) {
<section class="world-page__error" role="alert"> <section class="world-page__error" role="alert">
<p>{{ error }}</p> <p>{{ error }}</p>
<button type="button" (click)="retry()">Erneut versuchen</button> <button type="button" data-world-retry (click)="retry()">Erneut versuchen</button>
</section> </section>
} }
</section> </section>

View File

@@ -77,20 +77,18 @@
z-index: 2; z-index: 2;
} }
.world-page__node--current { .world-page__node--south-gate {
inset: 58% auto auto 12%; inset: 58% auto auto 12%;
} }
.world-page__node--reachable { .world-page__node--burned-road {
inset: 39% auto auto 60%; inset: 39% auto auto 60%;
} }
.world-page__travel-panel { .world-page__travel-panel {
position: absolute; display: block;
z-index: 3; inline-size: min(29rem, 100%);
inset: auto 50% var(--ar-space-5) auto; margin: var(--ar-space-4) auto 0;
inline-size: min(29rem, calc(100% - 2rem));
transform: translateX(50%);
} }
.world-page__loading, .world-page__loading,
@@ -148,11 +146,11 @@
min-block-size: 34rem; min-block-size: 34rem;
} }
.world-page__node--current { .world-page__node--south-gate {
inset-inline-start: 6%; inset-inline-start: 6%;
} }
.world-page__node--reachable { .world-page__node--burned-road {
inset-inline-start: 51%; inset-inline-start: 51%;
} }
} }

View File

@@ -34,6 +34,24 @@ const southGate: CurrentLocationResponse = {
connections: [burnedRoadConnection], connections: [burnedRoadConnection],
}; };
const burnedRoad: CurrentLocationResponse = {
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
connections: [
{
targetLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' },
travelDurationSeconds: 10,
danger: 'LOW',
},
],
};
describe('WorldPageComponent', () => { describe('WorldPageComponent', () => {
let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>; let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;
let store: { let store: {
@@ -93,6 +111,32 @@ describe('WorldPageComponent', () => {
expect(element.textContent).toContain('Niedrig'); expect(element.textContent).toContain('Niedrig');
}); });
it('keeps each location at its geographic position when the authoritative current location swaps', () => {
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.world-page__node--south-gate [data-location-key="south-gate"]'),
).not.toBeNull();
expect(
element.querySelector('.world-page__node--burned-road [data-location-key="burned-road"]'),
).not.toBeNull();
expect(element.querySelector('.world-page__scene')?.getAttribute('style')).toContain(
'map_ashen_realm-1440.jpg',
);
store.currentLocation.set(burnedRoad);
fixture.detectChanges();
expect(
element.querySelector('.world-page__node--south-gate [data-location-key="south-gate"]'),
).not.toBeNull();
expect(
element.querySelector('.world-page__node--burned-road [data-location-key="burned-road"]'),
).not.toBeNull();
});
it('starts server-authoritative travel from the travel panel', () => { it('starts server-authoritative travel from the travel panel', () => {
const fixture = TestBed.createComponent(WorldPageComponent); const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -123,5 +167,42 @@ describe('WorldPageComponent', () => {
expect(element.textContent).toContain('Ankunft in'); expect(element.textContent).toContain('Ankunft in');
expect(element.textContent).toContain('00:00:06'); expect(element.textContent).toContain('00:00:06');
expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true); expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true);
expect(element.querySelector('app-travel-panel section')?.hasAttribute('aria-live')).toBe(
false,
);
expect(element.querySelector<HTMLElement>('time[role="timer"]')?.textContent).toContain(
'00:00:06',
);
});
it('locks nodes and selected travel action while authoritative completion reloads', () => {
store.currentTravel.set({
status: 'COMPLETED',
targetLocation: burnedRoadConnection.targetLocation,
});
store.loading.set(true);
store.selectConnection(burnedRoadConnection);
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector<HTMLButtonElement>('[data-location-key="burned-road"]')?.disabled,
).toBe(true);
expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true);
});
it('retries a displayed world error through the store', () => {
store.error.set('Weltzustand nicht verfügbar.');
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Weltzustand nicht verfügbar.',
);
element.querySelector<HTMLButtonElement>('[data-world-retry]')?.click();
expect(store.load).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@@ -12,14 +12,15 @@ import { WorldStore } from './world.store';
}) })
export class WorldPageComponent implements OnInit { export class WorldPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore); protected readonly worldStore = inject(WorldStore);
protected readonly mapBackground = "url('/images/backgrounds/map_ashen_realm.png')"; protected readonly mapBackground =
"url('/images/backgrounds/runtime/map_ashen_realm-1440.jpg'), url('/images/backgrounds/map_ashen_realm.png')";
ngOnInit(): void { ngOnInit(): void {
void this.worldStore.load(); void this.worldStore.load();
} }
protected selectConnection(connection: CurrentLocationConnection): void { protected selectConnection(connection: CurrentLocationConnection): void {
if (this.worldStore.currentTravel()?.status !== 'TRAVELLING') { if (!this.worldStore.loading() && this.worldStore.currentTravel()?.status === 'IDLE') {
this.worldStore.selectConnection(connection); this.worldStore.selectConnection(connection);
} }
} }

View File

@@ -204,4 +204,37 @@ describe('WorldStore', () => {
expect(api.getCharacter).toHaveBeenCalledTimes(2); expect(api.getCharacter).toHaveBeenCalledTimes(2);
expect(api.getCurrentLocation).toHaveBeenCalledTimes(2); expect(api.getCurrentLocation).toHaveBeenCalledTimes(2);
}); });
it('clears selection and rejects a second start while authoritative completion reload is pending', async () => {
const pendingCharacter = new Subject<CharacterResponse>();
const pendingLocation = new Subject<CurrentLocationResponse>();
api.getCharacter.mockReturnValueOnce(of(character)).mockReturnValue(pendingCharacter);
api.getCurrentLocation
.mockReturnValueOnce(of(currentLocation))
.mockReturnValue(pendingLocation);
api.getCurrentTravel
.mockReturnValueOnce(of(travelling))
.mockReturnValueOnce(of({ status: 'COMPLETED', targetLocation: travelling.targetLocation }));
await store.load();
store.selectConnection(currentLocation.connections[0]);
await vi.advanceTimersByTimeAsync(10_000);
expect(store.selectedConnection()).toBeNull();
expect(store.loading()).toBe(true);
expect(store.currentLocation()).toEqual(currentLocation);
await store.startTravel();
expect(api.startTravel).not.toHaveBeenCalled();
pendingCharacter.next(character);
pendingCharacter.complete();
pendingLocation.next(currentLocation);
pendingLocation.complete();
await vi.advanceTimersByTimeAsync(0);
expect(store.loading()).toBe(false);
expect(store.currentTravel()).toEqual({ status: 'IDLE' });
});
}); });

View File

@@ -132,7 +132,18 @@ export class WorldStore implements OnDestroy {
this.remainingSecondsState.set(null); this.remainingSecondsState.set(null);
if (travel.status === 'COMPLETED') { if (travel.status === 'COMPLETED') {
await this.reloadAuthoritativeState(); this.selectedConnectionState.set(null);
this.loadingState.set(true);
try {
await this.reloadAuthoritativeState();
if (!this.destroyed) {
this.currentTravelState.set({ status: 'IDLE' });
}
} finally {
if (!this.destroyed) {
this.loadingState.set(false);
}
}
} }
} }

View File

@@ -7,11 +7,12 @@
<p class="context-panel__selection">Ausgewähltes Ziel: {{ selected.targetLocation.name }}</p> <p class="context-panel__selection">Ausgewähltes Ziel: {{ selected.targetLocation.name }}</p>
} }
<img <picture class="context-panel__artwork">
class="context-panel__artwork" @if (runtimeArtworkPath(location.artworkPath); as runtimeArtwork) {
[src]="location.artworkPath" <source [srcset]="runtimeArtwork" type="image/jpeg" />
[alt]="'Ortsansicht: ' + location.name" }
/> <img [src]="location.artworkPath" [alt]="'Ortsansicht: ' + location.name" />
</picture>
<p class="context-panel__description">{{ location.description }}</p> <p class="context-panel__description">{{ location.description }}</p>
<dl class="context-panel__facts"> <dl class="context-panel__facts">

View File

@@ -42,8 +42,13 @@
.context-panel__artwork { .context-panel__artwork {
display: block; display: block;
inline-size: 100%; inline-size: 100%;
aspect-ratio: 16 / 9;
margin-block-end: var(--ar-space-3); margin-block-end: var(--ar-space-3);
}
.context-panel__artwork img {
display: block;
inline-size: 100%;
aspect-ratio: 16 / 9;
border: 1px solid var(--ar-border); border: 1px solid var(--ar-border);
object-fit: cover; object-fit: cover;
} }

View File

@@ -0,0 +1,45 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { WorldStore } from '../../features/world/world.store';
import { ContextPanelComponent } from './context-panel.component';
describe('ContextPanelComponent', () => {
it('uses a runtime location derivative while preserving the API artwork path as image fallback', async () => {
await TestBed.configureTestingModule({
imports: [ContextPanelComponent],
providers: [
{
provide: WorldStore,
useValue: {
currentLocation: signal({
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
}),
selectedConnection: signal(null),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ContextPanelComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLSourceElement>('source')?.srcset).toContain(
'/images/backgrounds/runtime/Suedtor-960.jpg',
);
expect(element.querySelector<HTMLImageElement>('img')?.getAttribute('src')).toBe(
'/images/backgrounds/Suedtor.png',
);
});
});

View File

@@ -1,6 +1,11 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { WorldStore } from '../../features/world/world.store'; import { WorldStore } from '../../features/world/world.store';
const runtimeArtworkPaths: Readonly<Record<string, string>> = {
'/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg',
'/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg',
};
@Component({ @Component({
selector: 'app-context-panel', selector: 'app-context-panel',
templateUrl: './context-panel.component.html', templateUrl: './context-panel.component.html',
@@ -8,4 +13,8 @@ import { WorldStore } from '../../features/world/world.store';
}) })
export class ContextPanelComponent { export class ContextPanelComponent {
protected readonly worldStore = inject(WorldStore); protected readonly worldStore = inject(WorldStore);
protected runtimeArtworkPath(artworkPath: string): string | undefined {
return runtimeArtworkPaths[artworkPath];
}
} }