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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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