From 9b839623ceeaf0c4434048ca2b204b88e84f789c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 10:58:46 +0200 Subject: [PATCH] feat(web): route hunt, combat and arrival back to the location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished journey now opens the location view instead of leaving the player on the map, and backing out of the hunt returns to the place the hunt happens in. The victory and defeat screens gain "Zum Ort" alongside "Weiter jagen", so the location is always reachable without costing the hunt loop its one-click rhythm. The store raises the arrival only after the server-owned current location has been re-read, and does not navigate itself — timers, arrival times and the server-side completion are untouched; only the screen that shows the result changed. Co-Authored-By: Claude Opus 5 --- .../combat-page/combat-page.component.html | 32 +++++++++++--- .../combat-page/combat-page.component.scss | 12 +++++ .../combat-page/combat-page.component.spec.ts | 21 ++++++++- .../combat-page/combat-page.component.ts | 7 +++ .../hunt-page/hunt-page.component.html | 4 +- .../hunt-page/hunt-page.component.spec.ts | 10 ++--- .../hunting/hunt-page/hunt-page.component.ts | 6 ++- .../world/world-page.component.spec.ts | 44 ++++++++++++++++++- .../features/world/world-page.component.ts | 15 ++++++- .../app/features/world/world.store.spec.ts | 26 +++++++++++ .../web/src/app/features/world/world.store.ts | 13 ++++++ 11 files changed, 172 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index 2926081..7bd7c3f 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -117,17 +117,37 @@ } - +
+ + +
} @else if (combat.status === 'LOST') {

Niederlage

{{ combat.player.name }} wurde im Kampf besiegt.

- +
+ + +
} diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index 270597e..1762b4c 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -490,6 +490,18 @@ margin-block-start: var(--ar-space-2); } +.outcome__buttons { + display: flex; + gap: var(--ar-space-3); + justify-content: center; +} + +.outcome__button--secondary { + border-color: var(--ar-border); + color: var(--ar-text-muted); + background: var(--ar-panel); +} + .outcome__button:hover, .combat__notice--error button:hover { border-color: var(--ar-gold); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index 64edb5d..dbedd15 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -266,7 +266,7 @@ describe('CombatPageComponent', () => { expect(element.querySelector('[data-combat-attack]')).toBeNull(); }); - it('navigates to /hunt from the victory screen', async () => { + it('keeps the one-click hunt loop from the victory screen', async () => { const fixture = await setup({ ...activeCombat, status: 'WON' }); const element = fixture.nativeElement as HTMLElement; @@ -275,6 +275,25 @@ describe('CombatPageComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['/hunt']); }); + it('also offers the way back to the location from the victory screen', async () => { + const fixture = await setup({ ...activeCombat, status: 'WON' }); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('[data-combat-to-location]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/location']); + }); + + it('offers the same two ways out after a defeat', async () => { + const fixture = await setup({ ...activeCombat, status: 'LOST' }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-to-hunt]')).not.toBeNull(); + element.querySelector('[data-combat-to-location]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/location']); + }); + it('shows an error and retries loading the combat', async () => { const fixture = await setup(null); combatStore.error.set('Dieser Kampf wurde nicht gefunden.'); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 4af1f47..3a35305 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -161,6 +161,13 @@ export class CombatPageComponent implements OnInit { void this.router.navigate(['/hunt']); } + // The location is the screen a fight resolves back into. It sits beside + // "Weiter jagen" rather than replacing it, so the hunt loop keeps its + // one-click rhythm. + protected goToLocation(): void { + void this.router.navigate(['/location']); + } + protected monsterSprite(monsterKey: string, artworkPath: string): string { return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html index 5ec2587..40be676 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html @@ -7,7 +7,7 @@ Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres Gebiet, um nach Gegnern zu suchen.

- + } @else if (huntingStore.currentHunt(); as hunt) {
@@ -28,7 +28,7 @@ > Neu suchen - +
} @else { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index 9e790e4..42ad7b8 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -124,7 +124,7 @@ describe('HuntPageComponent', () => { return fixture; } - it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => { + it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a way back to the location', async () => { const fixture = await setup(southGate); const element = fixture.nativeElement as HTMLElement; @@ -136,11 +136,11 @@ describe('HuntPageComponent', () => { ), ).toBe(false); - const toWorldButton = element.querySelector('[data-hunt-to-world]'); - expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte'); - toWorldButton?.click(); + const backButton = element.querySelector('[data-hunt-to-location]'); + expect(backButton?.textContent?.trim()).toBe('Zurück zum Ort'); + backButton?.click(); - expect(router.navigate).toHaveBeenCalledWith(['/world']); + expect(router.navigate).toHaveBeenCalledWith(['/location']); }); it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts index 74eadeb..85bde9e 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts @@ -39,8 +39,10 @@ export class HuntPageComponent implements OnInit { } } - protected goToWorld(): void { - void this.router.navigate(['/world']); + // Back out of the hunt returns to the place the hunt happens in, not to the + // map: the location is the screen the player left to get here. + protected goToLocation(): void { + void this.router.navigate(['/location']); } protected async onAttack(encounterId: string): Promise { diff --git a/apps/web/src/app/features/world/world-page.component.spec.ts b/apps/web/src/app/features/world/world-page.component.spec.ts index 8576b29..3426b4b 100644 --- a/apps/web/src/app/features/world/world-page.component.spec.ts +++ b/apps/web/src/app/features/world/world-page.component.spec.ts @@ -1,10 +1,12 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; import { vi } from 'vitest'; import type { CurrentLocationConnection, CurrentLocationResponse, CurrentTravel, + LocationSummary, } from '../../core/api/game-api.models'; import { burnedRoadFixture, southGateFixture } from './current-location.fixture'; import { WorldStore } from './world.store'; @@ -33,9 +35,11 @@ describe('WorldPageComponent', () => { remainingSeconds: ReturnType>; loading: ReturnType>; error: ReturnType>; + arrived: ReturnType>; load: () => Promise; selectConnection: (connection: CurrentLocationConnection | null) => void; startTravel: () => Promise; + acknowledgeArrival: () => void; }; beforeEach(async () => { @@ -47,16 +51,18 @@ describe('WorldPageComponent', () => { remainingSeconds: signal(null), loading: signal(false), error: signal(null), + arrived: signal(null), load: vi.fn(() => Promise.resolve()), selectConnection: vi.fn((connection: CurrentLocationConnection | null) => selectedConnection.set(connection), ), startTravel: vi.fn(() => Promise.resolve()), + acknowledgeArrival: vi.fn(() => store.arrived.set(null)), }; await TestBed.configureTestingModule({ imports: [WorldPageComponent], - providers: [{ provide: WorldStore, useValue: store }], + providers: [provideRouter([]), { provide: WorldStore, useValue: store }], }).compileComponents(); }); @@ -176,4 +182,40 @@ describe('WorldPageComponent', () => { expect(store.load).toHaveBeenCalledTimes(2); }); + + it('opens the location view once a journey has finished', () => { + const fixture = TestBed.createComponent(WorldPageComponent); + const router = TestBed.inject(Router); + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + fixture.detectChanges(); + + expect(navigate).not.toHaveBeenCalled(); + + store.arrived.set({ + id: 'burned-road-id', + key: 'burned-road', + name: 'Verbrannte Straße', + }); + fixture.detectChanges(); + + expect(navigate).toHaveBeenCalledWith(['/location']); + // Acknowledged, so a later change detection cycle cannot navigate twice. + expect(store.acknowledgeArrival).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledTimes(1); + }); + + it('stays on the map while a journey is still running', () => { + store.currentTravel.set({ + status: 'TRAVELLING', + originLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' }, + targetLocation: burnedRoadConnection.targetLocation, + startedAt: '2026-08-20T10:00:00.000Z', + arrivesAt: '2026-08-20T10:00:10.000Z', + }); + const fixture = TestBed.createComponent(WorldPageComponent); + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + fixture.detectChanges(); + + expect(navigate).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/features/world/world-page.component.ts b/apps/web/src/app/features/world/world-page.component.ts index 0e65124..7378820 100644 --- a/apps/web/src/app/features/world/world-page.component.ts +++ b/apps/web/src/app/features/world/world-page.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, effect, inject } from '@angular/core'; +import { Router } from '@angular/router'; import { CurrentLocationConnection } from '../../core/api/game-api.models'; import { LocationNodeComponent } from './location-node.component'; import { TravelPanelComponent } from './travel-panel.component'; @@ -12,6 +13,18 @@ import { WorldStore } from './world.store'; }) export class WorldPageComponent implements OnInit { protected readonly worldStore = inject(WorldStore); + private readonly router = inject(Router); + + constructor() { + // A finished journey ends at the place, not back on the map. The server + // still owns the arrival itself; this only decides which screen shows it. + effect(() => { + if (this.worldStore.arrived()) { + this.worldStore.acknowledgeArrival(); + void this.router.navigate(['/location']); + } + }); + } ngOnInit(): void { void this.worldStore.load(); diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 7ab6fee..589c26c 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -201,6 +201,32 @@ describe('WorldStore', () => { expect(api.getCurrentLocation).toHaveBeenCalledTimes(2); }); + it('reports the arrival only once the new location has been re-read', async () => { + api.getCurrentTravel + .mockReturnValueOnce(of(travelling)) + .mockReturnValueOnce(of({ status: 'COMPLETED', targetLocation: travelling.targetLocation })); + + await store.load(); + expect(store.arrived()).toBeNull(); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(store.arrived()).toEqual(travelling.targetLocation); + expect(api.getCurrentLocation).toHaveBeenCalledTimes(2); + + store.acknowledgeArrival(); + expect(store.arrived()).toBeNull(); + }); + + it('never reports an arrival while the journey is still running', async () => { + api.getCurrentTravel.mockReturnValue(of(travelling)); + + await store.load(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(store.arrived()).toBeNull(); + }); + it('clears selection and rejects a second start while authoritative completion reload is pending', async () => { const pendingCharacter = new Subject(); const pendingLocation = new Subject(); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index 95d9c52..9f7ce4c 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -6,6 +6,7 @@ import { CurrentLocationConnection, CurrentLocationResponse, CurrentTravel, + LocationSummary, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; @@ -27,6 +28,7 @@ export class WorldStore implements OnDestroy { private readonly selectedConnectionState = signal(null); private readonly currentTravelState = signal(null); private readonly remainingSecondsState = signal(null); + private readonly arrivedState = signal(null); private readonly loadingState = signal(false); private readonly errorState = signal(null); private countdownTimer: ReturnType | undefined; @@ -39,6 +41,8 @@ export class WorldStore implements OnDestroy { readonly selectedConnection = this.selectedConnectionState.asReadonly(); readonly currentTravel = this.currentTravelState.asReadonly(); readonly remainingSeconds = this.remainingSecondsState.asReadonly(); + /** Set once a journey has finished and the new location has been re-read. */ + readonly arrived = this.arrivedState.asReadonly(); readonly loading = this.loadingState.asReadonly(); readonly error = this.errorState.asReadonly(); @@ -77,6 +81,11 @@ export class WorldStore implements OnDestroy { this.selectedConnectionState.set(connection); } + /** Clears the arrival flag once a screen has acted on it. */ + acknowledgeArrival(): void { + this.arrivedState.set(null); + } + /** * Re-reads the character from the server, e.g. after a combat granted XP and * silver. Never mutates the values locally: the server owns them (spec §35). @@ -172,6 +181,10 @@ export class WorldStore implements OnDestroy { await this.reloadAuthoritativeState(); if (!this.destroyed) { this.currentTravelState.set({ status: 'IDLE' }); + // Raised only after the server-owned current location has been + // re-read, so whoever reacts to an arrival sees the new place. The + // store does not navigate itself; routing stays with the screen. + this.arrivedState.set(travel.targetLocation); } } finally { if (!this.destroyed) {