feat(web): show quest markers on location hotspots

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 23:28:55 +02:00
parent ff8b7c8b78
commit 3a84fd5371
9 changed files with 253 additions and 6 deletions

View File

@@ -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. */

View File

@@ -24,6 +24,7 @@
<app-location-poi
[poi]="poi"
[busy]="busy"
[markers]="store.markersFor(poi.npcKey)"
(activate)="activatePoi($event)"
/>
}

View File

@@ -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<typeof vi.fn>;
runInteraction: ReturnType<typeof vi.fn>;
closeInteraction: ReturnType<typeof vi.fn>;
markersFor: ReturnType<typeof vi.fn>;
};
/** Markers by NPC key, as the server would have decided them. */
let markers: Record<string, NpcMarker[]>;
async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) {
async function setup(
current: CurrentLocationResponse | null = burnedRoadFixture(),
npcMarkers: Record<string, NpcMarker[]> = {},
) {
markers = npcMarkers;
location = signal(current);
error = signal<string | null>(null);
interactionResult = signal<LocationInteractionResult | null>(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'],
});
function southGateContent(): Partial<CurrentLocationResponse> {
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({
withNpcKey = true,
}: { withNpcKey?: boolean } = {}): Partial<CurrentLocationResponse> {
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: [

View File

@@ -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()"
>
<span class="location-poi__medallion">
<app-location-icon [iconKey]="poi.iconKey" />
@if (badge; as questBadge) {
<span class="location-poi__badge" data-poi-badge aria-hidden="true">{{
questBadge.glyph
}}</span>
}
</span>
<span class="location-poi__title">{{ poi.title }}</span>
@if (poi.actionLabel) {

View File

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

View File

@@ -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<LocationPoiComponent>;
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('?');
});
});

View File

@@ -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<Record<NpcMarker, { glyph: string; label: string }>>
> = {
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<LocationPointOfInterest>();
/**
* 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);

View File

@@ -62,6 +62,7 @@ describe('WorldStore', () => {
getCurrentLocation: ReturnType<typeof vi.fn>;
getCurrentTravel: ReturnType<typeof vi.fn>;
startTravel: ReturnType<typeof vi.fn>;
getLocationNpcs: ReturnType<typeof vi.fn>;
};
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([]);
});
});
});

View File

@@ -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<CurrentTravel | null>(null);
private readonly remainingSecondsState = signal<number | null>(null);
private readonly arrivedState = signal<LocationSummary | null>(null);
private readonly locationNpcsState = signal<NpcSummary[]>([]);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
private countdownTimer: ReturnType<typeof setInterval> | 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<void> {
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<void> {
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 {