feat(web): run quest steps from the NPC screen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user