격노와 계략 효과의 PHP 호환 보정

This commit is contained in:
2026-09-19 10:09:18 +00:00
parent e542a9beea
commit e34c24c74d
7 changed files with 541 additions and 11 deletions
@@ -0,0 +1,447 @@
import { readFile } from 'node:fs/promises';
import { ConstantRNG, RandUtil, type RandomGenerator } from '@sammo-ts/common';
import { describe, expect, it, vi } from 'vitest';
import { loadActionModuleBundle } from '../src/actionModules/bundle.js';
import {
DOMESTIC_TRAIT_KEYS,
EVENT_DOMESTIC_TRAIT_KEYS,
loadDomesticTraitModules,
loadEventDomesticTraitModules,
loadWarTraitModules,
WAR_TRAIT_KEYS,
} from '../src/actionModules/traits/index.js';
import { traitModule as rageTrait } from '../src/actionModules/traits/war/che_격노.js';
import { traitModule as recruitTrait } from '../src/actionModules/traits/war/che_징병.js';
import type { City, General, Nation } from '../src/domain/entities.js';
import {
createItemActionModules,
createItemModuleRegistry,
ITEM_KEYS,
loadItemModules,
} from '../src/items/index.js';
import { itemModule as defenceItem } from '../src/items/che_농성_주서음부.js';
import { itemModule as injuryItem } from '../src/items/che_부적_태현청생부.js';
import { itemModule as upperItem } from '../src/items/che_불굴_상편.js';
import { itemModule as intelligenceItem } from '../src/items/che_능력치_지력_이강주.js';
import { StrategyCommandResolver, type StrategyActionConfig, type StrategyContext } from '../src/actions/turn/general/strategyCommand.js';
import type { TurnCommandEnv } from '../src/actions/turn/commandEnv.js';
import { ActionLogger } from '../src/logging/actionLogger.js';
import { WarActionPipeline } from '../src/war/actions.js';
import { WarCrewType } from '../src/war/crewType.js';
import { createWarTriggerEnv } from '../src/war/triggers.js';
import { WarUnitGeneral } from '../src/war/units.js';
import type { WarEngineConfig } from '../src/war/types.js';
import { compileCrewTypeCatalog } from '../src/crewType/catalog.js';
import { createCrewTypeWarTriggerRegistry } from '../src/war/crewTypeTriggers.js';
import { parseUnitSetDefinition } from '../src/world/unitSet.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 = (id: number): Nation => ({
id,
name: `감사국${id}`,
color: '#000000',
capitalCityId: id,
chiefGeneralId: id,
gold: 100_000,
rice: 100_000,
power: 0,
level: 1,
typeCode: 'che_중립',
meta: { tech: 0 },
});
const city = (id: number, nationId: number): City => ({
id,
name: `감사성${id}`,
nationId,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 0,
securityMax: 1,
defence: 500,
defenceMax: 500,
wall: 500,
wallMax: 500,
supplyState: 0,
frontState: 0,
meta: {},
});
const general = (id: number, nationId: number, cityId: number, overrides: Partial<General> = {}): General => ({
id,
name: `감사장수${id}`,
nationId,
cityId,
troopId: 0,
stats: { leadership: 70, strength: 70, intelligence: 70 },
experience: 0,
dedication: 0,
officerLevel: 5,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 100_000,
rice: 100_000,
crew: 1_000,
crewTypeId: 1100,
train: 100,
atmos: 100,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
...overrides,
});
const unitType = (id: number, name: string, armType = 1) =>
new WarCrewType({
id,
name,
armType,
attack: 100,
defence: 100,
speed: 7,
avoid: 10,
magicCoef: 0,
cost: 10,
rice: 10,
requirements: [],
attackCoef: {},
defenceCoef: {},
info: [],
initSkillTrigger: null,
phaseSkillTrigger: null,
iActionList: null,
});
const buildUnit = (
value: General,
attacker: boolean,
pipeline: WarActionPipeline = new WarActionPipeline([]),
crew = unitType(value.crewTypeId, '보병')
): WarUnitGeneral =>
new WarUnitGeneral(
new RandUtil(new ConstantRNG(0)),
config,
value,
city(value.cityId, value.nationId),
nation(value.nationId),
attacker,
crew,
new ActionLogger({ generalId: value.id, nationId: value.nationId }),
pipeline
);
const strategyConfig: StrategyActionConfig = {
key: 'che_화계',
name: '화계',
statKey: 'intelligence',
statExpKey: 'intel_exp',
damageMode: 'fire',
injuryGeneral: true,
};
const strategyEnv: TurnCommandEnv = {
develCost: 100,
trainDelta: 35,
atmosDelta: 35,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
sabotageDefaultProb: 0.5,
sabotageProbCoefByStat: 300,
sabotageDefenceCoefByGeneralCount: 0,
sabotageDamageMin: 10,
sabotageDamageMax: 20,
openingPartYear: 180,
maxGeneral: 10,
defaultNpcGold: 1_000,
defaultNpcRice: 1_000,
defaultCrewTypeId: 1100,
defaultSpecialDomestic: null,
defaultSpecialWar: null,
initialNationGenLimit: 10,
maxTechLevel: 12,
baseGold: 1_000,
baseRice: 1_000,
maxResourceActionAmount: 1_000,
};
const strategyContext = (attacker: General, defender: General): StrategyContext => ({
general: attacker,
city: city(attacker.cityId, attacker.nationId),
nation: nation(attacker.nationId),
destCity: city(defender.cityId, defender.nationId),
destNation: nation(defender.nationId),
destGenerals: [defender],
distance: 1,
time: { year: 200, month: 1, startYear: 180 },
});
describe('hidche 효과 감사 회귀', () => {
it('A01: 격노 특기와 구정신단경은 시도는 두 번, 발동은 한 번만 처리한다', async () => {
const [item] = await loadItemModules(['che_격노_구정신단경']);
const pipeline = new WarActionPipeline([
rageTrait,
...createItemActionModules(createItemModuleRegistry([item!])).war,
]);
const attacker = buildUnit(
general(1, 1, 1, {
role: {
personality: null,
specialDomestic: null,
specialWar: 'che_격노',
items: { horse: null, weapon: null, book: null, item: 'che_격노_구정신단경' },
},
}),
true,
pipeline
);
const defender = buildUnit(general(2, 2, 2), false, pipeline);
defender.activateSkill('필살');
const rng = attacker.rng;
const nextBool = vi.spyOn(rng, 'nextBool').mockReturnValue(true);
vi.spyOn(attacker, 'criticalDamage').mockReturnValue(1.5);
const multiply = vi.spyOn(attacker, 'multiplyWarPowerMultiply');
pipeline
.getBattlePhaseTriggerList(attacker.getActionContext())
.fire({ rng, attacker, defender }, createWarTriggerEnv());
expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.5, 0.5]);
expect(multiply).toHaveBeenCalledExactlyOnceWith(1.5);
expect(attacker.getMaxPhase()).toBe(8);
});
it('A02: 격노 비급은 특기와 독립된 두 번째 시도와 진노 판정을 유지한다', async () => {
const [item] = await loadItemModules(['event_전투특기_격노']);
const pipeline = new WarActionPipeline([
rageTrait,
...createItemActionModules(createItemModuleRegistry([item!])).war,
]);
const attacker = buildUnit(
general(1, 1, 1, {
role: {
personality: null,
specialDomestic: null,
specialWar: 'che_격노',
items: { horse: null, weapon: null, book: null, item: 'event_전투특기_격노' },
},
}),
true,
pipeline
);
const defender = buildUnit(general(2, 2, 2), false, pipeline);
defender.activateSkill('회피');
const nextBool = vi
.spyOn(attacker.rng, 'nextBool')
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
pipeline
.getBattlePhaseTriggerList(attacker.getActionContext())
.fire({ rng: attacker.rng, attacker, defender }, createWarTriggerEnv());
expect(nextBool.mock.calls.map(([probability]) => probability)).toEqual([0.25, 0.25, 0.5]);
expect(attacker.hasActivatedSkill('격노')).toBe(true);
});
it('A03: 실제 번들 조립에서 삼황내문이 목우 RNG보다 먼저 강제 저지한다', async () => {
const raw = JSON.parse(
await readFile(new URL('../../../resources/unitset/unitset_che.json', import.meta.url), 'utf8')
) as unknown;
const unitSet = parseUnitSetDefinition(raw);
const bundle = await loadActionModuleBundle(unitSet);
const pipeline = new WarActionPipeline(bundle.war);
const ramDefinition = unitSet.crewTypes!.find((entry) => entry.id === 1503)!;
const defender = buildUnit(
general(2, 2, 2, {
crewTypeId: ramDefinition.id,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: 'che_저지_삼황내문' },
},
}),
false,
pipeline,
new WarCrewType(ramDefinition)
);
const attacker = buildUnit(general(1, 1, 1), true, pipeline);
const nextBool = vi.spyOn(defender.rng, 'nextBool');
pipeline
.getBattlePhaseTriggerList(defender.getActionContext())
.fire({ rng: defender.rng, attacker, defender }, createWarTriggerEnv());
expect(nextBool).not.toHaveBeenCalled();
expect(defender.hasActivatedSkill('특수')).toBe(true);
expect(defender.hasActivatedSkill('저지')).toBe(true);
});
it('A04: 상편은 부상·징병 특기·통솔 장비를 반영한 유효 통솔로 경계를 계산한다', async () => {
const [leadershipItem] = await loadItemModules(['che_명마_12_옥란백용구']);
const pipeline = new WarActionPipeline([
recruitTrait,
...createItemActionModules(createItemModuleRegistry([leadershipItem!, upperItem])).war,
]);
const value = general(1, 1, 1, {
injury: 20,
crew: 5_850,
stats: { leadership: 100, strength: 70, intelligence: 70 },
role: {
personality: null,
specialDomestic: null,
specialWar: 'che_징병',
items: { horse: 'che_명마_12_옥란백용구', weapon: null, book: null, item: upperItem.key },
},
});
const unit = buildUnit(value, true, pipeline);
const oppose = buildUnit(general(2, 2, 2), false, pipeline);
expect(unit.getComputedStat('leadership', value.stats.leadership)).toBe(117);
expect(unit.getComputedStat('leadership', value.stats.leadership)).toBeGreaterThan(value.stats.leadership);
pipeline
.getBattlePhaseTriggerList(unit.getActionContext())
.fire({ rng: unit.rng, attacker: unit, defender: oppose }, createWarTriggerEnv());
expect(unit.getWarPowerMultiply()).toBeCloseTo(1.3, 12);
});
it('A05: 농성 아이템은 실제 대상 장수 컨텍스트의 계략 방어 훅을 사용한다', () => {
const modules = createItemActionModules(createItemModuleRegistry([defenceItem])).general;
const defender = general(2, 2, 2, {
stats: { leadership: 0, strength: 0, intelligence: 0 },
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: defenceItem.key },
},
});
const resolver = new StrategyCommandResolver(modules, strategyEnv, strategyConfig);
expect(resolver.getProbability(strategyContext(general(1, 1, 1), defender)).defence).toBeCloseTo(0.3, 12);
});
it('A06: 태현청생부는 실제 대상 장수 컨텍스트의 계략 부상 훅을 사용한다', () => {
const modules = createItemActionModules(createItemModuleRegistry([injuryItem])).general;
const defender = general(2, 2, 2, {
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: injuryItem.key },
},
});
const resolver = new StrategyCommandResolver(modules, strategyEnv, strategyConfig);
const rng: RandomGenerator = {
nextFloat1: () => 0,
nextBool: (probability) => probability > 0,
nextInt: (minInclusive) => minInclusive,
};
const result = resolver.resolve(strategyContext(general(1, 1, 1), defender), rng);
expect(result.success).toBe(true);
expect(result.injuryCount).toBe(0);
expect(result.injuredGenerals).toEqual([]);
});
it('A07: 공격자와 각 방어자의 유효 능력치를 계산해 보정 후 최대값을 선택한다', () => {
const modules = createItemActionModules(createItemModuleRegistry([intelligenceItem])).general;
const attacker = general(1, 1, 1, {
stats: { leadership: 70, strength: 0, intelligence: 70 },
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: intelligenceItem.key },
},
});
const rawBest = general(2, 2, 2, { stats: { leadership: 70, strength: 0, intelligence: 90 } });
const effectiveBest = general(3, 2, 2, {
stats: { leadership: 70, strength: 0, intelligence: 85 },
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: intelligenceItem.key },
},
});
const context = strategyContext(attacker, rawBest);
context.destGenerals = [rawBest, effectiveBest];
const resolver = new StrategyCommandResolver(modules, strategyEnv, strategyConfig);
const probability = resolver.getProbability(context);
expect(probability.attack).toBeCloseTo(80 / 300, 12);
expect(probability.defence).toBeCloseTo(95 / 300, 12);
});
it('catalog: all shipped item and crew effect references resolve to executable contracts', async () => {
const bundle = await loadActionModuleBundle();
const items = await loadItemModules([...ITEM_KEYS]);
const domesticTraits = await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]);
const warTraits = await loadWarTraitModules([...WAR_TRAIT_KEYS]);
const eventDomesticTraits = await loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]);
const effectHooks = [
'onCalcDomestic',
'onCalcStat',
'onCalcOpposeStat',
'onCalcStrategic',
'onCalcNationalIncome',
'eventHandlers',
'getPreTurnExecuteTriggerList',
'getBattleInitTriggerList',
'getBattlePhaseTriggerList',
'getWarPowerMultiplier',
] as const;
expect(items).toHaveLength(ITEM_KEYS.length);
for (const item of [...items, ...domesticTraits, ...warTraits, ...eventDomesticTraits]) {
expect(effectHooks.some((hook) => item[hook] !== undefined), item.key).toBe(true);
}
expect(bundle.general.length).toBeGreaterThan(0);
expect(bundle.war.length).toBeGreaterThan(0);
const raw = JSON.parse(
await readFile(new URL('../../../resources/unitset/unitset_che.json', import.meta.url), 'utf8')
) as unknown;
const unitSet = parseUnitSetDefinition(raw);
const catalog = compileCrewTypeCatalog(unitSet, createCrewTypeWarTriggerRegistry());
for (const definition of unitSet.crewTypes ?? []) {
const compiled = catalog.byId.get(definition.id);
expect(compiled, definition.name).toBeDefined();
expect(compiled?.actions, definition.name).toHaveLength(definition.iActionList?.length ?? 0);
for (const triggerKey of [
...(definition.initSkillTrigger ?? []),
...(definition.phaseSkillTrigger ?? []),
]) {
expect(createCrewTypeWarTriggerRegistry()[triggerKey], `${definition.name}:${triggerKey}`).toBeTypeOf(
'function'
);
}
}
});
});
@@ -199,7 +199,13 @@ describe('전투 특기의 비전투 커맨드 효과', () => {
destGenerals: [],
distance: 1,
};
const base = general.stats[statKey] / env.sabotageProbCoefByStat;
const effectiveStat =
statKey === 'intelligence'
? general.stats.intelligence + Math.round(general.stats.strength / 4)
: statKey === 'strength'
? general.stats.strength + Math.round(general.stats.intelligence / 4)
: general.stats.leadership;
const base = effectiveStat / env.sabotageProbCoefByStat;
expect(new StrategyCommandResolver([strategistTrait], env, config).getProbability(context).attack).toBeCloseTo(
base + 0.1,