630 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|