격노와 계략 효과의 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
@@ -29,6 +29,8 @@ import type { Constraint, ConstraintContext, StateView } from '@sammo-ts/logic/c
import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { City, General, GeneralMeta, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js';
import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import { LogFormat } from '@sammo-ts/logic/logging/types.js';
import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js';
import type { GeneralStatName } from '@sammo-ts/logic/actionModules/types.js';
import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js';
import { searchDistance } from '@sammo-ts/logic/world/distance.js'; import { searchDistance } from '@sammo-ts/logic/world/distance.js';
import { clamp } from 'es-toolkit'; import { clamp } from 'es-toolkit';
import { z } from 'zod'; import { z } from 'zod';
@@ -64,8 +66,6 @@ export interface StrategyEnvironment {
sabotageDamageMax: number; sabotageDamageMax: number;
maxSuccessProbability?: number; maxSuccessProbability?: number;
getDistance?: (sourceCityId: number, destCityId: number) => number | null; getDistance?: (sourceCityId: number, destCityId: number) => number | null;
getDefenceCorrection?: (context: StrategyContext, defender: General) => number;
getInjuryProbability?: (context: StrategyContext, defender: General) => number;
} }
export interface StrategyContext< export interface StrategyContext<
@@ -119,7 +119,65 @@ const INJURY_MAX = 80;
const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1); const randomRangeInt = (rng: RandomGenerator, min: number, max: number): number => rng.nextInt(min, max + 1);
const getStatValue = (general: General, statKey: StrategyStatKey): number => general.stats[statKey]; const createTargetActionContext = <TriggerState extends GeneralTriggerState>(
context: StrategyContext<TriggerState>,
general: General<TriggerState>,
nation: Nation | null | undefined
): GeneralActionContext<TriggerState> => ({
...context,
general,
nation: nation ?? null,
});
interface EffectiveStatOptions {
withInjury?: boolean;
withStatAdjust?: boolean;
withActions?: boolean;
truncate?: boolean;
}
// Ref General::getStatValue()의 순서와 정수화 경계를 일반 장수 커맨드에서도 사용한다.
const getEffectiveStatValue = <TriggerState extends GeneralTriggerState>(
pipeline: GeneralActionPipeline<TriggerState>,
context: GeneralActionContext<TriggerState>,
statKey: StrategyStatKey,
options: EffectiveStatOptions = {}
): number => {
const withInjury = options.withInjury ?? true;
const withStatAdjust = options.withStatAdjust ?? true;
const withActions = options.withActions ?? true;
const truncate = options.truncate ?? true;
const general = context.general;
const injuryRatio = withInjury ? (100 - general.injury) / 100 : 1;
let value = general.stats[statKey] * injuryRatio;
if (withStatAdjust && statKey === 'strength') {
value += Math.round(
getEffectiveStatValue(pipeline, context, 'intelligence', {
withInjury,
withStatAdjust: false,
withActions,
truncate: false,
}) / 4
);
} else if (withStatAdjust && statKey === 'intelligence') {
value += Math.round(
getEffectiveStatValue(pipeline, context, 'strength', {
withInjury,
withStatAdjust: false,
withActions,
truncate: false,
}) / 4
);
}
value = clamp(value, 0, LEGACY_DEFAULT_MAX_LEVEL);
if (withActions) {
value = pipeline.onCalcStat(context, statKey as GeneralStatName, value);
}
value = clamp(value, 0, LEGACY_DEFAULT_MAX_LEVEL);
return truncate ? Math.trunc(value) : value;
};
const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => { const addMetaNumber = (meta: GeneralMeta, key: string, delta: number): GeneralMeta => {
const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0; const current = typeof meta[key] === 'number' ? (meta[key] as number) : 0;
@@ -144,7 +202,8 @@ export class StrategyCommandResolver<TriggerState extends GeneralTriggerState =
} }
getProbability(context: StrategyContext<TriggerState>): StrategyProbability { getProbability(context: StrategyContext<TriggerState>): StrategyProbability {
const attackBase = getStatValue(context.general, this.config.statKey) / this.env.sabotageProbCoefByStat; const attackBase =
getEffectiveStatValue(this.pipeline, context, this.config.statKey) / this.env.sabotageProbCoefByStat;
const attack = this.pipeline.onCalcDomestic(context, '계략', 'success', attackBase); const attack = this.pipeline.onCalcDomestic(context, '계략', 'success', attackBase);
const destNationId = context.destCity.nationId; const destNationId = context.destCity.nationId;
@@ -156,8 +215,16 @@ export class StrategyCommandResolver<TriggerState extends GeneralTriggerState =
continue; continue;
} }
affectCount += 1; affectCount += 1;
maxStat = Math.max(maxStat, getStatValue(defender, this.config.statKey)); const defenderContext = createTargetActionContext(context, defender, context.destNation);
defenceCorrection += this.env.getDefenceCorrection?.(context, defender) ?? 0; maxStat = Math.max(
maxStat,
getEffectiveStatValue(this.pipeline, defenderContext, this.config.statKey)
);
defenceCorrection = this.pipeline.onCalcStat(
defenderContext,
'sabotageDefence',
defenceCorrection
);
} }
let defence = maxStat / this.env.sabotageProbCoefByStat; let defence = maxStat / this.env.sabotageProbCoefByStat;
@@ -203,7 +270,8 @@ export class StrategyCommandResolver<TriggerState extends GeneralTriggerState =
if (defender.nationId !== context.destCity.nationId) { if (defender.nationId !== context.destCity.nationId) {
continue; continue;
} }
const injuryProbability = this.env.getInjuryProbability?.(context, defender) ?? 0.3; const defenderContext = createTargetActionContext(context, defender, context.destNation);
const injuryProbability = this.pipeline.onCalcStat(defenderContext, 'injuryProb', 0.3);
if (!rng.nextBool(injuryProbability)) { if (!rng.nextBool(injuryProbability)) {
continue; continue;
} }
@@ -23,7 +23,7 @@ export const itemModule: ItemModule = {
if (!context.unit) return null; if (!context.unit) return null;
return new WarTriggerCaller( return new WarTriggerCaller(
new che_격노시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM), new che_격노시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM),
new che_격노발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM) new che_격노발동(context.unit)
); );
}, },
}; };
@@ -23,7 +23,7 @@ export const itemModule: ItemModule = {
return null; return null;
} }
const general = unit.getGeneral(); const general = unit.getGeneral();
const leadership = general.stats.leadership; const leadership = unit.getComputedStat('leadership', general.stats.leadership);
const crew = general.crew; const crew = general.crew;
const crewRatio = clamp(crew / (leadership * 100), 0, 1); const crewRatio = clamp(crew / (leadership * 100), 0, 1);
return new WarTriggerCaller(new (unit, 1 + 0.6 * (1 - crewRatio))); return new WarTriggerCaller(new (unit, 1 + 0.6 * (1 - crewRatio)));
@@ -8,7 +8,7 @@ const ITEM_KEY = 'che_저지_삼황내문';
class ActivateItemHalt extends BaseWarUnitTrigger { class ActivateItemHalt extends BaseWarUnitTrigger {
constructor(unit: WarUnit) { constructor(unit: WarUnit) {
super(unit, TriggerPriority.Pre); super(unit, TriggerPriority.Begin);
} }
protected actionWar(self: WarUnit): boolean { protected actionWar(self: WarUnit): boolean {
@@ -3,6 +3,7 @@ import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/trigge
import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js'; import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js';
import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js'; import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js';
import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js'; import { che_반계발동, che_반계시도 } from '@sammo-ts/logic/war/triggers/che_반계.js';
import { che_격노발동, che_격노시도 } from '@sammo-ts/logic/war/triggers/che_격노.js';
import type { ItemModule } from './types.js'; import type { ItemModule } from './types.js';
export const createEventBattleTraitItemModule = ( export const createEventBattleTraitItemModule = (
@@ -73,6 +74,14 @@ export const createEventBattleTraitItemModule = (
new che_의술발동(context.unit) new che_의술발동(context.unit)
) )
: null; : null;
} else if (traitModule.key === 'che_격노') {
itemModule.getBattlePhaseTriggerList = (context) =>
context.unit
? new WarTriggerCaller(
new che_격노시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM),
new che_격노발동(context.unit)
)
: null;
} else { } else {
itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList; itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList;
} }
@@ -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: [], destGenerals: [],
distance: 1, 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( expect(new StrategyCommandResolver([strategistTrait], env, config).getProbability(context).attack).toBeCloseTo(
base + 0.1, base + 0.1,