import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js'; import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import { buildCrewTypeIndex as buildCrewTypeDefinitionIndex } from '@sammo-ts/logic/world/unitSet.js'; 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, WarBattleTraceUnitSnapshot, WarGeneralInput, WarUnitReport, } from './types.js'; import { getMetaNumber } from './utils.js'; import { WarUnitCity, WarUnitGeneral, type WarUnit } from './units.js'; const META_FULL_LEADERSHIP = 'fullLeadership'; const META_FULL_STRENGTH = 'fullStrength'; const META_FULL_INTELLIGENCE = 'fullIntelligence'; const META_DEFENCE_TRAIN = 'defenceTrain'; const defaultLoggerFactory = (options: { generalId?: number; nationId?: number }): ActionLogger => new ActionLogger(options); const resolveCrewType = (crewTypeIndex: Map, crewTypeId: number): WarCrewType => { const crewType = crewTypeIndex.get(crewTypeId); if (!crewType) { throw new Error(`Invalid crew type: ${crewTypeId}`); } return crewType; }; const buildWarCrewTypeIndex = (unitSet: WarBattleInput['unitSet']): Map => { const index = new Map(); const crewTypes = buildCrewTypeDefinitionIndex(unitSet); for (const [id, crewType] of crewTypes) { index.set(id, new WarCrewType(crewType)); } return index; }; const createPipeline = ( input: WarGeneralInput, crewTypeModule: WarActionModule ): WarActionPipeline => { const modules = input.modules ?? []; if (modules.some((module) => module && isCrewTypeWarActionRouter(module))) { return new WarActionPipeline(modules); } return new WarActionPipeline([crewTypeModule, ...modules]); }; const appendCrewTypeTriggers = ( caller: WarTriggerCaller, unit: WarUnit, names: string[], registry: WarTriggerRegistry ): void => { for (const name of names) { const factory = registry[name]; if (!factory) { throw new Error(`Unknown crew type war trigger: ${name}`); } const trigger = factory(unit); if (!trigger) { continue; } if (trigger instanceof WarTriggerCaller) { caller.merge(trigger); } else { caller.append(trigger); } } }; const buildBattleInitTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => { const caller = new WarTriggerCaller(); if (unit instanceof WarUnitGeneral) { const context = unit.getActionContext(); caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context)); } else if (!(unit instanceof WarUnitCity && unit.isSiege())) { appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry); } return caller; }; const buildBattlePhaseTriggers = (unit: WarUnit, registry: WarTriggerRegistry): WarTriggerCaller => { const caller = new WarTriggerCaller(); if (unit instanceof WarUnitGeneral) { appendCrewTypeTriggers(caller, unit, ['che_필살', 'che_회피', 'che_계략'], registry); const context = unit.getActionContext(); caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context)); } else if (!(unit instanceof WarUnitCity && unit.isSiege())) { appendCrewTypeTriggers(caller, unit, unit.getCrewType().phaseSkillTrigger, registry); } return caller; }; const resolveFullStats = ( general: General ): { leadership: number; strength: number; intelligence: number; } => ({ leadership: getMetaNumber(general.meta, META_FULL_LEADERSHIP, general.stats.leadership), strength: getMetaNumber(general.meta, META_FULL_STRENGTH, general.stats.strength), intelligence: getMetaNumber(general.meta, META_FULL_INTELLIGENCE, general.stats.intelligence), }); const isSupplyCity = (city: City): boolean => { const supply = city.meta.supply; if (typeof supply === 'boolean') { return supply; } if (typeof supply === 'number') { return supply > 0; } return city.supplyState > 0; }; export const computeBattleOrder = ( defender: WarUnit, attacker: WarUnitGeneral ): number => { if (defender instanceof WarUnitCity) { const context = attacker.getActionContext(); return attacker.getActionPipeline().onCalcOpposeStat(context, 'cityBattleOrder', -1); } if (!(defender instanceof WarUnitGeneral)) { return 0; } const general = defender.getGeneral(); if (general.crew <= 0) { return 0; } if (general.rice <= general.crew / 100) { return 0; } const defenceTrain = getMetaNumber(general.meta, META_DEFENCE_TRAIN, 0); if (general.train < defenceTrain) { return 0; } if (general.atmos < defenceTrain) { return 0; } const realStat = general.stats.leadership + general.stats.strength + general.stats.intelligence; const fullStats = resolveFullStats(general); const fullStat = fullStats.leadership + fullStats.strength + fullStats.intelligence; const totalStat = (realStat + fullStat) / 2; const totalCrew = (general.crew / 1_000_000) * Math.pow(general.train * general.atmos, 1.5); return totalStat + totalCrew / 100; }; const resolveUnitReport = (unit: WarUnit): WarUnitReport => { if (unit instanceof WarUnitGeneral) { return { id: unit.getGeneral().id, type: 'general', name: unit.getName(), isAttacker: unit.isAttacker(), killed: unit.getKilled(), dead: unit.getDead(), }; } if (unit instanceof WarUnitCity) { return { id: unit.getCityId(), type: 'city', name: unit.getName(), isAttacker: unit.isAttacker(), killed: unit.getKilled(), dead: unit.getDead(), }; } return { id: null, type: 'city', name: unit.getName(), isAttacker: unit.isAttacker(), killed: unit.getKilled(), dead: unit.getDead(), }; }; 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) { logs.push(...logger.flush()); } return logs; }; export const resolveWarBattle = ( input: WarBattleInput ): WarBattleOutcome => { // process_war.php 전투 루프를 순수 로직으로 이식한다. const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? '')); const loggerFactory = input.loggerFactory ?? defaultLoggerFactory; const triggerRegistry: WarTriggerRegistry = { ...createCrewTypeWarTriggerRegistry(), ...(input.triggerRegistry ?? {}), }; const crewTypeCatalog = compileCrewTypeCatalog(input.unitSet, triggerRegistry); const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet); const attackerPipeline = createPipeline(input.attacker, crewTypeCatalog.warActionModule); const attackerLogger = input.attacker.logger ?? loggerFactory({ generalId: input.attacker.general.id, nationId: input.attacker.general.nationId, }); const attackerUnit = new WarUnitGeneral( rng, input.config, input.attacker.general, input.attacker.city, input.attacker.nation, true, resolveCrewType(crewTypeIndex, input.attacker.general.crewTypeId), attackerLogger, attackerPipeline ); const cityLogger = loggerFactory({ nationId: input.defenderCity.nationId, }); const cityUnit = new WarUnitCity( rng, input.config, input.defenderCity, input.defenderNation, resolveCrewType(crewTypeIndex, input.config.castleCrewTypeId), cityLogger, input.time.year, input.time.startYear ); const defenderUnits: WarUnit[] = []; const defenderGenerals: WarUnitGeneral[] = []; for (const defender of input.defenders) { const defenderLogger = defender.logger ?? loggerFactory({ generalId: defender.general.id, nationId: defender.general.nationId, }); const unit = new WarUnitGeneral( rng, input.config, defender.general, input.defenderCity, defender.nation, false, resolveCrewType(crewTypeIndex, defender.general.crewTypeId), defenderLogger, createPipeline(defender, crewTypeCatalog.warActionModule) ); if (computeBattleOrder(unit, attackerUnit) <= 0) { continue; } defenderUnits.push(unit); defenderGenerals.push(unit); } if (defenderGenerals.length > 0 && computeBattleOrder(cityUnit, attackerUnit) > 0) { defenderUnits.push(cityUnit); } defenderUnits.sort( (lhs, rhs) => computeBattleOrder(rhs, attackerUnit) - computeBattleOrder(lhs, attackerUnit) ); const iter = defenderUnits.values(); const getNextDefender = ( _prevDefender: WarUnit | null, reqNext: boolean ): WarUnit | null => { if (!reqNext) { return null; } const next = iter.next(); if (next.done) { return null; } const candidate = next.value as WarUnit; if (computeBattleOrder(candidate, attackerUnit) <= 0) { return null; } return candidate; }; let defender = getNextDefender(null, true); let conquerCity = false; let logWritten = false; let traceSeq = 0; const emitTrace = ( event: string, currentDefender: WarUnit | 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(); const cityName = cityUnit.getName(); const seedText = input.seed ? `(전투시드: ${input.seed})` : ''; const josaRo = JosaUtil.pick(cityName, '로'); const josaYi = JosaUtil.pick(attackerName, '이'); attackerLogger.pushGlobalActionLog( `${attackerNationName}${attackerName}${josaYi} ${cityName}${josaRo} 진격합니다.${seedText}`, LogFormat.MONTH ); attackerLogger.pushGeneralActionLog( `${cityName}${josaRo} 진격합니다.${seedText}`, LogFormat.MONTH ); while (attackerUnit.getPhase() < attackerUnit.getMaxPhase()) { logWritten = false; if (!defender) { defender = cityUnit; cityUnit.setSiege(); const defenderRice = input.defenderNation?.rice ?? 0; if (isSupplyCity(input.defenderCity) && defenderRice <= 0) { attackerUnit.setOppose(defender); defender.setOppose(attackerUnit); attackerUnit.addTrain(1); attackerUnit.addWin(); defender.addLose(); cityUnit.heavyDecreaseWealth(); attackerLogger.pushGlobalActionLog( `병량 부족으로 ${cityName}의 수비병들이 패퇴합니다.`, LogFormat.MONTH ); const defenderNationName = input.defenderNation?.name ?? '재야'; const josaYiDefenderNation = JosaUtil.pick(defenderNationName, '이'); const josaUlCity = JosaUtil.pick(cityName, '을'); attackerLogger.pushGlobalHistoryLog( `【패퇴】${defenderNationName}${josaYiDefenderNation} 병량 부족으로 ${cityName}${josaUlCity} 뺏기고 말았습니다.`, LogFormat.YEAR_MONTH ); conquerCity = true; emitTrace('supply_retreat', defender); break; } } if (defender.getPhase() === 0 && !defender.getOppose()) { defender.setPrePhase(attackerUnit.getPhase()); attackerUnit.addTrain(1); defender.addTrain(1); const attackerName = attackerUnit.getName(); const attackerCrewName = attackerUnit.getCrewTypeName(); if (defender instanceof WarUnitGeneral) { const defenderName = defender.getName(); const defenderCrewName = defender.getCrewTypeName(); const josaWa = JosaUtil.pick(attackerCrewName, '와'); const josaYiDef = JosaUtil.pick(defenderCrewName, '이'); attackerLogger.pushGlobalActionLog( `${attackerName}의 ${attackerCrewName}${josaWa} ${defenderName}의 ${defenderCrewName}${josaYiDef} 대결합니다.`, LogFormat.MONTH ); const josaRo = JosaUtil.pick(attackerCrewName, '로'); const josaUl = JosaUtil.pick(defenderCrewName, '을'); attackerUnit .getLogger() .pushGeneralActionLog( `${attackerCrewName}${josaRo} ${defenderName}의 ${defenderCrewName}${josaUl} 공격합니다.`, LogFormat.MONTH ); const defJosaRo = JosaUtil.pick(defenderCrewName, '로'); const defJosaUl = JosaUtil.pick(attackerCrewName, '을'); defender .getLogger() .pushGeneralActionLog( `${defenderCrewName}${defJosaRo} ${attackerName}의 ${attackerCrewName}${defJosaUl} 수비합니다.`, LogFormat.MONTH ); } else { const josaYiName = JosaUtil.pick(attackerName, '이'); const josaRoCrew = JosaUtil.pick(attackerCrewName, '로'); attackerLogger.pushGlobalActionLog( `${attackerName}${josaYiName} ${attackerCrewName}${josaRoCrew} 성벽을 공격합니다.`, LogFormat.MONTH ); attackerLogger.pushGeneralActionLog( `${attackerCrewName}${josaRoCrew} 성벽을 공격합니다.`, LogFormat.PLAIN ); } attackerUnit.setOppose(defender); defender.setOppose(attackerUnit); const initCaller = buildBattleInitTriggers(attackerUnit, triggerRegistry); initCaller.merge(buildBattleInitTriggers(defender, triggerRegistry)); initCaller.fire({ rng, attacker: attackerUnit, defender }, createWarTriggerEnv()); emitTrace('opponent_initialized', defender); } attackerUnit.beginPhase(); defender.beginPhase(); emitTrace('phase_power', defender, { attackerAttack: attackerUnit.getComputedAttack(), attackerDefence: attackerUnit.getComputedDefence(), attackerTrain: attackerUnit.getComputedTrain(), attackerAtmos: attackerUnit.getComputedAtmos(), defenderAttack: defender.getComputedAttack(), defenderDefence: defender.getComputedDefence(), defenderTrain: defender.getComputedTrain(), defenderAtmos: defender.getComputedAtmos(), }); const battleCaller = buildBattlePhaseTriggers(attackerUnit, triggerRegistry); battleCaller.merge(buildBattlePhaseTriggers(defender, triggerRegistry)); battleCaller.fire({ rng, attacker: attackerUnit, defender }, createWarTriggerEnv()); emitTrace('phase_triggered', defender); let deadDefender = attackerUnit.calcDamage(); let deadAttacker = defender.calcDamage(); const rawDeadAttacker = deadAttacker; const rawDeadDefender = deadDefender; const attackerHP = attackerUnit.getHP(); const defenderHP = defender.getHP(); if (deadAttacker > attackerHP || deadDefender > defenderHP) { const deadAttackerRatio = deadAttacker / Math.max(1, attackerHP); const deadDefenderRatio = deadDefender / Math.max(1, defenderHP); if (deadDefenderRatio > deadAttackerRatio) { deadAttacker /= deadDefenderRatio; deadDefender = defenderHP; } else { deadDefender /= deadAttackerRatio; deadAttacker = attackerHP; } } deadAttacker = Math.min(Math.ceil(deadAttacker), attackerHP); deadDefender = Math.min(Math.ceil(deadDefender), defenderHP); attackerUnit.decreaseHP(deadAttacker); defender.decreaseHP(deadDefender); attackerUnit.increaseKilled(deadDefender); defender.increaseKilled(deadAttacker); emitTrace('phase_damage', defender, { rawDeadAttacker, rawDeadDefender, deadAttacker, deadDefender, attackerHpBefore: attackerHP, defenderHpBefore: defenderHP, }); const phaseNickname = defender.getPhase() < 0 ? '先' : `${attackerUnit.getPhase() + 1} `; if (deadAttacker > 0 || deadDefender > 0) { attackerUnit .getLogger() .pushGeneralBattleDetailLog( `${phaseNickname}: 【${attackerUnit.getName()}】 ${attackerUnit.getHP()} (-${deadAttacker}) VS ${defender.getHP()} (-${deadDefender}) 【${defender.getName()}】` ); defender .getLogger() .pushGeneralBattleDetailLog( `${phaseNickname}: 【${defender.getName()}】 ${defender.getHP()} (-${deadDefender}) VS ${attackerUnit.getHP()} (-${deadAttacker}) 【${attackerUnit.getName()}】` ); } attackerUnit.addPhase(); defender.addPhase(); emitTrace('phase_end', defender); const attackerState = attackerUnit.continueWar(); if (!attackerState.canContinue) { emitTrace('attacker_stopped', defender, { noRice: attackerState.noRice }); logWritten = true; attackerUnit.logBattleResult(); defender.logBattleResult(); attackerUnit.addLose(); defender.addWin(); attackerUnit.tryWound(); defender.tryWound(); const josaYiCrew = JosaUtil.pick(attackerUnit.getCrewTypeName(), '이'); attackerLogger.pushGlobalActionLog( `${attackerUnit.getName()}의 ${attackerUnit.getCrewTypeName()}${josaYiCrew} 퇴각했습니다.`, LogFormat.MONTH ); attackerUnit .getLogger() .pushGeneralActionLog( attackerState.noRice ? '군량 부족으로 퇴각합니다.' : '퇴각했습니다.', LogFormat.PLAIN ); defender .getLogger() .pushGeneralActionLog( `${attackerUnit.getName()}의 ${attackerUnit.getCrewTypeName()}${josaYiCrew} 퇴각했습니다.`, LogFormat.PLAIN ); break; } const defenderState = defender.continueWar(); if (!defenderState.canContinue) { emitTrace('defender_stopped', defender, { noRice: defenderState.noRice }); logWritten = true; attackerUnit.logBattleResult(); defender.logBattleResult(); if (!(defender instanceof WarUnitCity) || defender.isSiege()) { attackerUnit.addWin(); defender.addLose(); attackerUnit.tryWound(); defender.tryWound(); if (defender === cityUnit) { attackerUnit.addLevelExp(1000); conquerCity = true; break; } } const josaYiDefCrew = JosaUtil.pick(defender.getCrewTypeName(), '이'); if (defender instanceof WarUnitCity && !defender.isSiege()) { defender.setOppose(null); } else if (defenderState.noRice) { attackerLogger.pushGlobalActionLog( `${defender.getName()}의 ${defender.getCrewTypeName()}${josaYiDefCrew} 패퇴했습니다.`, LogFormat.MONTH ); attackerUnit .getLogger() .pushGeneralActionLog( `${defender.getName()}의 ${defender.getCrewTypeName()}${josaYiDefCrew} 패퇴했습니다.`, LogFormat.PLAIN ); defender.getLogger().pushGeneralActionLog('군량 부족으로 패퇴합니다.', LogFormat.PLAIN); } else { attackerLogger.pushGlobalActionLog( `${defender.getName()}의 ${defender.getCrewTypeName()}${josaYiDefCrew} 전멸했습니다.`, LogFormat.MONTH ); attackerUnit .getLogger() .pushGeneralActionLog( `${defender.getName()}의 ${defender.getCrewTypeName()}${josaYiDefCrew} 전멸했습니다.`, LogFormat.PLAIN ); defender.getLogger().pushGeneralActionLog('전멸했습니다.', LogFormat.PLAIN); } if (attackerUnit.getPhase() >= attackerUnit.getMaxPhase()) { break; } defender.finishBattle(); defender = getNextDefender(defender, true); emitTrace('opponent_switched', defender); } } if (!logWritten && defender) { attackerUnit.logBattleResult(); defender.logBattleResult(); attackerUnit.tryWound(); defender.tryWound(); } attackerUnit.finishBattle(); defender?.finishBattle(); if (cityUnit.getDead() > 0 || defender instanceof WarUnitCity) { if (cityUnit !== defender) { cityUnit.setOppose(attackerUnit); cityUnit.setSiege(); cityUnit.finishBattle(); } const newConflict = cityUnit.addConflict(); if (newConflict) { const nationName = attackerUnit.getNationVar('name') as string; const josaYiNation = JosaUtil.pick(nationName, '이'); attackerLogger.pushGlobalHistoryLog( `【분쟁】${nationName}${josaYiNation} ${cityName} 공략에 가담하여 분쟁이 발생하고 있습니다.`, LogFormat.YEAR_MONTH ); } } emitTrace('battle_end', defender, { conquered: conquerCity }); const logs = flushLoggers([attackerLogger, ...defenderGenerals.map((unit) => unit.getLogger())]); const reports: WarUnitReport[] = [ resolveUnitReport(attackerUnit), ...defenderUnits.map(resolveUnitReport), resolveUnitReport(cityUnit), ]; return { attacker: attackerUnit.getGeneral(), defenders: defenderGenerals.map((unit) => unit.getGeneral()), defenderCity: input.defenderCity, logs, conquered: conquerCity, reports, metrics: { attackerPhase: attackerUnit.getPhase(), attackerActivatedSkills: attackerUnit.getActivatedSkillLog(), defenderActivatedSkills: defenderUnits.map((unit) => unit.getActivatedSkillLog()), }, }; }; export const resolveDefenderOrder = ( input: WarBattleInput ): number[] => { const rng = input.rng ?? new RandUtil(LiteHashDRBG.build(input.seed ?? '')); const loggerFactory = input.loggerFactory ?? defaultLoggerFactory; const triggerRegistry: WarTriggerRegistry = { ...createCrewTypeWarTriggerRegistry(), ...(input.triggerRegistry ?? {}), }; const crewTypeCatalog = compileCrewTypeCatalog(input.unitSet, triggerRegistry); const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet); const attackerPipeline = createPipeline(input.attacker, crewTypeCatalog.warActionModule); const attackerLogger = input.attacker.logger ?? loggerFactory({ generalId: input.attacker.general.id, nationId: input.attacker.general.nationId, }); const attackerUnit = new WarUnitGeneral( rng, input.config, input.attacker.general, input.attacker.city, input.attacker.nation, true, resolveCrewType(crewTypeIndex, input.attacker.general.crewTypeId), attackerLogger, attackerPipeline ); const defenderUnits: WarUnitGeneral[] = []; for (const defender of input.defenders) { const defenderLogger = defender.logger ?? loggerFactory({ generalId: defender.general.id, nationId: defender.general.nationId, }); const unit = new WarUnitGeneral( rng, input.config, defender.general, input.defenderCity, defender.nation, false, resolveCrewType(crewTypeIndex, defender.general.crewTypeId), defenderLogger, createPipeline(defender, crewTypeCatalog.warActionModule) ); if (computeBattleOrder(unit, attackerUnit) <= 0) { continue; } defenderUnits.push(unit); } defenderUnits.sort((lhs, rhs) => computeBattleOrder(rhs, attackerUnit) - computeBattleOrder(lhs, attackerUnit)); return defenderUnits.map((unit) => unit.getGeneral().id); };