격노와 계략 효과의 PHP 호환 보정
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user