feat(web): add local location contracts, shared fixtures and content glyphs

Mirrors the extended current-location payload and the interaction endpoint
in the web API client. Replaces the per-spec CurrentLocationResponse
literals with one shared fixture factory, so a contract change is answered
in a single place. Adds the drawn glyph set used by hotspots, actions and
reward previews.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-20 10:34:41 +02:00
parent c85a70484f
commit 2664495ef4
8 changed files with 371 additions and 70 deletions

View File

@@ -21,6 +21,69 @@ export interface CurrentLocationConnection {
danger: 'LOW' | 'HIGH'; 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 { export interface CurrentLocationResponse {
id: string; id: string;
key: string; key: string;
@@ -33,6 +96,18 @@ export interface CurrentLocationResponse {
isSafe: boolean; isSafe: boolean;
huntingEnabled: boolean; huntingEnabled: boolean;
artworkPath: string; 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[]; connections: CurrentLocationConnection[];
possibleMonsters: string[]; possibleMonsters: string[];
} }

View File

@@ -48,6 +48,24 @@ describe('GameApiService', () => {
request.flush({ status: 'IDLE' }); 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', () => { it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
service.startCombat('encounter-uuid').subscribe(); service.startCombat('encounter-uuid').subscribe();

View File

@@ -8,6 +8,7 @@ import {
CurrentLocationResponse, CurrentLocationResponse,
CurrentTravel, CurrentTravel,
HuntResult, HuntResult,
LocationInteractionResult,
} from './game-api.models'; } from './game-api.models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -22,6 +23,17 @@ export class GameApiService {
return this.http.get<CurrentLocationResponse>('/api/world/current-location'); 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> { startTravel(targetLocationId: string): Observable<CurrentTravel> {
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId }); return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
} }

View File

@@ -5,38 +5,17 @@ import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models'; import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store'; import { CombatStore } from '../../combat/combat.store';
import {
burnedRoadFixture,
southGateFixture,
} from '../../world/current-location.fixture';
import { WorldStore } from '../../world/world.store'; import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store'; import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component'; import { HuntPageComponent } from './hunt-page.component';
const southGate: CurrentLocationResponse = { const southGate = southGateFixture({ connections: [] });
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 burnedRoad: CurrentLocationResponse = { const burnedRoad = burnedRoadFixture({ connections: [] });
...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 threeEncounterHunt: HuntResult = { const threeEncounterHunt: HuntResult = {
id: 'hunt-id', id: 'hunt-id',

View File

@@ -0,0 +1,190 @@
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: 'wounded-scout',
title: 'Verwundeter Kundschafter',
actionLabel: 'Sprechen',
type: 'NPC',
iconKey: 'speak',
xPercent: 20,
yPercent: 60,
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,
},
];
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/monsters/icons/ash-rat-128.png',
},
{
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
iconPath: '/images/monsters/icons/road-bandit-128.png',
},
],
rewardPreview: [
{ key: 'silver', label: 'Silber', iconKey: 'silver' },
{ key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
],
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,64 @@
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',
// 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

@@ -6,6 +6,7 @@ import type {
CurrentLocationResponse, CurrentLocationResponse,
CurrentTravel, CurrentTravel,
} from '../../core/api/game-api.models'; } from '../../core/api/game-api.models';
import { burnedRoadFixture, southGateFixture } from './current-location.fixture';
import { WorldStore } from './world.store'; import { WorldStore } from './world.store';
import { WorldPageComponent } from './world-page.component'; import { WorldPageComponent } from './world-page.component';
@@ -19,40 +20,9 @@ const burnedRoadConnection: CurrentLocationConnection = {
danger: 'LOW', danger: 'LOW',
}; };
const southGate: CurrentLocationResponse = { const southGate = southGateFixture({ connections: [burnedRoadConnection] });
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 burnedRoad: CurrentLocationResponse = { const burnedRoad = burnedRoadFixture();
...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',
},
],
};
describe('WorldPageComponent', () => { describe('WorldPageComponent', () => {
let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>; let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;

View File

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