Files
ashen-realms/apps/web/src/app/features/npc/merchant.store.spec.ts
Bastian Wagner ff8b7c8b78 feat(web): run quest steps from the NPC screen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:19:16 +02:00

630 lines
19 KiB
TypeScript

import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type {
ExchangeResult,
ExchangeView,
NpcInteraction,
QuestInteractionResult,
QuestView,
ShopOfferView,
ShopView,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { MerchantStore } from './merchant.store';
function interaction(
actionTypes: Array<
'TALK' | 'OPEN_SHOP' | 'OPEN_EXCHANGE' | 'VIEW_QUESTS'
> = ['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE'],
): NpcInteraction {
return {
npc: {
id: 'npc-1',
key: 'borin-quartermaster',
name: 'Borin',
title: 'Quartermaster of the Border Watch',
description: 'A broad, grey-bearded man.',
portraitPath: '/images/npcs/borin.png',
artworkPath: null,
capabilities: ['DIALOGUE', 'MERCHANT', 'RESOURCE_EXCHANGE'],
},
dialogue: { key: 'borin-default', text: 'Show me what you have.', responses: [] },
availableActions: actionTypes.map((type) => ({
type,
label: type,
key: type === 'TALK' ? null : 'some-key',
})),
};
}
function exchangeView(overrides: Partial<ExchangeView> = {}): ExchangeView {
return {
profileKey: 'borin-trade-in',
profileName: 'Border Watch Trade-In',
npcKey: 'borin-quartermaster',
offers: [
{
itemKey: 'ash-pelt',
itemName: 'Ashen Pelt',
iconPath: '/images/items/ash-pelt.png',
quantityCarried: 8,
inputQuantity: 1,
silverPerStep: 5,
reputationPerStep: 2,
factionKey: 'border-guard',
factionName: 'Border Watch',
renownMilestoneKey: 'first-goods-returned',
},
{
itemKey: 'tough-hide',
itemName: 'Tough Hide',
iconPath: '/images/items/tough-hide.png',
quantityCarried: 7,
inputQuantity: 5,
silverPerStep: 40,
reputationPerStep: 10,
factionKey: 'border-guard',
factionName: 'Border Watch',
renownMilestoneKey: null,
},
],
capacities: [{ category: 'HIDE', current: 8, capacity: 5, bag: null }],
...overrides,
};
}
/** A single shop offer, defaulting to the fields no test in this file varies. */
function offer(
itemKey: string,
itemName: string,
unlocked: boolean,
): ShopOfferView {
return {
itemKey,
itemName,
itemDescription: 'A bitter draught.',
iconPath: '/images/items/potion.png',
currencyType: 'SILVER',
price: 12,
quantity: 1,
effectSummary: null,
requirements: [],
unlocked,
affordable: true,
};
}
function shopView(offers?: ShopOfferView[]): ShopView {
return {
shopKey: 'borin-supplies',
shopName: "Quartermaster's Supplies",
npcKey: 'borin-quartermaster',
silver: 100,
offers: offers ?? [
offer('small-healing-potion', 'Small Healing Potion', true),
],
};
}
function tradeResult(): ExchangeResult {
return {
profileKey: 'borin-trade-in',
consumed: [{ itemKey: 'ash-pelt', itemName: 'Ashen Pelt', quantity: 5 }],
rewards: { silver: 25, regionalReputation: 10, worldRenown: 1 },
balances: { silver: 25, regionalReputation: 10, worldRenown: 2 },
reputationRankChanged: false,
newReputationRank: null,
renownMilestonesCompleted: ['first-goods-returned'],
capacities: [{ category: 'HIDE', current: 3, capacity: 5, bag: null }],
};
}
function createApi(
overrides: Partial<Record<string, unknown>> & { shopSequence?: ShopView[] } = {},
) {
// `shopSequence` lets a test hand back a different shop view on each call to
// `getShop`, so before/after `unlocked` flags can be observed across a trade
// without needing a real server round-trip.
const { shopSequence, ...rest } = overrides;
let shopCallIndex = 0;
return {
getNpcInteraction: vi.fn(() => of(interaction())),
getTradeIn: vi.fn(() => of(exchangeView())),
getShop: vi.fn(() =>
shopSequence
? of(shopSequence[Math.min(shopCallIndex++, shopSequence.length - 1)])
: of(shopView()),
),
tradeIn: vi.fn(() => of(tradeResult())),
getCharacter: vi.fn(() =>
of({
id: 'character-1',
name: 'Aric Duskwalker',
renown: 2,
silver: 25,
currentHp: 119,
maxHp: 119,
}),
),
purchase: vi.fn(() =>
of({
shopKey: 'borin-supplies',
itemKey: 'small-healing-potion',
itemName: 'Small Healing Potion',
quantity: 1,
silverSpent: 12,
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 }],
});
return TestBed.inject(MerchantStore);
}
describe('MerchantStore', () => {
afterEach(() => TestBed.resetTestingModule());
it('loads the NPC and the panels the server offered', async () => {
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
expect(store.interaction()?.npc.name).toBe('Borin');
expect(store.exchange()?.offers).toHaveLength(2);
expect(store.shop()?.offers).toHaveLength(1);
});
it('does not probe an endpoint the NPC did not offer', async () => {
// A person with no shop should not have their shop fetched. The server
// decides which interactions exist.
const api = createApi({
getNpcInteraction: vi.fn(() => of(interaction(['TALK', 'OPEN_EXCHANGE']))),
});
const store = createStore(api);
await store.load('borin-quartermaster');
expect(api.getShop).not.toHaveBeenCalled();
expect(store.shop()).toBeNull();
});
it('clamps a selection to what is actually carried', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 999);
expect(store.selection()['ash-pelt']).toBe(8);
});
it('rounds a batch rule down to whole steps', async () => {
// Tough Hide trades five at a time and seven are carried, so five is the
// most that can be handed over -- never seven.
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('tough-hide', 7);
expect(store.selection()['tough-hide']).toBe(5);
});
it('never selects a partial batch', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('tough-hide', 4);
expect(store.selection()['tough-hide']).toBe(0);
});
it('refuses a negative quantity', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', -5);
expect(store.selection()['ash-pelt']).toBe(0);
});
it('previews the payout the selection implies', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 4);
store.setQuantity('tough-hide', 5);
// 4 pelts at 5 silver, plus one hide batch at 40.
expect(store.preview()).toEqual({ silver: 60, reputation: 18 });
});
it('selects everything tradeable, in whole steps only', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
store.selectAll();
expect(store.selection()).toEqual({ 'ash-pelt': 8, 'tough-hide': 5 });
});
it('sends only keys and quantities, then re-reads from the server', async () => {
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 5);
await store.tradeSelected();
expect(api.tradeIn).toHaveBeenCalledWith('borin-quartermaster', [
{ itemKey: 'ash-pelt', quantity: 5 },
]);
// Carried goods, capacity and Silver all moved at once, so the view is
// re-fetched rather than patched locally.
expect(api.getTradeIn).toHaveBeenCalledTimes(2);
expect(store.lastTrade()?.rewards.silver).toBe(25);
expect(store.selection()).toEqual({});
expect(store.actionError()).toBeNull();
});
it('pushes the new Silver back to the shared character state', async () => {
// The purse in the top bar reads from `WorldStore`. Without this the
// player sells four pelts and watches their Silver stay put.
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 5);
await store.tradeSelected();
expect(api.getCharacter).toHaveBeenCalled();
});
it('will not trade with nothing selected', async () => {
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
await store.tradeSelected();
expect(api.tradeIn).not.toHaveBeenCalled();
});
it('surfaces a rejected trade as a readable message and keeps the selection', async () => {
const api = createApi({
tradeIn: vi.fn(() =>
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { code: 'EXCHANGE_INSUFFICIENT_QUANTITY' },
}),
),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 5);
await store.tradeSelected();
expect(store.actionError()).toBe('You are not carrying that many.');
expect(store.lastTrade()).toBeNull();
expect(store.selection()['ash-pelt']).toBe(5);
});
it('falls back to a generic message rather than leaking an unknown code', async () => {
const api = createApi({
tradeIn: vi.fn(() =>
throwError(
() =>
new HttpErrorResponse({ status: 500, error: { code: 'WAT' } }),
),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 1);
await store.tradeSelected();
expect(store.actionError()).toBe("That isn't possible right now.");
});
it('reports being unable to reach the NPC', async () => {
const api = createApi({
getNpcInteraction: vi.fn(() =>
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { code: 'NPC_UNAVAILABLE' },
}),
),
),
});
const store = createStore(api);
await store.load('borin-quartermaster');
expect(store.error()).toBe('You are not where this person is.');
expect(store.interaction()).toBeNull();
});
it('refreshes the shop after buying, so the purse cannot go stale', async () => {
const api = createApi();
const store = createStore(api);
await store.load('borin-quartermaster');
await store.buy('small-healing-potion');
expect(api.purchase).toHaveBeenCalledWith(
'borin-quartermaster',
'small-healing-potion',
1,
);
expect(api.getShop).toHaveBeenCalledTimes(2);
expect(store.lastPurchase()?.silverSpent).toBe(12);
});
it('starts on the dialogue panel and switches on request', async () => {
const store = createStore(createApi());
await store.load('borin-quartermaster');
expect(store.panel()).toBe('DIALOGUE');
store.showPanel('EXCHANGE');
expect(store.panel()).toBe('EXCHANGE');
});
it('announces an offer that a trade just unlocked', async () => {
// Reputation earned by the trade opened the pouch. The player should learn
// that without hunting for it (slice §9).
const api = createApi({
shopSequence: [
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]),
],
});
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 1);
await store.tradeSelected();
expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']);
});
it('says nothing when a trade unlocks nothing', async () => {
const api = createApi({
shopSequence: [
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
],
});
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 1);
await store.tradeSelected();
expect(store.newlyUnlocked()).toEqual([]);
});
it('does not re-announce an offer that was already open', async () => {
const api = createApi({
shopSequence: [
shopView([offer('small-healing-potion', 'Small Healing Potion', true)]),
shopView([offer('small-healing-potion', 'Small Healing Potion', true)]),
],
});
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 1);
await store.tradeSelected();
expect(store.newlyUnlocked()).toEqual([]);
});
it('clears the unlock banner on a fresh load and at the start of a purchase', async () => {
// A stale banner from a previous merchant visit, or from before a buy
// click resolves, would misattribute an unlock to the wrong action.
const api = createApi({
shopSequence: [
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', false)]),
shopView([offer('basic-trophy-pouch', 'Basic Trophy Pouch', true)]),
],
});
const store = createStore(api);
await store.load('borin-quartermaster');
store.setQuantity('ash-pelt', 1);
await store.tradeSelected();
expect(store.newlyUnlocked()).toEqual(['Basic Trophy Pouch']);
await store.buy('basic-trophy-pouch');
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);
});
});