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) {