From ff8b7c8b78ab0a71fa6b5c43413e04fd0f915ce6 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 22 Aug 2026 23:19:16 +0200 Subject: [PATCH] feat(web): run quest steps from the NPC screen Co-Authored-By: Claude Opus 5 --- .../features/npc/merchant-page.component.html | 66 ++++++ .../features/npc/merchant-page.component.scss | 60 +++++ .../npc/merchant-page.component.spec.ts | 210 ++++++++++++++++++ .../features/npc/merchant-page.component.ts | 46 +++- .../app/features/npc/merchant.store.spec.ts | 182 ++++++++++++++- .../src/app/features/npc/merchant.store.ts | 113 +++++++++- 6 files changed, 667 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/features/npc/merchant-page.component.html b/apps/web/src/app/features/npc/merchant-page.component.html index 3841a9c..51273db 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.html +++ b/apps/web/src/app/features/npc/merchant-page.component.html @@ -41,6 +41,7 @@ [class.merchant__button--active]=" (action.type === 'OPEN_EXCHANGE' && isPanel('EXCHANGE')) || (action.type === 'OPEN_SHOP' && isPanel('SHOP')) || + (action.type === 'VIEW_QUESTS' && isPanel('QUESTS')) || (action.type === 'TALK' && isPanel('DIALOGUE')) " [attr.data-action]="action.type" @@ -58,6 +59,71 @@ } + @if (store.questLine(); as line) { +
+ {{ line }} +
+ } + + @if (store.grantedBag(); as bag) { +
+

New Loot Bag

+

{{ bag.name }}

+

{{ capacityLabel(bag) }}

+ +
+ } + + @if (isPanel('QUESTS')) { +
+

Matters at Hand

+ + @for (quest of store.quests(); track quest.key) { +
+

{{ quest.title }}

+

{{ quest.description }}

+ + @if (currentObjective(quest); as objective) { + + } + + @if (quest.status === 'AVAILABLE') { + + } @else if (quest.status === 'ACTIVE' && isCurrentStepHere(quest)) { + + } +
+ } @empty { +

There is nothing to discuss.

+ } +
+ } + @if (isPanel('EXCHANGE') && store.exchange(); as exchange) {

{{ exchange.profileName }}

diff --git a/apps/web/src/app/features/npc/merchant-page.component.scss b/apps/web/src/app/features/npc/merchant-page.component.scss index 9e6f792..9613549 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.scss +++ b/apps/web/src/app/features/npc/merchant-page.component.scss @@ -391,6 +391,66 @@ 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) { .merchant__identity { grid-template-columns: 1fr; diff --git a/apps/web/src/app/features/npc/merchant-page.component.spec.ts b/apps/web/src/app/features/npc/merchant-page.component.spec.ts index a8cc064..308f5e5 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.spec.ts +++ b/apps/web/src/app/features/npc/merchant-page.component.spec.ts @@ -7,6 +7,8 @@ import type { ExchangeResult, ExchangeView, NpcInteraction, + QuestInteractionResult, + QuestView, ShopView, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; @@ -529,3 +531,211 @@ describe('MerchantPageComponent', () => { 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 { + 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 = {}, +): Promise<{ + fixture: ComponentFixture; + element: HTMLElement; + api: Record>; +}> { + 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('[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('[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('[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'); + }); +}); diff --git a/apps/web/src/app/features/npc/merchant-page.component.ts b/apps/web/src/app/features/npc/merchant-page.component.ts index 2a23a51..04f7549 100644 --- a/apps/web/src/app/features/npc/merchant-page.component.ts +++ b/apps/web/src/app/features/npc/merchant-page.component.ts @@ -1,6 +1,12 @@ import { Component, OnInit, inject } from '@angular/core'; 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 { QuestObjectiveLineComponent } from '../quests/quest-objective-line.component'; import { WorldStore } from '../world/world.store'; import { MerchantPanel, MerchantStore } from './merchant.store'; @@ -14,7 +20,7 @@ import { MerchantPanel, MerchantStore } from './merchant.store'; */ @Component({ selector: 'app-merchant-page', - imports: [LootCapacityStripComponent], + imports: [LootCapacityStripComponent, QuestObjectiveLineComponent], templateUrl: './merchant-page.component.html', styleUrl: './merchant-page.component.scss', }) @@ -49,13 +55,47 @@ export class MerchantPageComponent implements OnInit { case 'TALK': this.store.showPanel('DIALOGUE'); return; + case 'VIEW_QUESTS': + this.store.showPanel('QUESTS'); + return; default: - // VIEW_QUESTS has no screen until Slice 0.9. Ignored rather than - // rendered as a button that does nothing. 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 { return this.store.panel() === panel; } diff --git a/apps/web/src/app/features/npc/merchant.store.spec.ts b/apps/web/src/app/features/npc/merchant.store.spec.ts index 8e22aaf..20e5000 100644 --- a/apps/web/src/app/features/npc/merchant.store.spec.ts +++ b/apps/web/src/app/features/npc/merchant.store.spec.ts @@ -6,6 +6,8 @@ import type { ExchangeResult, ExchangeView, NpcInteraction, + QuestInteractionResult, + QuestView, ShopOfferView, ShopView, } from '../../core/api/game-api.models'; @@ -13,11 +15,9 @@ import { GameApiService } from '../../core/api/game-api.service'; import { MerchantStore } from './merchant.store'; function interaction( - actionTypes: Array<'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE'> = [ - 'TALK', - 'OPEN_SHOP', - 'OPEN_EXCHANGE', - ], + actionTypes: Array< + 'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE' | 'VIEW_QUESTS' + > = ['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE'], ): NpcInteraction { return { npc: { @@ -159,10 +159,49 @@ function createApi( silverBalance: 88, }), ), + getQuests: vi.fn(() => of([questView()])), + acceptQuest: vi.fn(() => of(questResult())), + advanceQuest: vi.fn(() => of(questResult())), ...rest, }; } +function questView(overrides: Partial = {}): 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 { + return { + quest: questView(), + npcLine: 'Take this.', + grantedBag: null, + consumedItems: [], + rewards: null, + ...overrides, + }; +} + function createStore(api: ReturnType): MerchantStore { TestBed.configureTestingModule({ providers: [{ provide: GameApiService, useValue: api }], @@ -454,4 +493,137 @@ describe('MerchantStore', () => { 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); + }); }); diff --git a/apps/web/src/app/features/npc/merchant.store.ts b/apps/web/src/app/features/npc/merchant.store.ts index 77be705..1893f19 100644 --- a/apps/web/src/app/features/npc/merchant.store.ts +++ b/apps/web/src/app/features/npc/merchant.store.ts @@ -1,14 +1,18 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, computed, inject, signal } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; +import { Observable, firstValueFrom } from 'rxjs'; import { ExchangeResult, ExchangeView, + GrantedLootBag, NpcInteraction, + QuestInteractionResult, + QuestView, ShopPurchaseResult, ShopView, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; +import { QuestStore } from '../quests/quest.store'; import { WorldStore } from '../world/world.store'; const GENERIC_ERROR = "That isn't possible right now."; @@ -33,9 +37,15 @@ const ERROR_MESSAGES: Readonly> = { MERCHANT_REPUTATION_TOO_LOW: 'You have not earned enough standing for this yet.', SHOP_BAG_ALREADY_OWNED: 'You already carry that.', 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). @@ -49,6 +59,7 @@ export type MerchantPanel = 'DIALOGUE' | 'EXCHANGE' | 'SHOP'; export class MerchantStore { private readonly api = inject(GameApiService); private readonly worldStore = inject(WorldStore); + private readonly questStore = inject(QuestStore); private readonly interactionState = signal(null); private readonly exchangeState = signal(null); @@ -62,6 +73,9 @@ export class MerchantStore { private readonly lastPurchaseState = signal(null); private readonly selectionState = signal>({}); private readonly newlyUnlockedState = signal([]); + private readonly questsState = signal([]); + private readonly questLineState = signal(null); + private readonly grantedBagState = signal(null); readonly interaction = this.interactionState.asReadonly(); readonly exchange = this.exchangeState.asReadonly(); @@ -75,6 +89,12 @@ export class MerchantStore { readonly lastPurchase = this.lastPurchaseState.asReadonly(); readonly selection = this.selectionState.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. */ readonly hasSelection = computed(() => @@ -115,6 +135,9 @@ export class MerchantStore { this.lastPurchaseState.set(null); this.selectionState.set({}); this.newlyUnlockedState.set([]); + this.questsState.set([]); + this.questLineState.set(null); + this.grantedBagState.set(null); this.panelState.set('DIALOGUE'); try { @@ -137,16 +160,102 @@ export class MerchantStore { ? await firstValueFrom(this.api.getShop(npcKey)) : null, ); + this.questsState.set( + actions.includes('VIEW_QUESTS') + ? await firstValueFrom(this.api.getQuests()) + : [], + ); } catch (error) { this.interactionState.set(null); this.exchangeState.set(null); this.shopState.set(null); + this.questsState.set([]); this.errorState.set(this.toMessage(error)); } finally { this.loadingState.set(false); } } + /** Takes a quest on and shows what changed (slice §3). */ + async acceptQuest(questKey: string): Promise { + 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 { + 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, + ): Promise { + 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 { this.panelState.set(panel); this.actionErrorState.set(null);