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) {
+ {{
+ questBadge.glyph
+ }}
+ }
{{ 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 {