fix(logic): 전투 특기의 비전투 커맨드 효과를 복구

모병에서도 실제 행동명을 특기 계산에 전달해 징병 특기의 훈련과 사기를 레거시 값으로 맞춘다. 의술의 환자별 기록 소유권과 PLAIN 형식, 다인 치료 요약 대상도 복구하고 전투 특기 비전투 hook 회귀를 추가한다.
This commit is contained in:
2026-08-21 02:59:15 +00:00
parent 2a73f80b52
commit fe438c5195
7 changed files with 348 additions and 14 deletions
+33
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { createRefOrderedActionStack } from '../src/actionModules/bundle.js';
import type { GeneralActionModule } from '../src/actionModules/general.js';
import { ActionDefinition } from '../src/actions/turn/general/che_소집해제.js';
import { traitModule as recruitTrait } from '../src/actionModules/traits/war/che_징병.js';
describe('che_소집해제', () => {
it('applies legacy experience and dedication stat hooks', () => {
@@ -43,4 +44,36 @@ describe('che_소집해제', () => {
expect(general.experience).toBe(1_077);
expect(general.dedication).toBe(2_090);
});
it('does not return population when the general has the 징병 trait', () => {
const definition = new ActionDefinition({
generalActionModules: [recruitTrait],
} as never);
const general = {
crew: 500,
experience: 1_000,
dedication: 2_000,
stats: { leadership: 80, strength: 70, intelligence: 60 },
role: {
personality: null,
specialDomestic: null,
specialWar: 'che_징병',
items: { horse: null, weapon: null, book: null, item: null },
},
meta: {},
};
const city = { population: 10_000 };
definition.resolve(
{
general,
city,
addLog: () => undefined,
} as never,
{}
);
expect(general.crew).toBe(0);
expect(city.population).toBe(10_000);
});
});
@@ -13,8 +13,10 @@ import {
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';
@@ -164,6 +166,23 @@ const buildUnitSet = (): UnitSetDefinition => ({
});
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_징병']);
@@ -286,6 +305,56 @@ describe('trait modules', () => {
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_징병']);
@@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '../src/domain/entities.js';
import {
ActionDefinition as DraftActionDefinition,
CommandResolver as RecruitmentCommandResolver,
} from '../src/actions/turn/general/che_징병.js';
import { ActionDefinition as MercenaryActionDefinition } from '../src/actions/turn/general/che_모병.js';
import { StrategyCommandResolver, type StrategyActionConfig } from '../src/actions/turn/general/strategyCommand.js';
import { traitModule as recruitTrait } from '../src/actionModules/traits/war/che_징병.js';
import { traitModule as footmanTrait } from '../src/actionModules/traits/war/che_보병.js';
import { traitModule as archerTrait } from '../src/actionModules/traits/war/che_궁병.js';
import { traitModule as cavalryTrait } from '../src/actionModules/traits/war/che_기병.js';
import { traitModule as wizardTrait } from '../src/actionModules/traits/war/che_귀병.js';
import { traitModule as siegeTrait } from '../src/actionModules/traits/war/che_공성.js';
import { traitModule as strategistTrait } from '../src/actionModules/traits/war/che_신산.js';
const buildGeneral = (overrides: Partial<General> = {}): General => ({
id: 1,
name: '특기 감사 장수',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
experience: 0,
dedication: 0,
officerLevel: 1,
gold: 100_000,
rice: 100_000,
crew: 0,
crewTypeId: 1,
train: 0,
atmos: 0,
injury: 0,
age: 30,
stats: { leadership: 80, strength: 70, intelligence: 60 },
role: {
personality: null,
specialDomestic: null,
specialWar: 'che_징병',
items: { horse: null, weapon: null, book: null, item: null },
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
...overrides,
});
const buildNation = (): Nation => ({
id: 1,
name: '특기 감사국',
color: '#000000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 100_000,
rice: 100_000,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: { tech: 0 },
});
const buildCity = (): City => ({
id: 2,
name: '특기 감사성',
nationId: 2,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
defence: 500,
defenceMax: 1_000,
wall: 500,
wallMax: 1_000,
supplyState: 1,
frontState: 0,
meta: { trust: 50 },
});
describe('전투 특기의 비전투 커맨드 효과', () => {
it('징병 특기가 징병과 모병의 훈련·사기를 각각 70과 84로 설정한다', () => {
const context = { general: buildGeneral(), nation: buildNation() };
const draft = new RecruitmentCommandResolver([recruitTrait], {
actionName: '징병',
defaultTrain: 40,
defaultAtmos: 40,
});
const mercenary = new RecruitmentCommandResolver([recruitTrait], {
actionName: '모병',
costOffset: 2,
defaultTrain: 70,
defaultAtmos: 70,
});
expect(draft.getTrain(context)).toBe(70);
expect(draft.getAtmos(context)).toBe(70);
expect(mercenary.getTrain(context)).toBe(84);
expect(mercenary.getAtmos(context)).toBe(84);
});
it('실제 징병·모병 action이 특기 훈사와 인구 보존을 저장 상태에 반영한다', () => {
const unitSet = {
id: 'war-trait-command-audit',
name: '전투특기 커맨드 감사',
crewTypes: [{ id: 101, name: '감사병', armType: 1, cost: 10, rice: 1, requirements: [] }],
};
const map = { id: 'war-trait-command-audit', name: '전투특기 커맨드 감사', cities: [] };
for (const [definition, expectedReadiness] of [
[new DraftActionDefinition([recruitTrait], {}), 70],
[new MercenaryActionDefinition([recruitTrait]), 84],
] as const) {
const general = buildGeneral();
const city = { ...buildCity(), id: 1, nationId: 1, population: 100_000 };
definition.resolve(
{
general,
city,
nation: buildNation(),
map,
unitSet,
cities: [city],
addLog: () => undefined,
} as never,
{ crewType: 101, amount: 1_000 }
);
expect(general.crew).toBe(1_000);
expect(general.train).toBe(expectedReadiness);
expect(general.atmos).toBe(expectedReadiness);
expect(city.population).toBe(100_000);
}
});
it('징병 특기가 통솔 상한을 25% 높이고 징병 인구를 소모하지 않는다', () => {
const context = { general: buildGeneral(), nation: buildNation() };
const command = new RecruitmentCommandResolver([recruitTrait], { actionName: '징병' });
expect(command.resolveLeadership(context)).toBe(100);
expect(command.resolveCrewPlan(context, 2, 20_000)).toEqual({ requested: 20_000, applied: 10_000 });
expect(command.getRecruitPopulation(context, 10_000)).toBe(0);
});
it.each([
['che_보병', footmanTrait, 1],
['che_궁병', archerTrait, 2],
['che_기병', cavalryTrait, 3],
['che_귀병', wizardTrait, 4],
['che_공성', siegeTrait, 5],
] as const)('%s 특기가 해당 계통의 징병·모병 비용만 10% 낮춘다', (_key, trait, armType) => {
const context = { general: buildGeneral(), nation: buildNation() };
const crewTypeId = 100 + armType;
const command = new RecruitmentCommandResolver([trait], {
actionName: '모병',
costOffset: 2,
defaultTrain: 70,
defaultAtmos: 70,
});
expect(command.getCost(context, crewTypeId, 1_000, { armType, cost: 10 }).gold).toBe(180);
expect(command.getCost(context, crewTypeId, 1_000, { armType: 9, cost: 10 }).gold).toBe(200);
});
it.each([
['che_화계', '화계', 'intelligence', 'fire', true],
['che_선동', '선동', 'leadership', 'agitate', true],
['che_파괴', '파괴', 'strength', 'destroy', true],
['che_탈취', '탈취', 'strength', 'seize', false],
] as const)('신산 특기가 %s 성공 공격값을 10%p 높인다', (key, name, statKey, damageMode, injuryGeneral) => {
const config: StrategyActionConfig = {
key,
name,
statKey,
statExpKey:
statKey === 'intelligence' ? 'intel_exp' : statKey === 'leadership' ? 'leadership_exp' : 'strength_exp',
damageMode,
injuryGeneral,
};
const env = {
develCost: 100,
sabotageDefaultProb: 0.5,
sabotageProbCoefByStat: 300,
sabotageDefenceCoefByGeneralCount: 0.1,
sabotageDamageMin: 10,
sabotageDamageMax: 20,
};
const general = buildGeneral();
const context = {
general,
city: { ...buildCity(), id: 1, nationId: 1 },
nation: buildNation(),
destCity: buildCity(),
destNation: { ...buildNation(), id: 2 },
destGenerals: [],
distance: 1,
};
const base = general.stats[statKey] / env.sabotageProbCoefByStat;
expect(new StrategyCommandResolver([strategistTrait], env, config).getProbability(context).attack).toBeCloseTo(
base + 0.1,
12
);
});
});