From 3a84fd5371c7b60c5254c57b1ac0b38fcc42efc5 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 23:28:55 +0200 Subject: [PATCH] feat(web): show quest markers on location hotspots Co-Authored-By: Claude Opus 5 --- .../features/world/local-location.store.ts | 10 ++++ .../location-page.component.html | 1 + .../location-page.component.spec.ts | 38 ++++++++++++-- .../location-poi/location-poi.component.html | 7 ++- .../location-poi/location-poi.component.scss | 21 ++++++++ .../location-poi.component.spec.ts | 51 ++++++++++++++++++- .../location-poi/location-poi.component.ts | 48 ++++++++++++++++- .../app/features/world/world.store.spec.ts | 44 ++++++++++++++++ .../web/src/app/features/world/world.store.ts | 39 ++++++++++++++ 9 files changed, 253 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/features/world/local-location.store.ts b/apps/web/src/app/features/world/local-location.store.ts index a47e999..7aea055 100644 --- a/apps/web/src/app/features/world/local-location.store.ts +++ b/apps/web/src/app/features/world/local-location.store.ts @@ -38,6 +38,16 @@ export class LocalLocationStore { readonly loading = this.worldStore.loading; readonly error = this.worldStore.error; + /** + * Quest and merchant markers for the NPC behind a hotspot (Slice 0.9 §12). + * + * Passed straight through from `WorldStore`, which reads them alongside the + * location, for the same reason the location itself is: two fetch paths for + * one screen can disagree about where the character is standing. + */ + readonly markersFor = (npcKey: string | undefined) => + this.worldStore.markersFor(npcKey); + readonly interactionResult = this.interactionResultState.asReadonly(); readonly interactionError = this.interactionErrorState.asReadonly(); /** Key of the interaction currently in flight, so only that control busies. */ 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 index 0a0b9e4..b85fd8d 100644 --- 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 @@ -24,6 +24,7 @@ } 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 index 102e0be..a3f6169 100644 --- 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 @@ -5,6 +5,7 @@ import { vi } from 'vitest'; import type { CurrentLocationResponse, LocationInteractionResult, + NpcMarker, } from '../../../core/api/game-api.models'; import { burnedRoadFixture, southGateFixture } from '../current-location.fixture'; import { LocalLocationStore } from '../local-location.store'; @@ -26,9 +27,16 @@ describe('LocationPageComponent', () => { load: ReturnType; runInteraction: ReturnType; closeInteraction: ReturnType; + markersFor: ReturnType; }; + /** Markers by NPC key, as the server would have decided them. */ + let markers: Record; - async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) { + async function setup( + current: CurrentLocationResponse | null = burnedRoadFixture(), + npcMarkers: Record = {}, + ) { + markers = npcMarkers; location = signal(current); error = signal(null); interactionResult = signal(null); @@ -44,6 +52,7 @@ describe('LocationPageComponent', () => { load: vi.fn().mockResolvedValue(undefined), runInteraction: vi.fn().mockResolvedValue(undefined), closeInteraction: vi.fn(), + markersFor: vi.fn((npcKey?: string) => markers[npcKey ?? ''] ?? []), }; await TestBed.configureTestingModule({ @@ -238,20 +247,43 @@ describe('LocationPageComponent', () => { expect(element.querySelectorAll('app-location-poi')).toHaveLength(1); expect(element.querySelectorAll('[data-action]')).toHaveLength(1); }); + + it('badges the hotspot of an NPC with something to ask', async () => { + const { element } = await setup(southGateFixture(southGateContent()), { + 'south-gate-warden': ['QUEST_AVAILABLE'], + }); + + expect( + element.querySelector('[data-poi-badge]')?.textContent?.trim(), + ).toBe('!'); + }); + + it('leaves a hotspot that names no NPC unbadged', async () => { + const { element } = await setup( + southGateFixture(southGateContent({ withNpcKey: false })), + { 'south-gate-warden': ['QUEST_AVAILABLE'] }, + ); + + expect(element.querySelector('[data-poi-badge]')).toBeNull(); + expect(store.markersFor).toHaveBeenCalledWith(undefined); + }); }); -function southGateContent(): Partial { +function southGateContent({ + withNpcKey = true, +}: { withNpcKey?: boolean } = {}): Partial { return { pointsOfInterest: [ { key: 'gate-watch', - title: 'Gate Watch', + title: 'Halvik, Warden of the South Gate', actionLabel: 'Talk', type: 'NPC', iconKey: 'speak', xPercent: 45, yPercent: 52, enabled: true, + ...(withNpcKey ? { npcKey: 'south-gate-warden' } : {}), }, ], primaryActions: [ 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 index 721530d..76387c3 100644 --- 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 @@ -4,11 +4,16 @@ [class.location-poi--busy]="busy" [disabled]="!poi.enabled || busy" [attr.data-poi]="poi.key" - [attr.aria-label]="poi.actionLabel ? poi.title + ': ' + poi.actionLabel : poi.title" + [attr.aria-label]="accessibleLabel" (click)="onActivate()" > + @if (badge; as questBadge) { + + } {{ poi.title }} @if (poi.actionLabel) { 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 index b19d4cf..ab539d2 100644 --- 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 @@ -23,6 +23,7 @@ } .location-poi__medallion { + position: relative; display: grid; place-items: center; inline-size: 2.6rem; @@ -39,6 +40,26 @@ 0 0 0.85rem rgb(201 164 95 / 0.28); } +/* A small forged disc on the medallion's shoulder. Same gold as the frame, so + it reads as part of the marker rather than a notification dot. */ +.location-poi__badge { + position: absolute; + inset-block-start: -0.3rem; + inset-inline-end: -0.3rem; + display: grid; + place-items: center; + inline-size: 1.15rem; + block-size: 1.15rem; + border: 0.1rem solid var(--ar-gold); + border-radius: 50%; + color: #14171a; + background: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 0.72rem; + line-height: 1; + box-shadow: 0 0 0.5rem rgb(201 164 95 / 0.5); +} + .location-poi__title { max-inline-size: 11rem; font-family: Georgia, 'Times New Roman', serif; 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 index ac059c2..32c144b 100644 --- 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 @@ -1,5 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import type { LocationPointOfInterest } from '../../../core/api/game-api.models'; +import type { + LocationPointOfInterest, + NpcMarker, +} from '../../../core/api/game-api.models'; import { LocationPoiComponent } from './location-poi.component'; const wagon: LocationPointOfInterest = { @@ -16,6 +19,7 @@ const wagon: LocationPointOfInterest = { async function setup( poi: LocationPointOfInterest = wagon, busy = false, + markers: NpcMarker[] = [], ): Promise<{ fixture: ComponentFixture; activated: LocationPointOfInterest[]; @@ -28,6 +32,7 @@ async function setup( const fixture = TestBed.createComponent(LocationPoiComponent); fixture.componentRef.setInput('poi', poi); fixture.componentRef.setInput('busy', busy); + fixture.componentRef.setInput('markers', markers); const activated: LocationPointOfInterest[] = []; fixture.componentInstance.activate.subscribe((value) => activated.push(value)); @@ -113,4 +118,48 @@ describe('LocationPoiComponent', () => { expect(button.getAttribute('aria-label')).toBe('Abandoned Wagon'); }); + + it('renders no badge without markers', async () => { + const { fixture } = await setup(); + + expect(fixture.nativeElement.querySelector('[data-poi-badge]')).toBeNull(); + }); + + it('renders a badge for a quest that can be started here', async () => { + const { fixture, button } = await setup(wagon, false, ['QUEST_AVAILABLE']); + + expect( + fixture.nativeElement.querySelector('[data-poi-badge]').textContent.trim(), + ).toBe('!'); + expect(button.getAttribute('aria-label')).toBe( + 'Abandoned Wagon: Search, quest available', + ); + }); + + it('renders a badge when the current step waits here', async () => { + const { fixture, button } = await setup(wagon, false, ['QUEST_TURN_IN']); + + expect( + fixture.nativeElement.querySelector('[data-poi-badge]').textContent.trim(), + ).toBe('?'); + expect(button.getAttribute('aria-label')).toContain('quest step ready'); + }); + + it('ignores markers that are not quest markers', async () => { + const { fixture } = await setup(wagon, false, ['MERCHANT', 'EXCHANGE']); + + expect(fixture.nativeElement.querySelector('[data-poi-badge]')).toBeNull(); + }); + + it('renders one badge at most, preferring the waiting step', async () => { + const { fixture } = await setup(wagon, false, [ + 'MERCHANT', + 'QUEST_AVAILABLE', + 'QUEST_TURN_IN', + ]); + + const badges = fixture.nativeElement.querySelectorAll('[data-poi-badge]'); + expect(badges).toHaveLength(1); + expect(badges[0].textContent.trim()).toBe('?'); + }); }); 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 index 1cecbf8..368189d 100644 --- 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 @@ -1,7 +1,31 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { LocationPointOfInterest } from '../../../core/api/game-api.models'; +import { + LocationPointOfInterest, + NpcMarker, +} from '../../../core/api/game-api.models'; import { LocationIconComponent } from '../location-icon/location-icon.component'; +/** + * What each quest marker looks like on a hotspot (Slice 0.9 §12). + * + * Glyph plus a spoken suffix, because a badge that only exists as a colour is + * not a marker for anyone using a screen reader. + */ +const QUEST_BADGES: Readonly< + Partial> +> = { + QUEST_AVAILABLE: { glyph: '!', label: 'quest available' }, + QUEST_TURN_IN: { glyph: '?', label: 'quest step ready' }, + QUEST_IN_PROGRESS: { glyph: '·', label: 'quest in progress' }, +}; + +/** Most useful first, matching the precedence the server already applies. */ +const BADGE_ORDER: readonly NpcMarker[] = [ + 'QUEST_TURN_IN', + 'QUEST_AVAILABLE', + 'QUEST_IN_PROGRESS', +]; + /** * One hotspot pinned to the location artwork. * @@ -24,8 +48,30 @@ import { LocationIconComponent } from '../location-icon/location-icon.component' export class LocationPoiComponent { @Input({ required: true }) poi!: LocationPointOfInterest; @Input() busy = false; + /** Markers for the NPC behind this hotspot, if it has one. */ + @Input() markers: NpcMarker[] = []; @Output() readonly activate = new EventEmitter(); + /** + * The single badge this hotspot shows, or null. + * + * One at most: a portrait wearing three symbols tells the player nothing. + */ + protected get badge(): { glyph: string; label: string } | null { + const marker = BADGE_ORDER.find((candidate) => + this.markers.includes(candidate), + ); + return marker ? (QUEST_BADGES[marker] ?? null) : null; + } + + protected get accessibleLabel(): string { + const base = this.poi.actionLabel + ? `${this.poi.title}: ${this.poi.actionLabel}` + : this.poi.title; + const badge = this.badge; + return badge ? `${base}, ${badge.label}` : base; + } + protected onActivate(): void { if (this.poi.enabled && !this.busy) { this.activate.emit(this.poi); diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 87d111d..4547797 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -62,6 +62,7 @@ describe('WorldStore', () => { getCurrentLocation: ReturnType; getCurrentTravel: ReturnType; startTravel: ReturnType; + getLocationNpcs: ReturnType; }; let store: WorldStore; @@ -73,6 +74,18 @@ describe('WorldStore', () => { getCurrentLocation: vi.fn(() => of(currentLocation)), getCurrentTravel: vi.fn(() => of({ status: 'IDLE' } satisfies CurrentTravel)), startTravel: vi.fn(() => of(travelling)), + getLocationNpcs: vi.fn(() => + of([ + { + id: 'npc-warden', + key: 'south-gate-warden', + name: 'Halvik', + title: 'Warden of the South Gate', + portraitPath: '/images/npcs/south-gate-warden.png', + markers: ['QUEST_AVAILABLE'], + }, + ]), + ), }; TestBed.configureTestingModule({ @@ -441,4 +454,35 @@ describe('WorldStore', () => { expect(store.displayedCharacter()?.currentHp).toBe(14); }); }); + + describe('location NPC markers', () => { + it('reads who is standing at the current location', async () => { + await store.load(); + + expect(api.getLocationNpcs).toHaveBeenCalledWith(currentLocation.id); + expect(store.markersFor('south-gate-warden')).toEqual([ + 'QUEST_AVAILABLE', + ]); + }); + + it('reports no markers for an NPC who is not here', async () => { + await store.load(); + + expect(store.markersFor('borin-quartermaster')).toEqual([]); + expect(store.markersFor(undefined)).toEqual([]); + }); + + it('still renders the location when the marker read fails', async () => { + // Markers are decoration; the screen has to come up regardless. + api.getLocationNpcs.mockReturnValue( + throwError(() => new HttpErrorResponse({ status: 500 })), + ); + + await store.load(); + + expect(store.currentLocation()).toEqual(currentLocation); + expect(store.error()).toBeNull(); + expect(store.markersFor('south-gate-warden')).toEqual([]); + }); + }); }); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index 7ef224a..1f14963 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -7,6 +7,8 @@ import { CurrentLocationResponse, CurrentTravel, LocationSummary, + NpcMarker, + NpcSummary, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; @@ -30,6 +32,7 @@ export class WorldStore implements OnDestroy { private readonly currentTravelState = signal(null); private readonly remainingSecondsState = signal(null); private readonly arrivedState = signal(null); + private readonly locationNpcsState = signal([]); private readonly loadingState = signal(false); private readonly errorState = signal(null); private countdownTimer: ReturnType | undefined; @@ -48,9 +51,21 @@ export class WorldStore implements OnDestroy { readonly arrived = this.arrivedState.asReadonly(); readonly loading = this.loadingState.asReadonly(); readonly error = this.errorState.asReadonly(); + /** The people standing here, with the markers the server decided on. */ + readonly locationNpcs = this.locationNpcsState.asReadonly(); constructor(private readonly api: GameApiService) {} + /** The markers for one hotspot's NPC, or none when nobody matches. */ + markersFor(npcKey: string | undefined): NpcMarker[] { + if (!npcKey) { + return []; + } + return ( + this.locationNpcsState().find((npc) => npc.key === npcKey)?.markers ?? [] + ); + } + async load(): Promise { if (this.destroyed) { return; @@ -68,6 +83,7 @@ export class WorldStore implements OnDestroy { this.applyCharacter(character); this.currentLocationState.set(location); this.selectedConnectionState.set(null); + await this.loadLocationNpcs(location); await this.setCurrentTravel(travel); } catch (error) { if (!this.destroyed) { @@ -266,6 +282,29 @@ export class WorldStore implements OnDestroy { this.applyCharacter(character); this.currentLocationState.set(location); this.selectedConnectionState.set(null); + await this.loadLocationNpcs(location); + } + + /** + * Reads who is standing here and what they currently want (Slice 0.9 §12). + * + * A failure is swallowed on purpose: markers are decoration on a screen that + * still has to render. Blanking the location because a badge could not be + * fetched would trade a small loss for a total one. + */ + private async loadLocationNpcs( + location: CurrentLocationResponse, + ): Promise { + try { + const npcs = await firstValueFrom(this.api.getLocationNpcs(location.id)); + if (!this.destroyed) { + this.locationNpcsState.set(npcs); + } + } catch { + if (!this.destroyed) { + this.locationNpcsState.set([]); + } + } } private applyCharacter(character: CharacterResponse): void {