This commit is contained in:
Bastian Wagner
2026-08-22 16:41:47 +02:00
parent dfa62fd152
commit 081c9f83f9
137 changed files with 11594 additions and 1302 deletions

View File

@@ -0,0 +1,362 @@
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,
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'> = [
'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,
};
}
function shopView(): ShopView {
return {
shopKey: 'borin-supplies',
shopName: "Quartermaster's Supplies",
npcKey: 'borin-quartermaster',
silver: 100,
offers: [
{
itemKey: 'small-healing-potion',
itemName: 'Small Healing Potion',
itemDescription: 'A bitter draught.',
iconPath: '/images/items/potion.png',
currencyType: 'SILVER',
price: 12,
quantity: 1,
unlocked: true,
affordable: 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>> = {}) {
return {
getNpcInteraction: vi.fn(() => of(interaction())),
getTradeIn: vi.fn(() => of(exchangeView())),
getShop: vi.fn(() => 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,
}),
),
...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');
});
});