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 @@
{{ store.actionError() }}
}
+ @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);