From 6f0020137bccd3f9a8814fcebb702f624097a05a Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 20 Aug 2026 10:51:08 +0200 Subject: [PATCH] feat(web): add the local location view at /location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen a player stands on between activities: name, region, scene artwork with hotspots pinned by percentage, a four-button action bar and a context sidebar covering identity, danger, encounters, interactions and rewards. It owns no knowledge of any particular place. Hotspots and actions are routed by interaction type: HUNT and MAP hand off to the existing hunt and map screens, and everything that reveals text goes through the server-authoritative interaction endpoint. A second location therefore renders by supplying different content, which the Südtor case in the page spec exercises. The shell drops its generic area rail on /location, where the screen's own sidebar says the same thing better, and Ort joins the navigation as its first entry. Root and unknown routes now land on the location rather than the map: arriving somewhere should mean arriving at a place. Co-Authored-By: Claude Opus 5 --- .../database/seeds/local-location.content.ts | 9 +- apps/web/src/app/app.routes.ts | 11 +- apps/web/src/app/app.spec.ts | 64 ++++- .../world/current-location.fixture.ts | 2 + .../world/local-location.store.spec.ts | 139 +++++++++ .../features/world/local-location.store.ts | 97 +++++++ .../location-icon/location-icon.component.ts | 4 + .../location-interaction-panel.component.html | 28 ++ .../location-interaction-panel.component.scss | 64 +++++ ...cation-interaction-panel.component.spec.ts | 86 ++++++ .../location-interaction-panel.component.ts | 21 ++ .../location-page.component.html | 72 +++++ .../location-page.component.scss | 166 +++++++++++ .../location-page.component.spec.ts | 269 ++++++++++++++++++ .../location-page/location-page.component.ts | 96 +++++++ .../location-poi/location-poi.component.html | 17 ++ .../location-poi/location-poi.component.scss | 88 ++++++ .../location-poi.component.spec.ts | 116 ++++++++ .../location-poi/location-poi.component.ts | 34 +++ .../location-sidebar.component.html | 73 +++++ .../location-sidebar.component.scss | 130 +++++++++ .../location-sidebar.component.spec.ts | 97 +++++++ .../location-sidebar.component.ts | 59 ++++ .../layout/app-shell/app-shell.component.html | 4 +- .../layout/app-shell/app-shell.component.ts | 11 +- .../side-navigation.component.html | 18 ++ .../side-navigation.component.scss | 18 ++ .../side-navigation.component.ts | 3 +- 28 files changed, 1784 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/features/world/local-location.store.spec.ts create mode 100644 apps/web/src/app/features/world/local-location.store.ts create mode 100644 apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html create mode 100644 apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss create mode 100644 apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts create mode 100644 apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts create mode 100644 apps/web/src/app/features/world/location-page/location-page.component.html create mode 100644 apps/web/src/app/features/world/location-page/location-page.component.scss create mode 100644 apps/web/src/app/features/world/location-page/location-page.component.spec.ts create mode 100644 apps/web/src/app/features/world/location-page/location-page.component.ts create mode 100644 apps/web/src/app/features/world/location-poi/location-poi.component.html create mode 100644 apps/web/src/app/features/world/location-poi/location-poi.component.scss create mode 100644 apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts create mode 100644 apps/web/src/app/features/world/location-poi/location-poi.component.ts create mode 100644 apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html create mode 100644 apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss create mode 100644 apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts create mode 100644 apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts diff --git a/apps/api/src/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts index c3795ce..1a651e6 100644 --- a/apps/api/src/database/seeds/local-location.content.ts +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -118,12 +118,15 @@ export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = { enabled: true, }, ], - // Only what the game actually grants today. Combat hands out silver and - // experience; there is no loot system yet, so nothing else is promised - // (spec §8, "Mögliche Belohnungen"). + // Only categories the loot tables on this road actually back: silver and + // experience from every kill, gear from the raiders, pelts from the beasts. + // Named items stay out — the view may not promise a drop the roll does not + // guarantee (spec §8, "Mögliche Belohnungen"). localRewardPreview: [ { 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' }, ], }; diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 8a0c082..648992c 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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' }, ]; diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 4dd3f38..f33cc2a 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -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('[data-navigation]'); + expect([...entries].map((entry) => entry.dataset['navigation'])).toEqual([ + 'location', + 'world', + 'hunt', + 'quests', + 'inventory', + 'character', + ]); + + const locationButton = element.querySelector( + '[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']), + ); }); }); diff --git a/apps/web/src/app/features/world/current-location.fixture.ts b/apps/web/src/app/features/world/current-location.fixture.ts index 0a5a69c..ecea033 100644 --- a/apps/web/src/app/features/world/current-location.fixture.ts +++ b/apps/web/src/app/features/world/current-location.fixture.ts @@ -172,6 +172,8 @@ export function burnedRoadFixture( 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: [ { diff --git a/apps/web/src/app/features/world/local-location.store.spec.ts b/apps/web/src/app/features/world/local-location.store.spec.ts new file mode 100644 index 0000000..7432a01 --- /dev/null +++ b/apps/web/src/app/features/world/local-location.store.spec.ts @@ -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 = {}, world: Partial = {}) { + 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(); + 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(); + 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'); + }); +}); diff --git a/apps/web/src/app/features/world/local-location.store.ts b/apps/web/src/app/features/world/local-location.store.ts new file mode 100644 index 0000000..10428f2 --- /dev/null +++ b/apps/web/src/app/features/world/local-location.store.ts @@ -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> = { + 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(null); + private readonly interactionErrorState = signal(null); + private readonly interactionPendingState = signal(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 { + 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 { + 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; + } +} 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 index 63e8489..dbec8ad 100644 --- 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 @@ -24,6 +24,10 @@ const GLYPHS: Readonly> = { 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', }; diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html new file mode 100644 index 0000000..69ff7ee --- /dev/null +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html @@ -0,0 +1,28 @@ +@if (result || error) { + +} diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss new file mode 100644 index 0000000..7153d64 --- /dev/null +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss @@ -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); +} diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts new file mode 100644 index 0000000..5af9cc4 --- /dev/null +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts @@ -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; + 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('[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(); + }); +}); diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts new file mode 100644 index 0000000..2acaf84 --- /dev/null +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts @@ -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(); +} diff --git a/apps/web/src/app/features/world/location-page/location-page.component.html b/apps/web/src/app/features/world/location-page/location-page.component.html new file mode 100644 index 0000000..1c9beb4 --- /dev/null +++ b/apps/web/src/app/features/world/location-page/location-page.component.html @@ -0,0 +1,72 @@ +
+ @if (store.location(); as location) { +
+
+

{{ location.name }}

+

+ {{ location.regionTierLabel }} + + {{ location.regionName }} +

+

{{ location.localDescription }}

+
+ +
+ + @if (runtimeArtwork(location.localArtworkPath); as runtime) { + + } + + + +
+ @for (poi of location.pointsOfInterest; track poi.key) { + + } +
+ + +
+ + +
+ + + } @else if (store.error(); as error) { + + } @else { +
+

Der Ort wird geladen…

+
+ } +
diff --git a/apps/web/src/app/features/world/location-page/location-page.component.scss b/apps/web/src/app/features/world/location-page/location-page.component.scss new file mode 100644 index 0000000..d009899 --- /dev/null +++ b/apps/web/src/app/features/world/location-page/location-page.component.scss @@ -0,0 +1,166 @@ +:host { + display: block; + block-size: 100%; + 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; + min-block-size: 16rem; + 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 , 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: 1rem; +} + +.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); + grid-template-rows: minmax(0, 1fr) auto; + } +} diff --git a/apps/web/src/app/features/world/location-page/location-page.component.spec.ts b/apps/web/src/app/features/world/location-page/location-page.component.spec.ts new file mode 100644 index 0000000..0633b21 --- /dev/null +++ b/apps/web/src/app/features/world/location-page/location-page.component.spec.ts @@ -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>; + let error: ReturnType>; + let interactionResult: ReturnType>; + let interactionError: ReturnType>; + let interactionPending: ReturnType>; + let store: { + location: typeof location; + loading: ReturnType>; + error: typeof error; + interactionResult: typeof interactionResult; + interactionError: typeof interactionError; + interactionPending: typeof interactionPending; + load: ReturnType; + runInteraction: ReturnType; + closeInteraction: ReturnType; + }; + + async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) { + location = signal(current); + error = signal(null); + interactionResult = signal(null); + interactionError = signal(null); + interactionPending = signal(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', + 'wounded-scout', + 'inspect-tracks', + 'search-abandoned-wagon', + ]); + }); + + 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('[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('[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('[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('[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('[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('[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('[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('[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('[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('[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 { + 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', + }, + ], + }; +} diff --git a/apps/web/src/app/features/world/location-page/location-page.component.ts b/apps/web/src/app/features/world/location-page/location-page.component.ts new file mode 100644 index 0000000..6a0eaa9 --- /dev/null +++ b/apps/web/src/app/features/world/location-page/location-page.component.ts @@ -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> = { + '/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; + } + } +} diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.html b/apps/web/src/app/features/world/location-poi/location-poi.component.html new file mode 100644 index 0000000..721530d --- /dev/null +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.html @@ -0,0 +1,17 @@ + diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.scss b/apps/web/src/app/features/world/location-poi/location-poi.component.scss new file mode 100644 index 0000000..b19d4cf --- /dev/null +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts b/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts new file mode 100644 index 0000000..27b057d --- /dev/null +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts @@ -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; + 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'); + }); +}); diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.ts b/apps/web/src/app/features/world/location-poi/location-poi.component.ts new file mode 100644 index 0000000..1cecbf8 --- /dev/null +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.ts @@ -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(); + + protected onActivate(): void { + if (this.poi.enabled && !this.busy) { + this.activate.emit(this.poi); + } + } +} diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html new file mode 100644 index 0000000..ac644aa --- /dev/null +++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html @@ -0,0 +1,73 @@ + diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss new file mode 100644 index 0000000..bf991cb --- /dev/null +++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss @@ -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); +} diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts new file mode 100644 index 0000000..719814e --- /dev/null +++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts @@ -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 { + 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('1–2'); + }); + + 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', + 'Sprechen', + 'Untersuchen', + 'Durchsuchen', + ]); + }); + + 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(); + }); +}); diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts new file mode 100644 index 0000000..cbfb826 --- /dev/null +++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts @@ -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> = { + 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(); + + 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()]; + } +} diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.html b/apps/web/src/app/layout/app-shell/app-shell.component.html index 07ff3cd..b8ae48c 100644 --- a/apps/web/src/app/layout/app-shell/app-shell.component.html +++ b/apps/web/src/app/layout/app-shell/app-shell.component.html @@ -1,12 +1,12 @@
-
+
- @if (!inCombat()) { + @if (showContextPanel()) { }
diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.ts b/apps/web/src/app/layout/app-shell/app-shell.component.ts index c8160f4..726e1f5 100644 --- a/apps/web/src/app/layout/app-shell/app-shell.component.ts +++ b/apps/web/src/app/layout/app-shell/app-shell.component.ts @@ -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(); } diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.html b/apps/web/src/app/layout/side-navigation/side-navigation.component.html index 563ea58..3c894d0 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.html +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.html @@ -1,4 +1,22 @@