From 531fa55c5f86ff08ffb375f36fd819a79354f9a0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 04:50:06 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B8=B4=EA=B8=89=EC=B2=9C=EB=8F=84=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=EB=A5=BC=20=EC=A7=80=EB=8F=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EA=B1=B0=EB=A6=AC=EB=A1=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/actions/turn/general/che_출병.ts | 5 +- packages/logic/src/war/aftermath.ts | 60 +++++----- packages/logic/src/war/types.ts | 4 +- packages/logic/test/warAftermath.test.ts | 103 +++++++++++++++++- ...rnCommandCoreReference.integration.test.ts | 6 +- 5 files changed, 143 insertions(+), 35 deletions(-) diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index 01df8565..83b48c06 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -59,7 +59,7 @@ export interface DispatchResolveContext< nations: Nation[]; generals: General[]; unitSet: UnitSetDefinition; - map?: MapDefinition; + map: MapDefinition; diplomacy?: Array<{ fromNationId: number; toNationId: number; state: number; term: number }>; time: WarTimeContext; seedBase: string; @@ -647,6 +647,7 @@ export class ActionDefinition< cities, generals, unitSet, + map: context.map, config: context.aftermathConfig, time, messageTime: context.messageTime, @@ -801,7 +802,7 @@ export class ActionDefinition< // 예약 턴 실행에 필요한 전투 컨텍스트를 구성한다. export const actionContextBuilder: ActionContextBuilder = (base, options) => { - if (!options.unitSet || !options.worldRef) { + if (!options.unitSet || !options.worldRef || !options.map) { return null; } const destCityId = options.actionArgs.destCityId; diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts index 2ba028c5..a405cc8b 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -6,7 +6,9 @@ import { GeneralActionPipeline } from '@sammo-ts/logic/actionModules/general.js' import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; import { buildScoutMessageDraft } from '@sammo-ts/logic/messages/scoutMessage.js'; +import { searchDistanceEntries } from '@sammo-ts/logic/world/distance.js'; import { buildCrewTypeIndex, getTechCost, getTechLevel } from '@sammo-ts/logic/world/unitSet.js'; +import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import { LEGACY_DEFAULT_MAX_LEVEL } from '@sammo-ts/logic/scenario/constants.js'; import type { WarUnitReport } from './types.js'; import type { @@ -197,43 +199,45 @@ const resolveConquerNation = (city: City, attackerNationId: number, nations: Nat 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 => { + map: MapDefinition +): City => { const candidates = cities.filter((city) => city.nationId === defenderNationId && city.id !== capturedCityId); if (!candidates.length) { - return null; + throw new Error('도시가 남지 않았는데 긴천을 시도하고 있습니다'); } - const oldPos = getCityPosition(oldCapital); - if (!oldPos) { - return candidates.sort((lhs, rhs) => rhs.population - lhs.population)[0]!; + const candidatesById = new Map(candidates.map((city) => [city.id, city] as const)); + let nearestDistance: number | null = null; + let nextCapital: City | null = null; + + // Ref searchDistance(..., true)는 CityConst::path 순서의 BFS 결과를 + // 거리별로 훑는다. 첫 보유 거리만 보고, 같은 인구면 뒤 도시로 + // 교체하는 findNextCapital의 >= 동률 처리까지 그대로 보존한다. + for (const [cityId, distance] of searchDistanceEntries(map, capturedCityId, 99)) { + if (distance === 0) { + continue; + } + if (nearestDistance !== null && distance > nearestDistance) { + break; + } + const candidate = candidatesById.get(cityId); + if (!candidate) { + continue; + } + nearestDistance ??= distance; + if (!nextCapital || candidate.population >= nextCapital.population) { + nextCapital = candidate; + } } - 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; + if (!nextCapital) { + throw new Error('도시가 남지 않았는데 긴천을 시도하고 있습니다'); + } + return nextCapital; }; const pushLogger = ( @@ -450,7 +454,7 @@ const resolveConquerCity = ( // 수도 함락 시 수도 이전 및 내부 사기/자원 페널티. if (!nationCollapsed && defenderNation && defenderNation.capitalCityId === defenderCity.id) { - const nextCapital = findNextCapital(cities, defenderNationId, defenderCity.id, defenderCity); + const nextCapital = findNextCapital(cities, defenderNationId, defenderCity.id, input.map); if (nextCapital) { const josaRo = JosaUtil.pick(nextCapital.name, '로'); const josaYi = JosaUtil.pick(defenderNation.name, '이'); diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts index ee03a100..20c85ebe 100644 --- a/packages/logic/src/war/types.ts +++ b/packages/logic/src/war/types.ts @@ -6,7 +6,7 @@ import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; import type { MessageDraft } from '@sammo-ts/logic/messages/message.js'; import type { TracePort } from '@sammo-ts/logic/ports/trace.js'; -import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; +import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; import type { WarActionModule } from './actions.js'; import type { WarTriggerRegistry } from './triggers.js'; import type { LegacyWarLogFlushSequence } from './legacyFlushSequence.js'; @@ -195,6 +195,8 @@ export interface WarAftermathInput[]; unitSet: UnitSetDefinition; + /** 긴급천도 BFS에 사용하는 Ref CityConst::path 대응 topology. */ + map: MapDefinition; config: WarAftermathConfig; time: WarTimeContext; /** Ref Message::gameNow() at the command transaction's logical tick. */ diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index 46dd71b7..e74f241d 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -4,7 +4,7 @@ import { ConstantRNG, RandUtil } from '@sammo-ts/common'; import type { City, General, Nation } from '../src/domain/entities.js'; import type { GeneralActionModule } from '../src/actionModules/general.js'; -import type { UnitSetDefinition } from '../src/world/types.js'; +import type { MapDefinition, UnitSetDefinition } from '../src/world/types.js'; import { resolveWarAftermath } from '../src/war/aftermath.js'; import type { WarAftermathConfig } from '../src/war/types.js'; import { LogFormat } from '../src/logging/types.js'; @@ -74,6 +74,43 @@ const buildCity = (id: number, nationId: number): City => ({ meta: {}, }); +const buildMap = (connections: Record): MapDefinition => ({ + id: 'test', + name: 'test', + cities: Object.entries(connections).map(([rawId, cityConnections]) => ({ + id: Number(rawId), + name: `City${rawId}`, + level: 2, + region: 1, + position: { x: Number(rawId), y: Number(rawId) }, + connections: cityConnections, + max: { + population: 10000, + agriculture: 1000, + commerce: 1000, + security: 1000, + defence: 200, + wall: 200, + }, + initial: { + population: 10000, + agriculture: 1000, + commerce: 1000, + security: 1000, + defence: 100, + wall: 100, + }, + })), +}); + +const DEFAULT_MAP = buildMap({ + 1: [2, 3, 4, 5], + 2: [1, 3, 4, 5], + 3: [1, 2, 4, 5], + 4: [1, 2, 3, 5], + 5: [1, 2, 3, 4], +}); + const buildNation = (id: number): Nation => ({ id, name: `Nation${id}`, @@ -189,6 +226,7 @@ describe('war aftermath', () => { cities: [attackerCity, defenderCity], generals: [attacker], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: { ...buildConfig(), maxTechLevel: 15 }, time: { year: 200, month: 1, startYear: 180 }, messageTime: MESSAGE_TIME, @@ -230,6 +268,7 @@ describe('war aftermath', () => { cities: [attackerCity, defenderCity], generals: [attacker], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: buildConfig(), time: { year: 200, @@ -274,6 +313,7 @@ describe('war aftermath', () => { cities: [attackerCity, defenderCity], generals: [attacker], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: buildConfig(), time: { year: 200, month: 1, startYear: 180 }, messageTime: MESSAGE_TIME, @@ -320,6 +360,7 @@ describe('war aftermath', () => { cities: [attackerCity, defenderCity, nextCapital], generals: [attacker, defender], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: buildConfig(), time: { year: 200, @@ -340,6 +381,61 @@ describe('war aftermath', () => { expect(outcome.logs.find((log) => log.text.startsWith('수뇌는'))?.format).toBe(LogFormat.MONTH); }); + it('chooses the most populous city at the nearest map-path distance for emergency relocation', () => { + const attackerNation = buildNation(1); + const defenderNation = buildNation(2); + const attackerCity = buildCity(1, 1); + const defenderCity = buildCity(2, 2); + const firstNearest = buildCity(3, 2); + const lastNearest = buildCity(4, 2); + const coordinateNearButTwoHopsAway = buildCity(5, 2); + firstNearest.population = 30_000; + lastNearest.population = 30_000; + coordinateNearButTwoHopsAway.population = 90_000; + defenderCity.meta.positionX = 0; + defenderCity.meta.positionY = 0; + firstNearest.meta.positionX = 100; + firstNearest.meta.positionY = 100; + lastNearest.meta.positionX = 200; + lastNearest.meta.positionY = 200; + coordinateNearButTwoHopsAway.meta.positionX = 0; + coordinateNearButTwoHopsAway.meta.positionY = 1; + const attacker = buildGeneral(1, 1, 1); + const defender = buildGeneral(2, 2, 2); + + resolveWarAftermath({ + battle: { + attacker, + defenders: [], + defenderCity, + logs: [], + conquered: true, + reports: [], + }, + attackerNation, + defenderNation, + attackerCity, + defenderCity, + nations: [attackerNation, defenderNation], + cities: [attackerCity, defenderCity, firstNearest, lastNearest, coordinateNearButTwoHopsAway], + generals: [attacker, defender], + unitSet: buildUnitSet(), + map: buildMap({ + 1: [], + 2: [3, 4], + 3: [2, 5], + 4: [2], + 5: [3], + }), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + messageTime: MESSAGE_TIME, + }); + + // Ref replaces on equal population, so the later city in the BFS layer wins. + expect(defenderNation.capitalCityId).toBe(lastNearest.id); + }); + it('uses the city battle phase, not retained casualties, for conquered supply-city rice', () => { const attackerNation = buildNation(1); const defenderNation = buildNation(2); @@ -378,6 +474,7 @@ describe('war aftermath', () => { cities: [attackerCity, defenderCity, defenderCapital], generals: [attacker], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: buildConfig(), time: { year: 200, month: 1, startYear: 180 }, messageTime: MESSAGE_TIME, @@ -438,6 +535,7 @@ describe('war aftermath', () => { cities: [attackerCity, defenderCity], generals: [attacker, defender], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: buildConfig(), time: { year: 200, @@ -510,6 +608,7 @@ describe('war aftermath', () => { // The caller order deliberately puts the lord first. generals: [attacker, lord, npc], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: { ...buildConfig(), joinMode: 'full', @@ -607,6 +706,7 @@ describe('war aftermath', () => { cities: [attackerCity, defenderCity], generals: [attacker, firstDefender, secondDefender, elsewhere], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: buildConfig(), time: { year: 200, @@ -694,6 +794,7 @@ describe('war aftermath', () => { cities: [attackerCity, defenderCity, defenderCapital], generals: [attacker], unitSet: buildUnitSet(), + map: DEFAULT_MAP, config: buildConfig(), time: { year: 200, diff --git a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts index eb699cb9..dda7323e 100644 --- a/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts +++ b/tools/integration-tests/test/turnCommandCoreReference.integration.test.ts @@ -164,15 +164,15 @@ integration('core ↔ legacy command-boundary differential', () => { } if (fixturePath.endsWith('live-sortie-emergency-capital.json')) { const defenderNation = reference.after.nations.find((nation) => nation.id === 2); - expect(defenderNation?.capitalCityId).toBe(71); + expect(defenderNation?.capitalCityId).toBe(35); expect(defenderNation?.gold).toBe(50_000); expect(defenderNation?.rice).toBe(40_000); expect(reference.after.generals.find((general) => general.id === 2)).toMatchObject({ nationId: 2, - cityId: 71, + cityId: 35, atmos: 80, }); - expect(reference.after.cities.find((city) => city.id === 71)?.supplyState).toBe(1); + expect(reference.after.cities.find((city) => city.id === 35)?.supplyState).toBe(1); } if (fixturePath.endsWith('live-sortie-collapse-conflict.json')) { expect(reference.before.cities.find((city) => city.id === 71)?.conflict).toEqual({ 2: 1234 });