diff --git a/app/game-engine/src/turn/frontStateHandler.ts b/app/game-engine/src/turn/frontStateHandler.ts new file mode 100644 index 0000000..a967db0 --- /dev/null +++ b/app/game-engine/src/turn/frontStateHandler.ts @@ -0,0 +1,164 @@ +import type { City, MapDefinition, Nation } from '@sammo-ts/logic'; +import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; +import type { TurnDiplomacy } from './types.js'; + +type ConnectionMap = Map; + +const buildConnectionMap = (map: MapDefinition): ConnectionMap => { + const connectionMap = new Map(); + for (const city of map.cities) { + connectionMap.set(city.id, city.connections ?? []); + } + return connectionMap; +}; + +const collectAdjacentCities = (cityIds: number[], connectionMap: ConnectionMap, bucket: Set): void => { + for (const cityId of cityIds) { + const neighbors = connectionMap.get(cityId) ?? []; + for (const neighborId of neighbors) { + bucket.add(neighborId); + } + } +}; + +const collectNationCityIds = (cities: City[]): Map => { + const map = new Map(); + for (const city of cities) { + const list = map.get(city.nationId) ?? []; + list.push(city.id); + map.set(city.nationId, list); + } + return map; +}; + +const collectDiplomacyTargets = (entries: TurnDiplomacy[], nationId: number): { + warTargets: number[]; + declareTargets: number[]; +} => { + const warTargets: number[] = []; + const declareTargets: number[] = []; + for (const entry of entries) { + if (entry.fromNationId !== nationId) { + continue; + } + if (entry.state === 0) { + warTargets.push(entry.toNationId); + } else if (entry.state === 1 && entry.term <= 5) { + declareTargets.push(entry.toNationId); + } + } + return { warTargets, declareTargets }; +}; + +const resolveNationFrontStates = ( + nationId: number, + connectionMap: ConnectionMap, + cityIdsByNation: Map, + diplomacy: TurnDiplomacy[] +): Map => { + const frontStates = new Map(); + if (nationId <= 0) { + return frontStates; + } + + const { warTargets, declareTargets } = collectDiplomacyTargets(diplomacy, nationId); + const adj3 = new Set(); + const adj2 = new Set(); + const adj1 = new Set(); + + for (const targetNationId of warTargets) { + const targetCities = cityIdsByNation.get(targetNationId) ?? []; + collectAdjacentCities(targetCities, connectionMap, adj3); + } + + for (const targetNationId of declareTargets) { + const targetCities = cityIdsByNation.get(targetNationId) ?? []; + collectAdjacentCities(targetCities, connectionMap, adj1); + } + + if (adj3.size === 0 && adj1.size === 0) { + const neutralCities = cityIdsByNation.get(0) ?? []; + collectAdjacentCities(neutralCities, connectionMap, adj2); + } + + const nationCityIds = cityIdsByNation.get(nationId) ?? []; + for (const cityId of nationCityIds) { + let nextFrontState = 0; + if (adj1.has(cityId)) { + nextFrontState = 1; + } + if (adj2.has(cityId)) { + nextFrontState = 2; + } + if (adj3.has(cityId)) { + nextFrontState = 3; + } + frontStates.set(cityId, nextFrontState); + } + return frontStates; +}; + +export const buildFrontStatePatches = (options: { + worldView: { + listCities(): City[]; + listNations(): Nation[]; + listDiplomacy(): TurnDiplomacy[]; + }; + map: MapDefinition | null | undefined; + nationIds?: number[]; +}): Array<{ id: number; patch: Partial }> => { + if (!options.map) { + return []; + } + const connectionMap = buildConnectionMap(options.map); + if (connectionMap.size === 0) { + return []; + } + const cities = options.worldView.listCities(); + const cityIdsByNation = collectNationCityIds(cities); + const diplomacy = options.worldView.listDiplomacy(); + const cityMap = new Map(); + for (const city of cities) { + cityMap.set(city.id, city); + } + + const nationList = options.nationIds + ? options.worldView + .listNations() + .filter((nation) => options.nationIds?.includes(nation.id)) + : options.worldView.listNations(); + const nations = nationList.filter((nation) => nation.level > 0 && nation.capitalCityId); + const patches: Array<{ id: number; patch: Partial }> = []; + for (const nation of nations) { + const frontStates = resolveNationFrontStates(nation.id, connectionMap, cityIdsByNation, diplomacy); + for (const [cityId, nextFrontState] of frontStates) { + const city = cityMap.get(cityId); + if (!city || city.frontState === nextFrontState) { + continue; + } + patches.push({ id: cityId, patch: { frontState: nextFrontState } }); + } + } + return patches; +}; + +export const createFrontStateHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + map: MapDefinition | null | undefined; +}): TurnCalendarHandler => { + return { + onMonthChanged: () => { + const world = options.getWorld(); + if (!world) { + return; + } + const patches = buildFrontStatePatches({ + worldView: world, + map: options.map, + }); + for (const patch of patches) { + world.updateCity(patch.id, patch.patch); + } + }, + }; +}; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 3ce8079..526db46 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -37,6 +37,7 @@ import { type DiplomacyPatch, } from '@sammo-ts/logic'; import { buildCommandEnv, buildReservedTurnDefinitions } from './reservedTurnCommands.js'; +import { buildFrontStatePatches } from './frontStateHandler.js'; import { buildActionContext } from './reservedTurnActionContext.js'; import { GeneralAI, shouldUseAi } from './ai/generalAi.js'; import type { AiReservedTurnProvider } from './ai/types.js'; @@ -701,6 +702,31 @@ export const createReservedTurnHandler = async (options: { } } + const hasDiplomacyChange = diplomacyPatches.length > 0; + const hasNationChange = (resolution.patches?.cities ?? []).some((patch) => + Object.prototype.hasOwnProperty.call(patch.patch ?? {}, 'nationId') + ); + if (hasDiplomacyChange || hasNationChange) { + const worldView = worldOverlay?.view ?? worldRef; + if (worldView && options.map) { + const frontPatches = buildFrontStatePatches({ + worldView, + map: options.map, + }); + if (frontPatches.length > 0) { + for (const patch of frontPatches) { + const existing = patches.cities.find((entry) => entry.id === patch.id); + if (existing) { + existing.patch = { ...existing.patch, ...patch.patch }; + } else { + patches.cities.push({ id: patch.id, patch: patch.patch }); + } + worldOverlay?.applyCityPatch(patch.id, patch.patch); + } + } + } + } + return { nextTurnAt: applyNextTurnAt ? resolution.nextTurnAt : undefined, actionKey, usedFallback, blockedReason }; }; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index b93c20d..4360e6c 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -19,6 +19,7 @@ import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js'; import { createGatewayProfileGate } from './gatewayProfileGate.js'; import { composeCalendarHandlers } from './calendarHandlers.js'; import { createIncomeHandler } from './incomeHandler.js'; +import { createFrontStateHandler } from './frontStateHandler.js'; import { createReservedTurnHandler } from './reservedTurnHandler.js'; import { createReservedTurnStore } from './reservedTurnStore.js'; import { createTurnDaemonCommandHandler } from './worldCommandHandler.js'; @@ -122,6 +123,10 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) scenarioConfig: snapshot.scenarioConfig, nationTraits: nationTraitMap, }); + const frontStateHandler = createFrontStateHandler({ + getWorld: () => worldRef, + map: snapshot.map ?? null, + }); const tournamentAutoStartHandler = createTournamentAutoStartHandler({ profileName: options.profileName ?? options.profile, getRedisClient: () => redisConnector?.client, @@ -131,6 +136,7 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions) const calendarHandler = composeCalendarHandlers( options.calendarHandler ?? unification?.handler, incomeHandler, + frontStateHandler, tournamentAutoStartHandler ); const worldOptions: InMemoryTurnWorldOptions = { diff --git a/app/game-engine/test/npcNationGrowthScenario.test.ts b/app/game-engine/test/npcNationGrowthScenario.test.ts index 83604e0..9615480 100644 --- a/app/game-engine/test/npcNationGrowthScenario.test.ts +++ b/app/game-engine/test/npcNationGrowthScenario.test.ts @@ -10,6 +10,7 @@ import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js'; import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; import { createIncomeHandler } from '../src/turn/incomeHandler.js'; import { createNpcTaxHandler } from '../src/turn/npcTaxHandler.js'; +import { createFrontStateHandler } from '../src/turn/frontStateHandler.js'; import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; @@ -272,7 +273,12 @@ describe('NPC 대형 시뮬레이션', () => { getWorld: () => wrapper.world, }); - const calendarHandler = composeCalendarHandlers(incomeHandler, npcTaxHandler); + const frontStateHandler = createFrontStateHandler({ + getWorld: () => wrapper.world, + map: LARGE_TEST_MAP, + }); + + const calendarHandler = composeCalendarHandlers(incomeHandler, npcTaxHandler, frontStateHandler); const world = new InMemoryTurnWorld(state, snapshot, { schedule,