fix: harden world travel presentation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
<p class="context-panel__selection">Ausgewähltes Ziel: {{ selected.targetLocation.name }}</p>
|
||||
}
|
||||
|
||||
<img
|
||||
class="context-panel__artwork"
|
||||
[src]="location.artworkPath"
|
||||
[alt]="'Ortsansicht: ' + location.name"
|
||||
/>
|
||||
<picture class="context-panel__artwork">
|
||||
@if (runtimeArtworkPath(location.artworkPath); as runtimeArtwork) {
|
||||
<source [srcset]="runtimeArtwork" type="image/jpeg" />
|
||||
}
|
||||
<img [src]="location.artworkPath" [alt]="'Ortsansicht: ' + location.name" />
|
||||
</picture>
|
||||
<p class="context-panel__description">{{ location.description }}</p>
|
||||
|
||||
<dl class="context-panel__facts">
|
||||
|
||||
@@ -42,8 +42,13 @@
|
||||
.context-panel__artwork {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
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);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
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({
|
||||
selector: 'app-context-panel',
|
||||
templateUrl: './context-panel.component.html',
|
||||
@@ -8,4 +13,8 @@ import { WorldStore } from '../../features/world/world.store';
|
||||
})
|
||||
export class ContextPanelComponent {
|
||||
protected readonly worldStore = inject(WorldStore);
|
||||
|
||||
protected runtimeArtworkPath(artworkPath: string): string | undefined {
|
||||
return runtimeArtworkPaths[artworkPath];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user