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

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

View File

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

View File

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

View File

@@ -34,6 +34,24 @@ const southGate: CurrentLocationResponse = {
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', () => {
let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;
let store: {
@@ -93,6 +111,32 @@ describe('WorldPageComponent', () => {
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', () => {
const fixture = TestBed.createComponent(WorldPageComponent);
fixture.detectChanges();
@@ -123,5 +167,42 @@ describe('WorldPageComponent', () => {
expect(element.textContent).toContain('Ankunft in');
expect(element.textContent).toContain('00:00:06');
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 {
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 {
void this.worldStore.load();
}
protected selectConnection(connection: CurrentLocationConnection): void {
if (this.worldStore.currentTravel()?.status !== 'TRAVELLING') {
if (!this.worldStore.loading() && this.worldStore.currentTravel()?.status === 'IDLE') {
this.worldStore.selectConnection(connection);
}
}

View File

@@ -204,4 +204,37 @@ describe('WorldStore', () => {
expect(api.getCharacter).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);
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);
}
}
}
}