import { describe, expect, it } from 'vitest'; import type { RandomGenerator } from '@sammo-ts/common'; import type { General, Nation } from '../src/domain/entities.js'; import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../src/actions/turn/commandEnv.js'; import { ActionDefinition as TradeItemAction } from '../src/actions/turn/general/che_장비매매.js'; import { consumeSuccessfulStrategyItem } from '../src/actions/turn/general/strategyItemConsumption.js'; import { GeneralActionPipeline } from '../src/actionModules/general.js'; import { createRefOrderedActionStack } from '../src/actionModules/bundle.js'; import { createItemActionModules, createItemModuleRegistry } from '../src/items/index.js'; import { getEquippedItemInstance } from '../src/items/inventory.js'; import { itemModule as dogiModule } from '../src/items/che_보물_도기.js'; import { itemModule as strategyItemModule } from '../src/items/che_계략_이추.js'; import { LogFormat } from '../src/logging/types.js'; const BASE_ENV: TurnCommandEnv = { develCost: 100, trainDelta: 35, atmosDelta: 35, maxTrainByCommand: 100, maxAtmosByCommand: 100, sabotageDefaultProb: 0.5, sabotageProbCoefByStat: 0.1, sabotageDefenceCoefByGeneralCount: 0.1, sabotageDamageMin: 10, sabotageDamageMax: 30, openingPartYear: 200, maxGeneral: 10, defaultNpcGold: 1000, defaultNpcRice: 1000, defaultCrewTypeId: 1, defaultSpecialDomestic: null, defaultSpecialWar: null, initialNationGenLimit: 10, maxTechLevel: 10, baseGold: 1000, baseRice: 1000, maxResourceActionAmount: 10000, }; const makeGeneral = (itemKey: string | null): General => ({ id: 1, name: '도기 장수', nationId: 1, cityId: 1, troopId: 0, stats: { leadership: 70, strength: 70, intelligence: 70 }, experience: 0, dedication: 0, officerLevel: 1, role: { personality: null, specialDomestic: null, specialWar: null, items: { horse: null, weapon: null, book: null, item: itemKey }, }, injury: 0, gold: 1000, rice: 1000, crew: 1000, crewTypeId: 1100, train: 100, atmos: 100, age: 30, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { killturn: 24 }, }); const makeNation = (): Nation => ({ id: 1, name: '도기국', color: '#000000', capitalCityId: 1, chiefGeneralId: 1, gold: 500, rice: 500, power: 0, level: 1, typeCode: 'None', meta: {}, }); const makeChoiceRng = (index: 0 | 1): { rng: RandomGenerator; calls: Array<[number, number]> } => { const calls: Array<[number, number]> = []; return { calls, rng: { nextFloat1: () => index, nextBool: () => index === 1, nextInt: (minInclusive, maxExclusive) => { calls.push([minInclusive, maxExclusive]); return index; }, }, }; }; const dogiCatalog: Record = { che_보물_도기: { slot: 'item', name: '도기(보물)', rawName: '도기', cost: 200, reqSecu: 0, buyable: false, unique: true, }, }; const createItemOnlyStack = (items: ReturnType['general']) => { const noOp = {}; return createRefOrderedActionStack({ nation: noOp, officer: noOp, domestic: noOp, war: noOp, personality: noOp, crewType: null, inheritance: noOp, scenario: null, items, }); }; describe('typed item lifecycle events', () => { it.each([ { name: 'bit 0은 ref choice index 0인 금을 선택한다', bit: 0 as const, year: 200, expectedGeneral: { gold: 6100, rice: 1000 }, expectedNation: { gold: 5500, rice: 500 }, resource: '금', }, { name: 'bit 1은 ref choice index 1인 쌀을 선택한다', bit: 1 as const, year: 200, expectedGeneral: { gold: 1100, rice: 6000 }, expectedNation: { gold: 500, rice: 5500 }, resource: '쌀', }, { name: '2년 경계에서 보충량이 15,000으로 증가한다', bit: 0 as const, year: 202, expectedGeneral: { gold: 8600, rice: 1000 }, expectedNation: { gold: 8000, rice: 500 }, resource: '금', }, ])('$name', ({ bit, year, expectedGeneral, expectedNation, resource }) => { const general = makeGeneral('che_보물_도기'); const nation = makeNation(); const logs: Array<{ message: string; format: LogFormat | undefined }> = []; let stateAtSaleLog: { gold: number; equipped: string | null } | null = null; const { rng, calls } = makeChoiceRng(bit); const itemModules = createItemActionModules(createItemModuleRegistry([dogiModule])); const action = new TradeItemAction({ ...BASE_ENV, itemCatalog: dogiCatalog, generalActionModules: createItemOnlyStack(itemModules.general), }); const outcome = action.resolve( { general, nation, rng, time: { year, month: 1, startYear: 200 }, addLog: (message, options) => { if (logs.length === 0) { stateAtSaleLog = { gold: general.gold, equipped: general.role.items.item, }; } logs.push({ message, format: options?.format }); }, }, { itemType: 'item', itemCode: 'None' } ); expect({ gold: general.gold, rice: general.rice }).toEqual(expectedGeneral); expect({ gold: nation.gold, rice: nation.rice }).toEqual(expectedNation); expect(general.role.items.item).toBeNull(); expect(calls).toEqual([[0, 2]]); expect(stateAtSaleLog).toEqual({ gold: 1000, equipped: 'che_보물_도기' }); expect(logs.slice(0, 2)).toEqual([ { message: '도기(보물)를 판매했습니다.', format: LogFormat.MONTH, }, { message: `재산과 국고에 총 ${resource} ${year === 202 ? '15,000' : '10,000'}을 보충합니다.`, format: LogFormat.MONTH, }, ]); expect(outcome.effects).toContainEqual( expect.objectContaining({ type: 'general:patch', patch: expect.objectContaining(expectedGeneral), }) ); expect(outcome.effects).toContainEqual( expect.objectContaining({ type: 'nation:patch', targetId: nation.id, patch: expect.objectContaining(expectedNation), }) ); }); it.each([ ['che_치료_환약', 3], ['event_충차', 2], ])('%s 구매 시 charge를 canonical inventory에 초기화한다', (itemKey, charges) => { const catalog: Record = { [itemKey]: { slot: 'item', name: itemKey, rawName: itemKey, cost: 100, reqSecu: 0, buyable: true, unique: false, initialCharges: charges, }, }; const general = makeGeneral(null); const action = new TradeItemAction({ ...BASE_ENV, itemCatalog: catalog, }); const { rng } = makeChoiceRng(0); action.resolve( { general, nation: makeNation(), rng, time: { year: 200, month: 1, startYear: 200 }, addLog: () => {}, }, { itemType: 'item', itemCode: itemKey } ); expect(getEquippedItemInstance(general, 'item')).toMatchObject({ itemKey, state: { charges }, }); }); it('계략 성공 capability만 소비하며 typed 결과로 소비 item을 반환한다', () => { const general = makeGeneral('che_계략_이추'); const itemModules = createItemActionModules(createItemModuleRegistry([strategyItemModule])); const pipeline = new GeneralActionPipeline(itemModules.general); const { rng } = makeChoiceRng(0); const consumedItems = consumeSuccessfulStrategyItem(pipeline, { general, nation: makeNation(), rng, addLog: () => {}, }); expect(consumedItems).toEqual(['che_계략_이추']); expect(general.role.items.item).toBeNull(); }); });