import { JosaUtil, LiteHashDRBG, RandUtil, } from '@sammo-ts/common'; import type { City, General, GeneralTriggerState, } from '@sammo-ts/logic/domain/entities.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 } from './actions.js'; import { WarCrewType } from './crewType.js'; import { WarTriggerCaller, createWarTriggerEnv, type WarTriggerRegistry, } from './triggers.js'; import type { WarBattleInput, WarBattleOutcome, 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 ): WarActionPipeline => new WarActionPipeline(input.modules ?? []); const appendCrewTypeTriggers = ( caller: WarTriggerCaller, unit: WarUnit, names: string[], registry: WarTriggerRegistry | undefined ): void => { if (!registry) { return; } for (const name of names) { const factory = registry[name]; if (!factory) { continue; } const trigger = factory(unit); if (!trigger) { continue; } if (trigger instanceof WarTriggerCaller) { caller.merge(trigger); } else { caller.append(trigger); } } }; const buildBattleInitTriggers = ( unit: WarUnit, registry: WarTriggerRegistry | undefined ): WarTriggerCaller => { const caller = new WarTriggerCaller(); if (unit instanceof WarUnitGeneral) { const context = unit.getActionContext(); caller.merge(unit.getActionPipeline().getBattleInitTriggerList(context)); } appendCrewTypeTriggers(caller, unit, unit.getCrewType().initSkillTrigger, registry); return caller; }; const buildBattlePhaseTriggers = ( unit: WarUnit, registry: WarTriggerRegistry | undefined ): WarTriggerCaller => { const caller = new WarTriggerCaller(); if (unit instanceof WarUnitGeneral) { appendCrewTypeTriggers(caller, unit, ['che_필살'], registry); const context = unit.getActionContext(); caller.merge(unit.getActionPipeline().getBattlePhaseTriggerList(context)); } 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 flushLoggers = (loggers: ActionLogger[]): Array[number]> => { const logs: Array[number]> = []; for (const logger of loggers) { logs.push(...logger.flush()); } return logs; }; export const resolveWarBattle = < TriggerState extends GeneralTriggerState = GeneralTriggerState >( input: WarBattleInput ): WarBattleOutcome => { // process_war.php 전투 루프를 순수 로직으로 이식한다. const rng = input.rng ?? new RandUtil( LiteHashDRBG.build(input.seed ?? '') ); const loggerFactory = input.loggerFactory ?? defaultLoggerFactory; const triggerRegistry = input.triggerRegistry; const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet); const attackerPipeline = createPipeline(input.attacker); 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) ); 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(); let defender: WarUnit | null = null; 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; }; defender = getNextDefender(null, true); let conquerCity = false; let logWritten = false; 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 ); conquerCity = true; 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() ); } attackerUnit.beginPhase(); defender.beginPhase(); const battleCaller = buildBattlePhaseTriggers(attackerUnit, triggerRegistry); battleCaller.merge(buildBattlePhaseTriggers(defender, triggerRegistry)); battleCaller.fire( { rng, attacker: attackerUnit, defender }, createWarTriggerEnv() ); let deadDefender = attackerUnit.calcDamage(); let deadAttacker = defender.calcDamage(); 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); 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(); const attackerState = attackerUnit.continueWar(); if (!attackerState.canContinue) { 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) { 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); } } 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 ); } } 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 = < TriggerState extends GeneralTriggerState = GeneralTriggerState >( input: WarBattleInput ): number[] => { const rng = input.rng ?? new RandUtil( LiteHashDRBG.build(input.seed ?? '') ); const loggerFactory = input.loggerFactory ?? defaultLoggerFactory; const crewTypeIndex = buildWarCrewTypeIndex(input.unitSet); const attackerPipeline = createPipeline(input.attacker); 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) ); 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); };