fix: 긴급천도 후보를 지도 연결 거리로 선택한다
This commit is contained in:
@@ -59,7 +59,7 @@ export interface DispatchResolveContext<
|
||||
nations: Nation[];
|
||||
generals: General<TriggerState>[];
|
||||
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;
|
||||
|
||||
@@ -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 = <TriggerState extends GeneralTriggerState>(
|
||||
|
||||
// 수도 함락 시 수도 이전 및 내부 사기/자원 페널티.
|
||||
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, '이');
|
||||
|
||||
@@ -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<TriggerState extends GeneralTriggerState = Ge
|
||||
cities: City[];
|
||||
generals: General<TriggerState>[];
|
||||
unitSet: UnitSetDefinition;
|
||||
/** 긴급천도 BFS에 사용하는 Ref CityConst::path 대응 topology. */
|
||||
map: MapDefinition;
|
||||
config: WarAftermathConfig;
|
||||
time: WarTimeContext;
|
||||
/** Ref Message::gameNow() at the command transaction's logical tick. */
|
||||
|
||||
@@ -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<number, number[]>): 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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user