diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 16f2ce2..26bf7da 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1035,7 +1035,7 @@ export class InMemoryTurnWorld { // nation. Without this, a later conquest can award a city to a // nation ID that no longer exists. for (const city of this.cities.values()) { - const rawConflict = city.meta.conflict; + const rawConflict = city.conflict; if (rawConflict === null || rawConflict === undefined) { continue; } @@ -1056,10 +1056,7 @@ export class InMemoryTurnWorld { delete conflict[key]; this.cities.set(city.id, { ...city, - meta: { - ...city.meta, - conflict: JSON.stringify(conflict), - }, + conflict: conflict as City['conflict'], }); this.dirtyCityIds.add(city.id); } @@ -1073,10 +1070,20 @@ export class InMemoryTurnWorld { if (general.nationId !== nationId) { continue; } + const belong = typeof general.meta.belong === 'number' ? general.meta.belong : 0; + const maxBelong = typeof general.meta.max_belong === 'number' ? general.meta.max_belong : 0; const updated = applyGeneralPatch(general, { nationId: 0, officerLevel: 0, troopId: 0, + meta: { + ...general.meta, + belong: 0, + officer_city: 0, + officerCity: 0, + permission: 'normal', + ...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}), + }, }); this.generals.set(general.id, normalizeGeneralTurnTime(updated, this.state.lastTurnTime)); this.dirtyGeneralIds.add(general.id); diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index f00c22c..2b8476e 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -129,8 +129,9 @@ const applyLegacyGeneralProgression = ( const preserveLevel = actionKey === 'che_은퇴' || actionKey === 'che_선양'; if (!preserveLevel && (forceRefreshLevel || general.experience !== previousGeneral.experience)) { const previousExpLevel = readMetaNumber(previousGeneral.meta, 'explevel', 0); + const actionResolvedExpLevel = readMetaNumber(general.meta, 'explevel', previousExpLevel); meta.explevel = expLevel; - if (expLevel !== previousExpLevel) { + if (expLevel !== previousExpLevel && actionResolvedExpLevel !== expLevel) { const josaRo = JosaUtil.pick(String(expLevel), '로'); logs.push({ scope: LogScope.GENERAL, @@ -1089,6 +1090,9 @@ export const createReservedTurnHandler = async (options: { const lastTurnBeforeExecution = JSON.stringify(currentGeneral.lastTurn ?? {}); const generalBeforeExecution = currentGeneral; + const cityNationIdsBeforeExecution = new Map( + (worldView?.listCities() ?? []).map((city) => [city.id, city.nationId] as const) + ); const resolution = resolveGeneralAction( definition, actionContext, @@ -1279,9 +1283,14 @@ export const createReservedTurnHandler = async (options: { } } - const hasNationChange = (resolution.patches?.cities ?? []).some((patch) => - Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId') - ); + const hasNationChange = (resolution.patches?.cities ?? []).some((patch) => { + if (!Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId')) { + return false; + } + const nextNationId = patch.patch.nationId; + const previousNationId = cityNationIdsBeforeExecution.get(patch.id); + return previousNationId === undefined || nextNationId !== previousNationId; + }); const refreshesFrontForDiplomacyState = actionKey === 'che_이호경식' && resolution.effects.some( diff --git a/app/game-engine/test/nationCollapseOnConquest.test.ts b/app/game-engine/test/nationCollapseOnConquest.test.ts index 92ee5e5..d9ab627 100644 --- a/app/game-engine/test/nationCollapseOnConquest.test.ts +++ b/app/game-engine/test/nationCollapseOnConquest.test.ts @@ -87,9 +87,7 @@ describe('도시 점령 시 국가 멸망 처리', () => { strongFrontCity.frontState = 1; strongFrontCity.supplyState = 1; const conflictCity = cities.find((city) => city.id === 3)!; - Object.assign(conflictCity.meta, { - conflict: JSON.stringify({ 2: 100, 1: 50 }), - }); + conflictCity.conflict = { 2: 100, 1: 50 }; const unitSet: UnitSetDefinition = { id: 'test_unit_set', @@ -170,6 +168,7 @@ describe('도시 점령 시 국가 멸망 처리', () => { gold: 0, rice: 0, turnTime: delayedTurnTime, + meta: { killturn: 800, belong: 10 }, } ); generals.push(weakGeneral); @@ -264,10 +263,11 @@ describe('도시 점령 시 국가 멸망 처리', () => { expect(world.getCityById(weakCityId)?.nationId).toBe(1); expect(world.getNationById(2)).toBeNull(); expect(world.listNations().some((nation) => nation.id === 2)).toBe(false); - expect(JSON.parse(String(world.getCityById(conflictCity.id)?.meta.conflict))).toEqual({ 1: 50 }); + expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 }); const updatedWeakGeneral = world.getGeneralById(weakGeneral.id); expect(updatedWeakGeneral?.nationId).toBe(0); expect(updatedWeakGeneral?.officerLevel).toBe(0); + expect(updatedWeakGeneral?.meta.belong).toBe(0); }); }); diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts index 106fb41..7ef74cb 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -13,10 +13,9 @@ import type { WarAftermathTechContext, WarDiplomacyDelta, } from './types.js'; -import { clamp, clampMin, getMetaNumber, round, simpleSerialize } from './utils.js'; +import { clamp, clampMin, getMetaNumber, parseConflict, round, simpleSerialize } from './utils.js'; const META_DEAD = 'dead'; -const META_CONFLICT = 'conflict'; const MAX_EXP_LEVEL = 255; const MAX_DEDICATION_LEVEL = 30; @@ -120,29 +119,24 @@ const applyNationTechGain = ( }; const resolveConquerNation = (city: City, attackerNationId: number, nations: Nation[]): number => { - const rawConflict = city.meta[META_CONFLICT]; - if (!rawConflict) { + const conflict = parseConflict(city.conflict); + if (!conflict) { return attackerNationId; } - try { - const parsed = JSON.parse(String(rawConflict)) as Record; - const activeNationIds = new Set(nations.map((nation) => nation.id)); - const entries = Object.entries(parsed) - .map(([key, value]) => [Number(key), value] as const) - .filter( - ([key, value]) => - Number.isFinite(key) && - typeof value === 'number' && - (key === attackerNationId || activeNationIds.has(key)) - ) - .sort(([, lhs], [, rhs]) => rhs - lhs); - if (!entries.length) { - return attackerNationId; - } - return entries[0]![0]; - } catch { + const activeNationIds = new Set(nations.map((nation) => nation.id)); + const entries = Object.entries(conflict) + .map(([key, value]) => [Number(key), value] as const) + .filter( + ([key, value]) => + Number.isFinite(key) && + typeof value === 'number' && + (key === attackerNationId || activeNationIds.has(key)) + ) + .sort(([, lhs], [, rhs]) => rhs - lhs); + if (!entries.length) { return attackerNationId; } + return entries[0]![0]; }; const getCityPosition = (city: City): { x: number; y: number } | null => { @@ -383,7 +377,7 @@ const resolveConquerCity = ( defenderCity.commerce = round(defenderCity.commerce * 0.7); defenderCity.security = round(defenderCity.security * 0.7); defenderCity.nationId = conquerNationId; - defenderCity.meta[META_CONFLICT] = '{}'; + defenderCity.conflict = {}; if (defenderCity.level > 3) { defenderCity.defence = config.defaultCityWall; diff --git a/packages/logic/src/war/units/city.ts b/packages/logic/src/war/units/city.ts index 6d7142f..791dd27 100644 --- a/packages/logic/src/war/units/city.ts +++ b/packages/logic/src/war/units/city.ts @@ -4,7 +4,7 @@ import type { City, Nation } from '@sammo-ts/logic/domain/entities.js'; import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import type { WarEngineConfig } from '../types.js'; import type { WarCrewType } from '../crewType.js'; -import { clampMin, round, parseConflict, sortConflict, stringifyConflict } from '../utils.js'; +import { clampMin, round, parseConflict, sortConflict } from '../utils.js'; import { WarUnit } from './base.js'; // 도시 성벽 전투 유닛(legacy WarUnitCity 포팅). @@ -133,7 +133,7 @@ export class WarUnitCity extends WarUnit { } public addConflict(): boolean { - const raw = this.city.meta.conflict; + const raw = this.city.conflict; const conflict = parseConflict(raw) ?? {}; const oppose = this.getOppose(); @@ -161,7 +161,7 @@ export class WarUnitCity extends WarUnit { } const sorted = sortConflict(conflict); - this.city.meta.conflict = stringifyConflict(sorted); + this.city.conflict = sorted; return isNew; } diff --git a/packages/logic/src/war/utils.ts b/packages/logic/src/war/utils.ts index 97abd33..56a3345 100644 --- a/packages/logic/src/war/utils.ts +++ b/packages/logic/src/war/utils.ts @@ -55,23 +55,26 @@ export const parseConflict = (raw: TriggerValue | undefined): Record; - const result: Record = {}; - for (const [key, value] of Object.entries(parsed)) { - const id = Number(key); - if (!Number.isFinite(id) || typeof value !== 'number') { - continue; - } - result[id] = value; - } - return Object.keys(result).length ? result : null; + parsed = JSON.parse(raw) as unknown; } catch { return null; } } - return null; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + const result: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + const id = Number(key); + if (!Number.isFinite(id) || typeof value !== 'number') { + continue; + } + result[id] = value; + } + return Object.keys(result).length ? result : null; }; export const stringifyConflict = (conflict: Record | null): string => { diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index 67a1638..3556d36 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -183,7 +183,7 @@ describe('war aftermath', () => { defenderNation.rice = 6000; const attackerCity = buildCity(1, 1); const defenderCity = buildCity(2, 2); - defenderCity.meta.conflict = JSON.stringify({ 99: 200, 1: 100 }); + defenderCity.conflict = { 99: 200, 1: 100 }; const attacker = buildGeneral(1, 1, 1); attacker.officerLevel = 12; const defender = buildGeneral(2, 2, 2); @@ -236,10 +236,10 @@ describe('war aftermath', () => { expect(attackerNation.rice).toBe(4600); expect(defender.experience).toBe(90); expect(defender.dedication).toBe(50); - // Removed nations can remain in old persisted conflict metadata. They + // Removed nations can remain in old persisted conflict data. They // must never receive ownership during a later conquest. expect(defenderCity.nationId).toBe(attackerNation.id); - expect(defenderCity.meta.conflict).toBe('{}'); + expect(defenderCity.conflict).toEqual({}); expect(outcome.logs.map((log) => log.text)).toEqual( expect.arrayContaining([ 'Nation2를 정복', diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 4c4655b..8f60c0b 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -300,6 +300,8 @@ describe('war triggers', () => { ); cityUnit.setOppose(firstAttacker); expect(cityUnit.addConflict()).toBe(false); + expect(city.conflict).toEqual({ 1: 1.05 }); + expect(city.meta.conflict).toBeUndefined(); const secondNation = { ...buildNation(), id: 2 }; const secondGeneral = { ...buildGeneral(80), id: 2, nationId: 2 }; @@ -316,6 +318,7 @@ describe('war triggers', () => { ); cityUnit.setOppose(secondAttacker); expect(cityUnit.addConflict()).toBe(true); + expect(city.conflict).toEqual({ 1: 1.05, 2: 1 }); }); }); diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index 33a8d6b..3bc4a9b 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -76,6 +76,7 @@ integration('core ↔ legacy command-boundary differential', () => { it.each([ ['nation declaration', 'fixtures/turn-differential/nation-declaration.json'], ['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'], + ['live sortie against a defending general', 'fixtures/turn-differential/live-sortie-defender.json'], ])( '%s matches command RNG and canonical state delta', async (_label, fixturePath) => {