Merge branch 'worktree-local-location-view'

This commit is contained in:
Bastian Wagner
2026-08-20 16:42:57 +02:00
60 changed files with 3744 additions and 170 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -2,11 +2,18 @@ import { Routes } from '@angular/router';
import { AppShellComponent } from './layout/app-shell/app-shell.component';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'world' },
{ path: '', pathMatch: 'full', redirectTo: 'location' },
{
path: '',
component: AppShellComponent,
children: [
{
path: 'location',
loadComponent: () =>
import('./features/world/location-page/location-page.component').then(
(module) => module.LocationPageComponent,
),
},
{
path: 'world',
loadComponent: () =>
@@ -30,5 +37,5 @@ export const routes: Routes = [
},
],
},
{ path: '**', redirectTo: 'world' },
{ path: '**', redirectTo: 'location' },
];

View File

@@ -20,6 +20,7 @@ describe('App', () => {
imports: [AppShellComponent],
providers: [
provideRouter([
{ path: 'location', children: [] },
{ path: 'world', children: [] },
{ path: 'hunt', children: [] },
]),
@@ -65,6 +66,55 @@ describe('App', () => {
expect(element.textContent).not.toContain('Shop');
});
it('offers Ort as the first navigation entry, marked active on /location', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/location');
fixture.detectChanges();
await fixture.whenStable();
const element = fixture.nativeElement as HTMLElement;
const entries = element.querySelectorAll<HTMLButtonElement>('[data-navigation]');
expect([...entries].map((entry) => entry.dataset['navigation'])).toEqual([
'location',
'world',
'hunt',
'quests',
'inventory',
'character',
]);
const locationButton = element.querySelector<HTMLButtonElement>(
'[data-navigation="location"]',
);
expect(locationButton?.disabled).toBe(false);
expect(locationButton?.getAttribute('aria-label')).toBe('Ort');
expect(locationButton?.getAttribute('aria-current')).toBe('page');
expect(
element.querySelector('[data-navigation="world"]')?.getAttribute('aria-current'),
).toBeNull();
});
it('drops the shell context rail on /location, where the screen brings its own', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/location');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
});
it('keeps the shell context rail on the map', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/world');
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-context-panel')).not.toBeNull();
});
it('marks Jagd as the active navigation entry while on /hunt', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
@@ -104,11 +154,19 @@ describe('App', () => {
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320');
});
it('redirects root and unknown routes to the world shell', () => {
it('lands root and unknown routes on the place the character is standing in', () => {
expect(routes.find((route) => route.path === '')).toMatchObject({
pathMatch: 'full',
redirectTo: 'world',
redirectTo: 'location',
});
expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'world' });
expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'location' });
});
it('keeps the map and hunt routes where they were', () => {
const shellChildren = routes.find((route) => route.children)?.children ?? [];
expect(shellChildren.map((route) => route.path)).toEqual(
expect.arrayContaining(['location', 'world', 'hunt']),
);
});
});

View File

@@ -22,6 +22,69 @@ export interface CurrentLocationConnection {
danger: 'LOW' | 'HIGH';
}
export type LocationInteractionType =
| 'HUNT'
| 'INVESTIGATE'
| 'SEARCH'
| 'NPC'
| 'MAP'
| 'TRAVEL'
| 'SHOP'
| 'QUEST'
| 'BOSS'
| 'DUNGEON';
export type LocationType =
| 'SAFE_HUB'
| 'TRANSITION'
| 'HUNTING_GROUND'
| 'QUEST_LOCATION'
| 'OUTPOST'
| 'ELITE_ZONE'
| 'BOSS_LOCATION'
| 'DUNGEON_ENTRANCE';
export interface LocationPointOfInterest {
key: string;
title: string;
actionLabel?: string;
type: LocationInteractionType;
iconKey: string;
xPercent: number;
yPercent: number;
enabled: boolean;
}
export interface LocationPrimaryAction {
key: string;
label: string;
description?: string;
type: LocationInteractionType;
iconKey: string;
enabled: boolean;
/** Set when the action reveals the same result as a hotspot on the artwork. */
poiKey?: string;
}
export interface EncounterPreview {
key: string;
name: string;
level: number;
iconPath: string;
}
export interface RewardPreview {
key: string;
label: string;
iconKey: string;
}
export interface LocationInteractionResult {
interactionKey: string;
title: string;
text: string;
}
export interface CurrentLocationResponse {
id: string;
key: string;
@@ -34,6 +97,18 @@ export interface CurrentLocationResponse {
isSafe: boolean;
huntingEnabled: boolean;
artworkPath: string;
regionName: string;
regionTierLabel: string;
locationType: LocationType;
localDescription: string;
localArtworkPath: string;
/** `null` where nothing hostile can be met — the view reads that as safe. */
dangerRating: DangerRating | null;
recommendationLabel: string;
pointsOfInterest: LocationPointOfInterest[];
primaryActions: LocationPrimaryAction[];
encounterPreview: EncounterPreview[];
rewardPreview: RewardPreview[];
connections: CurrentLocationConnection[];
possibleMonsters: string[];
}

View File

@@ -48,6 +48,24 @@ describe('GameApiService', () => {
request.flush({ status: 'IDLE' });
});
it('posts a local interaction by key alone, never naming a location', () => {
service.runLocationInteraction('inspect-tracks').subscribe();
const request = http.expectOne('/api/world/current-location/interactions/inspect-tracks');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush({ interactionKey: 'inspect-tracks', title: 'Spuren', text: '…' });
});
it('escapes an interaction key so it cannot break out of its path segment', () => {
service.runLocationInteraction('a/../b').subscribe();
const request = http.expectOne(
'/api/world/current-location/interactions/a%2F..%2Fb',
);
request.flush({ interactionKey: 'a/../b', title: '', text: '' });
});
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
service.startCombat('encounter-uuid').subscribe();

View File

@@ -8,6 +8,7 @@ import {
CurrentLocationResponse,
CurrentTravel,
HuntResult,
LocationInteractionResult,
} from './game-api.models';
@Injectable({ providedIn: 'root' })
@@ -22,6 +23,17 @@ export class GameApiService {
return this.http.get<CurrentLocationResponse>('/api/world/current-location');
}
/**
* Runs a local hotspot interaction. Only the key travels: the server pairs
* it with the character's actual location.
*/
runLocationInteraction(interactionKey: string): Observable<LocationInteractionResult> {
return this.http.post<LocationInteractionResult>(
`/api/world/current-location/interactions/${encodeURIComponent(interactionKey)}`,
{},
);
}
startTravel(targetLocationId: string): Observable<CurrentTravel> {
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
}

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

@@ -5,38 +5,17 @@ import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
import {
burnedRoadFixture,
southGateFixture,
} from '../../world/current-location.fixture';
import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component';
const southGate: CurrentLocationResponse = {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
possibleMonsters: [],
};
const southGate = southGateFixture({ connections: [] });
const burnedRoad: CurrentLocationResponse = {
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
connections: [],
};
const burnedRoad = burnedRoadFixture({ connections: [] });
const threeEncounterHunt: HuntResult = {
id: 'hunt-id',
@@ -150,7 +129,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;
@@ -162,11 +141,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

@@ -44,8 +44,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

@@ -0,0 +1,192 @@
import type {
CurrentLocationResponse,
LocationPointOfInterest,
LocationPrimaryAction,
} from '../../core/api/game-api.models';
/**
* Test fixtures for `GET /api/world/current-location`.
*
* Shared rather than re-declared per spec: the payload backs the map, the
* hunt screen and the local location view, so a field added to the contract
* needs to be answered in exactly one place.
*/
export const BURNED_ROAD_POIS: LocationPointOfInterest[] = [
{
key: 'hunt-area',
title: 'Jagdgebiet',
actionLabel: 'Jagd beginnen',
type: 'HUNT',
iconKey: 'hunt',
xPercent: 52,
yPercent: 44,
enabled: true,
},
{
key: 'inspect-tracks',
title: 'Verdächtige Spuren',
actionLabel: 'Untersuchen',
type: 'INVESTIGATE',
iconKey: 'investigate',
xPercent: 32,
yPercent: 78,
enabled: true,
},
{
key: 'search-abandoned-wagon',
title: 'Verlassener Wagen',
actionLabel: 'Durchsuchen',
type: 'SEARCH',
iconKey: 'search',
xPercent: 80,
yPercent: 68,
enabled: true,
},
{
key: 'wounded-scout',
title: 'Verwundeter Kundschafter',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 20,
yPercent: 60,
enabled: true,
},
];
export const BURNED_ROAD_ACTIONS: LocationPrimaryAction[] = [
{
key: 'start-hunt',
label: 'Jagd beginnen',
description: 'Im Gebiet jagen',
type: 'HUNT',
iconKey: 'hunt',
enabled: true,
},
{
key: 'investigate-tracks',
label: 'Spuren untersuchen',
description: 'Hinweise finden',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'inspect-tracks',
},
{
key: 'search-surroundings',
label: 'Umgebung durchsuchen',
description: 'Beute finden',
type: 'SEARCH',
iconKey: 'search',
enabled: true,
poiKey: 'search-abandoned-wagon',
},
{
key: 'open-map',
label: 'Zur Karte',
description: 'Gebiet wechseln',
type: 'MAP',
iconKey: 'map',
enabled: true,
},
];
export function southGateFixture(
overrides: Partial<CurrentLocationResponse> = {},
): CurrentLocationResponse {
return {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
regionName: 'Aschenfelder',
regionTierLabel: 'Gebiet 1',
locationType: 'TRANSITION',
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
localArtworkPath: '/images/backgrounds/Suedtor.png',
dangerRating: null,
recommendationLabel: '1',
pointsOfInterest: [],
primaryActions: [],
encounterPreview: [],
rewardPreview: [],
connections: [
{
targetLocation: {
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
},
travelDurationSeconds: 10,
danger: 'LOW',
},
],
possibleMonsters: [],
...overrides,
};
}
export function burnedRoadFixture(
overrides: Partial<CurrentLocationResponse> = {},
): CurrentLocationResponse {
return southGateFixture({
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
maxRecommendedLevel: 2,
dangerLevel: 1,
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
locationType: 'HUNTING_GROUND',
localDescription:
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
dangerRating: 'MATCH',
recommendationLabel: '12',
pointsOfInterest: BURNED_ROAD_POIS,
primaryActions: BURNED_ROAD_ACTIONS,
encounterPreview: [
{
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
iconPath: '/images/combat/icons/ash-rat-128.png',
},
{
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
iconPath: '/images/combat/icons/road-bandit-128.png',
},
],
rewardPreview: [
{ key: 'silver', label: 'Silber', iconKey: 'silver' },
{ key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
{ key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' },
{ key: 'material', label: 'Material', iconKey: 'material' },
],
connections: [
{
targetLocation: {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
},
travelDurationSeconds: 10,
danger: 'LOW',
},
],
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
...overrides,
});
}

View File

@@ -0,0 +1,139 @@
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { Subject, of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { GameApiService } from '../../core/api/game-api.service';
import { LocalLocationStore } from './local-location.store';
import { WorldStore } from './world.store';
const trackResult = {
interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren',
text: 'Frische Stiefelabdrücke führen nach Osten.',
};
function setup(api: Partial<GameApiService> = {}, world: Partial<WorldStore> = {}) {
TestBed.configureTestingModule({
providers: [
LocalLocationStore,
{ provide: GameApiService, useValue: api },
{ provide: WorldStore, useValue: { load: vi.fn(), ...world } },
],
});
return TestBed.inject(LocalLocationStore);
}
describe('LocalLocationStore', () => {
it('loads the location through the world store rather than a second fetch path', async () => {
const load = vi.fn().mockResolvedValue(undefined);
const store = setup({}, { load });
await store.load();
expect(load).toHaveBeenCalledTimes(1);
});
it('opens the panel with the result the server returned', async () => {
const store = setup({
runLocationInteraction: vi.fn().mockReturnValue(of(trackResult)),
});
await store.runInteraction('inspect-tracks');
expect(store.interactionResult()).toEqual(trackResult);
expect(store.interactionError()).toBeNull();
expect(store.interactionOpen()).toBe(true);
});
it('marks only the running interaction as pending', async () => {
const pending = new Subject<typeof trackResult>();
const store = setup({
runLocationInteraction: vi.fn().mockReturnValue(pending),
});
const running = store.runInteraction('inspect-tracks');
expect(store.interactionPending()).toBe('inspect-tracks');
pending.next(trackResult);
pending.complete();
await running;
expect(store.interactionPending()).toBeNull();
});
it('ignores a second interaction while one is still running', async () => {
const pending = new Subject<typeof trackResult>();
const runLocationInteraction = vi.fn().mockReturnValue(pending);
const store = setup({ runLocationInteraction });
const running = store.runInteraction('inspect-tracks');
await store.runInteraction('search-abandoned-wagon');
expect(runLocationInteraction).toHaveBeenCalledTimes(1);
expect(runLocationInteraction).toHaveBeenCalledWith('inspect-tracks');
pending.next(trackResult);
pending.complete();
await running;
});
it('translates a rejected interaction into a readable message without navigating', async () => {
const store = setup({
runLocationInteraction: vi.fn().mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 400,
error: { code: 'LOCATION_INTERACTION_UNAVAILABLE' },
}),
),
),
});
await store.runInteraction('inspect-tracks');
expect(store.interactionError()).toBe('Hier gibt es dazu nichts zu entdecken.');
expect(store.interactionResult()).toBeNull();
expect(store.interactionOpen()).toBe(true);
});
it('falls back to a generic message for an unmapped failure', async () => {
const store = setup({
runLocationInteraction: vi
.fn()
.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))),
});
await store.runInteraction('inspect-tracks');
expect(store.interactionError()).toBe('Diese Handlung ist gerade nicht möglich.');
});
it('closes the panel without clearing the location', async () => {
const store = setup({
runLocationInteraction: vi.fn().mockReturnValue(of(trackResult)),
});
await store.runInteraction('inspect-tracks');
store.closeInteraction();
expect(store.interactionOpen()).toBe(false);
expect(store.interactionResult()).toBeNull();
});
it('resolves the hotspot an action mirrors', () => {
const store = setup();
expect(
store.interactionKeyOf({
key: 'investigate-tracks',
label: 'Spuren untersuchen',
type: 'INVESTIGATE',
iconKey: 'investigate',
enabled: true,
poiKey: 'inspect-tracks',
}),
).toBe('inspect-tracks');
});
});

View File

@@ -0,0 +1,97 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
LocationInteractionResult,
LocationPointOfInterest,
LocationPrimaryAction,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from './world.store';
const GENERIC_INTERACTION_ERROR = 'Diese Handlung ist gerade nicht möglich.';
// Mirrors the codes in `apps/api/src/world/world.errors.ts`.
const INTERACTION_ERROR_MESSAGES: Readonly<Record<string, string>> = {
LOCATION_INTERACTION_UNAVAILABLE: 'Hier gibt es dazu nichts zu entdecken.',
CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
};
/**
* State for the local location view.
*
* Location data comes from `WorldStore`, not from a second fetch path: that
* store already settles due travel before answering, so arriving at a place
* and looking around cannot disagree about where the character is. This store
* adds only what is local to the screen — the open interaction result.
*/
@Injectable({ providedIn: 'root' })
export class LocalLocationStore {
private readonly api = inject(GameApiService);
private readonly worldStore = inject(WorldStore);
private readonly interactionResultState = signal<LocationInteractionResult | null>(null);
private readonly interactionErrorState = signal<string | null>(null);
private readonly interactionPendingState = signal<string | null>(null);
readonly location = this.worldStore.currentLocation;
readonly loading = this.worldStore.loading;
readonly error = this.worldStore.error;
readonly interactionResult = this.interactionResultState.asReadonly();
readonly interactionError = this.interactionErrorState.asReadonly();
/** Key of the interaction currently in flight, so only that control busies. */
readonly interactionPending = this.interactionPendingState.asReadonly();
readonly interactionOpen = computed(
() => this.interactionResultState() !== null || this.interactionErrorState() !== null,
);
load(): Promise<void> {
return this.worldStore.load();
}
/**
* Runs a hotspot or action that reveals text. Navigation types (HUNT, MAP)
* never reach here — the page routes those itself.
*/
async runInteraction(interactionKey: string): Promise<void> {
if (this.interactionPendingState() !== null) {
return;
}
this.interactionPendingState.set(interactionKey);
this.interactionResultState.set(null);
this.interactionErrorState.set(null);
try {
this.interactionResultState.set(
await firstValueFrom(this.api.runLocationInteraction(interactionKey)),
);
} catch (error) {
this.interactionErrorState.set(this.toErrorMessage(error));
} finally {
this.interactionPendingState.set(null);
}
}
closeInteraction(): void {
this.interactionResultState.set(null);
this.interactionErrorState.set(null);
}
/** The hotspot an action mirrors, so an action bar entry can highlight it. */
interactionKeyOf(
action: LocationPrimaryAction | LocationPointOfInterest,
): string | undefined {
return 'poiKey' in action ? action.poiKey : action.key;
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && INTERACTION_ERROR_MESSAGES[code]) || GENERIC_INTERACTION_ERROR;
}
return GENERIC_INTERACTION_ERROR;
}
}

View File

@@ -0,0 +1,68 @@
import { Component, Input } from '@angular/core';
/**
* Line glyphs for local location content, addressed by the `iconKey` the
* server stores alongside a hotspot, action or reward.
*
* Drawn rather than loaded: these sit on top of artwork at sizes from 16px to
* 40px, where a downscaled bitmap turns to mush, and a new location can use an
* existing key without anyone exporting an asset. Swapping a key for a painted
* medallion later is a one-line change here.
*/
const GLYPHS: Readonly<Record<string, string>> = {
// Crossed hunting arrows.
hunt: 'M4 20 19 5M15 5h4v4M20 20 5 5M9 5H5v4',
// Magnifying glass over a trail.
investigate: 'M11 4a6 6 0 1 0 0 12 6 6 0 0 0 0-12M15.5 15.5 21 21',
// Lidded chest.
search: 'M3 9h18v11H3zM3 9l2-4h14l2 4M12 9v11M10 12h4',
// Speech bubble.
speak: 'M4 5h16v11h-9l-5 4v-4H4z',
// Compass rose.
map: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M15.5 8.5l-2 5-5 2 2-5z',
// Struck coin.
silver: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 7l1.7 3.3L17 12l-3.3 1.7L12 17l-1.7-3.3L7 12l3.3-1.7z',
// Rising rune.
experience: 'M12 3 4 12l8 9 8-9zM12 8v8M9 11l3-3 3 3',
// Blade over a shield.
equipment: 'M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6zM12 8v7M10 10l2-2 2 2',
// Bundled pelt.
material: 'M4 8c4-3 12-3 16 0l-2 11H6zM9 8v11M15 8v11',
// Travel marker, for locations whose hotspots lead onward.
travel: 'M12 3a6 6 0 0 1 6 6c0 4.5-6 12-6 12S6 13.5 6 9a6 6 0 0 1 6-6M12 7a2 2 0 1 0 0 4 2 2 0 0 0 0-4',
};
const FALLBACK_GLYPH = 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 8v5M12 16h.01';
@Component({
selector: 'app-location-icon',
template: `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path [attr.d]="glyph" />
</svg>
`,
styles: `
:host {
display: inline-flex;
inline-size: 1.5rem;
block-size: 1.5rem;
}
svg {
inline-size: 100%;
block-size: 100%;
fill: none;
stroke: currentcolor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
}
`,
})
export class LocationIconComponent {
@Input({ required: true }) iconKey!: string;
protected get glyph(): string {
return GLYPHS[this.iconKey] ?? FALLBACK_GLYPH;
}
}

View File

@@ -0,0 +1,28 @@
@if (result || error) {
<div
class="interaction-panel"
role="dialog"
aria-modal="false"
[attr.aria-label]="result ? result.title : 'Handlung nicht möglich'"
data-interaction-panel
>
@if (result) {
<h2 class="interaction-panel__title">{{ result.title }}</h2>
<p class="interaction-panel__text">{{ result.text }}</p>
} @else {
<h2 class="interaction-panel__title">Nichts zu entdecken</h2>
<p class="interaction-panel__text interaction-panel__text--error" role="alert">
{{ error }}
</p>
}
<button
class="interaction-panel__close"
type="button"
data-interaction-close
(click)="closePanel.emit()"
>
Schließen
</button>
</div>
}

View File

@@ -0,0 +1,64 @@
:host {
position: absolute;
inset-block-end: var(--ar-space-5);
inset-inline: 0;
display: grid;
justify-items: center;
pointer-events: none;
}
.interaction-panel {
display: grid;
gap: var(--ar-space-3);
justify-items: start;
inline-size: min(38rem, calc(100% - var(--ar-space-6)));
padding: var(--ar-space-4) var(--ar-space-5);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md);
background:
linear-gradient(180deg, rgb(201 164 95 / 0.07), transparent 42%),
rgb(13 15 17 / 0.96);
box-shadow: var(--ar-shadow-raised);
pointer-events: auto;
}
.interaction-panel__title {
margin: 0;
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.25rem;
font-weight: 400;
letter-spacing: 0.01em;
}
.interaction-panel__text {
margin: 0;
color: var(--ar-text);
font-size: 0.95rem;
line-height: 1.55;
}
.interaction-panel__text--error {
color: var(--ar-danger);
}
.interaction-panel__close {
justify-self: end;
padding: var(--ar-space-2) var(--ar-space-5);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: var(--ar-panel);
font: inherit;
font-size: var(--ar-font-sm);
letter-spacing: 0.04em;
text-transform: uppercase;
transition:
border-color var(--ar-motion-fast),
color var(--ar-motion-fast);
}
.interaction-panel__close:hover {
border-color: var(--ar-border-highlight);
color: var(--ar-gold);
}

View File

@@ -0,0 +1,86 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LocationInteractionPanelComponent } from './location-interaction-panel.component';
async function setup(inputs: {
result?: { interactionKey: string; title: string; text: string } | null;
error?: string | null;
}): Promise<{
fixture: ComponentFixture<LocationInteractionPanelComponent>;
closed: number;
element: HTMLElement;
}> {
await TestBed.configureTestingModule({
imports: [LocationInteractionPanelComponent],
}).compileComponents();
const fixture = TestBed.createComponent(LocationInteractionPanelComponent);
fixture.componentRef.setInput('result', inputs.result ?? null);
fixture.componentRef.setInput('error', inputs.error ?? null);
let closed = 0;
fixture.componentInstance.closePanel.subscribe(() => {
closed += 1;
});
fixture.detectChanges();
return {
fixture,
get closed() {
return closed;
},
element: fixture.nativeElement as HTMLElement,
};
}
describe('LocationInteractionPanelComponent', () => {
it('shows the title and text the server returned', async () => {
const { element } = await setup({
result: {
interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren',
text: 'Frische Stiefelabdrücke führen nach Osten.',
},
});
expect(element.textContent).toContain('Verdächtige Spuren');
expect(element.textContent).toContain('Frische Stiefelabdrücke führen nach Osten.');
});
it('shows an NPC line through the same panel, with no branching choices', async () => {
const { element } = await setup({
result: {
interactionKey: 'wounded-scout',
title: 'Verwundeter Kundschafter',
text: '„Die Straße ist nicht mehr sicher."',
},
});
expect(element.textContent).toContain('Verwundeter Kundschafter');
// Exactly one control: close. A dialogue tree is out of scope here.
expect(element.querySelectorAll('button')).toHaveLength(1);
});
it('reports a rejected interaction in place instead of navigating away', async () => {
const { element } = await setup({ error: 'Hier gibt es dazu nichts zu entdecken.' });
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Hier gibt es dazu nichts zu entdecken.',
);
});
it('emits close without touching the router', async () => {
const panel = await setup({
result: { interactionKey: 'k', title: 'T', text: 'X' },
});
panel.element.querySelector<HTMLButtonElement>('[data-interaction-close]')?.click();
expect(panel.closed).toBe(1);
});
it('renders nothing while no interaction is open', async () => {
const { element } = await setup({});
expect(element.querySelector('[data-interaction-panel]')).toBeNull();
});
});

View File

@@ -0,0 +1,21 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { LocationInteractionResult } from '../../../core/api/game-api.models';
/**
* Result of a short local interaction, shown over the scene.
*
* Deliberately one panel for investigate, search and talk alike, and
* deliberately without choices: this slice reveals authored text and closes.
* The location stays visible behind it so the player never leaves the place
* they are standing in.
*/
@Component({
selector: 'app-location-interaction-panel',
templateUrl: './location-interaction-panel.component.html',
styleUrl: './location-interaction-panel.component.scss',
})
export class LocationInteractionPanelComponent {
@Input() result: LocationInteractionResult | null = null;
@Input() error: string | null = null;
@Output() readonly closePanel = new EventEmitter<void>();
}

View File

@@ -0,0 +1,72 @@
<section class="location-page" aria-label="Ortsansicht">
@if (store.location(); as location) {
<div class="location-page__main">
<header class="location-page__header">
<h1 class="location-page__title">{{ location.name }}</h1>
<p class="location-page__breadcrumb">
<span>{{ location.regionTierLabel }}</span>
<span class="location-page__separator" aria-hidden="true"></span>
<span>{{ location.regionName }}</span>
</p>
<p class="location-page__description">{{ location.localDescription }}</p>
</header>
<div class="location-page__scene">
<picture class="location-page__artwork">
@if (runtimeArtwork(location.localArtworkPath); as runtime) {
<source [srcset]="runtime" type="image/jpeg" />
}
<img [src]="location.localArtworkPath" [alt]="'Ortsansicht: ' + location.name" />
</picture>
<div class="location-page__hotspots">
@for (poi of location.pointsOfInterest; track poi.key) {
<app-location-poi
[poi]="poi"
[busy]="busy"
(activate)="activatePoi($event)"
/>
}
</div>
<app-location-interaction-panel
[result]="store.interactionResult()"
[error]="store.interactionError()"
(closePanel)="store.closeInteraction()"
/>
</div>
<nav class="location-page__actions" aria-label="Ortsaktionen">
@for (action of location.primaryActions; track action.key) {
<button
class="location-action"
type="button"
[disabled]="!action.enabled || busy"
[attr.data-action]="action.key"
(click)="activateAction(action)"
>
<app-location-icon [iconKey]="action.iconKey" />
<span class="location-action__label">{{ action.label }}</span>
@if (action.description) {
<span class="location-action__hint">
{{ isPending(action.poiKey ?? action.key) ? 'Einen Moment…' : action.description }}
</span>
}
</button>
}
</nav>
</div>
<app-location-sidebar class="location-page__sidebar" [location]="location" />
} @else if (store.error(); as error) {
<section class="location-page__notice" role="alert">
<p>Ort konnte nicht geladen werden.</p>
<p class="location-page__notice-detail">{{ error }}</p>
<button type="button" data-location-retry (click)="retry()">Erneut versuchen</button>
</section>
} @else {
<section class="location-page__notice" aria-live="polite">
<p role="status">Der Ort wird geladen…</p>
</section>
}
</section>

View File

@@ -0,0 +1,205 @@
// The app shell sizes itself with `min-block-size` everywhere (a floor, not a
// ceiling: apps/web/src/app/layout/app-shell/app-shell.component.scss), so
// `block-size: 100%` here would resolve against an indefinite ancestor and
// fall back to auto — a `minmax(0, 1fr)` row below would then track content
// size instead of clamping, letting the artwork push the action bar off
// screen. Giving this page its own definite, viewport-bounded height fixes
// that without touching the shell, which other screens still size freely.
//
// Reserve terms are the shell chrome's own `min-block-size` values plus this
// page's own padding, so the number tracks its sources rather than sitting as
// an opaque constant — if the top bar or footer ever needs more room than its
// current floor (a longer name, a wrapped nav row), bump the matching term
// here too:
// apps/web/src/app/layout/top-bar/top-bar.component.scss (5.6rem)
// + apps/web/src/app/layout/game-footer/game-footer.component.scss (3.3rem)
// + this page's own padding, 2 × var(--ar-space-5) (3rem)
// A small safety margin (0.5rem) absorbs sub-pixel/line-height drift so a
// few px of unplanned growth doesn't immediately reopen the clipping bug.
$app-shell-chrome-reserve: calc(5.6rem + 3.3rem + 3rem + 0.5rem);
:host {
display: block;
block-size: calc(100dvh - #{$app-shell-chrome-reserve});
min-block-size: 0;
}
.location-page {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(14rem, 17rem);
gap: var(--ar-space-4);
block-size: 100%;
min-block-size: 0;
}
.location-page__main {
// Header and action bar take what they need; the artwork absorbs the rest,
// which is what keeps it dominant instead of one card among many.
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--ar-space-3);
min-block-size: 0;
}
.location-page__header {
display: grid;
gap: 0.2rem;
}
.location-page__title {
margin: 0;
color: #e9dcc0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(1.8rem, 2.6vw, 2.6rem);
font-weight: 400;
letter-spacing: 0.01em;
line-height: 1.05;
text-shadow: 0 0.2rem 0.8rem rgb(0 0 0 / 0.8);
}
.location-page__breadcrumb {
display: flex;
gap: var(--ar-space-2);
margin: 0;
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
letter-spacing: 0.05em;
}
.location-page__separator {
color: var(--ar-gold);
}
.location-page__description {
max-inline-size: 44rem;
margin: var(--ar-space-2) 0 0;
color: var(--ar-text-muted);
font-size: 0.9rem;
line-height: 1.5;
}
.location-page__scene {
position: relative;
inline-size: 100%;
// Fills the `minmax(0, 1fr)` row exactly — the row is a real, bounded size
// now that `.location-page` has a definite height, so the artwork needs no
// aspect-ratio of its own; `object-fit: cover` on the <img> does the crop.
min-block-size: 14rem;
overflow: hidden;
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-md);
box-shadow: var(--ar-shadow-raised);
}
.location-page__artwork,
.location-page__artwork img {
display: block;
inline-size: 100%;
block-size: 100%;
}
.location-page__artwork img {
object-fit: cover;
}
// Hotspots are positioned against this box, not against the <picture>, so the
// percentages stay true to the painted image under `object-fit: cover`.
.location-page__hotspots {
position: absolute;
inset: 0;
}
.location-page__actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: var(--ar-space-3);
}
.location-action {
display: grid;
justify-items: center;
gap: 0.25rem;
padding: var(--ar-space-3) var(--ar-space-2);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, rgb(201 164 95 / 0.06), transparent 55%), var(--ar-panel);
font: inherit;
text-align: center;
transition:
border-color var(--ar-motion-fast),
color var(--ar-motion-fast),
background var(--ar-motion-fast);
}
.location-action app-location-icon {
color: var(--ar-gold);
}
.location-action__label {
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(0.85rem, 0.95vw, 1rem);
text-wrap: balance;
white-space: nowrap;
}
.location-action__hint {
color: var(--ar-text-muted);
font-size: 0.72rem;
}
.location-action:not(:disabled):hover {
border-color: var(--ar-border-highlight);
background: linear-gradient(180deg, rgb(201 164 95 / 0.12), transparent 55%), var(--ar-panel);
}
.location-action:not(:disabled):hover .location-action__label {
color: var(--ar-gold);
}
.location-action:disabled {
opacity: 0.5;
}
.location-page__notice {
display: grid;
align-content: center;
justify-items: center;
gap: var(--ar-space-3);
grid-column: 1 / -1;
color: var(--ar-text-muted);
}
.location-page__notice-detail {
margin: 0;
font-size: var(--ar-font-sm);
}
.location-page__notice button {
padding: var(--ar-space-2) var(--ar-space-5);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: var(--ar-panel);
font: inherit;
}
@media (width < 1100px) {
.location-page {
grid-template-columns: minmax(0, 1fr);
// Main first, sized to its own natural minimum (scene floors at its
// min-block-size); sidebar gets whatever remains and scrolls internally.
// The reverse order starved main entirely — an `auto` track claims its
// full max-content height before a later `1fr` track sees any space, so
// the sidebar's tall content once pushed the action bar off (0px) while
// consuming the whole column itself.
grid-template-rows: auto minmax(8rem, 1fr);
}
.location-page__scene {
// Narrower columns give the artwork more natural height at 100% width
// (it has no aspect-ratio of its own below 1100px); floor it lower so
// the sidebar keeps a visible sliver instead of being squeezed to 0.
min-block-size: 10rem;
}
}

View File

@@ -0,0 +1,269 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type {
CurrentLocationResponse,
LocationInteractionResult,
} from '../../../core/api/game-api.models';
import { burnedRoadFixture, southGateFixture } from '../current-location.fixture';
import { LocalLocationStore } from '../local-location.store';
import { LocationPageComponent } from './location-page.component';
describe('LocationPageComponent', () => {
let location: ReturnType<typeof signal<CurrentLocationResponse | null>>;
let error: ReturnType<typeof signal<string | null>>;
let interactionResult: ReturnType<typeof signal<LocationInteractionResult | null>>;
let interactionError: ReturnType<typeof signal<string | null>>;
let interactionPending: ReturnType<typeof signal<string | null>>;
let store: {
location: typeof location;
loading: ReturnType<typeof signal<boolean>>;
error: typeof error;
interactionResult: typeof interactionResult;
interactionError: typeof interactionError;
interactionPending: typeof interactionPending;
load: ReturnType<typeof vi.fn>;
runInteraction: ReturnType<typeof vi.fn>;
closeInteraction: ReturnType<typeof vi.fn>;
};
async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) {
location = signal(current);
error = signal<string | null>(null);
interactionResult = signal<LocationInteractionResult | null>(null);
interactionError = signal<string | null>(null);
interactionPending = signal<string | null>(null);
store = {
location,
loading: signal(false),
error,
interactionResult,
interactionError,
interactionPending,
load: vi.fn().mockResolvedValue(undefined),
runInteraction: vi.fn().mockResolvedValue(undefined),
closeInteraction: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [LocationPageComponent],
providers: [provideRouter([]), { provide: LocalLocationStore, useValue: store }],
}).compileComponents();
const fixture = TestBed.createComponent(LocationPageComponent);
const router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture.detectChanges();
return { fixture, router, element: fixture.nativeElement as HTMLElement };
}
it('names the place and its region straight away', async () => {
const { element } = await setup();
expect(element.querySelector('h1')?.textContent).toContain('Verbrannte Straße');
expect(element.textContent).toContain('Gebiet 1');
expect(element.textContent).toContain('Aschenfelder');
expect(element.textContent).toContain(
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
);
});
it('renders the location artwork, not the composition mockup', async () => {
const { element } = await setup();
const image = element.querySelector('img') as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/images/backgrounds/Aschestrasse.png');
expect(image.getAttribute('alt')).toBe('Ortsansicht: Verbrannte Straße');
});
it('places every hotspot on the artwork', async () => {
const { element } = await setup();
const hotspots = element.querySelectorAll('app-location-poi');
expect(hotspots).toHaveLength(4);
expect([...hotspots].map((poi) => poi.querySelector('button')?.dataset['poi'])).toEqual([
'hunt-area',
'inspect-tracks',
'search-abandoned-wagon',
'wounded-scout',
]);
});
it('renders the four primary actions in the authored order', async () => {
const { element } = await setup();
const actions = element.querySelectorAll('[data-action]');
expect([...actions].map((action) => action.querySelector('.location-action__label')?.textContent?.trim())).toEqual([
'Jagd beginnen',
'Spuren untersuchen',
'Umgebung durchsuchen',
'Zur Karte',
]);
});
it('renders the context sidebar', async () => {
const { element } = await setup();
expect(element.querySelector('app-location-sidebar')).not.toBeNull();
});
it('hands a hunt hotspot to the existing hunt screen without rolling encounters', async () => {
const { element, router } = await setup();
element.querySelector<HTMLButtonElement>('[data-poi="hunt-area"]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
expect(store.runInteraction).not.toHaveBeenCalled();
});
it('sends the hunt action to the hunt screen too', async () => {
const { element, router } = await setup();
element.querySelector<HTMLButtonElement>('[data-action="start-hunt"]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
});
it('opens the existing map route without completing any travel itself', async () => {
const { element, router } = await setup();
element.querySelector<HTMLButtonElement>('[data-action="open-map"]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/world']);
expect(store.runInteraction).not.toHaveBeenCalled();
});
it('asks the server what investigating the tracks reveals', async () => {
const { element, router } = await setup();
element.querySelector<HTMLButtonElement>('[data-poi="inspect-tracks"]')?.click();
expect(store.runInteraction).toHaveBeenCalledWith('inspect-tracks');
expect(router.navigate).not.toHaveBeenCalled();
});
it('asks the server what searching the wagon reveals', async () => {
const { element } = await setup();
element.querySelector<HTMLButtonElement>('[data-poi="search-abandoned-wagon"]')?.click();
expect(store.runInteraction).toHaveBeenCalledWith('search-abandoned-wagon');
});
it('asks the server what the scout says', async () => {
const { element } = await setup();
element.querySelector<HTMLButtonElement>('[data-poi="wounded-scout"]')?.click();
expect(store.runInteraction).toHaveBeenCalledWith('wounded-scout');
});
it('routes an action through the hotspot it mirrors, so both show the same text', async () => {
const { element } = await setup();
element.querySelector<HTMLButtonElement>('[data-action="investigate-tracks"]')?.click();
expect(store.runInteraction).toHaveBeenCalledWith('inspect-tracks');
});
it('shows the interaction result over the still-visible location', async () => {
const { fixture, element } = await setup();
interactionResult.set({
interactionKey: 'inspect-tracks',
title: 'Verdächtige Spuren',
text: 'Frische Stiefelabdrücke führen nach Osten.',
});
fixture.detectChanges();
expect(element.querySelector('[data-interaction-panel]')?.textContent).toContain(
'Frische Stiefelabdrücke führen nach Osten.',
);
// The scene is not replaced by the panel.
expect(element.querySelector('img')).not.toBeNull();
expect(element.querySelectorAll('app-location-poi')).toHaveLength(4);
});
it('closes the panel without navigating', async () => {
const { fixture, element, router } = await setup();
interactionResult.set({ interactionKey: 'k', title: 'T', text: 'X' });
fixture.detectChanges();
element.querySelector<HTMLButtonElement>('[data-interaction-close]')?.click();
expect(store.closeInteraction).toHaveBeenCalledTimes(1);
expect(router.navigate).not.toHaveBeenCalled();
});
it('disables the controls while an interaction is running', async () => {
const { fixture, element } = await setup();
interactionPending.set('inspect-tracks');
fixture.detectChanges();
const actions = element.querySelectorAll<HTMLButtonElement>('[data-action]');
expect([...actions].every((action) => action.disabled)).toBe(true);
expect(element.textContent).toContain('Einen Moment…');
});
it('renders a restrained loading state that shows no misplaced hotspots', async () => {
const { element } = await setup(null);
expect(element.querySelector('[role="status"]')?.textContent).toContain(
'Der Ort wird geladen',
);
expect(element.querySelectorAll('app-location-poi')).toHaveLength(0);
});
it('offers a retry when the location could not be loaded', async () => {
const { fixture, element } = await setup(null);
error.set('Weltzustand konnte nicht geladen werden.');
fixture.detectChanges();
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Ort konnte nicht geladen werden.',
);
element.querySelector<HTMLButtonElement>('[data-location-retry]')?.click();
expect(store.load).toHaveBeenCalled();
});
it('renders a second location from its own data alone', async () => {
const { element } = await setup(southGateFixture(southGateContent()));
expect(element.querySelector('h1')?.textContent).toContain('Südtor von Graufurt');
expect(element.querySelectorAll('app-location-poi')).toHaveLength(1);
expect(element.querySelectorAll('[data-action]')).toHaveLength(1);
});
});
function southGateContent(): Partial<CurrentLocationResponse> {
return {
pointsOfInterest: [
{
key: 'gate-watch',
title: 'Torwache',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 45,
yPercent: 52,
enabled: true,
},
],
primaryActions: [
{
key: 'talk-to-watch',
label: 'Wache ansprechen',
description: 'Lage erfragen',
type: 'NPC',
iconKey: 'speak',
enabled: true,
poiKey: 'gate-watch',
},
],
};
}

View File

@@ -0,0 +1,96 @@
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
LocationInteractionType,
LocationPointOfInterest,
LocationPrimaryAction,
} from '../../../core/api/game-api.models';
import { LocalLocationStore } from '../local-location.store';
import { LocationInteractionPanelComponent } from '../location-interaction-panel/location-interaction-panel.component';
import { LocationIconComponent } from '../location-icon/location-icon.component';
import { LocationPoiComponent } from '../location-poi/location-poi.component';
import { LocationSidebarComponent } from '../location-sidebar/location-sidebar.component';
const RUNTIME_ARTWORK: Readonly<Record<string, string>> = {
'/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg',
'/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg',
};
/**
* The screen the player stands on between activities.
*
* It renders whatever the current location's content describes and owns no
* knowledge of any particular place. Hunting and travel are not reimplemented
* here: HUNT and MAP hotspots hand off to the existing screens, and everything
* that reveals text goes through the server-authoritative interaction endpoint.
*/
@Component({
selector: 'app-location-page',
imports: [
LocationIconComponent,
LocationInteractionPanelComponent,
LocationPoiComponent,
LocationSidebarComponent,
],
templateUrl: './location-page.component.html',
styleUrl: './location-page.component.scss',
})
export class LocationPageComponent implements OnInit {
protected readonly store = inject(LocalLocationStore);
private readonly router = inject(Router);
ngOnInit(): void {
if (this.store.location() === null) {
void this.store.load();
}
}
protected retry(): void {
void this.store.load();
}
protected activatePoi(poi: LocationPointOfInterest): void {
this.dispatch(poi.type, poi.key);
}
protected activateAction(action: LocationPrimaryAction): void {
this.dispatch(action.type, action.poiKey ?? action.key);
}
protected runtimeArtwork(artworkPath: string): string | undefined {
return RUNTIME_ARTWORK[artworkPath];
}
/** True while this control's own interaction is in flight. */
protected isPending(key: string | undefined): boolean {
return key !== undefined && this.store.interactionPending() === key;
}
protected get busy(): boolean {
return this.store.interactionPending() !== null;
}
/**
* Routes a hotspot or action by its interaction type. Navigation types leave
* for the screen that owns them; every other type asks the server what
* happened. Unimplemented types are ignored rather than faked.
*/
private dispatch(type: LocationInteractionType, interactionKey: string): void {
switch (type) {
case 'HUNT':
void this.router.navigate(['/hunt']);
return;
case 'MAP':
case 'TRAVEL':
void this.router.navigate(['/world']);
return;
case 'INVESTIGATE':
case 'SEARCH':
case 'NPC':
void this.store.runInteraction(interactionKey);
return;
default:
return;
}
}
}

View File

@@ -0,0 +1,17 @@
<button
class="location-poi"
type="button"
[class.location-poi--busy]="busy"
[disabled]="!poi.enabled || busy"
[attr.data-poi]="poi.key"
[attr.aria-label]="poi.actionLabel ? poi.title + ': ' + poi.actionLabel : poi.title"
(click)="onActivate()"
>
<span class="location-poi__medallion">
<app-location-icon [iconKey]="poi.iconKey" />
</span>
<span class="location-poi__title">{{ poi.title }}</span>
@if (poi.actionLabel) {
<span class="location-poi__action">{{ poi.actionLabel }}</span>
}
</button>

View File

@@ -0,0 +1,88 @@
:host {
position: absolute;
// The host carries left/top from the percentage coordinates; this centres
// the marker on that point instead of hanging it off the top-left corner.
transform: translate(-50%, -50%);
}
.location-poi {
display: grid;
justify-items: center;
gap: 0.2rem;
padding: 0.35rem;
border: 0;
border-radius: var(--ar-radius-md);
color: var(--ar-text);
background: transparent;
font: inherit;
text-align: center;
text-shadow: 0 0.15rem 0.55rem rgb(0 0 0 / 0.95);
transition:
transform var(--ar-motion-fast),
filter var(--ar-motion-fast);
}
.location-poi__medallion {
display: grid;
place-items: center;
inline-size: 2.6rem;
block-size: 2.6rem;
border: 0.13rem solid var(--ar-gold);
border-radius: 50%;
color: #e8d5a8;
background:
radial-gradient(circle at 50% 38%, rgb(201 164 95 / 0.22), transparent 62%),
radial-gradient(circle, #221c14 30%, #14171a 76%);
box-shadow:
0 0 0 0.14rem rgb(6 8 9 / 0.8),
0 0.3rem 0.9rem rgb(0 0 0 / 0.6),
0 0 0.85rem rgb(201 164 95 / 0.28);
}
.location-poi__title {
max-inline-size: 11rem;
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.95rem;
line-height: 1.2;
}
.location-poi__action {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.02em;
}
.location-poi:not(:disabled):hover,
.location-poi:not(:disabled):focus-visible {
transform: translateY(-0.1rem);
}
.location-poi:not(:disabled):hover .location-poi__medallion,
.location-poi:not(:disabled):focus-visible .location-poi__medallion {
border-color: #e1bd72;
box-shadow:
0 0 0 0.14rem rgb(6 8 9 / 0.8),
0 0.3rem 0.9rem rgb(0 0 0 / 0.6),
0 0 1.15rem rgb(225 189 114 / 0.72);
}
.location-poi:focus-visible {
outline: 2px solid var(--ar-blue);
outline-offset: 0.15rem;
}
.location-poi:disabled {
filter: grayscale(0.7);
opacity: 0.55;
}
.location-poi--busy {
opacity: 0.75;
}
@media (prefers-reduced-motion: reduce) {
.location-poi:not(:disabled):hover,
.location-poi:not(:disabled):focus-visible {
transform: none;
}
}

View File

@@ -0,0 +1,116 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import type { LocationPointOfInterest } from '../../../core/api/game-api.models';
import { LocationPoiComponent } from './location-poi.component';
const wagon: LocationPointOfInterest = {
key: 'search-abandoned-wagon',
title: 'Verlassener Wagen',
actionLabel: 'Durchsuchen',
type: 'SEARCH',
iconKey: 'search',
xPercent: 80,
yPercent: 68,
enabled: true,
};
async function setup(
poi: LocationPointOfInterest = wagon,
busy = false,
): Promise<{
fixture: ComponentFixture<LocationPoiComponent>;
activated: LocationPointOfInterest[];
button: HTMLButtonElement;
}> {
await TestBed.configureTestingModule({
imports: [LocationPoiComponent],
}).compileComponents();
const fixture = TestBed.createComponent(LocationPoiComponent);
fixture.componentRef.setInput('poi', poi);
fixture.componentRef.setInput('busy', busy);
const activated: LocationPointOfInterest[] = [];
fixture.componentInstance.activate.subscribe((value) => activated.push(value));
fixture.detectChanges();
return {
fixture,
activated,
button: fixture.nativeElement.querySelector('button') as HTMLButtonElement,
};
}
describe('LocationPoiComponent', () => {
it('anchors itself with the percentage coordinates so it scales with the artwork', async () => {
const { fixture } = await setup();
const host = fixture.nativeElement as HTMLElement;
expect(host.style.left).toBe('80%');
expect(host.style.top).toBe('68%');
});
it('renders title and action label', async () => {
const { fixture } = await setup();
const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
expect(text).toContain('Verlassener Wagen');
expect(text).toContain('Durchsuchen');
});
it('emits the whole hotspot when clicked', async () => {
const { button, activated } = await setup();
button.click();
expect(activated).toEqual([wagon]);
});
it('is reachable and activatable from the keyboard', async () => {
const { button, activated } = await setup();
// A native button gives Enter and Space for free; assert it stayed a
// button rather than becoming a click-only div.
expect(button.tagName).toBe('BUTTON');
expect(button.tabIndex).toBe(0);
button.focus();
expect(document.activeElement).toBe(button);
button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
button.click();
expect(activated).toHaveLength(1);
});
it('does nothing when the hotspot is disabled', async () => {
const { button, activated } = await setup({ ...wagon, enabled: false });
expect(button.disabled).toBe(true);
button.click();
expect(activated).toEqual([]);
});
it('does nothing while an interaction is already running', async () => {
const { button, activated } = await setup(wagon, true);
button.click();
expect(activated).toEqual([]);
});
it('describes itself with both title and action for screen readers', async () => {
const { button } = await setup();
expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen: Durchsuchen');
});
it('falls back to the title alone when a hotspot has no action label', async () => {
const { button } = await setup({
...wagon,
actionLabel: undefined,
});
expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen');
});
});

View File

@@ -0,0 +1,34 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { LocationPointOfInterest } from '../../../core/api/game-api.models';
import { LocationIconComponent } from '../location-icon/location-icon.component';
/**
* One hotspot pinned to the location artwork.
*
* Purely presentational: it renders the marker, reports activation and knows
* nothing about what the interaction does. The page decides whether a hotspot
* navigates or opens the interaction panel.
*/
@Component({
selector: 'app-location-poi',
imports: [LocationIconComponent],
templateUrl: './location-poi.component.html',
styleUrl: './location-poi.component.scss',
host: {
// Percentages of the artwork box, so a marker keeps sitting on the same
// painted detail at every viewport width.
'[style.left.%]': 'poi.xPercent',
'[style.top.%]': 'poi.yPercent',
},
})
export class LocationPoiComponent {
@Input({ required: true }) poi!: LocationPointOfInterest;
@Input() busy = false;
@Output() readonly activate = new EventEmitter<LocationPointOfInterest>();
protected onActivate(): void {
if (this.poi.enabled && !this.busy) {
this.activate.emit(this.poi);
}
}
}

View File

@@ -0,0 +1,73 @@
<aside class="location-sidebar" aria-label="Ortsinformationen">
<dl class="location-sidebar__identity">
<div>
<dt>Gebiet</dt>
<dd>{{ location.regionName }}</dd>
</div>
<div>
<dt>Ort</dt>
<dd class="location-sidebar__name">{{ location.name }}</dd>
</div>
<div>
<dt>Ortstyp</dt>
<dd>{{ locationTypeLabel }}</dd>
</div>
<div>
<dt>Empfohlene Stufe</dt>
<dd class="location-sidebar__recommendation">{{ location.recommendationLabel }}</dd>
</div>
<div>
<dt>Gefahr</dt>
<dd>
@if (location.dangerRating; as rating) {
<app-danger-badge [rating]="rating" />
} @else {
<span class="location-sidebar__safe">Sicher</span>
}
</dd>
</div>
</dl>
@if (location.encounterPreview.length) {
<section class="location-sidebar__block" data-sidebar="encounters">
<h3>Mögliche Begegnungen</h3>
<ul class="location-sidebar__encounters">
@for (encounter of location.encounterPreview; track encounter.key) {
<li>
<img [src]="encounter.iconPath" [alt]="''" width="48" height="48" />
<span>{{ encounter.name }}</span>
</li>
}
</ul>
<p class="location-sidebar__note">Vorschau — die Jagd würfelt eigenständig.</p>
</section>
}
@if (interactions.length) {
<section class="location-sidebar__block" data-sidebar="interactions">
<h3>Verfügbare Interaktionen</h3>
<ul class="location-sidebar__interactions">
@for (interaction of interactions; track interaction.label) {
<li>
<app-location-icon [iconKey]="interaction.iconKey" />
<span>{{ interaction.label }}</span>
</li>
}
</ul>
</section>
}
@if (location.rewardPreview.length) {
<section class="location-sidebar__block" data-sidebar="rewards">
<h3>Mögliche Belohnungen</h3>
<ul class="location-sidebar__rewards">
@for (reward of location.rewardPreview; track reward.key) {
<li [attr.title]="reward.label">
<app-location-icon [iconKey]="reward.iconKey" />
<span>{{ reward.label }}</span>
</li>
}
</ul>
</section>
}
</aside>

View File

@@ -0,0 +1,130 @@
:host {
display: block;
min-block-size: 0;
}
.location-sidebar {
display: grid;
align-content: start;
gap: var(--ar-space-4);
block-size: 100%;
padding: var(--ar-space-4);
overflow-y: auto;
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-md);
background:
linear-gradient(180deg, rgb(201 164 95 / 0.05), transparent 30%), var(--ar-panel);
}
.location-sidebar__identity {
display: grid;
gap: var(--ar-space-3);
margin: 0;
}
.location-sidebar__identity dt {
color: var(--ar-text-muted);
font-size: 0.7rem;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.location-sidebar__identity dd {
margin: 0.15rem 0 0;
color: var(--ar-text);
font-size: 0.95rem;
}
.location-sidebar__name {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.1rem;
}
.location-sidebar__recommendation {
color: var(--ar-success);
font-weight: 600;
}
.location-sidebar__safe {
color: var(--ar-success);
font-weight: 600;
}
.location-sidebar__block {
display: grid;
gap: var(--ar-space-2);
padding-block-start: var(--ar-space-4);
border-block-start: 1px solid var(--ar-border);
}
.location-sidebar__block h3 {
margin: 0;
color: var(--ar-text-muted);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.location-sidebar__block ul {
margin: 0;
padding: 0;
list-style: none;
}
.location-sidebar__encounters {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(3.5rem, 1fr));
gap: var(--ar-space-2);
}
.location-sidebar__encounters li {
display: grid;
justify-items: center;
gap: 0.2rem;
text-align: center;
}
.location-sidebar__encounters img {
inline-size: 3rem;
block-size: 3rem;
border: 1px solid var(--ar-border);
border-radius: 50%;
}
.location-sidebar__encounters span {
color: var(--ar-text-muted);
font-size: 0.65rem;
line-height: 1.25;
}
.location-sidebar__note {
margin: 0;
color: var(--ar-text-muted);
font-size: 0.68rem;
font-style: italic;
}
.location-sidebar__interactions,
.location-sidebar__rewards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--ar-space-2);
}
.location-sidebar__interactions li,
.location-sidebar__rewards li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--ar-space-2);
color: var(--ar-text);
font-size: var(--ar-font-sm);
}
.location-sidebar__interactions app-location-icon,
.location-sidebar__rewards app-location-icon {
inline-size: 1.35rem;
block-size: 1.35rem;
color: var(--ar-gold);
}

View File

@@ -0,0 +1,97 @@
import { TestBed } from '@angular/core/testing';
import type { CurrentLocationResponse } from '../../../core/api/game-api.models';
import { burnedRoadFixture, southGateFixture } from '../current-location.fixture';
import { LocationSidebarComponent } from './location-sidebar.component';
async function render(location: CurrentLocationResponse): Promise<HTMLElement> {
await TestBed.configureTestingModule({
imports: [LocationSidebarComponent],
}).compileComponents();
const fixture = TestBed.createComponent(LocationSidebarComponent);
fixture.componentRef.setInput('location', location);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
describe('LocationSidebarComponent', () => {
it('states where the player is and what kind of place it is', async () => {
const element = await render(burnedRoadFixture());
const text = element.textContent ?? '';
expect(text).toContain('Aschenfelder');
expect(text).toContain('Verbrannte Straße');
expect(text).toContain('Jagdgebiet');
expect(text).toContain('12');
});
it('shows the danger rating as the shared badge', async () => {
const element = await render(burnedRoadFixture());
expect(element.querySelector('app-danger-badge')?.textContent).toContain('Passend');
});
it('calls a location with no hostile pool safe instead of inventing a rating', async () => {
const element = await render(southGateFixture());
expect(element.querySelector('app-danger-badge')).toBeNull();
expect(element.textContent).toContain('Sicher');
});
it('previews the encounters and marks them as preview only', async () => {
const element = await render(burnedRoadFixture());
const encounters = element.querySelectorAll('[data-sidebar="encounters"] li');
expect([...encounters].map((item) => item.textContent?.trim())).toEqual([
'Aschenratte',
'Straßenräuber',
]);
expect(element.textContent).toContain('die Jagd würfelt eigenständig');
});
it('lists each interaction kind once, taken from the enabled hotspots', async () => {
const element = await render(burnedRoadFixture());
const interactions = element.querySelectorAll('[data-sidebar="interactions"] li');
expect([...interactions].map((item) => item.textContent?.trim())).toEqual([
'Jagd beginnen',
'Untersuchen',
'Durchsuchen',
'Sprechen',
]);
});
it('never advertises an interaction whose hotspot is disabled', async () => {
const location = burnedRoadFixture();
const element = await render({
...location,
pointsOfInterest: location.pointsOfInterest.map((poi) =>
poi.type === 'NPC' ? { ...poi, enabled: false } : poi,
),
});
expect(element.querySelector('[data-sidebar="interactions"]')?.textContent).not.toContain(
'Sprechen',
);
});
it('shows the reward categories the location actually backs', async () => {
const element = await render(burnedRoadFixture());
const rewards = element.querySelectorAll('[data-sidebar="rewards"] li');
expect([...rewards].map((item) => item.textContent?.trim())).toEqual([
'Silber',
'Erfahrung',
'Ausrüstung',
'Material',
]);
});
it('omits empty blocks entirely rather than rendering headings with nothing under them', async () => {
const element = await render(southGateFixture());
expect(element.querySelector('[data-sidebar="encounters"]')).toBeNull();
expect(element.querySelector('[data-sidebar="rewards"]')).toBeNull();
});
});

View File

@@ -0,0 +1,59 @@
import { Component, Input } from '@angular/core';
import { CurrentLocationResponse } from '../../../core/api/game-api.models';
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
import { LocationIconComponent } from '../location-icon/location-icon.component';
const LOCATION_TYPE_LABELS: Readonly<Record<string, string>> = {
SAFE_HUB: 'Zuflucht',
TRANSITION: 'Übergang',
HUNTING_GROUND: 'Jagdgebiet',
QUEST_LOCATION: 'Questort',
OUTPOST: 'Außenposten',
ELITE_ZONE: 'Elitegebiet',
BOSS_LOCATION: 'Bossort',
DUNGEON_ENTRANCE: 'Verlieseingang',
};
interface InteractionSummary {
label: string;
iconKey: string;
}
/**
* What this place means in play: identity, danger, who lives here, what can be
* done and what it pays. Every block is fed from the location payload, so a
* new location fills the same sidebar without a code change.
*/
@Component({
selector: 'app-location-sidebar',
imports: [DangerBadgeComponent, LocationIconComponent],
templateUrl: './location-sidebar.component.html',
styleUrl: './location-sidebar.component.scss',
})
export class LocationSidebarComponent {
@Input({ required: true }) location!: CurrentLocationResponse;
protected get locationTypeLabel(): string {
return LOCATION_TYPE_LABELS[this.location.locationType] ?? this.location.locationType;
}
/**
* Distinct interaction kinds available here, taken from the hotspots that
* are actually enabled — the list can never advertise more than the scene
* offers.
*/
protected get interactions(): InteractionSummary[] {
const seen = new Map<string, InteractionSummary>();
for (const poi of this.location.pointsOfInterest) {
if (poi.enabled && !seen.has(poi.type)) {
seen.set(poi.type, {
label: poi.actionLabel ?? poi.title,
iconKey: poi.iconKey,
});
}
}
return [...seen.values()];
}
}

View File

@@ -1,11 +1,14 @@
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';
import { WorldPageComponent } from './world-page.component';
@@ -19,40 +22,9 @@ const burnedRoadConnection: CurrentLocationConnection = {
danger: 'LOW',
};
const southGate: CurrentLocationResponse = {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [burnedRoadConnection],
possibleMonsters: [],
};
const southGate = southGateFixture({ connections: [burnedRoadConnection] });
const burnedRoad: CurrentLocationResponse = {
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['goblin', 'skeleton'],
connections: [
{
targetLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' },
travelDurationSeconds: 10,
danger: 'LOW',
},
],
};
const burnedRoad = burnedRoadFixture();
describe('WorldPageComponent', () => {
let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;
@@ -63,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 () => {
@@ -77,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();
});
@@ -206,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

@@ -8,6 +8,7 @@ import type {
CurrentTravel,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { southGateFixture } from './current-location.fixture';
import { WorldStore } from './world.store';
const character: CharacterResponse = {
@@ -22,19 +23,11 @@ const character: CharacterResponse = {
currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
};
const currentLocation: CurrentLocationResponse = {
const currentLocation = southGateFixture({
id: 'origin-id',
key: 'south-gate',
name: 'Südtor',
description: 'Der Ausgang zur Wildnis.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 1,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
possibleMonsters: [],
connections: [
{
targetLocation: {
@@ -46,7 +39,7 @@ const currentLocation: CurrentLocationResponse = {
danger: 'LOW',
},
],
};
});
const travelling: CurrentTravel = {
status: 'TRAVELLING',
@@ -208,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) {

View File

@@ -1,12 +1,12 @@
<div class="app-shell">
<app-top-bar [character]="worldStore.character()" />
<div class="app-shell__content" [class.app-shell__content--no-context]="inCombat()">
<div class="app-shell__content" [class.app-shell__content--no-context]="!showContextPanel()">
<app-side-navigation />
<main class="app-shell__main" aria-label="Spielinhalt">
<router-outlet />
</main>
@if (!inCombat()) {
@if (showContextPanel()) {
<app-context-panel />
}
</div>

View File

@@ -19,9 +19,18 @@ import { TopBarComponent } from '../top-bar/top-bar.component';
styleUrl: './app-shell.component.scss',
})
export class AppShellComponent {
private readonly router = inject(Router);
protected readonly worldStore = inject(WorldStore);
// The fight has its own log rail and wants the width, and the area info
// belongs to the world view anyway, so the rail is dropped during combat.
protected readonly inCombat = isActive('/combat', inject(Router));
private readonly inCombat = isActive('/combat', this.router);
// The location view brings its own, far richer context sidebar. Showing the
// shell's generic area panel next to it would say the same thing twice and
// squeeze the artwork the screen is built around.
private readonly atLocation = isActive('/location', this.router);
protected readonly showContextPanel = () => !this.inCombat() && !this.atLocation();
}

View File

@@ -1,4 +1,22 @@
<nav class="side-navigation" aria-label="Spielnavigation">
<button
class="side-navigation__item"
type="button"
routerLink="/location"
routerLinkActive="side-navigation__item--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
data-navigation="location"
aria-label="Ort"
>
<!-- Drawn rather than loaded: no medallion for "Ort" has been painted yet.
Framed to sit alongside the existing icons until one exists. -->
<span class="side-navigation__glyph">
<app-location-icon iconKey="travel" />
</span>
<span>Ort</span>
</button>
<button
class="side-navigation__item"
type="button"

View File

@@ -37,6 +37,24 @@
border-radius: 50%;
}
// Same footprint as the painted icons, so a drawn glyph does not shift the row.
.side-navigation__glyph {
display: grid;
flex: none;
place-items: center;
inline-size: 2.25rem;
block-size: 2.25rem;
border: 1px solid var(--ar-border-highlight);
border-radius: 50%;
color: var(--ar-gold);
background: radial-gradient(circle, #221c14 30%, #14171a 78%);
}
.side-navigation__glyph app-location-icon {
inline-size: 1.25rem;
block-size: 1.25rem;
}
.side-navigation__item--active {
border-inline-start-color: var(--ar-blue);
color: var(--ar-text);

View File

@@ -1,9 +1,10 @@
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { LocationIconComponent } from '../../features/world/location-icon/location-icon.component';
@Component({
selector: 'app-side-navigation',
imports: [RouterLink, RouterLinkActive],
imports: [LocationIconComponent, RouterLink, RouterLinkActive],
templateUrl: './side-navigation.component.html',
styleUrl: './side-navigation.component.scss',
})

View File

@@ -16,6 +16,12 @@ describe('monsterCutoutPath', () => {
it('returns the background-free cut-out for a known monster key', () => {
expect(monsterCutoutPath('ash-rat')).toBe('/images/combat/sprites/ash-rat-760.png');
expect(monsterCutoutPath('road-bandit')).toBe('/images/combat/sprites/road-bandit-620.png');
expect(monsterCutoutPath('wild-road-dog')).toBe(
'/images/combat/sprites/wild-road-dog-760.png',
);
expect(monsterCutoutPath('charred-looter')).toBe(
'/images/combat/sprites/charred-looter-620.png',
);
});
it('returns undefined for a monster without a cut-out', () => {
@@ -27,6 +33,9 @@ describe('monsterIconPath', () => {
it('returns the medallion icon for a known monster key', () => {
expect(monsterIconPath('ash-rat')).toBe('/images/combat/icons/ash-rat-128.png');
expect(monsterIconPath('road-bandit')).toBe('/images/combat/icons/road-bandit-128.png');
expect(monsterIconPath('charred-looter')).toBe(
'/images/combat/icons/charred-looter-128.png',
);
});
it('returns undefined for a monster without an icon', () => {

View File

@@ -30,6 +30,8 @@ const MONSTER_ICON: Readonly<Record<string, string>> = {
const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
'ash-rat': 0.46,
'road-bandit': 0.82,
'wild-road-dog': 0.58,
'charred-looter': 0.86,
};
const DEFAULT_MONSTER_SCALE = 0.6;