feat(web): run quest steps from the NPC screen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 23:19:16 +02:00
parent bc0d910190
commit ff8b7c8b78
6 changed files with 667 additions and 10 deletions

View File

@@ -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);
});
});