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 @@