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 loading = this.worldStore.loading;
|
||||||
readonly error = this.worldStore.error;
|
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 interactionResult = this.interactionResultState.asReadonly();
|
||||||
readonly interactionError = this.interactionErrorState.asReadonly();
|
readonly interactionError = this.interactionErrorState.asReadonly();
|
||||||
/** Key of the interaction currently in flight, so only that control busies. */
|
/** Key of the interaction currently in flight, so only that control busies. */
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<app-location-poi
|
<app-location-poi
|
||||||
[poi]="poi"
|
[poi]="poi"
|
||||||
[busy]="busy"
|
[busy]="busy"
|
||||||
|
[markers]="store.markersFor(poi.npcKey)"
|
||||||
(activate)="activatePoi($event)"
|
(activate)="activatePoi($event)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { vi } from 'vitest';
|
|||||||
import type {
|
import type {
|
||||||
CurrentLocationResponse,
|
CurrentLocationResponse,
|
||||||
LocationInteractionResult,
|
LocationInteractionResult,
|
||||||
|
NpcMarker,
|
||||||
} from '../../../core/api/game-api.models';
|
} from '../../../core/api/game-api.models';
|
||||||
import { burnedRoadFixture, southGateFixture } from '../current-location.fixture';
|
import { burnedRoadFixture, southGateFixture } from '../current-location.fixture';
|
||||||
import { LocalLocationStore } from '../local-location.store';
|
import { LocalLocationStore } from '../local-location.store';
|
||||||
@@ -26,9 +27,16 @@ describe('LocationPageComponent', () => {
|
|||||||
load: ReturnType<typeof vi.fn>;
|
load: ReturnType<typeof vi.fn>;
|
||||||
runInteraction: ReturnType<typeof vi.fn>;
|
runInteraction: ReturnType<typeof vi.fn>;
|
||||||
closeInteraction: 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);
|
location = signal(current);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
interactionResult = signal<LocationInteractionResult | null>(null);
|
interactionResult = signal<LocationInteractionResult | null>(null);
|
||||||
@@ -44,6 +52,7 @@ describe('LocationPageComponent', () => {
|
|||||||
load: vi.fn().mockResolvedValue(undefined),
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
runInteraction: vi.fn().mockResolvedValue(undefined),
|
runInteraction: vi.fn().mockResolvedValue(undefined),
|
||||||
closeInteraction: vi.fn(),
|
closeInteraction: vi.fn(),
|
||||||
|
markersFor: vi.fn((npcKey?: string) => markers[npcKey ?? ''] ?? []),
|
||||||
};
|
};
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -238,20 +247,43 @@ describe('LocationPageComponent', () => {
|
|||||||
expect(element.querySelectorAll('app-location-poi')).toHaveLength(1);
|
expect(element.querySelectorAll('app-location-poi')).toHaveLength(1);
|
||||||
expect(element.querySelectorAll('[data-action]')).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 {
|
return {
|
||||||
pointsOfInterest: [
|
pointsOfInterest: [
|
||||||
{
|
{
|
||||||
key: 'gate-watch',
|
key: 'gate-watch',
|
||||||
title: 'Gate Watch',
|
title: 'Halvik, Warden of the South Gate',
|
||||||
actionLabel: 'Talk',
|
actionLabel: 'Talk',
|
||||||
type: 'NPC',
|
type: 'NPC',
|
||||||
iconKey: 'speak',
|
iconKey: 'speak',
|
||||||
xPercent: 45,
|
xPercent: 45,
|
||||||
yPercent: 52,
|
yPercent: 52,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
...(withNpcKey ? { npcKey: 'south-gate-warden' } : {}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
primaryActions: [
|
primaryActions: [
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
[class.location-poi--busy]="busy"
|
[class.location-poi--busy]="busy"
|
||||||
[disabled]="!poi.enabled || busy"
|
[disabled]="!poi.enabled || busy"
|
||||||
[attr.data-poi]="poi.key"
|
[attr.data-poi]="poi.key"
|
||||||
[attr.aria-label]="poi.actionLabel ? poi.title + ': ' + poi.actionLabel : poi.title"
|
[attr.aria-label]="accessibleLabel"
|
||||||
(click)="onActivate()"
|
(click)="onActivate()"
|
||||||
>
|
>
|
||||||
<span class="location-poi__medallion">
|
<span class="location-poi__medallion">
|
||||||
<app-location-icon [iconKey]="poi.iconKey" />
|
<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>
|
||||||
<span class="location-poi__title">{{ poi.title }}</span>
|
<span class="location-poi__title">{{ poi.title }}</span>
|
||||||
@if (poi.actionLabel) {
|
@if (poi.actionLabel) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.location-poi__medallion {
|
.location-poi__medallion {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
inline-size: 2.6rem;
|
inline-size: 2.6rem;
|
||||||
@@ -39,6 +40,26 @@
|
|||||||
0 0 0.85rem rgb(201 164 95 / 0.28);
|
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 {
|
.location-poi__title {
|
||||||
max-inline-size: 11rem;
|
max-inline-size: 11rem;
|
||||||
font-family: Georgia, 'Times New Roman', serif;
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
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';
|
import { LocationPoiComponent } from './location-poi.component';
|
||||||
|
|
||||||
const wagon: LocationPointOfInterest = {
|
const wagon: LocationPointOfInterest = {
|
||||||
@@ -16,6 +19,7 @@ const wagon: LocationPointOfInterest = {
|
|||||||
async function setup(
|
async function setup(
|
||||||
poi: LocationPointOfInterest = wagon,
|
poi: LocationPointOfInterest = wagon,
|
||||||
busy = false,
|
busy = false,
|
||||||
|
markers: NpcMarker[] = [],
|
||||||
): Promise<{
|
): Promise<{
|
||||||
fixture: ComponentFixture<LocationPoiComponent>;
|
fixture: ComponentFixture<LocationPoiComponent>;
|
||||||
activated: LocationPointOfInterest[];
|
activated: LocationPointOfInterest[];
|
||||||
@@ -28,6 +32,7 @@ async function setup(
|
|||||||
const fixture = TestBed.createComponent(LocationPoiComponent);
|
const fixture = TestBed.createComponent(LocationPoiComponent);
|
||||||
fixture.componentRef.setInput('poi', poi);
|
fixture.componentRef.setInput('poi', poi);
|
||||||
fixture.componentRef.setInput('busy', busy);
|
fixture.componentRef.setInput('busy', busy);
|
||||||
|
fixture.componentRef.setInput('markers', markers);
|
||||||
|
|
||||||
const activated: LocationPointOfInterest[] = [];
|
const activated: LocationPointOfInterest[] = [];
|
||||||
fixture.componentInstance.activate.subscribe((value) => activated.push(value));
|
fixture.componentInstance.activate.subscribe((value) => activated.push(value));
|
||||||
@@ -113,4 +118,48 @@ describe('LocationPoiComponent', () => {
|
|||||||
|
|
||||||
expect(button.getAttribute('aria-label')).toBe('Abandoned Wagon');
|
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 { 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';
|
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.
|
* One hotspot pinned to the location artwork.
|
||||||
*
|
*
|
||||||
@@ -24,8 +48,30 @@ import { LocationIconComponent } from '../location-icon/location-icon.component'
|
|||||||
export class LocationPoiComponent {
|
export class LocationPoiComponent {
|
||||||
@Input({ required: true }) poi!: LocationPointOfInterest;
|
@Input({ required: true }) poi!: LocationPointOfInterest;
|
||||||
@Input() busy = false;
|
@Input() busy = false;
|
||||||
|
/** Markers for the NPC behind this hotspot, if it has one. */
|
||||||
|
@Input() markers: NpcMarker[] = [];
|
||||||
@Output() readonly activate = new EventEmitter<LocationPointOfInterest>();
|
@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 {
|
protected onActivate(): void {
|
||||||
if (this.poi.enabled && !this.busy) {
|
if (this.poi.enabled && !this.busy) {
|
||||||
this.activate.emit(this.poi);
|
this.activate.emit(this.poi);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe('WorldStore', () => {
|
|||||||
getCurrentLocation: ReturnType<typeof vi.fn>;
|
getCurrentLocation: ReturnType<typeof vi.fn>;
|
||||||
getCurrentTravel: ReturnType<typeof vi.fn>;
|
getCurrentTravel: ReturnType<typeof vi.fn>;
|
||||||
startTravel: ReturnType<typeof vi.fn>;
|
startTravel: ReturnType<typeof vi.fn>;
|
||||||
|
getLocationNpcs: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
let store: WorldStore;
|
let store: WorldStore;
|
||||||
|
|
||||||
@@ -73,6 +74,18 @@ describe('WorldStore', () => {
|
|||||||
getCurrentLocation: vi.fn(() => of(currentLocation)),
|
getCurrentLocation: vi.fn(() => of(currentLocation)),
|
||||||
getCurrentTravel: vi.fn(() => of({ status: 'IDLE' } satisfies CurrentTravel)),
|
getCurrentTravel: vi.fn(() => of({ status: 'IDLE' } satisfies CurrentTravel)),
|
||||||
startTravel: vi.fn(() => of(travelling)),
|
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({
|
TestBed.configureTestingModule({
|
||||||
@@ -441,4 +454,35 @@ describe('WorldStore', () => {
|
|||||||
expect(store.displayedCharacter()?.currentHp).toBe(14);
|
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,
|
CurrentLocationResponse,
|
||||||
CurrentTravel,
|
CurrentTravel,
|
||||||
LocationSummary,
|
LocationSummary,
|
||||||
|
NpcMarker,
|
||||||
|
NpcSummary,
|
||||||
} from '../../core/api/game-api.models';
|
} from '../../core/api/game-api.models';
|
||||||
import { GameApiService } from '../../core/api/game-api.service';
|
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 currentTravelState = signal<CurrentTravel | null>(null);
|
||||||
private readonly remainingSecondsState = signal<number | null>(null);
|
private readonly remainingSecondsState = signal<number | null>(null);
|
||||||
private readonly arrivedState = signal<LocationSummary | null>(null);
|
private readonly arrivedState = signal<LocationSummary | null>(null);
|
||||||
|
private readonly locationNpcsState = signal<NpcSummary[]>([]);
|
||||||
private readonly loadingState = signal(false);
|
private readonly loadingState = signal(false);
|
||||||
private readonly errorState = signal<string | null>(null);
|
private readonly errorState = signal<string | null>(null);
|
||||||
private countdownTimer: ReturnType<typeof setInterval> | undefined;
|
private countdownTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
@@ -48,9 +51,21 @@ export class WorldStore implements OnDestroy {
|
|||||||
readonly arrived = this.arrivedState.asReadonly();
|
readonly arrived = this.arrivedState.asReadonly();
|
||||||
readonly loading = this.loadingState.asReadonly();
|
readonly loading = this.loadingState.asReadonly();
|
||||||
readonly error = this.errorState.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) {}
|
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> {
|
async load(): Promise<void> {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
@@ -68,6 +83,7 @@ export class WorldStore implements OnDestroy {
|
|||||||
this.applyCharacter(character);
|
this.applyCharacter(character);
|
||||||
this.currentLocationState.set(location);
|
this.currentLocationState.set(location);
|
||||||
this.selectedConnectionState.set(null);
|
this.selectedConnectionState.set(null);
|
||||||
|
await this.loadLocationNpcs(location);
|
||||||
await this.setCurrentTravel(travel);
|
await this.setCurrentTravel(travel);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.destroyed) {
|
if (!this.destroyed) {
|
||||||
@@ -266,6 +282,29 @@ export class WorldStore implements OnDestroy {
|
|||||||
this.applyCharacter(character);
|
this.applyCharacter(character);
|
||||||
this.currentLocationState.set(location);
|
this.currentLocationState.set(location);
|
||||||
this.selectedConnectionState.set(null);
|
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 {
|
private applyCharacter(character: CharacterResponse): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user