Files
core2026/packages/logic/test/itemActionEvents.test.ts
T

272 lines
8.8 KiB
TypeScript

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<string, TurnCommandItemCatalogEntry> = {
che_보물_도기: {
slot: 'item',
name: '도기(보물)',
rawName: '도기',
cost: 200,
reqSecu: 0,
buyable: false,
unique: true,
},
};
const createItemOnlyStack = (items: ReturnType<typeof createItemActionModules>['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: '<C>도기(보물)</>를 판매했습니다.',
format: LogFormat.MONTH,
},
{
message: `재산과 국고에 총 ${resource} <C>${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<string, TurnCommandItemCatalogEntry> = {
[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();
});
});