From 4d424d16c6b29b62157bc9b0c30f5efdd3be6ce2 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 23 Aug 2026 11:08:30 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=8B=A8=EC=9D=BC=20=EC=9E=94=EC=A1=B4?= =?UTF-8?q?=20=EA=B5=AD=EA=B0=80=20=EC=9E=84=EA=B4=80=20=EA=B8=88=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마지막 도시 점령으로 국가가 삭제되면 destroy_nation 시나리오 이벤트를 같은 턴 경계에서 실행한다. Ref와 같이 잔존국 scout를 금지로 바꾸고 일회성 이벤트를 삭제하되 정책 변경 잠금은 설정하지 않는다. --- .../src/turn/inMemoryTurnProcessor.ts | 22 ++- app/game-engine/src/turn/inMemoryWorld.ts | 14 +- .../src/turn/monthlyEventHandler.ts | 4 +- .../src/turn/reservedTurnHandler.ts | 5 + app/game-engine/src/turn/turnDaemon.ts | 1 + .../test/helpers/turnTestHarness.ts | 2 + .../test/monthlyScoutBlockAction.test.ts | 131 ++++++++++++++++++ .../test/nationCollapseOnConquest.test.ts | 29 +++- packages/logic/src/actions/engine.ts | 3 + .../src/actions/turn/general/che_출병.ts | 3 + packages/logic/test/dispatchWarAction.test.ts | 1 + 11 files changed, 209 insertions(+), 6 deletions(-) diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index 1dc66e8a..fb8382fa 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -1,6 +1,6 @@ import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; -import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { InMemoryTurnWorld, TurnCalendarContext } from './inMemoryWorld.js'; import type { TurnGeneral } from './types.js'; import { asNumber, asRecord, calculateAccessRefreshLimit } from '@sammo-ts/common'; @@ -8,6 +8,7 @@ export interface InMemoryTurnProcessorOptions { tickMinutes?: number; beforeExecuteGeneral?: (general: TurnGeneral) => Promise; afterExecuteGeneral?: (general: TurnGeneral, result: TurnGeneralExecutionResult) => Promise; + dispatchScenarioEvent?: (targetCode: string, context: TurnCalendarContext) => Promise; } export type TurnGeneralExecutionResult = { @@ -38,12 +39,14 @@ export class InMemoryTurnProcessor implements TurnProcessor { private readonly tickMinutesOverride?: number; private readonly beforeExecuteGeneral?: (general: TurnGeneral) => Promise; private readonly afterExecuteGeneral?: (general: TurnGeneral, result: TurnGeneralExecutionResult) => Promise; + private readonly dispatchScenarioEvent?: (targetCode: string, context: TurnCalendarContext) => Promise; constructor(world: InMemoryTurnWorld, options: InMemoryTurnProcessorOptions = {}) { this.world = world; this.tickMinutesOverride = options.tickMinutes; this.beforeExecuteGeneral = options.beforeExecuteGeneral; this.afterExecuteGeneral = options.afterExecuteGeneral; + this.dispatchScenarioEvent = options.dispatchScenarioEvent; } async run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise { @@ -99,7 +102,22 @@ export class InMemoryTurnProcessor implements TurnProcessor { let nextTurnAt: Date | undefined; let executionError: unknown; try { - nextTurnAt = this.world.executeGeneralTurn(general); + const execution = this.world.executeGeneralTurn(general); + nextTurnAt = execution.nextTurnAt; + if (execution.destroyedNationIds.length > 0 && this.dispatchScenarioEvent) { + const state = this.world.getState(); + const eventContext: TurnCalendarContext = { + previousYear: state.currentYear, + previousMonth: state.currentMonth, + currentYear: state.currentYear, + currentMonth: state.currentMonth, + turnTime: new Date(state.lastTurnTime.getTime()), + legacyTurnTime: new Date(state.lastTurnTime.getTime()), + }; + for (const _nationId of execution.destroyedNationIds) { + await this.dispatchScenarioEvent('destroy_nation', eventContext); + } + } } catch (error) { executionError = error; } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index c055bc25..d2a81299 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -67,9 +67,15 @@ export interface GeneralTurnResult { general: boolean; troopIds?: number[]; }; + destroyedNationIds?: number[]; lifecycleEvent?: GeneralLifecycleEvent; } +export interface GeneralTurnExecution { + nextTurnAt: Date; + destroyedNationIds: number[]; +} + export interface GeneralLifecycleEvent { generalId: number; outcome: 'active' | 'detached' | 'deleted' | 'retired'; @@ -90,6 +96,7 @@ export interface TurnCalendarContext { currentYear: number; currentMonth: number; turnTime: Date; + legacyTurnTime?: Date; } export interface TurnCalendarHandler { @@ -1432,7 +1439,7 @@ export class InMemoryTurnWorld { return due; } - executeGeneralTurn(general: TurnGeneral): Date { + executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution { const currentGeneral = this.generals.get(general.id) ?? general; const city = this.cities.get(currentGeneral.cityId); const nation = currentGeneral.nationId > 0 ? (this.nations.get(currentGeneral.nationId) ?? null) : null; @@ -1599,7 +1606,10 @@ export class InMemoryTurnWorld { this.removeCollapsedNations(); - return nextTurnAt; + return { + nextTurnAt, + destroyedNationIds: (result.destroyedNationIds ?? []).filter((nationId) => !this.nations.has(nationId)), + }; } async advanceMonth(turnTime: Date): Promise { diff --git a/app/game-engine/src/turn/monthlyEventHandler.ts b/app/game-engine/src/turn/monthlyEventHandler.ts index db7f1809..5cc10654 100644 --- a/app/game-engine/src/turn/monthlyEventHandler.ts +++ b/app/game-engine/src/turn/monthlyEventHandler.ts @@ -232,7 +232,9 @@ export const createMonthlyEventHandler = (options: { // postUpdateMonthly step has completed. Event actions therefore see // the previous monthly boundary even after turnDate() has advanced // year/month. Generated general turn times depend on this distinction. - const legacyTurnTime = new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000); + const legacyTurnTime = + context.legacyTurnTime ?? + new Date(context.turnTime.getTime() - world.getState().tickSeconds * 1_000); for (const event of world.listEvents(targetCode)) { const environment: MonthlyEventEnvironment = { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 8e3b2945..1dac6ae2 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -916,6 +916,7 @@ export const createReservedTurnHandler = async (options: { const createdGenerals: TurnGeneral[] = []; const createdNations: Nation[] = []; const commandDeletedTroopIds = new Set(); + const destroyedNationIds = new Set(); let currentGeneral = context.general; let currentCity = context.city; @@ -1309,6 +1310,9 @@ export const createReservedTurnHandler = async (options: { } logs.push(...resolution.logs); + for (const nationId of resolution.destroyedNationIds ?? []) { + destroyedNationIds.add(nationId); + } if (worldOverlay) { worldOverlay.syncGeneral(currentGeneral); if (currentCity) { @@ -2166,6 +2170,7 @@ export const createReservedTurnHandler = async (options: { }, } : undefined), + ...(destroyedNationIds.size > 0 ? { destroyedNationIds: [...destroyedNationIds] } : undefined), lifecycleEvent: { generalId: currentGeneral.id, outcome: lifecycleOutcome, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 09d76b59..f2221850 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -778,6 +778,7 @@ const createTurnDaemonRuntimeWithLease = async ( const stateStore = new InMemoryTurnStateStore(world); let fastForwardPreparedMonth = ''; const processor = new InMemoryTurnProcessor(world, { + dispatchScenarioEvent: (targetCode, context) => monthlyEventHandler.dispatchTarget(targetCode, context), beforeExecuteGeneral: reservedTurnStoreHandle ? async (general) => { if (options.exclusiveFastForward) { diff --git a/app/game-engine/test/helpers/turnTestHarness.ts b/app/game-engine/test/helpers/turnTestHarness.ts index 64e0a7dc..c03ecf43 100644 --- a/app/game-engine/test/helpers/turnTestHarness.ts +++ b/app/game-engine/test/helpers/turnTestHarness.ts @@ -69,6 +69,7 @@ export type TurnTestHarnessOptions = { tickMinutes: number; beforeExecuteGeneral?: InMemoryTurnProcessorOptions['beforeExecuteGeneral']; afterExecuteGeneral?: InMemoryTurnProcessorOptions['afterExecuteGeneral']; + dispatchScenarioEvent?: InMemoryTurnProcessorOptions['dispatchScenarioEvent']; }; worldRef?: { current: InMemoryTurnWorld | null }; onActionResolved?: Parameters[0]['onActionResolved']; @@ -155,6 +156,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => tickMinutes: options.turnProcessorOptions?.tickMinutes ?? 10, beforeExecuteGeneral: options.turnProcessorOptions?.beforeExecuteGeneral, afterExecuteGeneral: options.turnProcessorOptions?.afterExecuteGeneral, + dispatchScenarioEvent: options.turnProcessorOptions?.dispatchScenarioEvent, }); const collectedLogs: LogEntryDraft[] = []; diff --git a/app/game-engine/test/monthlyScoutBlockAction.test.ts b/app/game-engine/test/monthlyScoutBlockAction.test.ts index 2c97d593..7b81156a 100644 --- a/app/game-engine/test/monthlyScoutBlockAction.test.ts +++ b/app/game-engine/test/monthlyScoutBlockAction.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Nation } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js'; import { createScoutBlockHandler } from '../src/turn/monthlyScoutBlockAction.js'; import type { TurnEvent, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; @@ -120,4 +121,134 @@ describe('monthly scout block actions', () => { ) ).toThrow('BlockScoutAction blockChangeScout must be a boolean or null.'); }); + + it('dispatches the destroy-nation event before the next turn after only one nation remains', async () => { + const baseTime = new Date('0200-01-01T00:00:00.000Z'); + const destroyedNationEvent: TurnEvent = { + id: 7, + targetCode: 'destroy_nation', + priority: 1_000, + condition: ['and', ['Date', '>=', 183, 1], ['RemainNation', '==', 1]], + action: [['BlockScoutAction'], ['DeleteEvent']], + meta: {}, + }; + const general = { + id: 1, + name: '공격장', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 80, intelligence: 80 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + turnTime: new Date(baseTime.getTime() + 60_000), + } satisfies TurnWorldSnapshot['generals'][number]; + const buildCity = (id: number, nationId: number): TurnWorldSnapshot['cities'][number] => ({ + id, + name: `도시${id}`, + nationId, + level: 5, + state: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: {}, + }); + const nations = [buildNation(1, 0), buildNation(2, 0)]; + const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [buildCity(1, 1), buildCity(2, 2)], + nations, + troops: [], + diplomacy: [], + events: [destroyedNationEvent], + initialEvents: [], + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: baseTime, + meta: {}, + }; + let world: InMemoryTurnWorld | null = null; + const blockScout = createScoutBlockHandler({ actionName: 'BlockScoutAction', getWorld: () => world }); + const eventHandler = createMonthlyEventHandler({ + getWorld: () => world, + startYear: 180, + actions: new Map([['BlockScoutAction', blockScout]]), + }); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: { + execute: ({ general: currentGeneral }) => ({ + general: currentGeneral, + patches: { + generals: [], + cities: [{ id: 2, patch: { nationId: 1 } }], + nations: [{ id: 2, patch: { meta: { ...nations[1]!.meta, collapsed: true } } }], + troops: [], + }, + destroyedNationIds: [2], + }), + }, + }); + + const processor = new InMemoryTurnProcessor(world, { + dispatchScenarioEvent: eventHandler.dispatchTarget, + }); + await processor.run(new Date(baseTime.getTime() + 5 * 60_000), { + budgetMs: 1_000, + maxGenerals: 10, + catchUpCap: 1, + }); + + expect(world.getNationById(2)).toBeNull(); + expect(world.getNationById(1)?.meta.scout).toBe(1); + expect(world.listEvents('destroy_nation')).toEqual([]); + expect(world.getState().meta.block_change_scout).toBeUndefined(); + }); }); diff --git a/app/game-engine/test/nationCollapseOnConquest.test.ts b/app/game-engine/test/nationCollapseOnConquest.test.ts index d9ab6270..b391312e 100644 --- a/app/game-engine/test/nationCollapseOnConquest.test.ts +++ b/app/game-engine/test/nationCollapseOnConquest.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import { DIPLOMACY_STATE } from '@sammo-ts/logic'; import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createMonthlyEventHandler } from '../src/turn/monthlyEventHandler.js'; +import { createScoutBlockHandler } from '../src/turn/monthlyScoutBlockAction.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; @@ -209,7 +211,16 @@ describe('도시 점령 시 국가 멸망 처리', () => { { fromNationId: 1, toNationId: 2, state: DIPLOMACY_STATE.WAR, term: 12, dead: 0, meta: {} }, { fromNationId: 2, toNationId: 1, state: DIPLOMACY_STATE.WAR, term: 12, dead: 0, meta: {} }, ], - events: [], + events: [ + { + id: 1, + targetCode: 'destroy_nation', + priority: 1_000, + condition: ['and', ['Date', '>=', 183, 1], ['RemainNation', '==', 1]], + action: [['BlockScoutAction'], ['DeleteEvent']], + meta: {}, + }, + ], initialEvents: [], map: LARGE_TEST_MAP as any, scenarioConfig: { @@ -244,12 +255,25 @@ describe('도시 점령 시 국가 멸망 처리', () => { }; const worldRef = { current: null as InMemoryTurnWorld | null }; + const blockScoutHandler = createScoutBlockHandler({ + actionName: 'BlockScoutAction', + getWorld: () => worldRef.current, + }); + const scenarioEventHandler = createMonthlyEventHandler({ + getWorld: () => worldRef.current, + startYear: 180, + actions: new Map([['BlockScoutAction', blockScoutHandler]]), + }); const { world, reservedTurnStore, runOneTick } = await createTurnTestHarness({ snapshot, state, schedule, map: LARGE_TEST_MAP, worldRef, + turnProcessorOptions: { + tickMinutes: 10, + dispatchScenarioEvent: scenarioEventHandler.dispatchTarget, + }, }); const turns = reservedTurnStore.getGeneralTurns(strongLeader.id); @@ -263,6 +287,9 @@ describe('도시 점령 시 국가 멸망 처리', () => { expect(world.getCityById(weakCityId)?.nationId).toBe(1); expect(world.getNationById(2)).toBeNull(); expect(world.listNations().some((nation) => nation.id === 2)).toBe(false); + expect(world.getNationById(1)?.meta.scout).toBe(1); + expect(world.listEvents('destroy_nation')).toEqual([]); + expect(world.getState().meta.block_change_scout).toBeUndefined(); expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 }); const updatedWeakGeneral = world.getGeneralById(weakGeneral.id); diff --git a/packages/logic/src/actions/engine.ts b/packages/logic/src/actions/engine.ts index dde02334..08de4950 100644 --- a/packages/logic/src/actions/engine.ts +++ b/packages/logic/src/actions/engine.ts @@ -101,6 +101,7 @@ export interface GeneralActionOutcome[]; completed?: boolean; deletedTroopIds?: number[]; + destroyedNationIds?: NationId[]; reservedGeneralTurnPlans?: Array<{ generalId: number; joinTurn: number; @@ -125,6 +126,7 @@ export interface GeneralActionResolution { nextTurnAt: Date; logs: LogEntryDraft[]; effects: GeneralActionEffect[]; + destroyedNationIds?: NationId[]; created?: { generals: General[]; nations?: Nation[]; @@ -406,6 +408,7 @@ export const resolveGeneralAction = { ); expect(resolution.logs.length).toBeGreaterThan(0); + expect(resolution.destroyedNationIds).toEqual([defenderNation.id]); expect(resolution.general.recentWarTime?.toISOString()).toBe(attacker.turnTime.toISOString()); expect(resolution.patches?.generals.some((patch) => patch.id === defender.id)).toBe(true); expect(resolution.patches?.cities.some((patch) => patch.id === defenderCity.id)).toBe(true);