refactor(logic): replace arbitrary action hooks with typed events

This commit is contained in:
2026-07-30 03:31:31 +00:00
parent 96b41ef4b4
commit 7d3cb50a5b
168 changed files with 1554 additions and 631 deletions
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import type { General } from '../src/domain/entities.js';
import { createGeneralActionEvent, type GeneralActionEvent } from '../src/actionModules/events.js';
import { GeneralActionPipeline, type GeneralActionModule } from '../src/actionModules/general.js';
import { createRefOrderedActionStack } from '../src/actionModules/bundle.js';
const makeGeneral = (): 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: null },
},
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 },
});
describe('typed general action events', () => {
it('folds synchronous event handlers in the supplied ref ownership order', () => {
const trace: string[] = [];
const names = ['nation', 'officer', 'domestic', 'war', 'personality', 'crew', 'inherit', 'item'];
const modules: GeneralActionModule[] = names.map((name) => ({
eventHandlers: {
'strategy.succeeded': (_context, event) => {
trace.push(name);
return createGeneralActionEvent('strategy.succeeded', {
consumedItems: [...event.payload.consumedItems, name],
});
},
},
}));
const stack = createRefOrderedActionStack({
nation: modules[0]!,
officer: modules[1]!,
domestic: modules[2]!,
war: modules[3]!,
personality: modules[4]!,
crewType: modules[5]!,
inheritance: modules[6]!,
scenario: null,
items: [modules[7]!],
});
const pipeline = new GeneralActionPipeline(stack);
const result = pipeline.dispatch(
{ general: makeGeneral() },
createGeneralActionEvent('strategy.succeeded', { consumedItems: [] })
);
expect(trace).toEqual(names);
expect(result.payload.consumedItems).toEqual(names);
});
});
// tsc가 실행될 때만 평가하는 negative type contract입니다.
const assertCompileTimeContracts = (pipeline: GeneralActionPipeline, general: General): void => {
// @ts-expect-error item.purchased에는 slot이 필수입니다.
createGeneralActionEvent('item.purchased', { itemKey: 'x' });
const sold = createGeneralActionEvent('item.sold', {
itemKey: 'x',
slot: 'item',
});
// @ts-expect-error item.sold dispatch에는 RNG와 time context가 모두 필수입니다.
pipeline.dispatch({ general }, sold);
// @ts-expect-error 닫힌 protocol에 임의 event key를 추가할 수 없습니다.
createGeneralActionEvent('strategy.failed', { consumedItems: [] });
// @ts-expect-error unique-symbol shadow brand가 없는 event를 직접 위조할 수 없습니다.
const forged: GeneralActionEvent<'strategy.succeeded'> = {
type: 'strategy.succeeded',
payload: { consumedItems: [] },
};
void forged;
const legacyEscapeHatch = {
// @ts-expect-error onArbitraryAction은 action module capability가 아닙니다.
onArbitraryAction: () => null,
} satisfies GeneralActionModule;
void legacyEscapeHatch;
// @ts-expect-error leaf handler와 composite router를 한 module에 함께 선언할 수 없습니다.
const doubleEventPath: GeneralActionModule = {
eventHandlers: {
'strategy.succeeded': (_context, event) => event,
},
handleEvent: (_context, event) => event,
};
void doubleEventPath;
// @ts-expect-error 표준 stack은 officer slot을 생략할 수 없습니다.
createRefOrderedActionStack({
nation: {},
domestic: {},
war: {},
personality: {},
crewType: null,
inheritance: {},
scenario: null,
items: [],
});
};
void assertCompileTimeContracts;
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { General } from '../src/domain/entities.js';
import { createInheritBuffModules } from '../src/inheritance/inheritBuff.js';
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
import { GeneralActionPipeline } from '../src/actionModules/general.js';
const buildGeneral = (inheritBuff: Record<string, number>): General => ({
id: 1,
@@ -0,0 +1,271 @@
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();
});
});
@@ -23,6 +23,7 @@ import {
getEquippedItemInstance,
loadItemModules,
} from '../../src/items/index.js';
import { createRefOrderedActionStack } from '../../src/actionModules/bundle.js';
describe('General Commands New Scenario', () => {
// 1. Setup Environment
@@ -449,7 +450,21 @@ describe('General Commands New Scenario', () => {
expect(spyInfo['2']).toBe(3); // City 2 spied level 3
// 3. Destroy (G1 -> C2)
const strategyEnv = { ...systemEnv, generalActionModules: itemGeneralModules };
const noOpModule = {};
const strategyEnv: TurnCommandEnv = {
...systemEnv,
generalActionModules: createRefOrderedActionStack({
nation: noOpModule,
officer: noOpModule,
domestic: noOpModule,
war: noOpModule,
personality: noOpModule,
crewType: null,
inheritance: noOpModule,
scenario: null,
items: itemGeneralModules,
}),
};
const destroyDef = destroySpec.createDefinition(strategyEnv);
await runner.runTurn([
{
+6 -6
View File
@@ -5,14 +5,14 @@ import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import type { City, General, Nation } from '../src/domain/entities.js';
import type { RandomGenerator } from '../src/index.js';
import type { UnitSetDefinition } from '../src/world/types.js';
import { GeneralActionPipeline } from '../src/triggers/general-action.js';
import { GeneralActionPipeline } from '../src/actionModules/general.js';
import { createGeneralTriggerContext } from '../src/triggers/general.js';
import {
createTraitModuleRegistry,
createTraitCatalog,
createTraitModules,
loadDomesticTraitModules,
loadWarTraitModules,
} from '../src/triggers/special/index.js';
} from '../src/actionModules/traits/index.js';
import { ActionLogger } from '../src/logging/actionLogger.js';
import { WarActionPipeline } from '../src/war/actions.js';
import { WarCrewType } from '../src/war/crewType.js';
@@ -174,7 +174,7 @@ describe('trait modules', () => {
it('applies domestic and war modifiers in general pipeline', async () => {
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
const registry = createTraitModuleRegistry({ domestic, war });
const registry = createTraitCatalog({ domestic, war });
const traitModules = createTraitModules(registry);
const pipeline = new GeneralActionPipeline(traitModules.general);
@@ -201,7 +201,7 @@ describe('trait modules', () => {
it('heals city generals with 의술 pre-turn trigger', async () => {
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
const registry = createTraitModuleRegistry({ domestic, war });
const registry = createTraitCatalog({ domestic, war });
const traitModules = createTraitModules(registry);
const pipeline = new GeneralActionPipeline(traitModules.general);
@@ -254,7 +254,7 @@ describe('trait modules', () => {
it('activates 의술 battle trigger and reduces damage', async () => {
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
const registry = createTraitModuleRegistry({ domestic, war });
const registry = createTraitCatalog({ domestic, war });
const traitModules = createTraitModules(registry);
const rng = new RandUtil(new ConstantRNG(0));
@@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
import { RandUtil, type RNG } from '@sammo-ts/common';
import { TraitRequirement, TraitWeightType } from '../src/triggers/special/requirements.js';
import { TraitSelector } from '../src/triggers/special/selector.js';
import type { TraitModule } from '../src/triggers/special/types.js';
import { TraitRequirement, TraitWeightType } from '../src/actionModules/traits/requirements.js';
import { TraitSelector } from '../src/actionModules/traits/selector.js';
import type { TraitModule } from '../src/actionModules/traits/types.js';
class ScriptedRng implements RNG {
public floatCalls = 0;
@@ -62,22 +62,12 @@ const trait = (
describe('legacy speciality selector parity', () => {
it('sets positive and negative stat bits in the legacy order', () => {
expect(
TraitSelector.calcCondGeneric(
{ leadership: 80, strength: 75, intelligence: 40 },
scenarioStat
)
).toBe(
TraitRequirement.STAT_LEADERSHIP |
TraitRequirement.STAT_STRENGTH |
TraitRequirement.STAT_NOT_INTEL
expect(TraitSelector.calcCondGeneric({ leadership: 80, strength: 75, intelligence: 40 }, scenarioStat)).toBe(
TraitRequirement.STAT_LEADERSHIP | TraitRequirement.STAT_STRENGTH | TraitRequirement.STAT_NOT_INTEL
);
expect(TraitSelector.calcCondGeneric({ leadership: 70, strength: 70, intelligence: 70 }, scenarioStat)).toBe(
TraitRequirement.STAT_STRENGTH
);
expect(
TraitSelector.calcCondGeneric(
{ leadership: 70, strength: 70, intelligence: 70 },
scenarioStat
)
).toBe(TraitRequirement.STAT_STRENGTH);
});
it('rounds the dex threshold and still consumes a value choice when every dex is zero', () => {
+85 -1
View File
@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import type { City, General, Nation } from '../src/domain/entities.js';
import type { GeneralActionModule } from '../src/actionModules/general.js';
import type { UnitSetDefinition } from '../src/world/types.js';
import { resolveWarAftermath } from '../src/war/aftermath.js';
import type { WarAftermathConfig } from '../src/war/types.js';
import { LogFormat } from '../src/logging/types.js';
const buildUnitSet = (): UnitSetDefinition => ({
id: 'test',
@@ -305,6 +307,88 @@ describe('war aftermath', () => {
);
});
it('dispatches city conquest to every stationed defender before collapse RNG', () => {
const rng = new RandUtil(new ConstantRNG(0));
const draws = [0.01, 0.02, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
const nextFloat = vi.spyOn(rng, 'nextFloat1').mockImplementation(() => {
const value = draws.shift();
if (value === undefined) {
throw new Error('unexpected RNG draw');
}
return value;
});
const attackerNation = buildNation(1);
const defenderNation = buildNation(2);
const attackerCity = buildCity(1, 1);
const defenderCity = buildCity(2, 2);
const attacker = buildGeneral(1, 1, 1);
const firstDefender = buildGeneral(2, 2, 2);
const secondDefender = buildGeneral(3, 2, 2);
secondDefender.crew = 0;
const elsewhere = buildGeneral(4, 2, 3);
const dispatchOrder: number[] = [];
const module: GeneralActionModule = {
eventHandlers: {
'city.conquered': (context, event) => {
dispatchOrder.push(context.general.id);
context.general.triggerState.meta.conqueredBy = event.payload.attacker.id;
context.rng.nextFloat1();
context.log?.push(`점령 이벤트 ${context.general.id}`);
},
},
};
const outcome = resolveWarAftermath({
battle: {
attacker,
defenders: [firstDefender],
defenderCity,
logs: [],
conquered: true,
reports: [],
},
attackerNation,
defenderNation,
attackerCity,
defenderCity,
nations: [attackerNation, defenderNation],
cities: [attackerCity, defenderCity],
generals: [attacker, firstDefender, secondDefender, elsewhere],
unitSet: buildUnitSet(),
config: buildConfig(),
time: {
year: 200,
month: 1,
startYear: 180,
},
rng,
generalActionModules: [module],
});
expect(dispatchOrder).toEqual([firstDefender.id, secondDefender.id]);
expect(firstDefender.triggerState.meta.conqueredBy).toBe(attacker.id);
expect(secondDefender.triggerState.meta.conqueredBy).toBe(attacker.id);
expect(elsewhere.triggerState.meta.conqueredBy).toBeUndefined();
// The first two draws belong to the two event handlers. Collapse then
// consumes the same stream: 0.2/0.3 for the first defender and
// 0.4/0.5 for the second.
expect(firstDefender.gold).toBe(740);
expect(firstDefender.rice).toBe(710);
expect(secondDefender.gold).toBe(680);
expect(secondDefender.rice).toBe(650);
expect(elsewhere.gold).toBe(620);
expect(elsewhere.rice).toBe(590);
expect(nextFloat).toHaveBeenCalledTimes(8);
expect(draws).toEqual([]);
expect(outcome.logs.filter((log) => log.text.startsWith('점령 이벤트')).map((log) => log.format)).toEqual([
LogFormat.MONTH,
LogFormat.MONTH,
]);
expect(outcome.generals.map((general) => general.id)).toEqual(
expect.arrayContaining([firstDefender.id, secondDefender.id])
);
});
it('preserves the first contributor when conflict values are tied', () => {
const attackerNation = buildNation(1);
const defenderNation = buildNation(2);