feat(web): show quest markers on location hotspots
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<app-location-poi
|
||||
[poi]="poi"
|
||||
[busy]="busy"
|
||||
[markers]="store.markersFor(poi.npcKey)"
|
||||
(activate)="activatePoi($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
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<CurrentLocationResponse> {
|
||||
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: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user