import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic/domain/entities.js'; import { createGeneralActionEvent } from '@sammo-ts/logic/actionModules/events.js'; import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js'; import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; import { buildCrewTypeIndex, getTechCost, getTechLevel } from '@sammo-ts/logic/world/unitSet.js'; import type { WarUnitReport } from './types.js'; import type { ConquerCityOutcome, WarAftermathConfig, WarAftermathInput, WarAftermathOutcome, WarAftermathTechContext, WarDiplomacyDelta, } from './types.js'; import { clamp, clampMin, getMetaNumber, parseConflict, readConflictOrder, round, simpleSerialize, sortConflictEntries, } from './utils.js'; const META_DEAD = 'dead'; const MAX_EXP_LEVEL = 255; const MAX_DEDICATION_LEVEL = 30; const updateLegacyProgressionLevels = (general: General): void => { const expLevel = general.experience < 1_000 ? Math.trunc(general.experience / 100) : Math.trunc(Math.sqrt(general.experience / 10)); general.meta.explevel = clamp(expLevel, 0, MAX_EXP_LEVEL); general.meta.dedlevel = clamp(Math.ceil(Math.sqrt(general.dedication) / 10), 0, MAX_DEDICATION_LEVEL); }; const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) => boolean): WarUnitReport | null => { for (const report of reports) { if (predicate(report)) { return report; } } return null; }; const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0); const increaseDeadCounter = (city: City, delta: number): void => { // Ref binds each `dead + %i` increment as an integer before MariaDB adds // it. Truncate each 40/60 percent split independently; rounding the // accumulated counter changes monthly recovery and war income. city.meta[META_DEAD] = getDeadCounter(city) + Math.trunc(delta); }; const isSupplyCity = (city: City): boolean => { const raw = city.meta.supply; if (typeof raw === 'boolean') { return raw; } if (typeof raw === 'number') { return raw > 0; } return city.supplyState > 0; }; const resolveCityTrainAtmos = (year: number, startYear: number): number => clamp(year - startYear + 59, 60, 110); const isTechLimited = (tech: number, year: number, startYear: number, config: WarAftermathConfig): boolean => { const relYear = clampMin(year - startYear, 0); const relMaxTech = clamp( Math.floor(relYear / config.techLevelIncYear) + config.initialAllowedTechLevel, 1, config.maxTechLevel ); const techLevel = getTechLevel(tech, config.maxTechLevel); return techLevel >= relMaxTech; }; const resolveNationGenCount = ( nation: Nation, generals: General[], config: WarAftermathConfig ): { total: number; effective: number } => { const fallback = generals.filter((general) => general.nationId === nation.id).length; let total = getMetaNumber(nation.meta, 'gennum', fallback); let effective = generals.filter((general) => general.nationId === nation.id && general.npcState !== 5).length; if (effective < config.initialNationGenLimit) { total = config.initialNationGenLimit; effective = config.initialNationGenLimit; } return { total, effective }; }; const applyNationTechGain = ( nation: Nation, baseGain: number, input: WarAftermathInput, context: Omit ): void => { const config = input.config; let gain = baseGain; if (input.calcNationTechGain) { gain = input.calcNationTechGain({ ...context, baseGain: gain, }); } const { total, effective } = resolveNationGenCount(nation, input.generals, config); if (total !== effective) { gain *= total / effective; } if (isTechLimited(getMetaNumber(nation.meta, 'tech', 0), input.time.year, input.time.startYear, config)) { gain /= 4; } const divisor = Math.max(config.initialNationGenLimit, total); const currentTech = getMetaNumber(nation.meta, 'tech', 0); const delta = gain / divisor; // Ref executes `tech + delta` inside MariaDB for battle gains, so the // arithmetic starts from the stored binary32 value without a PHP text read. nation.meta.tech = Math.fround(currentTech + delta); if ((process.env.CORE_WAR_TECH_TRACE_NATION_IDS?.split(',') ?? []).includes(String(nation.id))) { process.stdout.write( `WAR_TECH_TRACE ${JSON.stringify({ engine: 'core', nationId: nation.id, side: context.side, currentTech, baseGain, gain, total, effective, divisor, delta, storedTech: nation.meta.tech, attackerGeneralId: context.attackerReport.id })}\n` ); } }; const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => { const conflict = parseConflict(city.conflict); if (!conflict) { return attackerNationId; } const activeNationIds = new Set(nations.map((nation) => nation.id)); const entries = sortConflictEntries(conflict, readConflictOrder(city.meta)).filter( ([key, value]) => Number.isFinite(key) && typeof value === 'number' && (key === attackerNationId || activeNationIds.has(key)) ); if (!entries.length) { return attackerNationId; } return entries[0]![0]; }; const getCityPosition = (city: City): { x: number; y: number } | null => { const x = getMetaNumber(city.meta, 'positionX', Number.NaN); const y = getMetaNumber(city.meta, 'positionY', Number.NaN); if (!Number.isFinite(x) || !Number.isFinite(y)) { return null; } return { x, y }; }; const findNextCapital = ( cities: City[], defenderNationId: number, capturedCityId: number, oldCapital: City ): City | null => { const candidates = cities.filter((city) => city.nationId === defenderNationId && city.id !== capturedCityId); if (!candidates.length) { return null; } const oldPos = getCityPosition(oldCapital); if (!oldPos) { return candidates.sort((lhs, rhs) => rhs.population - lhs.population)[0]!; } return candidates .map((city) => { const pos = getCityPosition(city); const distance = pos ? Math.hypot(pos.x - oldPos.x, pos.y - oldPos.y) : Number.MAX_SAFE_INTEGER; return { city, distance }; }) .sort((lhs, rhs) => { if (lhs.distance !== rhs.distance) { return lhs.distance - rhs.distance; } return rhs.city.population - lhs.city.population; })[0]!.city; }; const pushLoggers = (loggers: ActionLogger[], logs: LogEntryDraft[]): void => { for (const logger of loggers) { logs.push(...logger.flush()); } }; // 도시 점령 이후의 국가 붕괴/수도 이동/도시 리셋 처리. const resolveConquerCity = ( input: WarAftermathInput, rng: RandUtil ): ConquerCityOutcome => { const { attackerNation, defenderNation, defenderCity, cities, generals, config } = input; const attacker = input.battle.attacker; const logs: LogEntryDraft[] = []; const affectedCities = new Set(); const affectedGenerals = new Set>(); const affectedNations = new Set(); const conquerNationId = resolveConquerNation(defenderCity, attackerNation.id, input.nations); const attackerLogger = new ActionLogger({ generalId: attacker.id, nationId: attackerNation.id, }); const defenderNationId = defenderNation?.id ?? 0; const defenderNationName = defenderNation?.name ?? '공백지'; const defenderNationDecoration = defenderNationId ? `${defenderNationName}의` : '공백지인'; const attackerNationName = attackerNation.name; const attackerGeneralName = attacker.name; const cityName = defenderCity.name; const josaUl = JosaUtil.pick(cityName, '을'); const josaYiNation = JosaUtil.pick(attackerNationName, '이'); const josaYiGen = JosaUtil.pick(attackerGeneralName, '이'); const josaYiCity = JosaUtil.pick(cityName, '이'); attackerLogger.pushGeneralActionLog(`${cityName} 공략에 성공했습니다.`, LogFormat.PLAIN); attackerLogger.pushGeneralHistoryLog(`${cityName}${josaUl} 점령`); attackerLogger.pushGlobalActionLog( `${attackerGeneralName}${josaYiGen} ${cityName} 공략에 성공했습니다.` ); attackerLogger.pushGlobalHistoryLog( `【지배】${attackerNationName}${josaYiNation} ${cityName}${josaUl} 지배했습니다.` ); attackerLogger.pushNationHistoryLog( `${attackerGeneralName}${josaYiGen} ${defenderNationDecoration} ${cityName} ${josaUl} 점령` ); if (defenderNationId) { const defenderNationLogger = new ActionLogger({ nationId: defenderNationId }); defenderNationLogger.pushNationHistoryLog( `${attackerNationName}${attackerGeneralName}에 의해 ${cityName}${josaYiCity} 함락` ); pushLoggers([defenderNationLogger], logs); } const defenderCityCount = defenderNationId ? cities.filter((city) => city.nationId === defenderNationId).length : 0; const nationCollapsed = defenderNationId !== 0 && defenderCityCount === 1; let collapseRewardGold = 0; let collapseRewardRice = 0; const ruinedNpcJoinPlans: ConquerCityOutcome['ruinedNpcJoinPlans'] = []; // 국가 붕괴 시 자원 손실과 포상 정산. if (nationCollapsed && defenderNation) { const defenderGenerals = generals .filter((general) => general.nationId === defenderNationId) .sort((lhs, rhs) => { // deleteNation() reads the non-lord rows in primary-key order, // then appends the lord object to the returned PHP array. const lhsIsLord = lhs.id === defenderNation.chiefGeneralId; const rhsIsLord = rhs.id === defenderNation.chiefGeneralId; if (lhsIsLord !== rhsIsLord) { return lhsIsLord ? 1 : -1; } return lhs.id - rhs.id; }); let totalGoldLoss = 0; let totalRiceLoss = 0; const defenderNationJosaUl = JosaUtil.pick(defenderNationName, '을'); const defenderNationJosaUn = JosaUtil.pick(defenderNationName, '은'); const defenderNationJosaYi = JosaUtil.pick(defenderNationName, '이'); attackerLogger.pushNationHistoryLog(`${defenderNationName}${defenderNationJosaUl} 정복`); attackerLogger.pushGlobalHistoryLog( `【멸망】${defenderNationName}${defenderNationJosaUn} 멸망했습니다.` ); for (const general of defenderGenerals) { // Legacy Util::toInt truncates these losses rather than rounding. const loseGold = Math.trunc(general.gold * rng.nextRange(0.2, 0.5)); const loseRice = Math.trunc(general.rice * rng.nextRange(0.2, 0.5)); general.gold = clampMin(general.gold - loseGold, 0); general.rice = clampMin(general.rice - loseRice, 0); general.experience = round(general.experience * 0.9); general.dedication = round(general.dedication * 0.5); updateLegacyProgressionLevels(general); totalGoldLoss += loseGold; totalRiceLoss += loseRice; const generalLogger = new ActionLogger({ generalId: general.id, nationId: general.nationId, }); generalLogger.pushGeneralActionLog( `${defenderNationName}${defenderNationJosaYi} 멸망했습니다.`, LogFormat.PLAIN ); generalLogger.pushGeneralHistoryLog(`${defenderNationName}${defenderNationJosaYi} 멸망`); generalLogger.pushGeneralActionLog( `도주하며 금${loseGold} 쌀${loseRice}을 분실했습니다.`, LogFormat.PLAIN ); pushLoggers([generalLogger], logs); affectedGenerals.add(general); if (config.joinMode !== 'onlyRandom') { // Ref attempts to build/send a scout message after every loss. // Message availability does not affect this draw. rng.nextBool(0.5); const eligibleNpc = general.npcState >= 2 && general.npcState <= 8 && general.npcState !== 5; if (eligibleNpc && rng.nextBool(config.joinRuinedNpcProbability ?? 0.1)) { ruinedNpcJoinPlans.push({ generalId: general.id, destNationId: attackerNation.id, joinTurn: rng.nextRangeInt(0, 12), }); } } } collapseRewardGold = Math.floor((Math.max(0, defenderNation.gold - config.baseGold) + totalGoldLoss) / 2); collapseRewardRice = Math.floor((Math.max(0, defenderNation.rice - config.baseRice) + totalRiceLoss) / 2); attackerNation.gold = round(attackerNation.gold + collapseRewardGold); attackerNation.rice = round(attackerNation.rice + collapseRewardRice); const resourceLog = `${defenderNationName} 정복으로 ` + `금${collapseRewardGold.toLocaleString('en-US')} ` + `쌀${collapseRewardRice.toLocaleString('en-US')}을 획득했습니다.`; for (const general of generals) { if (general.nationId !== attackerNation.id || general.officerLevel < 5) { continue; } if (general.id === attacker.id) { attackerLogger.pushGeneralActionLog(resourceLog, LogFormat.PLAIN); continue; } const chiefLogger = new ActionLogger({ generalId: general.id, nationId: attackerNation.id, }); chiefLogger.pushGeneralActionLog(resourceLog, LogFormat.PLAIN); pushLoggers([chiefLogger], logs); } defenderNation.meta.collapsed = true; affectedNations.add(defenderNation); affectedNations.add(attackerNation); } // 수도 함락 시 수도 이전 및 내부 사기/자원 페널티. if (!nationCollapsed && defenderNation && defenderNation.capitalCityId === defenderCity.id) { const nextCapital = findNextCapital(cities, defenderNationId, defenderCity.id, defenderCity); if (nextCapital) { const josaRo = JosaUtil.pick(nextCapital.name, '로'); const josaYi = JosaUtil.pick(defenderNation.name, '이'); attackerLogger.pushGlobalHistoryLog( `【긴급천도】${defenderNation.name}${josaYi} 수도가 함락되어 ${nextCapital.name}${josaRo} 긴급천도하였습니다.` ); const moveLog = `수도가 함락되어 ${nextCapital.name}${josaRo} 긴급천도합니다.`; const gatherLog = `수뇌는 ${nextCapital.name}${josaRo} 집합되었습니다.`; defenderNation.capitalCityId = nextCapital.id; defenderNation.gold = round(defenderNation.gold * 0.5); defenderNation.rice = round(defenderNation.rice * 0.5); nextCapital.supplyState = 1; affectedCities.add(nextCapital); for (const general of generals) { if (general.nationId !== defenderNationId) { continue; } const defenderLogger = new ActionLogger({ generalId: general.id, nationId: defenderNationId, }); defenderLogger.pushGeneralActionLog(moveLog, LogFormat.PLAIN); if (general.officerLevel >= 5) { defenderLogger.pushGeneralActionLog(gatherLog, LogFormat.PLAIN); } pushLoggers([defenderLogger], logs); general.atmos = round(general.atmos * 0.8); if (general.officerLevel >= 5) { general.cityId = nextCapital.id; } affectedGenerals.add(general); } affectedNations.add(defenderNation); } } const conquerNation = conquerNationId === attackerNation.id ? attackerNation : (input.nations.find((nation) => nation.id === conquerNationId) ?? attackerNation); if (conquerNationId === attackerNation.id) { attacker.cityId = defenderCity.id; affectedGenerals.add(attacker); } else { const conquerNationName = conquerNation.name; const conquerNationLogger = new ActionLogger({ nationId: conquerNationId }); const josaUl = JosaUtil.pick(cityName, '을'); const josaYi = JosaUtil.pick(conquerNationName, '이'); attackerLogger.pushGlobalHistoryLog( `【분쟁협상】${conquerNationName}${josaYi} 영토분쟁에서 우위를 점하여 ${cityName}${josaUl} 양도받았습니다.` ); conquerNationLogger.pushNationHistoryLog( `${attackerNationName}에서 ${cityName}${josaUl} 양도 받음` ); attackerLogger.pushNationHistoryLog( `${cityName}${josaUl} ${conquerNationName}양도` ); pushLoggers([conquerNationLogger], logs); } // 점령 후 도시 상태를 방어 기본 상태로 되돌린다. defenderCity.supplyState = 1; defenderCity.frontState = 0; defenderCity.meta.term = 0; defenderCity.agriculture = round(defenderCity.agriculture * 0.7); defenderCity.commerce = round(defenderCity.commerce * 0.7); defenderCity.security = round(defenderCity.security * 0.7); defenderCity.nationId = conquerNationId; defenderCity.conflict = {}; defenderCity.meta.conflict_order = []; if (defenderCity.level > 3) { defenderCity.defence = config.defaultCityWall; defenderCity.wall = config.defaultCityWall; } else { defenderCity.defence = round(defenderCity.defenceMax / 2); defenderCity.wall = round(defenderCity.wallMax / 2); } affectedCities.add(defenderCity); affectedNations.add(conquerNation); pushLoggers([attackerLogger], logs); return { conquerNationId, nationCollapsed, collapseRewardGold: round(collapseRewardGold), collapseRewardRice: round(collapseRewardRice), logs, nations: Array.from(affectedNations), cities: Array.from(affectedCities), generals: Array.from(affectedGenerals), ruinedNpcJoinPlans, }; }; export const resolveWarAftermath = ( input: WarAftermathInput ): WarAftermathOutcome => { const logs: LogEntryDraft[] = []; const diplomacyDeltas: WarDiplomacyDelta[] = []; const affectedNations = new Set(); const affectedCities = new Set(); const affectedGenerals = new Set>(); const attackerReport = findReport(input.battle.reports, (report) => report.type === 'general' && report.isAttacker); const cityReport = findReport(input.battle.reports, (report) => report.type === 'city'); const attackerKilled = attackerReport?.killed ?? 0; const attackerDead = attackerReport?.dead ?? 0; const totalDead = attackerKilled + attackerDead; // 전투 사망자 누적: 공격/수비 도시로 분배. if (totalDead > 0) { increaseDeadCounter(input.attackerCity, totalDead * 0.4); increaseDeadCounter(input.defenderCity, totalDead * 0.6); affectedCities.add(input.attackerCity); affectedCities.add(input.defenderCity); } // 수성 도시의 식량 소모/보상 처리. if (input.defenderNation && input.defenderNation.id !== 0 && isSupplyCity(input.defenderCity)) { const defenderNation = input.defenderNation; const cityKilled = cityReport?.killed ?? 0; // Ref branches on WarUnitCity::getPhase(), not accumulated city // casualties. A city can retain dead casualties from earlier battles // while being conquered before its wall receives a phase. const cityPhase = cityReport?.phase ?? ((cityReport?.dead ?? 0) > 0 ? 1 : 0); if (cityPhase > 0) { const crewTypeIndex = buildCrewTypeIndex(input.unitSet); const crewType = crewTypeIndex.get(input.config.castleCrewTypeId); const riceCoef = crewType?.rice ?? 1; let rice = (cityKilled / 100) * 0.8; rice *= riceCoef; rice *= getTechCost(getMetaNumber(defenderNation.meta, 'tech', 0)); rice *= resolveCityTrainAtmos(input.time.year, input.time.startYear) / 100 - 0.2; rice = round(rice); defenderNation.rice = clampMin(defenderNation.rice - rice, 0); affectedNations.add(defenderNation); } else if (input.battle.conquered) { const bonus = defenderNation.capitalCityId === input.defenderCity.id ? 1000 : 500; defenderNation.rice = round(defenderNation.rice + bonus); affectedNations.add(defenderNation); } } // 기술 경험치와 외교 사망자 수치 갱신. if (input.attackerNation.id && attackerReport) { const attackerTechGain = attackerDead * 0.012; applyNationTechGain(input.attackerNation, attackerTechGain, input, { side: 'attacker', nation: input.attackerNation, attackerReport, }); affectedNations.add(input.attackerNation); } if (input.defenderNation && input.defenderNation.id !== 0 && attackerReport) { const defenderTechGain = attackerKilled * 0.009; applyNationTechGain(input.defenderNation, defenderTechGain, input, { side: 'defender', nation: input.defenderNation, attackerReport, }); affectedNations.add(input.defenderNation); diplomacyDeltas.push( { fromNationId: input.attackerNation.id, toNationId: input.defenderNation.id, deadDelta: round(attackerDead), }, { fromNationId: input.defenderNation.id, toNationId: input.attackerNation.id, deadDelta: round(attackerKilled), } ); } // 점령 성공 시 ConquerCity 로직 수행. let conquest: ConquerCityOutcome | undefined; if (input.battle.conquered) { const rng = input.rng ?? new RandUtil( LiteHashDRBG.build( simpleSerialize( input.hiddenSeed ?? '', 'ConquerCity', input.time.year, input.time.month, input.attackerNation.id, input.battle.attacker.id, input.defenderCity.id ) ) ); // Legacy ConquerCity calls every general action module for every // defender-nation general stationed in the conquered city, before any // nation-collapse RNG is consumed. Keep that order and the same RNG // object instead of opening a second random stream. const pipeline = new GeneralActionPipeline(input.generalActionModules ?? []); const cityDefenders = input.generals.filter( (general) => general.nationId !== 0 && general.nationId === input.defenderCity.nationId && general.cityId === input.defenderCity.id ); for (const general of cityDefenders) { pipeline.dispatch( { general, nation: input.defenderNation, rng, worldView: { listGenerals: () => input.generals, listGeneralsByCity: (cityId) => input.generals.filter((candidate) => candidate.cityId === cityId), listNations: () => input.nations, }, log: { push: (text) => logs.push({ scope: LogScope.GENERAL, category: LogCategory.ACTION, format: LogFormat.MONTH, generalId: general.id, nationId: general.nationId, text, }), }, }, createGeneralActionEvent('city.conquered', { attacker: input.battle.attacker, }) ); affectedGenerals.add(general); } conquest = resolveConquerCity(input, rng); logs.push(...conquest.logs); conquest.nations.forEach((nation) => affectedNations.add(nation)); conquest.cities.forEach((city) => affectedCities.add(city)); conquest.generals.forEach((general) => affectedGenerals.add(general)); } const outcome: WarAftermathOutcome = { nations: Array.from(affectedNations), cities: Array.from(affectedCities), generals: Array.from(affectedGenerals), diplomacyDeltas, logs, conquered: input.battle.conquered, }; if (conquest) { outcome.conquest = conquest; } return outcome; };