diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index d8b1cb2..f1acfa2 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -99,6 +99,7 @@ export const buildBattleSimEnvironment = async ( maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], DEFAULT_WAR_CONFIG.maxAtmosByCommand), maxTrainByWar: resolveNumber(constValues, ['maxTrainByWar'], DEFAULT_WAR_CONFIG.maxTrainByWar), maxAtmosByWar: resolveNumber(constValues, ['maxAtmosByWar'], DEFAULT_WAR_CONFIG.maxAtmosByWar), + maxGeneralStat: resolveNumber(constValues, ['maxLevel'], 255), castleCrewTypeId, armTypes: { footman: 1, diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index aec1414..168d595 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; +import type { RandUtil } from '@sammo-ts/common'; import { formatLogText, getTechCost, @@ -13,6 +14,16 @@ import { ITEM_KEYS, loadItemModules, createInheritBuffModules, + createTraitModuleRegistry, + DOMESTIC_TRAIT_KEYS, + loadDomesticTraitModules, + loadNationTraitModules, + loadPersonalityTraitModules, + loadWarTraitModules, + NATION_TRAIT_KEYS, + PERSONALITY_TRAIT_KEYS, + TraitWarActionRouter, + WAR_TRAIT_KEYS, compileCrewTypeCatalog, createCrewTypeWarTriggerRegistry, type City, @@ -21,7 +32,9 @@ import { type UnitSetDefinition, type WarBattleOutcome, type WarActionModule, + type WarActionContext, type WarUnitReport, + type WarBattleTraceEvent, type CrewTypeDefinition, } from '@sammo-ts/logic'; @@ -35,10 +48,50 @@ const itemWarModules: WarActionModule[] = createItemActionModules( createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])) ).war; const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry(); +const traitRegistry = createTraitModuleRegistry({ + domestic: await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]), + war: await loadWarTraitModules([...WAR_TRAIT_KEYS]), + personality: await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), + nation: await loadNationTraitModules([...NATION_TRAIT_KEYS]), +}); +const traitWarModules: WarActionModule[] = [ + new TraitWarActionRouter('nation', traitRegistry), + { + onCalcStat: (context: WarActionContext, statName, value) => { + if (statName !== 'leadership' || typeof value !== 'number') { + return value; + } + let officerLevel = context.general.officerLevel; + // Legacy simulator payload omits general.city, so low city offices are + // always downgraded by TriggerOfficerLevel's officer_city comparison. + if (officerLevel >= 2 && officerLevel <= 4) { + officerLevel = 1; + } + const nationLevel = context.nation?.level ?? 0; + const leadershipBonus = officerLevel === 12 ? nationLevel * 2 : officerLevel >= 5 ? nationLevel : 0; + return value + leadershipBonus; + }, + getWarPowerMultiplier: (context: WarActionContext) => { + let officerLevel = context.general.officerLevel; + if (officerLevel >= 2 && officerLevel <= 4) { + officerLevel = 1; + } + if (officerLevel === 12) return [1.07, 0.93]; + if (officerLevel === 11) return [1.05, 0.95]; + if ([10, 8, 6].includes(officerLevel)) return [1.1, 1]; + if ([9, 7, 5].includes(officerLevel)) return [1, 0.9]; + if ([4, 3, 2].includes(officerLevel)) return [1.05, 0.95]; + return [1, 1]; + }, + }, + new TraitWarActionRouter('domestic', traitRegistry), + new TraitWarActionRouter('war', traitRegistry), + new TraitWarActionRouter('personality', traitRegistry), +]; const buildWarActionModules = (unitSet: UnitSetDefinition): WarActionModule[] => { const crewTypeCatalog = compileCrewTypeCatalog(unitSet, crewTypeWarTriggerRegistry); - return [crewTypeCatalog.warActionModule, inheritBuffModules.war, ...itemWarModules]; + return [...traitWarModules, crewTypeCatalog.warActionModule, inheritBuffModules.war, ...itemWarModules]; }; const normalizeItemCode = (value: string | null): string | null => (value === 'None' ? null : value); @@ -284,7 +337,15 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => }); }; -export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResultPayload => { +export interface BattleSimProcessorOptions { + trace?: (event: WarBattleTraceEvent) => void; + rngFactory?: (seed: string) => RandUtil; +} + +export const processBattleSimJob = ( + payload: BattleSimJobPayload, + options: BattleSimProcessorOptions = {} +): BattleSimResultPayload => { if (payload.action === 'reorder') { return { result: true, @@ -333,6 +394,7 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu const outcome = resolveWarBattle({ seed, + rng: options.rngFactory?.(seed), unitSet: payload.unitSet, config: payload.config, time: payload.time, @@ -350,6 +412,7 @@ export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResu })), defenderCity, defenderNation, + trace: options.trace, }); lastBattle = outcome; diff --git a/packages/logic/src/triggers/types.ts b/packages/logic/src/triggers/types.ts index 82529d4..fc8746f 100644 --- a/packages/logic/src/triggers/types.ts +++ b/packages/logic/src/triggers/types.ts @@ -48,6 +48,7 @@ export type WarStatName = | 'killRice' | 'criticalDamageRange' | 'warMagicSuccessDamage' + | 'warMagicFailDamage' | 'warMagicTrialProb' | 'warMagicSuccessProb' | 'addDex' diff --git a/packages/logic/src/war/crewTypeTriggers.ts b/packages/logic/src/war/crewTypeTriggers.ts index a45ea35..5875c4c 100644 --- a/packages/logic/src/war/crewTypeTriggers.ts +++ b/packages/logic/src/war/crewTypeTriggers.ts @@ -1,9 +1,12 @@ -import type { WarTriggerRegistry } from './triggers.js'; +import { WarTriggerCaller, type WarTriggerRegistry } from './triggers.js'; import { che_기병병종전투 } from './triggers/che_기병병종전투.js'; import { che_방어력증가5p } from './triggers/che_방어력증가5p.js'; import { che_선제사격발동, che_선제사격시도 } from './triggers/che_선제사격.js'; import { che_성벽부상무효 } from './triggers/che_성벽부상무효.js'; import { che_저지, che_저지_시도 } from './triggers/che_저지.js'; +import { che_필살발동, che_필살시도 } from './triggers/che_필살.js'; +import { che_회피발동, che_회피시도 } from './triggers/che_회피.js'; +import { che_계략발동, che_계략실패, che_계략시도 } from './triggers/che_계략.js'; export const CREW_TYPE_WAR_TRIGGER_KEYS = [ 'che_성벽부상무효', @@ -13,6 +16,9 @@ export const CREW_TYPE_WAR_TRIGGER_KEYS = [ 'che_선제사격발동', 'che_저지시도', 'che_저지발동', + 'che_필살', + 'che_회피', + 'che_계략', ] as const; export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({ @@ -23,4 +29,8 @@ export const createCrewTypeWarTriggerRegistry = (): WarTriggerRegistry => ({ che_선제사격발동: (unit) => new che_선제사격발동(unit), che_저지시도: (unit) => new che_저지_시도(unit), che_저지발동: (unit) => new che_저지(unit), + che_필살: (unit) => new WarTriggerCaller(new che_필살시도(unit), new che_필살발동(unit)), + che_회피: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)), + che_계략: (unit) => + new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)), }); diff --git a/packages/logic/src/war/engine.ts b/packages/logic/src/war/engine.ts index a91e7dc..c100d53 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -9,7 +9,13 @@ import { WarActionPipeline, type WarActionModule } from './actions.js'; import { createCrewTypeWarTriggerRegistry } from './crewTypeTriggers.js'; import { WarCrewType } from './crewType.js'; import { WarTriggerCaller, createWarTriggerEnv, type WarTriggerRegistry } from './triggers.js'; -import type { WarBattleInput, WarBattleOutcome, WarGeneralInput, WarUnitReport } from './types.js'; +import type { + WarBattleInput, + WarBattleOutcome, + WarBattleTraceUnitSnapshot, + WarGeneralInput, + WarUnitReport, +} from './types.js'; import { getMetaNumber } from './utils.js'; import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js'; @@ -77,7 +83,7 @@ const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): W if (unit instanceof WarUnitGeneral) { const context = unit.getActionContext(); caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context)); - } else { + } else if (!(unit instanceof WarUnitCity && unit.isSiege())) { appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry); } return caller; @@ -86,12 +92,10 @@ const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): W const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => { const caller = new WarTriggerCaller(); if (unit instanceof WarUnitGeneral) { - if (registry['che_필살']) { - appendCrewTypeTriggers(caller, unit, ['che_필살'], registry); - } + appendCrewTypeTriggers(caller, unit, ['che_필살', 'che_회피', 'che_계략'], registry); const context = unit.getActionContext(); caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context)); - } else { + } else if (!(unit instanceof WarUnitCity && unit.isSiege())) { appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry); } return caller; @@ -192,6 +196,57 @@ const resolveUnitReport = (unit: WarUnit): WarUnitReport => { }; }; +const buildTraceUnitSnapshot = ( + unit: WarUnit, + defenderCity: City +): WarBattleTraceUnitSnapshot => { + const common = { + kind: unit instanceof WarUnitGeneral ? ('general' as const) : ('city' as const), + id: unit instanceof WarUnitGeneral ? unit.getGeneral().id : (unit as WarUnitCity).getCityId(), + name: unit.getName(), + isAttacker: unit.isAttacker(), + crewTypeId: unit.getCrewType().id, + phase: unit.getPhase(), + realPhase: unit.getRealPhase(), + maxPhase: unit.getMaxPhase(), + hp: unit.getHP(), + rawWarPower: unit.getRawWarPower(), + warPower: unit.getWarPower(), + warPowerMultiplier: unit.getWarPowerMultiply(), + killed: unit.getKilled(), + dead: unit.getDead(), + activatedSkills: unit.getActivatedSkillLog(), + }; + if (unit instanceof WarUnitGeneral) { + const general = unit.getGeneral(); + return { + ...common, + general: { + crew: general.crew, + rice: general.rice, + train: general.train, + atmos: general.atmos, + injury: general.injury, + experience: general.experience, + dedication: general.dedication, + dex1: getMetaNumber(general.meta, 'dex1'), + dex2: getMetaNumber(general.meta, 'dex2'), + dex3: getMetaNumber(general.meta, 'dex3'), + dex4: getMetaNumber(general.meta, 'dex4'), + dex5: getMetaNumber(general.meta, 'dex5'), + }, + }; + } + return { + ...common, + cityState: { + defence: defenderCity.defence, + wall: defenderCity.wall, + population: defenderCity.population, + }, + }; +}; + const flushLoggers = (loggers: ActionLogger[]): Array[number]> => { const logs: Array[number]> = []; for (const logger of loggers) { @@ -307,6 +362,22 @@ export const resolveWarBattle = | null, + details: Record = {} + ): void => { + input.trace?.({ + seq: traceSeq++, + event, + attacker: buildTraceUnitSnapshot(attackerUnit, input.defenderCity), + defender: currentDefender ? buildTraceUnitSnapshot(currentDefender, input.defenderCity) : null, + city: buildTraceUnitSnapshot(cityUnit, input.defenderCity), + details, + }); + }; + emitTrace('battle_start', defender, { seed: input.seed ?? '' }); const attackerNationName = (attackerUnit.getNationVar('name') as string | null) ?? 'UNKNOWN'; const attackerName = attackerUnit.getName(); @@ -349,6 +420,7 @@ export const resolveWarBattle = unit.getLogger())]); diff --git a/packages/logic/src/war/triggers/che_계략.ts b/packages/logic/src/war/triggers/che_계략.ts new file mode 100644 index 0000000..77e93e0 --- /dev/null +++ b/packages/logic/src/war/triggers/che_계략.ts @@ -0,0 +1,169 @@ +import { JosaUtil } from '@sammo-ts/common'; + +import { LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; +import type { WarTriggerModule } from './types.js'; + +const MAGIC_TO_GENERAL = { + 위보: [1.2, 1.1], + 매복: [1.4, 1.2], + 반목: [1.6, 1.3], + 화계: [1.8, 1.4], + 혼란: [2.0, 1.5], +} as const; + +const MAGIC_TO_CITY = { + 급습: [1.2, 1.1], + 위보: [1.4, 1.2], + 혼란: [1.6, 1.3], +} as const; + +type MagicState = [name: string, damage: number]; + +const applyMagicDamageModifiers = ( + self: WarUnitGeneral, + oppose: WarUnit, + statName: 'warMagicSuccessDamage' | 'warMagicFailDamage', + damage: number, + magic: string +): number => { + let result = self.getActionPipeline().onCalcStat(self.getActionContext(), statName, damage, magic); + if (oppose instanceof WarUnitGeneral) { + result = oppose.getActionPipeline().onCalcOpposeStat(oppose.getActionContext(), statName, result, magic); + } + return result; +}; + +export class che_계략시도 extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Pre + 300); + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!(self instanceof WarUnitGeneral) || self.hasActivatedSkill('계략불가')) return true; + + const general = self.getGeneral(); + let trialProbability = + (self.getComputedStat('intelligence', general.stats.intelligence) / 100) * + self.getCrewType().magicCoef; + trialProbability = self + .getActionPipeline() + .onCalcStat(self.getActionContext(), 'warMagicTrialProb', trialProbability); + if (oppose instanceof WarUnitGeneral) { + trialProbability = oppose + .getActionPipeline() + .onCalcOpposeStat(oppose.getActionContext(), 'warMagicTrialProb', trialProbability); + } + if (trialProbability <= 0) return true; + + const rawIntelligence = general.stats.intelligence; + const allRawStats = general.stats.leadership + general.stats.strength + rawIntelligence; + if (self.getPhase() === 0 && rawIntelligence * 3 >= allRawStats) { + trialProbability *= 3; + } + if (!self.rng.nextBool(trialProbability)) return true; + + let successProbability = 0.7; + successProbability = self + .getActionPipeline() + .onCalcStat(self.getActionContext(), 'warMagicSuccessProb', successProbability); + if (oppose instanceof WarUnitGeneral) { + successProbability = oppose + .getActionPipeline() + .onCalcOpposeStat(oppose.getActionContext(), 'warMagicSuccessProb', successProbability); + } + + const table = oppose instanceof WarUnitCity ? MAGIC_TO_CITY : MAGIC_TO_GENERAL; + const magic = self.rng.choice(Object.keys(table)); + const [rawSuccessDamage, failDamage] = table[magic as keyof typeof table]; + const successDamage = applyMagicDamageModifiers( + self, + oppose, + 'warMagicSuccessDamage', + rawSuccessDamage, + magic + ); + + self.activateSkill('계략시도', magic); + if (self.rng.nextBool(successProbability)) { + self.activateSkill('계략'); + selfEnv['magic'] = [magic, successDamage] satisfies MagicState; + } else { + self.activateSkill('계략실패'); + selfEnv['magic'] = [magic, failDamage] satisfies MagicState; + } + return true; + } +} + +const readMagic = (env: Record): MagicState | null => { + const value = env['magic']; + if (!Array.isArray(value) || typeof value[0] !== 'string' || typeof value[1] !== 'number') return null; + return [value[0], value[1]]; +}; + +export class che_계략발동 extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Post + 300); + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략') || selfEnv['계략발동']) return true; + const magicState = readMagic(selfEnv); + if (!magicState) return true; + selfEnv['계략발동'] = true; + const [magic, rawDamage] = magicState; + const damage = applyMagicDamageModifiers(self, oppose, 'warMagicFailDamage', rawDamage, magic); + const particle = JosaUtil.pick(magic, '을'); + self.getLogger().pushGeneralBattleDetailLog(`${magic}${particle} 성공했다!`, LogFormat.PLAIN); + oppose.getLogger().pushGeneralBattleDetailLog(`${magic}에 당했다!`, LogFormat.PLAIN); + self.multiplyWarPowerMultiply(damage); + return true; + } +} + +export class che_계략실패 extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Post + 300); + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!(self instanceof WarUnitGeneral) || !self.hasActivatedSkill('계략실패') || selfEnv['계략실패']) return true; + const magicState = readMagic(selfEnv); + if (!magicState) return true; + selfEnv['계략실패'] = true; + const [magic, rawDamage] = magicState; + const damage = applyMagicDamageModifiers(self, oppose, 'warMagicFailDamage', rawDamage, magic); + const particle = JosaUtil.pick(magic, '을'); + self.getLogger().pushGeneralBattleDetailLog(`${magic}${particle} 실패했다!`, LogFormat.PLAIN); + oppose.getLogger().pushGeneralBattleDetailLog(`${magic}${particle} 간파했다!`, LogFormat.PLAIN); + self.multiplyWarPowerMultiply(1 / damage); + oppose.multiplyWarPowerMultiply(damage); + return true; + } +} + +export const triggerModule: WarTriggerModule = { + key: 'che_계략', + name: '계략', + info: '[전투] 귀병의 계략 시도/성공/실패', + createTriggerList: (unit) => + new WarTriggerCaller(new che_계략시도(unit), new che_계략발동(unit), new che_계략실패(unit)), +}; diff --git a/packages/logic/src/war/triggers/che_회피.ts b/packages/logic/src/war/triggers/che_회피.ts new file mode 100644 index 0000000..0699cbb --- /dev/null +++ b/packages/logic/src/war/triggers/che_회피.ts @@ -0,0 +1,50 @@ +import { LogFormat } from '@sammo-ts/logic/logging/types.js'; +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; +import type { WarTriggerModule } from './types.js'; + +export class che_회피시도 extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Pre + 200); + } + + protected actionWar( + self: WarUnit, + _oppose: WarUnit, + _selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!(self instanceof WarUnitGeneral)) return true; + if (self.hasActivatedSkill('특수') || self.hasActivatedSkill('회피불가')) return true; + if (!self.rng.nextBool(self.getComputedAvoidRatio())) return true; + self.activateSkill('회피시도', '회피'); + return true; + } +} + +export class che_회피발동 extends BaseWarUnitTrigger { + constructor(unit: WarUnit) { + super(unit, TriggerPriority.Post + 500); + } + + protected actionWar( + self: WarUnit, + oppose: WarUnit, + _selfEnv: Record, + _opposeEnv: Record + ): boolean { + if (!self.hasActivatedSkill('회피')) return true; + oppose.getLogger().pushGeneralBattleDetailLog('상대가 회피했다!', LogFormat.PLAIN); + self.getLogger().pushGeneralBattleDetailLog('회피했다!', LogFormat.PLAIN); + oppose.multiplyWarPowerMultiply(1 / 6); + return true; + } +} + +export const triggerModule: WarTriggerModule = { + key: 'che_회피', + name: '회피', + info: '[전투] 페이즈마다 확률로 회피 발동', + createTriggerList: (unit) => new WarTriggerCaller(new che_회피시도(unit), new che_회피발동(unit)), +}; diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts index dd87188..d5357b2 100644 --- a/packages/logic/src/war/types.ts +++ b/packages/logic/src/war/types.ts @@ -23,6 +23,7 @@ export interface WarEngineConfig { maxAtmosByCommand: number; maxTrainByWar: number; maxAtmosByWar: number; + maxGeneralStat?: number; castleCrewTypeId: number; armTypes: WarArmTypes; } @@ -53,6 +54,53 @@ export interface WarBattleInput ActionLogger; + trace?: (event: WarBattleTraceEvent) => void; +} + +export interface WarBattleTraceUnitSnapshot { + kind: 'general' | 'city'; + id: number; + name: string; + isAttacker: boolean; + crewTypeId: number; + phase: number; + realPhase: number; + maxPhase: number; + hp: number; + rawWarPower: number; + warPower: number; + warPowerMultiplier: number; + killed: number; + dead: number; + activatedSkills: Record; + general?: { + crew: number; + rice: number; + train: number; + atmos: number; + injury: number; + experience: number; + dedication: number; + dex1: number; + dex2: number; + dex3: number; + dex4: number; + dex5: number; + }; + cityState?: { + defence: number; + wall: number; + population: number; + }; +} + +export interface WarBattleTraceEvent { + seq: number; + event: string; + attacker: WarBattleTraceUnitSnapshot; + defender: WarBattleTraceUnitSnapshot | null; + city: WarBattleTraceUnitSnapshot; + details: Record; } export interface WarUnitReport { diff --git a/packages/logic/src/war/units/general.ts b/packages/logic/src/war/units/general.ts index 2e7b464..8244591 100644 --- a/packages/logic/src/war/units/general.ts +++ b/packages/logic/src/war/units/general.ts @@ -147,14 +147,33 @@ export class WarUnitGeneral< return phase + this.bonusPhase; } - private resolveStatValue(statName: keyof StatBlock, base: number): number { - return this.actionPipeline.onCalcStat(this.getActionContext(), statName, base); + public getComputedStat( + statName: keyof StatBlock, + base: number, + options: { withInjury?: boolean; withStatAdjust?: boolean; withActions?: boolean } = {} + ): number { + const withInjury = options.withInjury ?? true; + const withStatAdjust = options.withStatAdjust ?? true; + const withActions = options.withActions ?? true; + const injuryRatio = withInjury ? (100 - this.general.injury) / 100 : 1; + let value = base * injuryRatio; + if (withStatAdjust && statName === 'strength') { + value += round((this.general.stats.intelligence * injuryRatio) / 4); + } else if (withStatAdjust && statName === 'intelligence') { + value += round((this.general.stats.strength * injuryRatio) / 4); + } + const maxGeneralStat = this.config.maxGeneralStat ?? 255; + value = clamp(value, 0, maxGeneralStat); + if (withActions) { + value = this.actionPipeline.onCalcStat(this.getActionContext(), statName, value); + } + return clamp(value, 0, maxGeneralStat); } - private resolveMainStat(armType: number): number { - const leadership = this.resolveStatValue('leadership', this.general.stats.leadership); - const strength = this.resolveStatValue('strength', this.general.stats.strength); - const intelligence = this.resolveStatValue('intelligence', this.general.stats.intelligence); + private resolveMainStat(armType: number, withInjury = true): number { + const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { withInjury }); + const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury }); + const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { withInjury }); if (armType === this.config.armTypes.wizard) { return intelligence; @@ -173,20 +192,22 @@ export class WarUnitGeneral< if (!(oppose instanceof WarUnitGeneral)) { return value; } - return oppose.getActionPipeline().onCalcOpposeStat(this.getActionContext(), statName, value, aux); + return oppose.getActionPipeline().onCalcOpposeStat(oppose.getActionContext(), statName, value, aux); } public override getDex(crewType: WarCrewType): number { - const armType = + const storedArmType = crewType.armType === this.config.armTypes.castle ? (this.config.armTypes.siege ?? crewType.armType) : crewType.armType; - const base = getMetaNumber(this.general.meta, `${META_DEX_PREFIX}${armType}`); + const base = getMetaNumber(this.general.meta, `${META_DEX_PREFIX}${storedArmType}`); const aux = { isAttacker: this.isAttacker(), opposeType: this.oppose?.getCrewType() ?? null, }; - const statName = toDexStatName(armType); + // Legacy stores castle dexterity in the siege column, but still exposes + // the requested castle arm type to trait handlers as `dex0`. + const statName = toDexStatName(crewType.armType); let dex = this.actionPipeline.onCalcStat(this.getActionContext(), statName, base, aux); dex = this.resolveOpposeStatValue(statName, dex, aux); return dex; @@ -236,7 +257,7 @@ export class WarUnitGeneral< return 0; } - const mainStat = this.resolveMainStat(armType); + const mainStat = this.resolveMainStat(armType, false); const coef = armType === this.config.armTypes.wizard || armType === this.config.armTypes.siege || @@ -318,7 +339,10 @@ export class WarUnitGeneral< } public addDex(crewType: WarCrewType, exp: number): void { - const armType = crewType.armType; + const armType = + crewType.armType === this.config.armTypes.castle + ? (this.config.armTypes.siege ?? crewType.armType) + : crewType.armType; const key = `${META_DEX_PREFIX}${armType}`; const base = getMetaNumber(this.general.meta, key); this.general.meta[key] = round(base + exp); diff --git a/tools/integration-tests/fixtures/battle/basic-infantry.json b/tools/integration-tests/fixtures/battle/basic-infantry.json new file mode 100644 index 0000000..200d42e --- /dev/null +++ b/tools/integration-tests/fixtures/battle/basic-infantry.json @@ -0,0 +1,52 @@ +{ + "seed": "battle-differential-basic-v1", + "year": 200, + "month": 1, + "startYear": 180, + "repeatCnt": 1, + "action": "battle", + "attackerGeneral": { + "no": 1, "name": "공격자", "nation": 1, "turntime": "2026-01-01 00:00:00", + "personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100, + "atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None", + "strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0, + "leadership": 70, "leadership_exp": 0, "horse": "None", "item": "None", + "explevel": 0, "experience": 100, "dedication": 100, "officer_level": 3, + "officer_city": 1, "gold": 1000, "rice": 10000, "dex1": 0, "dex2": 0, + "dex3": 0, "dex4": 0, "dex5": 0, "defence_train": 0, "recent_war": null, + "warnum": 0, "killnum": 0, "killcrew": 0 + }, + "attackerCity": { + "city": 1, "nation": 1, "supply": 1, "name": "공격도시", "pop": 10000, + "agri": 1000, "comm": 1000, "secu": 1000, "def": 100, "wall": 100, + "trust": 100, "level": 2, "pop_max": 10000, "agri_max": 1000, + "comm_max": 1000, "secu_max": 1000, "def_max": 200, "wall_max": 200, + "dead": 0, "state": 0, "conflict": "{}" + }, + "attackerNation": { + "type": "che_유가", "tech": 1000, "level": 1, "capital": 1, "nation": 1, + "name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1 + }, + "defenderGenerals": [{ + "no": 2, "name": "수비자", "nation": 2, "turntime": "2026-01-01 00:00:00", + "personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100, + "atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None", + "strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0, + "leadership": 60, "leadership_exp": 0, "horse": "None", "item": "None", + "explevel": 0, "experience": 100, "dedication": 100, "officer_level": 3, + "officer_city": 2, "gold": 1000, "rice": 10000, "dex1": 0, "dex2": 0, + "dex3": 0, "dex4": 0, "dex5": 0, "defence_train": 0, "recent_war": null, + "warnum": 0, "killnum": 0, "killcrew": 0 + }], + "defenderCity": { + "city": 2, "nation": 2, "supply": 1, "name": "수비도시", "pop": 10000, + "agri": 1000, "comm": 1000, "secu": 1000, "def": 100, "wall": 100, + "trust": 100, "level": 2, "pop_max": 10000, "agri_max": 1000, + "comm_max": 1000, "secu_max": 1000, "def_max": 200, "wall_max": 200, + "dead": 0, "state": 0, "conflict": "{}" + }, + "defenderNation": { + "type": "che_유가", "tech": 1000, "level": 1, "capital": 2, "nation": 2, + "name": "수비국", "gold": 1000, "rice": 10000, "gennum": 1 + } +} diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts new file mode 100644 index 0000000..cc76a16 --- /dev/null +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -0,0 +1,439 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; +import type { UnitSetDefinition, WarBattleTraceEvent, WarEngineConfig } from '@sammo-ts/logic'; +import { describe, expect, it } from 'vitest'; + +import { processBattleSimJob } from '../../../app/game-api/src/battleSim/processor.js'; +import type { BattleSimJobPayload, BattleSimRequestPayload } from '../../../app/game-api/src/battleSim/types.js'; + +interface ReferenceTrace { + engine: 'ref'; + conquered: boolean; + events: WarBattleTraceEvent[]; + rng: RandomCall[]; +} + +interface RandomCall { + seq: number; + operation: string; + arguments: Record; + result: unknown; +} + +class TracingRng implements RNG { + public readonly calls: RandomCall[] = []; + + public constructor(private readonly inner: RNG) {} + + public getMaxInt(): number { + return this.inner.getMaxInt(); + } + + public nextBytes(bytes: number): Uint8Array { + const result = this.inner.nextBytes(bytes); + this.record('nextBytes', { bytes }, Buffer.from(result).toString('hex')); + return result; + } + + public nextBits(bits: number): Uint8Array { + const result = this.inner.nextBits(bits); + this.record('nextBits', { bits }, Buffer.from(result).toString('hex')); + return result; + } + + public nextInt(max?: number): number { + const result = this.inner.nextInt(max); + this.record('nextInt', { maxInclusive: max ?? null }, result); + return result; + } + + public nextFloat1(): number { + const result = this.inner.nextFloat1(); + this.record('nextFloat1', {}, result); + return result; + } + + private record(operation: string, args: Record, result: unknown): void { + this.calls.push({ seq: this.calls.length, operation, arguments: args, result }); + } +} + +const findWorkspaceRoot = (start: string): string | null => { + let current = path.resolve(start); + while (true) { + if ( + fs.existsSync(path.join(current, 'docker_compose_files/reference/compose.yml')) && + fs.existsSync(path.join(current, 'ref/sam/hwe/compare/battle_trace.php')) + ) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +}; + +const readJson = (filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; + +const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => { + const stdout = execFileSync( + 'docker', + [ + 'compose', + 'exec', + '-T', + 'php', + 'php', + '/var/www/html/hwe/compare/battle_trace.php', + '-', + ], + { + cwd: path.join(workspaceRoot, 'docker_compose_files/reference'), + input: fixtureJson, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + return JSON.parse(stdout) as ReferenceTrace; +}; + +const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => { + expect(typeof actual, `${label}: actual type`).toBe('number'); + expect(typeof expected, `${label}: reference type`).toBe('number'); + const reference = expected as number; + const tolerance = Math.max(1, Math.abs(reference) * 0.01); + expect( + Math.abs((actual as number) - reference), + `${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}` + ).toBeLessThanOrEqual(tolerance); +}; + +const assertTraceParity = ( + coreEvents: WarBattleTraceEvent[], + reference: ReferenceTrace, + coreRng: TracingRng | null +): void => { + expect(coreEvents.map((event) => event.event)).toEqual(reference.events.map((event) => event.event)); + expect(coreRng?.calls.map(({ operation, result }) => ({ operation, result }))).toEqual( + reference.rng.map(({ operation, result }) => ({ operation, result })) + ); + + for (let index = 0; index < reference.events.length; index += 1) { + const core = coreEvents[index]!; + const ref = reference.events[index]!; + expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`); + expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`); + if (core.defender && ref.defender) { + expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind); + expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`); + expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`); + } else { + expect(core.defender, `event ${index} defender presence`).toBe(ref.defender); + } + if (core.event === 'phase_damage') { + for (const key of ['rawDeadAttacker', 'rawDeadDefender', 'deadAttacker', 'deadDefender']) { + expectNearlyEqual(core.details[key], ref.details[key], `event ${index} ${key}`); + } + } + } +}; + +const workspaceRoot = findWorkspaceRoot(process.cwd()); +const describeWithReference = workspaceRoot ? describe : describe.skip; + +describeWithReference('ref ↔ core2026 battle differential', () => { + it('keeps the detailed event sequence and phase values within 1%', () => { + const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json'); + const fixtureJson = fs.readFileSync(fixturePath, 'utf8'); + const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number }; + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + const castleCrewTypeId = 1000; + const config: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId, + armTypes: { + footman: 1, + archer: 2, + cavalry: 3, + wizard: 4, + siege: 5, + misc: 6, + castle: unitSet.crewTypes?.find((crewType) => crewType.id === castleCrewTypeId)?.armType ?? 0, + }, + }; + const payload: BattleSimJobPayload = { + ...request, + unitSet, + config, + time: { year: request.year, month: request.month, startYear: request.startYear }, + }; + + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + const coreResult = processBattleSimJob(payload, { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + }); + const reference = runReferenceTrace(workspaceRoot!, fixtureJson); + + expect(coreResult.result).toBe(true); + assertTraceParity(coreEvents, reference, coreRng); + }); + + it('matches wizard strategy attempts, outcomes, and RNG consumption', () => { + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = 'battle-differential-magic-v1'; + base.attackerGeneral.crew = 4000; + base.attackerGeneral.crewtype = 1400; + base.attackerGeneral.leadership = 60; + base.attackerGeneral.strength = 30; + base.attackerGeneral.intel = 95; + base.attackerGeneral.special2 = 'che_신산'; + base.attackerGeneral.personal = 'che_재간'; + base.attackerGeneral.dex4 = 12000; + base.defenderGenerals[0]!.crew = 4000; + base.defenderGenerals[0]!.crewtype = 1100; + base.defenderGenerals[0]!.leadership = 65; + base.defenderGenerals[0]!.strength = 75; + base.defenderGenerals[0]!.intel = 35; + base.defenderGenerals[0]!.special2 = 'che_견고'; + base.defenderGenerals[0]!.personal = 'che_유지'; + + const fixtureJson = JSON.stringify(base); + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + 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 payload: BattleSimJobPayload = { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + const result = processBattleSimJob(payload, { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + }); + const reference = runReferenceTrace(workspaceRoot!, fixtureJson); + + expect(result.result).toBe(true); + expect( + reference.events.some((event) => + Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill)) + ) + ).toBe(true); + assertTraceParity(coreEvents, reference, coreRng); + }); + + it('matches cavalry, item, inherit-buff, and multiple-defender handling', () => { + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = 'battle-differential-cavalry-items-v1'; + base.attackerGeneral.crew = 7000; + base.attackerGeneral.crewtype = 1300; + base.attackerGeneral.leadership = 88; + base.attackerGeneral.strength = 92; + base.attackerGeneral.intel = 55; + base.attackerGeneral.special2 = 'che_기병'; + base.attackerGeneral.personal = 'che_패권'; + base.attackerGeneral.horse = 'che_명마_07_백상'; + base.attackerGeneral.dex3 = 18000; + base.attackerGeneral.inheritBuff = { + warAvoidRatio: 2, + warCriticalRatio: 3, + warCriticalRatioOppose: 2, + }; + + const firstDefender = base.defenderGenerals[0]!; + firstDefender.crew = 900; + firstDefender.crewtype = 1200; + firstDefender.leadership = 58; + firstDefender.strength = 55; + firstDefender.intel = 62; + firstDefender.dex2 = 6000; + firstDefender.inheritBuff = { warAvoidRatioOppose: 2 }; + base.defenderGenerals.push({ + ...firstDefender, + no: 3, + name: '수비자2', + crew: 1100, + crewtype: 1300, + leadership: 64, + strength: 72, + intel: 48, + dex2: 0, + dex3: 8000, + special2: 'che_기병', + personal: 'che_패권', + horse: 'che_명마_07_백상', + inheritBuff: { + warAvoidRatio: 1, + warCriticalRatioOppose: 1, + }, + }); + + const fixtureJson = JSON.stringify(base); + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + 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 payload: BattleSimJobPayload = { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + const result = processBattleSimJob(payload, { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + }); + const reference = runReferenceTrace(workspaceRoot!, fixtureJson); + + expect(result.result).toBe(true); + expect(reference.events.filter((event) => event.event === 'opponent_switched')).toHaveLength(2); + assertTraceParity(coreEvents, reference, coreRng); + }); + + it('matches siege dexterity and castle damage handling', () => { + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = 'battle-differential-siege-v1'; + base.attackerGeneral.crew = 5000; + base.attackerGeneral.crewtype = 1501; + base.attackerGeneral.leadership = 90; + base.attackerGeneral.strength = 65; + base.attackerGeneral.intel = 55; + base.attackerGeneral.dex5 = 16000; + base.defenderGenerals[0]!.crew = 300; + base.defenderGenerals[0]!.crewtype = 1200; + base.defenderGenerals[0]!.leadership = 65; + base.defenderGenerals[0]!.strength = 72; + base.defenderGenerals[0]!.intel = 50; + base.defenderGenerals[0]!.dex2 = 10000; + + const fixtureJson = JSON.stringify(base); + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + 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 payload: BattleSimJobPayload = { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + const result = processBattleSimJob(payload, { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + }); + const reference = runReferenceTrace(workspaceRoot!, fixtureJson); + + expect(result.result).toBe(true); + expect(reference.events.some((event) => event.defender?.kind === 'city')).toBe(true); + assertTraceParity(coreEvents, reference, coreRng); + }); + + it('matches the no-defender supply-retreat branch without consuming RNG', () => { + const base = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + base.seed = 'battle-differential-supply-v1'; + base.defenderGenerals = []; + base.defenderNation.rice = 0; + + const fixtureJson = JSON.stringify(base); + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + 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 payload: BattleSimJobPayload = { + ...base, + unitSet, + config, + time: { year: base.year, month: base.month, startYear: base.startYear }, + }; + const coreEvents: WarBattleTraceEvent[] = []; + let coreRng: TracingRng | null = null; + const result = processBattleSimJob(payload, { + trace: (event) => coreEvents.push(event), + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return new RandUtil(coreRng); + }, + }); + const reference = runReferenceTrace(workspaceRoot!, fixtureJson); + + expect(result.result).toBe(true); + expect(reference.events.map((event) => event.event)).toEqual([ + 'battle_start', + 'supply_retreat', + 'battle_end', + ]); + assertTraceParity(coreEvents, reference, coreRng); + }); +});