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