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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user