Implement crew type execution model

This commit is contained in:
2026-07-25 05:16:11 +00:00
parent 2824e5af13
commit c5da507df9
24 changed files with 1165 additions and 48 deletions
@@ -0,0 +1,365 @@
import { readdir, readFile } from 'node:fs/promises';
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { describe, expect, it } from 'vitest';
import { compileCrewTypeCatalog } from '../src/crewType/catalog.js';
import { ActionLogger } from '../src/logging/actionLogger.js';
import type { City, General, Nation } from '../src/domain/entities.js';
import { WarActionPipeline } from '../src/war/actions.js';
import { WarCrewType } from '../src/war/crewType.js';
import { createCrewTypeWarTriggerRegistry } from '../src/war/crewTypeTriggers.js';
import { computeBattleOrder, resolveWarBattle } from '../src/war/engine.js';
import { createWarTriggerEnv, WarTriggerCaller } from '../src/war/triggers.js';
import type { WarEngineConfig } from '../src/war/types.js';
import { WarUnitCity, WarUnitGeneral, type WarUnit } from '../src/war/units.js';
import { getCrewTypePickScore, parseUnitSetDefinition } from '../src/world/unitSet.js';
import type { CrewTypeDefinition, UnitSetDefinition } from '../src/world/types.js';
const config: WarEngineConfig = {
armPerPhase: 500,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
maxTrainByWar: 110,
maxAtmosByWar: 150,
castleCrewTypeId: 1000,
armTypes: {
footman: 1,
archer: 2,
cavalry: 3,
wizard: 4,
siege: 5,
misc: 6,
castle: 0,
},
};
const nation: Nation = {
id: 1,
name: '테스트국',
color: '#000000',
capitalCityId: 1,
chiefGeneralId: null,
gold: 10000,
rice: 10000,
power: 0,
level: 1,
typeCode: 'test',
meta: { tech: 3000 },
};
const city: City = {
id: 1,
name: '테스트성',
nationId: 1,
level: 1,
state: 0,
population: 10000,
populationMax: 10000,
agriculture: 500,
agricultureMax: 1000,
commerce: 500,
commerceMax: 1000,
security: 500,
securityMax: 1000,
defence: 100,
defenceMax: 1000,
wall: 1000,
wallMax: 1000,
supplyState: 1,
frontState: 0,
meta: {},
};
const crewType = (
id: number,
armType: number,
name: string,
options: Partial<CrewTypeDefinition> = {}
): CrewTypeDefinition => ({
id,
armType,
name,
attack: 100,
defence: 100,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 10,
rice: 10,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
...options,
});
const buildGeneral = (id: number, crewTypeId: number): General => ({
id,
name: `장수${id}`,
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 80, strength: 80, intelligence: 80 },
experience: 0,
dedication: 0,
officerLevel: 3,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1000,
rice: 10000,
crew: 1000,
crewTypeId,
train: 100,
atmos: 100,
age: 20,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, dex1: 1000, dex2: 1000, dex3: 1000, dex5: 1000 },
});
const buildGeneralUnit = (
rng: RandUtil,
general: General,
definition: CrewTypeDefinition,
attacker: boolean,
modules: WarActionPipeline = new WarActionPipeline([])
) =>
new WarUnitGeneral(
rng,
config,
general,
city,
nation,
attacker,
new WarCrewType(definition),
new ActionLogger({ generalId: general.id, nationId: general.nationId }),
modules
);
const fireTriggers = (keys: string[], self: WarUnit, attacker: WarUnit, defender: WarUnit): void => {
const registry = createCrewTypeWarTriggerRegistry();
const caller = new WarTriggerCaller();
for (const key of keys) {
const trigger = registry[key]?.(self);
if (!trigger) {
throw new Error(`Missing trigger: ${key}`);
}
if (trigger instanceof WarTriggerCaller) {
caller.merge(trigger);
} else {
caller.append(trigger);
}
}
caller.fire({ rng: self.rng, attacker, defender }, createWarTriggerEnv());
};
describe('crew type catalog', () => {
it('compiles every shipped unit set and resolves all crew handlers', async () => {
const unitSetDirectory = new URL('../../../resources/unitset/', import.meta.url);
const fileNames = (await readdir(unitSetDirectory)).filter((fileName) => fileName.endsWith('.json'));
for (const fileName of fileNames) {
const raw = JSON.parse(await readFile(new URL(fileName, unitSetDirectory), 'utf8')) as unknown;
const unitSet = parseUnitSetDefinition(raw);
const catalog = compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry());
expect(catalog.byId.size, fileName).toBe(unitSet.crewTypes?.length);
}
const raw = JSON.parse(await readFile(new URL('unitset_che.json', unitSetDirectory), 'utf8')) as unknown;
const cheCatalog = compileCrewTypeCatalog(parseUnitSetDefinition(raw), createCrewTypeWarTriggerRegistry());
expect(cheCatalog.byId.get(1500)?.actions.map((action) => action.key)).toEqual(['che_성벽선제']);
});
it('fails fast for unresolved crew actions and war triggers', () => {
const base: UnitSetDefinition = {
id: 'invalid',
name: 'invalid',
defaultCrewTypeId: 1100,
crewTypes: [
crewType(1100, 1, '보병', {
iActionList: ['missing_action'],
phaseSkillTrigger: ['missing_trigger'],
}),
],
};
expect(() => compileCrewTypeCatalog(base, createCrewTypeWarTriggerRegistry())).toThrow(
'Unknown crew type action'
);
base.crewTypes![0]!.iActionList = null;
expect(() => compileCrewTypeCatalog(base, createCrewTypeWarTriggerRegistry())).toThrow(
'Unknown crew type war trigger'
);
});
it('routes 정란의 성벽 우선 action through the war pipeline', () => {
const tower = crewType(1500, 5, '정란', { iActionList: ['che_성벽선제'] });
const wall = crewType(1000, 0, '성벽');
const unitSet: UnitSetDefinition = {
id: 'tower',
name: 'tower',
defaultCrewTypeId: tower.id,
crewTypes: [wall, tower],
};
const catalog = compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry());
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(
rng,
buildGeneral(1, tower.id),
tower,
true,
new WarActionPipeline([catalog.warActionModule])
);
const defender = new WarUnitCity(
rng,
config,
city,
nation,
new WarCrewType(wall),
new ActionLogger({ nationId: nation.id }),
200,
180
);
expect(computeBattleOrder(defender, attacker)).toBe(10000);
});
});
describe('crew type war triggers', () => {
it('loads crew triggers automatically in the live battle engine', () => {
const archer = crewType(1200, 2, '궁병', {
phaseSkillTrigger: ['che_선제사격시도', 'che_선제사격발동'],
});
const footman = crewType(1100, 1, '보병');
const wall = crewType(1000, 0, '성벽');
const unitSet: UnitSetDefinition = {
id: 'live-engine',
name: 'live-engine',
defaultCrewTypeId: footman.id,
crewTypes: [wall, footman, archer],
};
const attacker = buildGeneral(1, archer.id);
const defender = buildGeneral(2, footman.id);
const outcome = resolveWarBattle({
rng: new RandUtil(new ConstantRNG(0)),
unitSet,
config,
time: { year: 200, month: 1, startYear: 180 },
attacker: { general: attacker, city, nation },
defenders: [{ general: defender, city, nation }],
defenderCity: city,
defenderNation: nation,
});
expect(outcome.metrics?.attackerActivatedSkills['선제']).toBe(1);
});
it('activates wound immunity only for a general fighting a city wall', () => {
const siege = crewType(1501, 5, '충차');
const wall = crewType(1000, 0, '성벽');
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(rng, buildGeneral(1, siege.id), siege, true);
const defender = new WarUnitCity(
rng,
config,
city,
nation,
new WarCrewType(wall),
new ActionLogger({ nationId: nation.id }),
200,
180
);
attacker.setOppose(defender);
defender.setOppose(attacker);
fireTriggers(['che_성벽부상무효'], attacker, attacker, defender);
expect(attacker.hasActivatedSkill('부상무효')).toBe(true);
});
it('applies cavalry and footman end-of-phase multipliers', () => {
const cavalry = crewType(1300, 3, '기병');
const footman = crewType(1100, 1, '보병');
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(rng, buildGeneral(1, cavalry.id), cavalry, true);
const defender = buildGeneralUnit(rng, buildGeneral(2, footman.id), footman, false);
attacker.setOppose(defender);
defender.setOppose(attacker);
fireTriggers(['che_기병병종전투'], attacker, attacker, defender);
fireTriggers(['che_방어력증가5p'], defender, attacker, defender);
expect(attacker.getWarPowerMultiply()).toBeCloseTo(1.02 / 1.05);
expect(defender.getWarPowerMultiply()).toBeCloseTo(0.97);
});
it('executes one-sided preemptive fire once and suppresses the opponent', () => {
const archer = crewType(1200, 2, '궁병');
const footman = crewType(1100, 1, '보병');
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(rng, buildGeneral(1, archer.id), archer, true);
const defender = buildGeneralUnit(rng, buildGeneral(2, footman.id), footman, false);
attacker.setOppose(defender);
defender.setOppose(attacker);
fireTriggers(['che_선제사격시도', 'che_선제사격발동'], attacker, attacker, defender);
expect(attacker.getPhase()).toBe(-1);
expect(defender.getPhase()).toBe(-1);
expect(attacker.getWarPowerMultiply()).toBeCloseTo(2 / 3);
expect(defender.getWarPowerMultiply()).toBe(0);
expect(attacker.hasActivatedSkill('선제')).toBe(true);
expect(defender.hasActivatedSkill('회피불가')).toBe(true);
});
it('uses the legacy stop probability and never lets an attacker initiate 저지', () => {
const ram = crewType(1503, 5, '목우');
const footman = crewType(1100, 1, '보병');
const rng = new RandUtil(new ConstantRNG(0));
const attacker = buildGeneralUnit(rng, buildGeneral(1, footman.id), footman, true);
const defender = buildGeneralUnit(rng, buildGeneral(2, ram.id), ram, false);
attacker.setOppose(defender);
defender.setOppose(attacker);
fireTriggers(['che_저지시도'], attacker, attacker, defender);
expect(attacker.hasActivatedSkill('저지')).toBe(false);
fireTriggers(['che_저지시도', 'che_저지발동'], defender, attacker, defender);
expect(defender.hasActivatedSkill('저지')).toBe(true);
expect(attacker.getWarPowerMultiply()).toBe(0);
expect(defender.getWarPowerMultiply()).toBe(0);
const missRng = new RandUtil(new ConstantRNG(1));
const missedAttacker = buildGeneralUnit(missRng, buildGeneral(3, footman.id), footman, true);
const missedDefender = buildGeneralUnit(missRng, buildGeneral(4, ram.id), ram, false);
missedAttacker.setOppose(missedDefender);
missedDefender.setOppose(missedAttacker);
fireTriggers(['che_저지시도', 'che_저지발동'], missedDefender, missedAttacker, missedDefender);
expect(missedDefender.hasActivatedSkill('저지')).toBe(false);
});
});
describe('crew type numeric policy', () => {
it('matches the legacy pickScore formula including magicCoef', () => {
const wizard = crewType(1400, 4, '귀병', {
attack: 80,
defence: 80,
speed: 7,
avoid: 5,
magicCoef: 0.5,
});
const expected = ((500 + 80 + 80 + 75 * 2) * (1 + 7 / 2) * (1 + 0.5 / 2)) / (1 - 0.05);
expect(getCrewTypePickScore(wizard, 3000, 500)).toBeCloseTo(expected);
});
});