import crypto from 'node:crypto'; import { formatLogText, getTechCost, LogCategory, LogFormat, LogScope, resolveDefenderOrder, resolveWarBattle, type City, type General, type Nation, type UnitSetDefinition, type WarBattleOutcome, } from '@sammo-ts/logic'; import { type BattleSimJobPayload, type BattleSimLogBuckets, type BattleSimResultPayload, } from './types.js'; import { convertLog } from './logFormatter.js'; const DEFAULT_GENERAL_AGE = 20; const normalizeItemCode = (value: string | null): string | null => value === 'None' ? null : value; const mapNationPayload = (payload: BattleSimJobPayload['attackerNation']): Nation => ({ id: payload.nation, name: payload.name, color: '#000000', capitalCityId: payload.capital, chiefGeneralId: null, gold: payload.gold, rice: payload.rice, power: 0, level: payload.level, typeCode: payload.type, meta: { tech: payload.tech, gennum: payload.gennum, }, }); const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => ({ id: payload.city, name: payload.name, nationId: payload.nation, level: payload.level, state: payload.state, population: payload.pop, populationMax: payload.pop_max, agriculture: payload.agri, agricultureMax: payload.agri_max, commerce: payload.comm, commerceMax: payload.comm_max, security: payload.secu, securityMax: payload.secu_max, supplyState: payload.supply, frontState: payload.state, defence: payload.def, defenceMax: payload.def_max, wall: payload.wall, wallMax: payload.wall_max, meta: { trust: payload.trust, dead: payload.dead, conflict: payload.conflict, supply: payload.supply, }, }); const mapGeneralPayload = ( payload: BattleSimJobPayload['attackerGeneral'] ): General => ({ id: payload.no, name: payload.name, nationId: payload.nation, cityId: payload.officer_city, troopId: 0, stats: { leadership: payload.leadership, strength: payload.strength, intelligence: payload.intel, }, experience: payload.experience, dedication: payload.dedication, officerLevel: payload.officer_level, role: { personality: payload.personal, specialDomestic: null, specialWar: payload.special2, items: { horse: normalizeItemCode(payload.horse), weapon: normalizeItemCode(payload.weapon), book: normalizeItemCode(payload.book), item: normalizeItemCode(payload.item), }, }, injury: payload.injury, gold: payload.gold, rice: payload.rice, crew: payload.crew, crewTypeId: payload.crewtype, train: payload.train, atmos: payload.atmos, age: DEFAULT_GENERAL_AGE, npcState: 0, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: payload.inheritBuff ? { inheritBuff: JSON.stringify(payload.inheritBuff) } : {}, }, meta: { explevel: payload.explevel, turnTime: payload.turntime, recentWar: payload.recent_war ?? '', dex1: payload.dex1, dex2: payload.dex2, dex3: payload.dex3, dex4: payload.dex4, dex5: payload.dex5, intelExp: payload.intel_exp, strengthExp: payload.strength_exp, leadershipExp: payload.leadership_exp, rank_warnum: payload.warnum, rank_killnum: payload.killnum, rank_killcrew: payload.killcrew, }, }); const buildLogBuckets = (options: { logs: WarBattleOutcome['logs']; year: number; month: number; attackerId: number; attackerNationId: number; }): BattleSimLogBuckets => { const buckets = { generalHistoryLog: [] as string[], generalActionLog: [] as string[], generalBattleResultLog: [] as string[], generalBattleDetailLog: [] as string[], nationalHistoryLog: [] as string[], globalHistoryLog: [] as string[], globalActionLog: [] as string[], }; for (const entry of options.logs) { const format = entry.format ?? LogFormat.RAWTEXT; const text = formatLogText(entry.text, format, options.year, options.month); if (entry.scope === LogScope.GENERAL && entry.generalId === options.attackerId) { switch (entry.category) { case LogCategory.HISTORY: buckets.generalHistoryLog.push(text); break; case LogCategory.ACTION: buckets.generalActionLog.push(text); break; case LogCategory.BATTLE_BRIEF: buckets.generalBattleResultLog.push(text); break; case LogCategory.BATTLE_DETAIL: buckets.generalBattleDetailLog.push(text); break; default: break; } continue; } if ( entry.scope === LogScope.NATION && entry.nationId === options.attackerNationId && entry.category === LogCategory.HISTORY ) { buckets.nationalHistoryLog.push(text); continue; } if (entry.scope === LogScope.SYSTEM) { if (entry.category === LogCategory.HISTORY) { buckets.globalHistoryLog.push(text); } else if (entry.category === LogCategory.SUMMARY) { buckets.globalActionLog.push(text); } } } return { generalHistoryLog: convertLog(buckets.generalHistoryLog.join('
')), generalActionLog: convertLog(buckets.generalActionLog.join('
')), generalBattleResultLog: convertLog(buckets.generalBattleResultLog.join('
')), generalBattleDetailLog: convertLog(buckets.generalBattleDetailLog.join('
')), nationalHistoryLog: convertLog(buckets.nationalHistoryLog.join('
')), globalHistoryLog: convertLog(buckets.globalHistoryLog.join('
')), globalActionLog: convertLog(buckets.globalActionLog.join('
')), }; }; const resolveRandomSeed = (): string => crypto.randomUUID(); const resolveCityTrainAtmos = (year: number, startYear: number): number => Math.min(110, Math.max(60, year - startYear + 59)); const resolveCityRiceConsumption = (options: { battle: WarBattleOutcome; defenderNation: Nation; unitSet: UnitSetDefinition; castleCrewTypeId: number; year: number; startYear: number; }): number => { const cityReport = options.battle.reports.find( (report) => report.type === 'city' ); if (!cityReport) { return 0; } if (cityReport.killed <= 0 && cityReport.dead <= 0) { return 0; } const crewType = options.unitSet.crewTypes?.find( (item) => item.id === options.castleCrewTypeId ); const riceCoef = crewType?.rice ?? 1; const tech = Number(options.defenderNation.meta.tech ?? 0); const trainAtmos = resolveCityTrainAtmos(options.year, options.startYear); let rice = (cityReport.killed / 100) * 0.8; rice *= riceCoef; rice *= getTechCost(tech); rice *= trainAtmos / 100 - 0.2; return Math.round(rice); }; const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => { const attackerNation = mapNationPayload(payload.attackerNation); const defenderNation = mapNationPayload(payload.defenderNation); const attackerCity = mapCityPayload(payload.attackerCity); const defenderCity = mapCityPayload(payload.defenderCity); const attacker = mapGeneralPayload(payload.attackerGeneral); const defenders = payload.defenderGenerals.map(mapGeneralPayload); return resolveDefenderOrder({ unitSet: payload.unitSet, config: payload.config, time: payload.time, seed: 'order', attacker: { general: attacker, city: attackerCity, nation: attackerNation, }, defenders: defenders.map((general) => ({ general, city: defenderCity, nation: defenderNation, })), defenderCity, defenderNation, }); }; export const processBattleSimJob = (payload: BattleSimJobPayload): BattleSimResultPayload => { if (payload.action === 'reorder') { return { result: true, reason: 'success', order: resolveDefenderOrderPayload(payload), }; } let repeatCnt = payload.repeatCnt; const baseSeed = payload.seed ?? ''; if (baseSeed) { repeatCnt = 1; } let lastBattle: WarBattleOutcome | null = null; let attackerKilled = 0; let attackerDead = 0; let attackerMaxKilled = 0; let attackerMinKilled = Number.POSITIVE_INFINITY; let attackerMaxDead = 0; let attackerMinDead = Number.POSITIVE_INFINITY; let attackerAvgRice = 0; let defenderAvgRice = 0; let avgPhase = 0; let avgWar = 0; const attackerSkills: Record = {}; const defendersSkills: Array> = []; const weight = 1 / Math.max(1, repeatCnt); for (let idx = 0; idx < repeatCnt; idx += 1) { const seed = idx === 0 ? baseSeed || resolveRandomSeed() : resolveRandomSeed(); const attackerNation = mapNationPayload(payload.attackerNation); const defenderNation = mapNationPayload(payload.defenderNation); const attackerCity = mapCityPayload(payload.attackerCity); const defenderCity = mapCityPayload(payload.defenderCity); const attackerGeneral = mapGeneralPayload(payload.attackerGeneral); const defenderGenerals = payload.defenderGenerals.map(mapGeneralPayload); const initialRice = new Map(); initialRice.set(attackerGeneral.id, attackerGeneral.rice); for (const defender of defenderGenerals) { initialRice.set(defender.id, defender.rice); } const outcome = resolveWarBattle({ seed, unitSet: payload.unitSet, config: payload.config, time: payload.time, attacker: { general: attackerGeneral, city: attackerCity, nation: attackerNation, }, defenders: defenderGenerals.map((general) => ({ general, city: defenderCity, nation: defenderNation, })), defenderCity, defenderNation, }); lastBattle = outcome; const attackerReport = outcome.reports.find( (report) => report.type === 'general' && report.isAttacker ); const killed = attackerReport?.killed ?? 0; const dead = attackerReport?.dead ?? 0; attackerKilled += killed * weight; attackerDead += dead * weight; attackerMaxKilled = Math.max(attackerMaxKilled, killed); attackerMinKilled = Math.min(attackerMinKilled, killed); attackerMaxDead = Math.max(attackerMaxDead, dead); attackerMinDead = Math.min(attackerMinDead, dead); const phase = outcome.metrics?.attackerPhase ?? 0; avgPhase += phase * weight; const defenderCount = outcome.metrics?.defenderActivatedSkills.length ?? 0; avgWar += defenderCount * weight; const attackerRiceInit = initialRice.get(attackerGeneral.id) ?? attackerGeneral.rice; attackerAvgRice += (attackerRiceInit - outcome.attacker.rice) * weight; let defenderRiceInit = 0; let defenderRiceAfter = 0; for (const defender of outcome.defenders) { defenderRiceInit += initialRice.get(defender.id) ?? defender.rice; defenderRiceAfter += defender.rice; } const cityRice = resolveCityRiceConsumption({ battle: outcome, defenderNation, unitSet: payload.unitSet, castleCrewTypeId: payload.config.castleCrewTypeId, year: payload.time.year, startYear: payload.time.startYear, }); defenderAvgRice += (defenderRiceInit - defenderRiceAfter + cityRice) * weight; const attackerActivated = outcome.metrics?.attackerActivatedSkills ?? {}; for (const [skillName, value] of Object.entries(attackerActivated)) { attackerSkills[skillName] = (attackerSkills[skillName] ?? 0) + value * weight; } const defenderActivated = outcome.metrics?.defenderActivatedSkills ?? []; for (let defIdx = 0; defIdx < defenderActivated.length; defIdx += 1) { while (defIdx >= defendersSkills.length) { defendersSkills.push({}); } const bucket = defendersSkills[defIdx]!; for (const [skillName, value] of Object.entries(defenderActivated[defIdx]!)) { bucket[skillName] = (bucket[skillName] ?? 0) + value * weight; } } } if (!lastBattle) { return { result: false, reason: '전투 결과를 생성하지 못했습니다.', }; } const logBuckets = buildLogBuckets({ logs: lastBattle.logs, year: payload.time.year, month: payload.time.month, attackerId: lastBattle.attacker.id, attackerNationId: payload.attackerNation.nation, }); return { result: true, reason: 'success', datetime: payload.attackerGeneral.turntime, lastWarLog: logBuckets, avgWar, phase: avgPhase, killed: attackerKilled, maxKilled: attackerMaxKilled, minKilled: attackerMinKilled === Number.POSITIVE_INFINITY ? 0 : attackerMinKilled, dead: attackerDead, maxDead: attackerMaxDead, minDead: attackerMinDead === Number.POSITIVE_INFINITY ? 0 : attackerMinDead, attackerRice: attackerAvgRice, defenderRice: defenderAvgRice, attackerSkills, defendersSkills, }; };