From 97439f64a956571cd1ace5ad5a1ca6f6f1c8798d Mon Sep 17 00:00:00 2001 From: Hide_D Date: Tue, 30 Dec 2025 10:40:30 +0000 Subject: [PATCH] feat: add aftermath module with war aftermath logic and related types --- packages/logic/src/war/aftermath.ts | 578 +++++++++++++++++++++++ packages/logic/src/war/index.ts | 1 + packages/logic/src/war/types.ts | 68 +++ packages/logic/src/war/utils.ts | 20 + packages/logic/test/warAftermath.test.ts | 240 ++++++++++ 5 files changed, 907 insertions(+) create mode 100644 packages/logic/src/war/aftermath.ts create mode 100644 packages/logic/test/warAftermath.test.ts diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts new file mode 100644 index 0000000..cbc51ae --- /dev/null +++ b/packages/logic/src/war/aftermath.ts @@ -0,0 +1,578 @@ +import { + JosaUtil, + LiteHashDRBG, + RandUtil, +} from '@sammo-ts/common'; + +import type { + City, + General, + GeneralTriggerState, + Nation, +} from '../domain/entities.js'; +import { ActionLogger } from '../logging/actionLogger.js'; +import { LogFormat, type LogEntryDraft } from '../logging/types.js'; +import { + buildCrewTypeIndex, + getTechCost, + getTechLevel, +} from '../world/unitSet.js'; +import type { WarUnitReport } from './types.js'; +import type { + ConquerCityOutcome, + WarAftermathConfig, + WarAftermathInput, + WarAftermathOutcome, + WarAftermathTechContext, + WarDiplomacyDelta, +} from './types.js'; +import { + clamp, + clampMin, + getMetaNumber, + round, + simpleSerialize, +} from './utils.js'; + +const META_DEAD = 'dead'; +const META_CONFLICT = 'conflict'; + +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 setDeadCounter = (city: City, value: number): void => { + city.meta[META_DEAD] = round(value); +}; + +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: WarAftermathTechContext +): 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 tech = getMetaNumber(nation.meta, 'tech', 0) + gain / divisor; + nation.meta.tech = round(tech); +}; + +const resolveConquerNation = ( + city: City, + attackerNationId: number +): number => { + const rawConflict = city.meta[META_CONFLICT]; + if (!rawConflict) { + return attackerNationId; + } + try { + const parsed = JSON.parse(String(rawConflict)) as Record; + const entries = Object.entries(parsed) + .map(([key, value]) => [Number(key), value] as const) + .filter(([key, value]) => Number.isFinite(key) && typeof value === 'number') + .sort(([, lhs], [, rhs]) => rhs - lhs); + if (!entries.length) { + return attackerNationId; + } + return entries[0]![0]; + } catch { + return attackerNationId; + } +}; + +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); + 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; + + // 국가 붕괴 시 자원 손실과 포상 정산. + if (nationCollapsed && defenderNation) { + const defenderGenerals = generals.filter( + (general) => general.nationId === defenderNationId + ); + let totalGoldLoss = 0; + let totalRiceLoss = 0; + + for (const general of defenderGenerals) { + const loseGold = round(general.gold * rng.nextRange(0.2, 0.5)); + const loseRice = round(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); + + totalGoldLoss += loseGold; + totalRiceLoss += loseRice; + + const generalLogger = new ActionLogger({ + generalId: general.id, + nationId: general.nationId, + }); + generalLogger.pushGeneralActionLog( + `도주하며 금${loseGold} 쌀${loseRice}을 분실했습니다.`, + LogFormat.PLAIN + ); + pushLoggers([generalLogger], logs); + affectedGenerals.add(general); + } + + collapseRewardGold = + Math.max(0, defenderNation.gold - config.baseGold) * 0.5 + + totalGoldLoss * 0.5; + collapseRewardRice = + Math.max(0, defenderNation.rice - config.baseRice) * 0.5 + + totalRiceLoss * 0.5; + + attackerNation.gold = round(attackerNation.gold + collapseRewardGold); + attackerNation.rice = round(attackerNation.rice + collapseRewardRice); + + 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) { + 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; + } + 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.agriculture = round(defenderCity.agriculture * 0.7); + defenderCity.commerce = round(defenderCity.commerce * 0.7); + defenderCity.security = round(defenderCity.security * 0.7); + defenderCity.nationId = conquerNationId; + defenderCity.meta[META_CONFLICT] = '{}'; + + 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), + }; +}; + +export const resolveWarAftermath = < + TriggerState extends GeneralTriggerState = GeneralTriggerState +>( + 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) { + const attackerCityDead = getDeadCounter(input.attackerCity) + totalDead * 0.4; + const defenderCityDead = getDeadCounter(input.defenderCity) + totalDead * 0.6; + setDeadCounter(input.attackerCity, attackerCityDead); + setDeadCounter(input.defenderCity, defenderCityDead); + 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; + + if ((cityReport?.dead ?? 0) > 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 + ) + ) + ); + 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; +}; diff --git a/packages/logic/src/war/index.ts b/packages/logic/src/war/index.ts index 99a407f..67b037c 100644 --- a/packages/logic/src/war/index.ts +++ b/packages/logic/src/war/index.ts @@ -1,6 +1,7 @@ export * from './types.js'; export * from './actions.js'; export * from './engine.js'; +export * from './aftermath.js'; export * from './units.js'; export * from './triggers.js'; export * from './triggersChePilsal.js'; diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts index 36937e2..9e7bcaa 100644 --- a/packages/logic/src/war/types.ts +++ b/packages/logic/src/war/types.ts @@ -83,3 +83,71 @@ export interface WarBattleOutcome< conquered: boolean; reports: WarUnitReport[]; } + +export interface WarAftermathConfig { + initialNationGenLimit: number; + techLevelIncYear: number; + initialAllowedTechLevel: number; + maxTechLevel: number; + defaultCityWall: number; + baseGold: number; + baseRice: number; + castleCrewTypeId: number; +} + +export interface WarAftermathTechContext { + side: 'attacker' | 'defender'; + nation: Nation; + attackerReport: WarUnitReport; + baseGain?: number; +} + +export interface WarDiplomacyDelta { + fromNationId: number; + toNationId: number; + deadDelta: number; +} + +export interface ConquerCityOutcome< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + conquerNationId: number; + nationCollapsed: boolean; + collapseRewardGold: number; + collapseRewardRice: number; + logs: LogEntryDraft[]; + nations: Nation[]; + cities: City[]; + generals: General[]; +} + +export interface WarAftermathInput< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + battle: WarBattleOutcome; + attackerNation: Nation; + defenderNation: Nation | null; + attackerCity: City; + defenderCity: City; + nations: Nation[]; + cities: City[]; + generals: General[]; + unitSet: UnitSetDefinition; + config: WarAftermathConfig; + time: WarTimeContext; + hiddenSeed?: string; + rng?: RandUtil; + calcNationTechGain?: (context: WarAftermathTechContext) => number; +} + +export interface WarAftermathOutcome< + TriggerState extends GeneralTriggerState = GeneralTriggerState +> { + nations: Nation[]; + cities: City[]; + generals: General[]; + diplomacyDeltas: WarDiplomacyDelta[]; + logs: LogEntryDraft[]; + conquered: boolean; + conquest?: ConquerCityOutcome; +} diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts index 8eaa2dd..9aed950 100644 --- a/packages/logic/src/war/utils.ts +++ b/packages/logic/src/war/utils.ts @@ -154,3 +154,23 @@ export const sortConflict = ( return ordered; }; + +export const simpleSerialize = (...values: Array): string => { + const result: string[] = []; + for (const value of values) { + if (typeof value === 'string') { + result.push(`str(${value.length},${value})`); + continue; + } + if (Number.isInteger(value)) { + result.push(`int(${value})`); + continue; + } + const floatValue = value.toLocaleString('en-US', { + maximumFractionDigits: 6, + useGrouping: false, + }); + result.push(`float(${floatValue})`); + } + return result.join('|'); +}; diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts new file mode 100644 index 0000000..d1527de --- /dev/null +++ b/packages/logic/test/warAftermath.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; + +import { ConstantRNG, RandUtil } from '@sammo-ts/common'; + +import type { City, General, Nation } from '../src/domain/entities.js'; +import type { UnitSetDefinition } from '../src/world/types.js'; +import { resolveWarAftermath } from '../src/war/aftermath.js'; +import type { WarAftermathConfig } from '../src/war/types.js'; + +const buildUnitSet = (): UnitSetDefinition => ({ + id: 'test', + name: 'test', + crewTypes: [ + { + id: 999, + armType: 9, + name: '성벽', + attack: 0, + defence: 0, + speed: 1, + avoid: 0, + magicCoef: 0, + cost: 0, + rice: 10, + requirements: [], + attackCoef: {}, + defenceCoef: {}, + info: [], + initSkillTrigger: null, + phaseSkillTrigger: null, + iActionList: null, + }, + ], +}); + +const buildConfig = (): WarAftermathConfig => ({ + initialNationGenLimit: 1, + techLevelIncYear: 5, + initialAllowedTechLevel: 1, + maxTechLevel: 12, + defaultCityWall: 1000, + baseGold: 0, + baseRice: 0, + castleCrewTypeId: 999, +}); + +const buildCity = (id: number, nationId: number): City => ({ + id, + name: `City${id}`, + nationId, + level: 2, + population: 10000, + populationMax: 10000, + agriculture: 1000, + agricultureMax: 1000, + commerce: 1000, + commerceMax: 1000, + security: 1000, + securityMax: 1000, + defence: 100, + defenceMax: 200, + supplyState: 1, + frontState: 0, + wall: 100, + wallMax: 200, + meta: {}, +}); + +const buildNation = (id: number): Nation => ({ + id, + name: `Nation${id}`, + color: '#000000', + capitalCityId: id, + chiefGeneralId: null, + gold: 1000, + rice: 1000, + power: 0, + level: 1, + typeCode: 'test', + meta: { + tech: 1000, + }, +}); + +const buildGeneral = (id: number, nationId: number, cityId: number): General => ({ + id, + name: `General${id}`, + nationId, + cityId, + troopId: 0, + stats: { + leadership: 70, + strength: 70, + intelligence: 70, + }, + experience: 100, + dedication: 100, + officerLevel: 3, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: null, + weapon: null, + book: null, + item: null, + }, + }, + injury: 0, + gold: 1000, + rice: 1000, + crew: 1000, + crewTypeId: 999, + train: 80, + atmos: 80, + age: 20, + npcState: 0, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + meta: {}, +}); + +describe('war aftermath', () => { + it('updates tech and diplomacy deltas', () => { + const attackerNation = buildNation(1); + const defenderNation = buildNation(2); + const attackerCity = buildCity(1, 1); + const defenderCity = buildCity(2, 2); + const attacker = buildGeneral(1, 1, 1); + + const outcome = resolveWarAftermath({ + battle: { + attacker, + defenders: [], + defenderCity, + logs: [], + conquered: false, + reports: [ + { + id: attacker.id, + type: 'general', + name: attacker.name, + isAttacker: true, + killed: 100, + dead: 50, + }, + ], + }, + attackerNation, + defenderNation, + attackerCity, + defenderCity, + nations: [attackerNation, defenderNation], + cities: [attackerCity, defenderCity], + generals: [attacker], + unitSet: buildUnitSet(), + config: buildConfig(), + time: { + year: 200, + month: 1, + startYear: 180, + }, + }); + + expect(attackerNation.meta.tech).toBe(1001); + expect(defenderNation.meta.tech).toBe(1001); + expect(outcome.diplomacyDeltas).toHaveLength(2); + expect(attackerCity.meta.dead).toBe(60); + expect(defenderCity.meta.dead).toBe(90); + }); + + it('applies conquest collapse rewards', () => { + const rng = new RandUtil(new ConstantRNG(0)); + const attackerNation = buildNation(1); + const defenderNation = buildNation(2); + defenderNation.gold = 5000; + defenderNation.rice = 6000; + const attackerCity = buildCity(1, 1); + const defenderCity = buildCity(2, 2); + defenderCity.meta.conflict = JSON.stringify({ 1: 100 }); + const attacker = buildGeneral(1, 1, 1); + const defender = buildGeneral(2, 2, 2); + + const outcome = resolveWarAftermath({ + battle: { + attacker, + defenders: [defender], + defenderCity, + logs: [], + conquered: true, + reports: [ + { + id: attacker.id, + type: 'general', + name: attacker.name, + isAttacker: true, + killed: 10, + dead: 5, + }, + { + id: defenderCity.id, + type: 'city', + name: defenderCity.name, + isAttacker: false, + killed: 0, + dead: 0, + }, + ], + }, + attackerNation, + defenderNation, + attackerCity, + defenderCity, + nations: [attackerNation, defenderNation], + cities: [attackerCity, defenderCity], + generals: [attacker, defender], + unitSet: buildUnitSet(), + config: buildConfig(), + time: { + year: 200, + month: 1, + startYear: 180, + }, + rng, + }); + + expect(outcome.conquest?.nationCollapsed).toBe(true); + expect(attackerNation.gold).toBe(3600); + expect(attackerNation.rice).toBe(4600); + expect(defender.experience).toBe(90); + expect(defender.dedication).toBe(50); + expect(defenderCity.nationId).toBe(attackerNation.id); + expect(defenderCity.meta.conflict).toBe('{}'); + }); +});