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 { 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> & { 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 { 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 { return { quest: questView(), npcLine: 'Take this.', grantedBag: null, consumedItems: [], rewards: null, ...overrides, }; } function createStore(api: ReturnType): 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); }); });