feat(web): run quest steps from the NPC screen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 23:19:16 +02:00
parent bc0d910190
commit ff8b7c8b78
6 changed files with 667 additions and 10 deletions

View File

@@ -41,6 +41,7 @@
[class.merchant__button--active]=" [class.merchant__button--active]="
(action.type === 'OPEN_EXCHANGE' && isPanel('EXCHANGE')) || (action.type === 'OPEN_EXCHANGE' && isPanel('EXCHANGE')) ||
(action.type === 'OPEN_SHOP' && isPanel('SHOP')) || (action.type === 'OPEN_SHOP' && isPanel('SHOP')) ||
(action.type === 'VIEW_QUESTS' && isPanel('QUESTS')) ||
(action.type === 'TALK' && isPanel('DIALOGUE')) (action.type === 'TALK' && isPanel('DIALOGUE'))
" "
[attr.data-action]="action.type" [attr.data-action]="action.type"
@@ -58,6 +59,71 @@
<p class="merchant__error" role="alert">{{ store.actionError() }}</p> <p class="merchant__error" role="alert">{{ store.actionError() }}</p>
} }
@if (store.questLine(); as line) {
<blockquote class="merchant__dialogue" data-quest-line aria-live="polite">
{{ line }}
</blockquote>
}
@if (store.grantedBag(); as bag) {
<section class="bag-granted" data-granted-bag aria-live="polite">
<h2 class="bag-granted__heading">New Loot Bag</h2>
<p class="bag-granted__name">{{ bag.name }}</p>
<p class="bag-granted__capacity">{{ capacityLabel(bag) }}</p>
<button
type="button"
class="merchant__link"
(click)="store.dismissGrantedBag()"
>
Dismiss
</button>
</section>
}
@if (isPanel('QUESTS')) {
<section class="merchant__panel" aria-label="Quests">
<h2 class="merchant__panel-title">Matters at Hand</h2>
@for (quest of store.quests(); track quest.key) {
<article class="npc-quest" [attr.data-quest]="quest.key">
<h3 class="npc-quest__title">{{ quest.title }}</h3>
<p class="npc-quest__description">{{ quest.description }}</p>
@if (currentObjective(quest); as objective) {
<app-quest-objective-line
[objective]="objective"
[hint]="quest.hint"
/>
}
@if (quest.status === 'AVAILABLE') {
<button
type="button"
class="merchant__button"
data-quest-accept
[disabled]="store.pending() !== null"
(click)="store.acceptQuest(quest.key)"
>
Accept
</button>
} @else if (quest.status === 'ACTIVE' && isCurrentStepHere(quest)) {
<button
type="button"
class="merchant__button"
data-quest-advance
[disabled]="store.pending() !== null"
(click)="store.advanceQuest(quest.key)"
>
Continue
</button>
}
</article>
} @empty {
<p class="merchant__empty">There is nothing to discuss.</p>
}
</section>
}
@if (isPanel('EXCHANGE') && store.exchange(); as exchange) { @if (isPanel('EXCHANGE') && store.exchange(); as exchange) {
<section class="merchant__panel" aria-label="Trade in goods"> <section class="merchant__panel" aria-label="Trade in goods">
<h2 class="merchant__panel-title">{{ exchange.profileName }}</h2> <h2 class="merchant__panel-title">{{ exchange.profileName }}</h2>

View File

@@ -391,6 +391,66 @@
font-size: var(--ar-font-sm); font-size: var(--ar-font-sm);
} }
/* ---------- quests ---------- */
.npc-quest + .npc-quest {
padding-block-start: var(--ar-space-4);
border-block-start: 1px solid rgb(85 74 57 / 0.5);
}
.npc-quest__title {
margin: 0 0 var(--ar-space-1);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.05rem;
font-weight: 400;
letter-spacing: 0.03em;
}
.npc-quest__description {
margin: 0 0 var(--ar-space-3);
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
line-height: 1.6;
}
.npc-quest .merchant__button {
margin-block-start: var(--ar-space-3);
}
/* The one moment the slice is actually about (§12). Loud enough to notice,
quiet enough not to be a modal -- the same restraint the unlock line uses. */
.bag-granted {
padding: var(--ar-space-3) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md);
background: var(--ar-panel-muted);
}
.bag-granted__heading {
margin: 0;
color: var(--ar-text-muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.78rem;
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.bag-granted__name {
margin: var(--ar-space-1) 0 0;
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.15rem;
}
.bag-granted__capacity {
margin: 0 0 var(--ar-space-2);
color: var(--ar-text);
font-size: var(--ar-font-sm);
font-variant-numeric: tabular-nums;
}
@media (max-width: 40rem) { @media (max-width: 40rem) {
.merchant__identity { .merchant__identity {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -7,6 +7,8 @@ import type {
ExchangeResult, ExchangeResult,
ExchangeView, ExchangeView,
NpcInteraction, NpcInteraction,
QuestInteractionResult,
QuestView,
ShopView, ShopView,
} 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';
@@ -529,3 +531,211 @@ describe('MerchantPageComponent', () => {
expect(button.textContent).toContain('Buy'); expect(button.textContent).toContain('Buy');
}); });
}); });
/** Borin, but with a quest to talk about (Playable Slice 0.9 §6). */
function questInteraction(): NpcInteraction {
return {
...INTERACTION,
availableActions: [
...INTERACTION.availableActions,
{ type: 'VIEW_QUESTS', label: 'Quests', key: null },
],
};
}
function questView(overrides: Partial<QuestView> = {}): QuestView {
return {
key: 'trouble-beyond-the-gate',
title: 'Trouble Beyond the Gate',
description: 'The warden wants five Ashen Pelts.',
status: 'ACTIVE',
objectives: [
{
key: 'collect-bag',
description: 'Speak with Borin in Graufurt',
type: 'TALK_TO_NPC',
targetKey: 'borin-quartermaster',
required: 1,
current: 0,
completed: false,
},
],
currentObjectiveKey: 'collect-bag',
hint: null,
...overrides,
};
}
async function renderWithQuest(
quest: QuestView,
advanceResult: Partial<QuestInteractionResult> = {},
): Promise<{
fixture: ComponentFixture<MerchantPageComponent>;
element: HTMLElement;
api: Record<string, ReturnType<typeof vi.fn>>;
}> {
const api = {
getNpcInteraction: vi.fn(() => of(questInteraction())),
getTradeIn: vi.fn(() => of(EXCHANGE)),
getShop: vi.fn(() => of(SHOP)),
tradeIn: vi.fn(() => of(TRADE_RESULT)),
purchase: vi.fn(() => of({})),
getQuests: vi.fn(() => of([quest])),
getCharacter: vi.fn(() => of({})),
acceptQuest: vi.fn(() =>
of({
quest,
npcLine: null,
grantedBag: null,
consumedItems: [],
rewards: null,
}),
),
advanceQuest: vi.fn(() =>
of({
quest,
npcLine: 'Take this.',
grantedBag: null,
consumedItems: [],
rewards: null,
...advanceResult,
}),
),
};
await TestBed.configureTestingModule({
imports: [MerchantPageComponent],
providers: [
provideZonelessChangeDetection(),
{ provide: GameApiService, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: { get: () => 'borin-quartermaster' } },
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(MerchantPageComponent);
fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
element
.querySelector<HTMLButtonElement>('[data-action="VIEW_QUESTS"]')
?.click();
fixture.detectChanges();
return { fixture, element, api };
}
describe('MerchantPageComponent quests panel', () => {
it('offers a quests panel when the server says so', async () => {
const { element } = await renderWithQuest(questView());
expect(element.querySelector('[data-action="VIEW_QUESTS"]')).not.toBeNull();
expect(element.textContent).toContain('Trouble Beyond the Gate');
expect(element.textContent).toContain('Speak with Borin in Graufurt');
});
it('offers Accept for a quest that is not started', async () => {
const { element } = await renderWithQuest(
questView({ status: 'AVAILABLE', currentObjectiveKey: null }),
);
expect(element.querySelector('[data-quest-accept]')).not.toBeNull();
expect(element.querySelector('[data-quest-advance]')).toBeNull();
});
it('offers Continue when this NPC is the current step', async () => {
const { element } = await renderWithQuest(questView());
expect(element.querySelector('[data-quest-advance]')).not.toBeNull();
expect(element.querySelector('[data-quest-accept]')).toBeNull();
});
it('offers nothing when the step is somewhere else', async () => {
// The warden's step, seen from Borin's screen.
const { element } = await renderWithQuest(
questView({
objectives: [
{
key: 'turn-in',
description: 'Bring the pelts to the South Gate Warden',
type: 'TALK_TO_NPC',
targetKey: 'south-gate-warden',
required: 1,
current: 0,
completed: false,
},
],
currentObjectiveKey: 'turn-in',
}),
);
expect(element.querySelector('[data-quest-advance]')).toBeNull();
expect(element.querySelector('[data-quest-accept]')).toBeNull();
});
it('shows the line the step returned', async () => {
const { fixture, element } = await renderWithQuest(questView());
element.querySelector<HTMLButtonElement>('[data-quest-advance]')?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(
element.querySelector('[data-quest-line]')?.textContent,
).toContain('Take this.');
});
it('announces the new loot bag with its capacity', async () => {
const { fixture, element } = await renderWithQuest(questView(), {
grantedBag: {
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: 'HIDE',
capacity: 5,
},
});
element.querySelector<HTMLButtonElement>('[data-quest-advance]')?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
// The exact block Slice 0.9 §12 prints.
const notice = element.querySelector('[data-granted-bag]');
expect(notice?.textContent).toContain('New Loot Bag');
expect(notice?.textContent).toContain('Basic Hide Bag');
expect(notice?.textContent).toContain('Hide Capacity: 5');
});
it('renders the blocked hint on the current objective', async () => {
const { element } = await renderWithQuest(
questView({
objectives: [
{
key: 'collect-pelts-first',
description: 'Collect Ashen Pelts',
type: 'COLLECT_ITEM',
targetKey: 'ash-pelt',
required: 5,
current: 1,
completed: false,
},
],
currentObjectiveKey: 'collect-pelts-first',
hint: 'You cannot carry enough pelts. Return to the South Gate Warden.',
}),
);
expect(
element.querySelector('[data-objective-hint]')?.textContent,
).toContain('You cannot carry enough pelts.');
expect(
element.querySelector('[data-objective-progress]')?.textContent?.trim(),
).toBe('1 / 5');
});
});

View File

@@ -1,6 +1,12 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import {
GrantedLootBag,
QuestObjectiveView,
QuestView,
} from '../../core/api/game-api.models';
import { LootCapacityStripComponent } from '../../shared/loot-capacity-strip/loot-capacity-strip.component'; import { LootCapacityStripComponent } from '../../shared/loot-capacity-strip/loot-capacity-strip.component';
import { QuestObjectiveLineComponent } from '../quests/quest-objective-line.component';
import { WorldStore } from '../world/world.store'; import { WorldStore } from '../world/world.store';
import { MerchantPanel, MerchantStore } from './merchant.store'; import { MerchantPanel, MerchantStore } from './merchant.store';
@@ -14,7 +20,7 @@ import { MerchantPanel, MerchantStore } from './merchant.store';
*/ */
@Component({ @Component({
selector: 'app-merchant-page', selector: 'app-merchant-page',
imports: [LootCapacityStripComponent], imports: [LootCapacityStripComponent, QuestObjectiveLineComponent],
templateUrl: './merchant-page.component.html', templateUrl: './merchant-page.component.html',
styleUrl: './merchant-page.component.scss', styleUrl: './merchant-page.component.scss',
}) })
@@ -49,13 +55,47 @@ export class MerchantPageComponent implements OnInit {
case 'TALK': case 'TALK':
this.store.showPanel('DIALOGUE'); this.store.showPanel('DIALOGUE');
return; return;
case 'VIEW_QUESTS':
this.store.showPanel('QUESTS');
return;
default: default:
// VIEW_QUESTS has no screen until Slice 0.9. Ignored rather than
// rendered as a button that does nothing.
return; return;
} }
} }
/** The step the player is on, or null when the quest is not started. */
protected currentObjective(quest: QuestView): QuestObjectiveView | null {
return (
quest.objectives.find(
(objective) => objective.key === quest.currentObjectiveKey,
) ?? null
);
}
/**
* Whether this NPC is the one the current step is waiting on.
*
* The server refuses a step at the wrong person anyway; this is what keeps
* the screen from offering a button that is going to be refused.
*/
protected isCurrentStepHere(quest: QuestView): boolean {
const objective = this.currentObjective(quest);
const npcKey = this.store.interaction()?.npc.key;
return (
objective?.type === 'TALK_TO_NPC' && objective.targetKey === npcKey
);
}
/** "Hide Capacity: 5" from whatever category the bag covers (slice §12). */
protected capacityLabel(bag: GrantedLootBag): string {
const category = bag.lootCategory
.toLowerCase()
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
return `${category} Capacity: ${bag.capacity}`;
}
protected isPanel(panel: MerchantPanel): boolean { protected isPanel(panel: MerchantPanel): boolean {
return this.store.panel() === panel; return this.store.panel() === panel;
} }

View File

@@ -6,6 +6,8 @@ import type {
ExchangeResult, ExchangeResult,
ExchangeView, ExchangeView,
NpcInteraction, NpcInteraction,
QuestInteractionResult,
QuestView,
ShopOfferView, ShopOfferView,
ShopView, ShopView,
} from '../../core/api/game-api.models'; } from '../../core/api/game-api.models';
@@ -13,11 +15,9 @@ import { GameApiService } from '../../core/api/game-api.service';
import { MerchantStore } from './merchant.store'; import { MerchantStore } from './merchant.store';
function interaction( function interaction(
actionTypes: Array<'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE'> = [ actionTypes: Array<
'TALK', 'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE' | 'VIEW_QUESTS'
'OPEN_SHOP', > = ['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE'],
'OPEN_EXCHANGE',
],
): NpcInteraction { ): NpcInteraction {
return { return {
npc: { npc: {
@@ -159,10 +159,49 @@ function createApi(
silverBalance: 88, silverBalance: 88,
}), }),
), ),
getQuests: vi.fn(() => of([questView()])),
acceptQuest: vi.fn(() => of(questResult())),
advanceQuest: vi.fn(() => of(questResult())),
...rest, ...rest,
}; };
} }
function questView(overrides: Partial<QuestView> = {}): QuestView {
return {
key: 'trouble-beyond-the-gate',
title: 'Trouble Beyond the Gate',
description: 'Five pelts.',
status: 'ACTIVE',
objectives: [
{
key: 'collect-bag',
description: 'Speak with Borin in Graufurt',
type: 'TALK_TO_NPC',
targetKey: 'borin-quartermaster',
required: 1,
current: 0,
completed: false,
},
],
currentObjectiveKey: 'collect-bag',
hint: null,
...overrides,
};
}
function questResult(
overrides: Partial<QuestInteractionResult> = {},
): QuestInteractionResult {
return {
quest: questView(),
npcLine: 'Take this.',
grantedBag: null,
consumedItems: [],
rewards: null,
...overrides,
};
}
function createStore(api: ReturnType<typeof createApi>): MerchantStore { function createStore(api: ReturnType<typeof createApi>): MerchantStore {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [{ provide: GameApiService, useValue: api }], providers: [{ provide: GameApiService, useValue: api }],
@@ -454,4 +493,137 @@ describe('MerchantStore', () => {
expect(store.newlyUnlocked()).toEqual([]); expect(store.newlyUnlocked()).toEqual([]);
}); });
it('reads the quest log only when the NPC offers it', async () => {
const withoutQuests = createApi();
await createStore(withoutQuests).load('borin-quartermaster');
expect(withoutQuests.getQuests).not.toHaveBeenCalled();
TestBed.resetTestingModule();
const withQuests = createApi({
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))),
});
const store = createStore(withQuests);
await store.load('borin-quartermaster');
expect(withQuests.getQuests).toHaveBeenCalled();
expect(store.quests()).toHaveLength(1);
});
it('accepts a quest and re-reads the screen', async () => {
const api = createApi({
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))),
});
const store = createStore(api);
await store.load('borin-quartermaster');
await store.acceptQuest('trouble-beyond-the-gate');
expect(api.acceptQuest).toHaveBeenCalledWith(
'borin-quartermaster',
'trouble-beyond-the-gate',
);
// The step can change what this person says, so the interaction is re-read.
expect(api.getNpcInteraction).toHaveBeenCalledTimes(2);
});
it('advances a step and re-reads the shop and capacities with it', async () => {
// One step can set the referral flag, unlock the Hide Bag offer and raise
// HIDE capacity from 1 to 5 at once. Patching locally would miss two of
// the three.
const api = createApi({
getNpcInteraction: vi.fn(() =>
of(interaction(['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE', 'VIEW_QUESTS'])),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
await store.advanceQuest('trouble-beyond-the-gate');
expect(api.advanceQuest).toHaveBeenCalledWith(
'borin-quartermaster',
'trouble-beyond-the-gate',
);
expect(api.getShop).toHaveBeenCalledTimes(2);
expect(api.getTradeIn).toHaveBeenCalledTimes(2);
expect(api.getCharacter).toHaveBeenCalled();
});
it('surfaces the line the step returned', async () => {
const api = createApi({
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))),
});
const store = createStore(api);
await store.load('borin-quartermaster');
await store.advanceQuest('trouble-beyond-the-gate');
expect(store.questLine()).toBe('Take this.');
});
it('holds the granted bag until it is dismissed', async () => {
const api = createApi({
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))),
advanceQuest: vi.fn(() =>
of(
questResult({
grantedBag: {
key: 'basic-hide-bag',
name: 'Basic Hide Bag',
lootCategory: 'HIDE',
capacity: 5,
},
}),
),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
await store.advanceQuest('trouble-beyond-the-gate');
expect(store.grantedBag()?.name).toBe('Basic Hide Bag');
store.dismissGrantedBag();
expect(store.grantedBag()).toBeNull();
});
it('maps a quest error code to something the player can read', async () => {
const api = createApi({
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))),
advanceQuest: vi.fn(() =>
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { code: 'QUEST_STEP_NOT_HERE' },
}),
),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
await store.advanceQuest('trouble-beyond-the-gate');
expect(store.actionError()).toBe(
'This is not what the quest needs from you right now.',
);
});
it('ignores a second click while a step is still running', async () => {
const api = createApi({
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'VIEW_QUESTS']))),
});
const store = createStore(api);
await store.load('borin-quartermaster');
await Promise.all([
store.advanceQuest('trouble-beyond-the-gate'),
store.advanceQuest('trouble-beyond-the-gate'),
]);
// Turning in twice would try to consume the pelts twice.
expect(api.advanceQuest).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -1,14 +1,18 @@
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { Observable, firstValueFrom } from 'rxjs';
import { import {
ExchangeResult, ExchangeResult,
ExchangeView, ExchangeView,
GrantedLootBag,
NpcInteraction, NpcInteraction,
QuestInteractionResult,
QuestView,
ShopPurchaseResult, ShopPurchaseResult,
ShopView, ShopView,
} 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';
import { QuestStore } from '../quests/quest.store';
import { WorldStore } from '../world/world.store'; import { WorldStore } from '../world/world.store';
const GENERIC_ERROR = "That isn't possible right now."; const GENERIC_ERROR = "That isn't possible right now.";
@@ -33,9 +37,15 @@ const ERROR_MESSAGES: Readonly<Record<string, string>> = {
MERCHANT_REPUTATION_TOO_LOW: 'You have not earned enough standing for this yet.', MERCHANT_REPUTATION_TOO_LOW: 'You have not earned enough standing for this yet.',
SHOP_BAG_ALREADY_OWNED: 'You already carry that.', SHOP_BAG_ALREADY_OWNED: 'You already carry that.',
CHARACTER_NOT_FOUND: 'Your character could not be found.', CHARACTER_NOT_FOUND: 'Your character could not be found.',
QUEST_NOT_FOUND: 'This quest could not be found.',
QUEST_NOT_OFFERED_HERE: 'This person has nothing to ask of you.',
QUEST_ALREADY_ACCEPTED: 'You have already taken this on.',
QUEST_NOT_ACTIVE: 'You are not on this quest.',
QUEST_STEP_NOT_HERE: 'This is not what the quest needs from you right now.',
QUEST_OBJECTIVE_INCOMPLETE: 'You do not have what this step needs yet.',
}; };
export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP'; export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP' | 'QUESTS';
/** /**
* State for one merchant screen (Playable Slice 0.8). * State for one merchant screen (Playable Slice 0.8).
@@ -49,6 +59,7 @@ export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP';
export class MerchantStore { export class MerchantStore {
private readonly api = inject(GameApiService); private readonly api = inject(GameApiService);
private readonly worldStore = inject(WorldStore); private readonly worldStore = inject(WorldStore);
private readonly questStore = inject(QuestStore);
private readonly interactionState = signal<NpcInteraction | null>(null); private readonly interactionState = signal<NpcInteraction | null>(null);
private readonly exchangeState = signal<ExchangeView | null>(null); private readonly exchangeState = signal<ExchangeView | null>(null);
@@ -62,6 +73,9 @@ export class MerchantStore {
private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null); private readonly lastPurchaseState = signal<ShopPurchaseResult | null>(null);
private readonly selectionState = signal<Record<string, number>>({}); private readonly selectionState = signal<Record<string, number>>({});
private readonly newlyUnlockedState = signal<string[]>([]); private readonly newlyUnlockedState = signal<string[]>([]);
private readonly questsState = signal<QuestView[]>([]);
private readonly questLineState = signal<string | null>(null);
private readonly grantedBagState = signal<GrantedLootBag | null>(null);
readonly interaction = this.interactionState.asReadonly(); readonly interaction = this.interactionState.asReadonly();
readonly exchange = this.exchangeState.asReadonly(); readonly exchange = this.exchangeState.asReadonly();
@@ -75,6 +89,12 @@ export class MerchantStore {
readonly lastPurchase = this.lastPurchaseState.asReadonly(); readonly lastPurchase = this.lastPurchaseState.asReadonly();
readonly selection = this.selectionState.asReadonly(); readonly selection = this.selectionState.asReadonly();
readonly newlyUnlocked = this.newlyUnlockedState.asReadonly(); readonly newlyUnlocked = this.newlyUnlockedState.asReadonly();
/** The quests this NPC is involved in, as the server reported them. */
readonly quests = this.questsState.asReadonly();
/** What the NPC said for the step just performed (slice §5, §6, §8). */
readonly questLine = this.questLineState.asReadonly();
/** The bag a step just handed over, until it is dismissed (slice §12). */
readonly grantedBag = this.grantedBagState.asReadonly();
/** True once anything is selected, so the trade button can enable. */ /** True once anything is selected, so the trade button can enable. */
readonly hasSelection = computed(() => readonly hasSelection = computed(() =>
@@ -115,6 +135,9 @@ export class MerchantStore {
this.lastPurchaseState.set(null); this.lastPurchaseState.set(null);
this.selectionState.set({}); this.selectionState.set({});
this.newlyUnlockedState.set([]); this.newlyUnlockedState.set([]);
this.questsState.set([]);
this.questLineState.set(null);
this.grantedBagState.set(null);
this.panelState.set('DIALOGUE'); this.panelState.set('DIALOGUE');
try { try {
@@ -137,16 +160,102 @@ export class MerchantStore {
? await firstValueFrom(this.api.getShop(npcKey)) ? await firstValueFrom(this.api.getShop(npcKey))
: null, : null,
); );
this.questsState.set(
actions.includes('VIEW_QUESTS')
? await firstValueFrom(this.api.getQuests())
: [],
);
} catch (error) { } catch (error) {
this.interactionState.set(null); this.interactionState.set(null);
this.exchangeState.set(null); this.exchangeState.set(null);
this.shopState.set(null); this.shopState.set(null);
this.questsState.set([]);
this.errorState.set(this.toMessage(error)); this.errorState.set(this.toMessage(error));
} finally { } finally {
this.loadingState.set(false); this.loadingState.set(false);
} }
} }
/** Takes a quest on and shows what changed (slice §3). */
async acceptQuest(questKey: string): Promise<void> {
await this.runQuestStep(questKey, (npcKey) =>
this.api.acceptQuest(npcKey, questKey),
);
}
/** Performs whatever step this NPC is owed (slice §5, §6, §8). */
async advanceQuest(questKey: string): Promise<void> {
await this.runQuestStep(questKey, (npcKey) =>
this.api.advanceQuest(npcKey, questKey),
);
}
/**
* One shape for both quest calls, because both change the same things.
*
* A single step can rewrite the NPC's dialogue, unlock a shop offer through
* the referral flag and raise HIDE capacity from 1 to 5 all at once, so the
* screen is re-read rather than patched locally -- the server is the only
* place that knows all of it.
*/
private async runQuestStep(
questKey: string,
call: (npcKey: string) => Observable<QuestInteractionResult>,
): Promise<void> {
const npcKey = this.interactionState()?.npc.key;
if (!npcKey || this.pendingState() !== null) {
return;
}
this.pendingState.set(questKey);
this.actionErrorState.set(null);
this.newlyUnlockedState.set([]);
try {
const result = await firstValueFrom(call(npcKey));
this.questLineState.set(result.npcLine);
if (result.grantedBag) {
this.grantedBagState.set(result.grantedBag);
}
this.questStore.setQuest(result.quest);
const interaction = await firstValueFrom(
this.api.getNpcInteraction(npcKey),
);
this.interactionState.set(interaction);
const actions = interaction.availableActions.map((action) => action.type);
this.questsState.set(
actions.includes('VIEW_QUESTS')
? await firstValueFrom(this.api.getQuests())
: [],
);
this.exchangeState.set(
actions.includes('OPEN_EXCHANGE')
? await firstValueFrom(this.api.getTradeIn(npcKey))
: null,
);
this.shopState.set(
actions.includes('OPEN_SHOP')
? await firstValueFrom(this.api.getShop(npcKey))
: null,
);
// Reputation and Silver both change on turn-in, and the HUD reads them
// from the shared character state.
await this.worldStore.refreshCharacter();
} catch (error) {
this.actionErrorState.set(this.toMessage(error));
} finally {
this.pendingState.set(null);
}
}
dismissGrantedBag(): void {
this.grantedBagState.set(null);
}
showPanel(panel: MerchantPanel): void { showPanel(panel: MerchantPanel): void {
this.panelState.set(panel); this.panelState.set(panel);
this.actionErrorState.set(null); this.actionErrorState.set(null);