feat(web): run quest steps from the NPC screen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 @@
|
||||
<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) {
|
||||
<section class="merchant__panel" aria-label="Trade in goods">
|
||||
<h2 class="merchant__panel-title">{{ exchange.profileName }}</h2>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {}): 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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 {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
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<NpcInteraction | 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 selectionState = signal<Record<string, number>>({});
|
||||
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 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<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 {
|
||||
this.panelState.set(panel);
|
||||
this.actionErrorState.set(null);
|
||||
|
||||
Reference in New Issue
Block a user