모병에서도 실제 행동명을 특기 계산에 전달해 징병 특기의 훈련과 사기를 레거시 값으로 맞춘다. 의술의 환자별 기록 소유권과 PLAIN 형식, 다인 치료 요약 대상도 복구하고 전투 특기 비전투 hook 회귀를 추가한다.
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
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/actionModules/general.js';
|
|
import { createGeneralTriggerContext } from '../src/triggers/general.js';
|
|
import {
|
|
createTraitCatalog,
|
|
createTraitModules,
|
|
loadEventDomesticTraitModules,
|
|
loadDomesticTraitModules,
|
|
loadWarTraitModules,
|
|
WAR_TRAIT_KEYS,
|
|
} from '../src/actionModules/traits/index.js';
|
|
import { ActionLogger } from '../src/logging/actionLogger.js';
|
|
import { LogFormat } from '../src/logging/types.js';
|
|
import { WarActionPipeline } from '../src/war/actions.js';
|
|
import { WarCrewType } from '../src/war/crewType.js';
|
|
import { createWarTriggerEnv } from '../src/war/triggers.js';
|
|
import type { WarEngineConfig } from '../src/war/types.js';
|
|
import { WarUnitCity, WarUnitGeneral } from '../src/war/units.js';
|
|
|
|
const buildGeneral = (overrides: Partial<General> = {}): General => ({
|
|
id: 1,
|
|
name: 'Tester',
|
|
nationId: 1,
|
|
cityId: 1,
|
|
troopId: 0,
|
|
stats: {
|
|
leadership: 80,
|
|
strength: 70,
|
|
intelligence: 60,
|
|
},
|
|
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: 1000,
|
|
crew: 1000,
|
|
crewTypeId: 100,
|
|
train: 80,
|
|
atmos: 80,
|
|
age: 20,
|
|
npcState: 0,
|
|
triggerState: {
|
|
flags: {},
|
|
counters: {},
|
|
modifiers: {},
|
|
meta: {},
|
|
},
|
|
meta: { killturn: 24 },
|
|
...overrides,
|
|
});
|
|
|
|
const buildCity = (): City => ({
|
|
id: 1,
|
|
name: 'TestCity',
|
|
nationId: 1,
|
|
level: 2,
|
|
state: 0,
|
|
population: 10000,
|
|
populationMax: 10000,
|
|
agriculture: 500,
|
|
agricultureMax: 1000,
|
|
commerce: 500,
|
|
commerceMax: 1000,
|
|
security: 500,
|
|
securityMax: 1000,
|
|
defence: 100,
|
|
defenceMax: 200,
|
|
supplyState: 1,
|
|
frontState: 0,
|
|
wall: 100,
|
|
wallMax: 200,
|
|
meta: {},
|
|
});
|
|
|
|
const buildNation = (): Nation => ({
|
|
id: 1,
|
|
name: 'TestNation',
|
|
color: '#000000',
|
|
capitalCityId: 1,
|
|
chiefGeneralId: null,
|
|
gold: 1000,
|
|
rice: 1000,
|
|
power: 0,
|
|
level: 1,
|
|
typeCode: 'test',
|
|
meta: {},
|
|
});
|
|
|
|
const buildConfig = (): WarEngineConfig => ({
|
|
armPerPhase: 500,
|
|
maxTrainByCommand: 100,
|
|
maxAtmosByCommand: 100,
|
|
maxTrainByWar: 110,
|
|
maxAtmosByWar: 150,
|
|
castleCrewTypeId: 999,
|
|
armTypes: {
|
|
footman: 1,
|
|
wizard: 4,
|
|
siege: 5,
|
|
misc: 6,
|
|
castle: 9,
|
|
},
|
|
});
|
|
|
|
const buildUnitSet = (): UnitSetDefinition => ({
|
|
id: 'test',
|
|
name: 'test',
|
|
crewTypes: [
|
|
{
|
|
id: 100,
|
|
armType: 1,
|
|
name: '보병',
|
|
attack: 100,
|
|
defence: 100,
|
|
speed: 7,
|
|
avoid: 10,
|
|
magicCoef: 0,
|
|
cost: 9,
|
|
rice: 9,
|
|
requirements: [],
|
|
attackCoef: {},
|
|
defenceCoef: {},
|
|
info: [],
|
|
initSkillTrigger: null,
|
|
phaseSkillTrigger: null,
|
|
iActionList: null,
|
|
},
|
|
{
|
|
id: 999,
|
|
armType: 9,
|
|
name: '성벽',
|
|
attack: 0,
|
|
defence: 0,
|
|
speed: 1,
|
|
avoid: 0,
|
|
magicCoef: 0,
|
|
cost: 0,
|
|
rice: 0,
|
|
requirements: [],
|
|
attackCoef: {},
|
|
defenceCoef: {},
|
|
info: [],
|
|
initSkillTrigger: null,
|
|
phaseSkillTrigger: null,
|
|
iActionList: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
describe('trait modules', () => {
|
|
it('keeps the Ref inventory of battle traits with non-battle hooks', async () => {
|
|
const war = await loadWarTraitModules([...WAR_TRAIT_KEYS]);
|
|
|
|
expect(war.filter((module) => module.onCalcDomestic).map((module) => module.key)).toEqual([
|
|
'che_귀병',
|
|
'che_신산',
|
|
'che_보병',
|
|
'che_궁병',
|
|
'che_기병',
|
|
'che_공성',
|
|
'che_징병',
|
|
]);
|
|
expect(war.filter((module) => module.getPreTurnExecuteTriggerList).map((module) => module.key)).toEqual([
|
|
'che_의술',
|
|
]);
|
|
});
|
|
|
|
it('loads trait modules by key', async () => {
|
|
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
|
|
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
|
|
|
|
expect(domestic.map((module) => module.key)).toEqual(['che_인덕', 'che_발명']);
|
|
expect(war.map((module) => module.key)).toEqual(['che_의술', 'che_징병']);
|
|
});
|
|
|
|
it('applies domestic and war modifiers in general pipeline', async () => {
|
|
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
|
|
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
|
|
const registry = createTraitCatalog({ domestic, war });
|
|
const traitModules = createTraitModules(registry);
|
|
|
|
const pipeline = new GeneralActionPipeline(traitModules.general);
|
|
const general = buildGeneral({
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: 'che_인덕',
|
|
specialWar: 'che_징병',
|
|
items: {
|
|
horse: null,
|
|
weapon: null,
|
|
book: null,
|
|
item: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const context = { general };
|
|
expect(pipeline.onCalcDomestic(context, '민심', 'score', 100)).toBeCloseTo(110);
|
|
expect(pipeline.onCalcDomestic(context, '징병', 'train', 40)).toBe(70);
|
|
expect(pipeline.onCalcStat(context, 'leadership', 80)).toBe(100);
|
|
});
|
|
|
|
it('heals city generals with 의술 pre-turn trigger', async () => {
|
|
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
|
|
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
|
|
const registry = createTraitCatalog({ domestic, war });
|
|
const traitModules = createTraitModules(registry);
|
|
const pipeline = new GeneralActionPipeline(traitModules.general);
|
|
|
|
const general = buildGeneral({
|
|
injury: 20,
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: 'che_의술',
|
|
items: {
|
|
horse: null,
|
|
weapon: null,
|
|
book: null,
|
|
item: null,
|
|
},
|
|
},
|
|
});
|
|
const patient = buildGeneral({
|
|
id: 2,
|
|
name: 'Patient',
|
|
injury: 15,
|
|
});
|
|
const worldView = {
|
|
listGeneralsByCity: (_cityId: number) => [general, patient],
|
|
listGenerals: () => [general, patient],
|
|
};
|
|
const rng: RandomGenerator = {
|
|
nextFloat1: () => 0,
|
|
nextBool: () => true,
|
|
nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive,
|
|
};
|
|
|
|
const caller = pipeline.getPreTurnExecuteTriggerList({
|
|
general,
|
|
worldView,
|
|
});
|
|
const triggerContext = createGeneralTriggerContext({
|
|
general,
|
|
rng,
|
|
worldView,
|
|
log: { push: () => {} },
|
|
});
|
|
caller.fire(triggerContext, {});
|
|
|
|
expect(general.injury).toBe(0);
|
|
expect(patient.injury).toBe(0);
|
|
expect(general.triggerState.flags['pre.치료']).toBe(true);
|
|
});
|
|
|
|
it('assigns event-의술 healing draws in legacy general-id order', async () => {
|
|
const eventDomestic = await loadEventDomesticTraitModules(['che_event_의술']);
|
|
const registry = createTraitCatalog({ domestic: eventDomestic });
|
|
const traitModules = createTraitModules(registry);
|
|
const pipeline = new GeneralActionPipeline(traitModules.general);
|
|
const healer = buildGeneral({
|
|
id: 818,
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: 'che_event_의술',
|
|
specialWar: null,
|
|
items: { horse: null, weapon: null, book: null, item: null },
|
|
},
|
|
});
|
|
const lowerIdPatient = buildGeneral({ id: 444, name: 'Lower', injury: 12 });
|
|
const higherIdPatient = buildGeneral({ id: 775, name: 'Higher', injury: 30 });
|
|
const worldView = {
|
|
listGeneralsByCity: (_cityId: number) => [healer, higherIdPatient, lowerIdPatient],
|
|
listGenerals: () => [healer, higherIdPatient, lowerIdPatient],
|
|
};
|
|
let draw = 0;
|
|
const rng: RandomGenerator = {
|
|
nextFloat1: () => 0,
|
|
nextBool: () => draw++ === 0,
|
|
nextInt: (minInclusive: number, _maxExclusive: number) => minInclusive,
|
|
};
|
|
const triggerContext = createGeneralTriggerContext({ general: healer, rng, worldView });
|
|
|
|
pipeline.getPreTurnExecuteTriggerList({ general: healer, worldView }).fire(triggerContext, {});
|
|
|
|
expect(lowerIdPatient.injury).toBe(0);
|
|
expect(higherIdPatient.injury).toBe(30);
|
|
});
|
|
|
|
it('writes Ref-compatible patient and healer logs for 의술 city healing', async () => {
|
|
const war = await loadWarTraitModules(['che_의술']);
|
|
const registry = createTraitCatalog({ war });
|
|
const pipeline = new GeneralActionPipeline(createTraitModules(registry).general);
|
|
const healer = buildGeneral({
|
|
name: '의사',
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: 'che_의술',
|
|
items: { horse: null, weapon: null, book: null, item: null },
|
|
},
|
|
});
|
|
const firstPatient = buildGeneral({ id: 2, name: '환자갑', injury: 20 });
|
|
const lastPatient = buildGeneral({ id: 3, name: '환자을', injury: 20 });
|
|
const worldView = {
|
|
listGeneralsByCity: () => [lastPatient, healer, firstPatient],
|
|
listGenerals: () => [lastPatient, healer, firstPatient],
|
|
};
|
|
const rng: RandomGenerator = {
|
|
nextFloat1: () => 0,
|
|
nextBool: () => true,
|
|
nextInt: (minInclusive: number) => minInclusive,
|
|
};
|
|
const healerLogs: string[] = [];
|
|
const patientLogs: Array<{ generalId: number; message: string; format: LogFormat | undefined }> = [];
|
|
const healerFormats: Array<LogFormat | undefined> = [];
|
|
const context = createGeneralTriggerContext({
|
|
general: healer,
|
|
rng,
|
|
worldView,
|
|
log: {
|
|
push: (message, options) => {
|
|
healerLogs.push(message);
|
|
healerFormats.push(options?.format);
|
|
},
|
|
pushForGeneral: (generalId, message, options) =>
|
|
patientLogs.push({ generalId, message, format: options?.format }),
|
|
},
|
|
});
|
|
|
|
pipeline.getPreTurnExecuteTriggerList(context).fire(context, {});
|
|
|
|
expect(patientLogs.map((log) => log.generalId)).toEqual([2, 3]);
|
|
expect(patientLogs.every((log) => log.message.includes('<Y>의사</>'))).toBe(true);
|
|
expect(patientLogs.every((log) => log.format === LogFormat.PLAIN)).toBe(true);
|
|
expect(healerLogs.at(-1)).toContain('<Y>환자을</> 외 <C>1</>명');
|
|
expect(healerFormats.every((format) => format === LogFormat.PLAIN)).toBe(true);
|
|
});
|
|
|
|
it('activates 의술 battle trigger and reduces damage', async () => {
|
|
const domestic = await loadDomesticTraitModules(['che_인덕', 'che_발명']);
|
|
const war = await loadWarTraitModules(['che_의술', 'che_징병']);
|
|
const registry = createTraitCatalog({ domestic, war });
|
|
const traitModules = createTraitModules(registry);
|
|
|
|
const rng = new RandUtil(new ConstantRNG(0));
|
|
const config = buildConfig();
|
|
const unitSet = buildUnitSet();
|
|
const crewType = new WarCrewType(unitSet.crewTypes![0]!);
|
|
const city = buildCity();
|
|
const nation = buildNation();
|
|
const general = buildGeneral({
|
|
injury: 10,
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: 'che_의술',
|
|
items: {
|
|
horse: null,
|
|
weapon: null,
|
|
book: null,
|
|
item: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const attacker = new WarUnitGeneral(
|
|
rng,
|
|
config,
|
|
general,
|
|
city,
|
|
nation,
|
|
true,
|
|
crewType,
|
|
new ActionLogger({ generalId: 1, nationId: 1 }),
|
|
new WarActionPipeline(traitModules.war)
|
|
);
|
|
const defender = new WarUnitCity(
|
|
rng,
|
|
config,
|
|
city,
|
|
nation,
|
|
new WarCrewType(unitSet.crewTypes![1]!),
|
|
new ActionLogger({}),
|
|
200,
|
|
180
|
|
);
|
|
|
|
attacker.setOppose(defender);
|
|
defender.setOppose(attacker);
|
|
attacker.beginPhase();
|
|
|
|
const caller = attacker.getActionPipeline().getBattlePhaseTriggerList(attacker.getActionContext());
|
|
caller.fire({ rng, attacker, defender }, createWarTriggerEnv());
|
|
|
|
expect(defender.getWarPowerMultiply()).toBeCloseTo(0.7);
|
|
expect(general.injury).toBe(0);
|
|
});
|
|
});
|