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]="
|
[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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user