From 2664495ef4176cb7aba7319859cf4ea9412145ad Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 10:34:41 +0200 Subject: [PATCH] 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 --- apps/web/src/app/core/api/game-api.models.ts | 75 +++++++ .../src/app/core/api/game-api.service.spec.ts | 18 ++ apps/web/src/app/core/api/game-api.service.ts | 12 ++ .../hunt-page/hunt-page.component.spec.ts | 33 +-- .../world/current-location.fixture.ts | 190 ++++++++++++++++++ .../location-icon/location-icon.component.ts | 64 ++++++ .../world/world-page.component.spec.ts | 36 +--- .../app/features/world/world.store.spec.ts | 13 +- 8 files changed, 371 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/app/features/world/current-location.fixture.ts create mode 100644 apps/web/src/app/features/world/location-icon/location-icon.component.ts diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index e7646fc..87c8938 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -21,6 +21,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; @@ -33,6 +96,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[]; } diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts index bc3a992..ffc16ff 100644 --- a/apps/web/src/app/core/api/game-api.service.spec.ts +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -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(); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index ae438f7..d9cd6a2 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -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('/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 { + return this.http.post( + `/api/world/current-location/interactions/${encodeURIComponent(interactionKey)}`, + {}, + ); + } + startTravel(targetLocationId: string): Observable { return this.http.post('/api/travel', { targetLocationId }); } diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index b8c5963..b516e3b 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -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', diff --git a/apps/web/src/app/features/world/current-location.fixture.ts b/apps/web/src/app/features/world/current-location.fixture.ts new file mode 100644 index 0000000..0a5a69c --- /dev/null +++ b/apps/web/src/app/features/world/current-location.fixture.ts @@ -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 { + 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 { + 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: '1–2', + 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, + }); +} diff --git a/apps/web/src/app/features/world/location-icon/location-icon.component.ts b/apps/web/src/app/features/world/location-icon/location-icon.component.ts new file mode 100644 index 0000000..63e8489 --- /dev/null +++ b/apps/web/src/app/features/world/location-icon/location-icon.component.ts @@ -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> = { + // 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: ` + + `, + 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; + } +} diff --git a/apps/web/src/app/features/world/world-page.component.spec.ts b/apps/web/src/app/features/world/world-page.component.spec.ts index ae0ce72..8576b29 100644 --- a/apps/web/src/app/features/world/world-page.component.spec.ts +++ b/apps/web/src/app/features/world/world-page.component.spec.ts @@ -6,6 +6,7 @@ import type { CurrentLocationResponse, CurrentTravel, } 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 +20,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>; diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 2eb3512..3d5730d 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -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 = { @@ -21,19 +22,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: { @@ -45,7 +38,7 @@ const currentLocation: CurrentLocationResponse = { danger: 'LOW', }, ], -}; +}); const travelling: CurrentTravel = { status: 'TRAVELLING',