feat(web): route hunt, combat and arrival back to the location

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 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 10:58:46 +02:00
parent 6f0020137b
commit 9b839623ce
11 changed files with 172 additions and 18 deletions

View File

@@ -117,17 +117,37 @@
</section>
}
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
</button>
<div class="outcome__buttons">
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Weiter jagen
</button>
<button
type="button"
class="outcome__button outcome__button--secondary"
data-combat-to-location
(click)="goToLocation()"
>
Zum Ort
</button>
</div>
</div>
} @else if (combat.status === 'LOST') {
<div class="outcome outcome--lost" data-combat-result="LOST">
<h2 class="outcome__title">Niederlage</h2>
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
</button>
<div class="outcome__buttons">
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Weiter jagen
</button>
<button
type="button"
class="outcome__button outcome__button--secondary"
data-combat-to-location
(click)="goToLocation()"
>
Zum Ort
</button>
</div>
</div>
}
</div>

View File

@@ -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);

View File

@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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.');

View File

@@ -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;
}

View File

@@ -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.
</p>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
<button type="button" data-hunt-to-location (click)="goToLocation()">Zurück zum Ort</button>
</section>
} @else if (huntingStore.currentHunt(); as hunt) {
<section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name">
@@ -28,7 +28,7 @@
>
Neu suchen
</button>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
<button type="button" data-hunt-to-location (click)="goToLocation()">Zurück zum Ort</button>
</div>
</section>
} @else {

View File

@@ -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<HTMLButtonElement>('[data-hunt-to-world]');
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
toWorldButton?.click();
const backButton = element.querySelector<HTMLButtonElement>('[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 () => {

View File

@@ -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<void> {

View File

@@ -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<typeof signal<number | null>>;
loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
arrived: ReturnType<typeof signal<LocationSummary | null>>;
load: () => Promise<void>;
selectConnection: (connection: CurrentLocationConnection | null) => void;
startTravel: () => Promise<void>;
acknowledgeArrival: () => void;
};
beforeEach(async () => {
@@ -47,16 +51,18 @@ describe('WorldPageComponent', () => {
remainingSeconds: signal<number | null>(null),
loading: signal(false),
error: signal<string | null>(null),
arrived: signal<LocationSummary | null>(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();
});
});

View File

@@ -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();

View File

@@ -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<CharacterResponse>();
const pendingLocation = new Subject<CurrentLocationResponse>();

View File

@@ -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<CurrentLocationConnection | null>(null);
private readonly currentTravelState = signal<CurrentTravel | null>(null);
private readonly remainingSecondsState = signal<number | null>(null);
private readonly arrivedState = signal<LocationSummary | null>(null);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
private countdownTimer: ReturnType<typeof setInterval> | 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) {