fix: 단일 잔존 국가 임관 금지를 연결한다

마지막 도시 점령으로 국가가 삭제되면 destroy_nation 시나리오 이벤트를 같은 턴 경계에서 실행한다. Ref와 같이 잔존국 scout를 금지로 바꾸고 일회성 이벤트를 삭제하되 정책 변경 잠금은 설정하지 않는다.
This commit is contained in:
2026-08-23 11:08:30 +00:00
parent 51ca5e9d70
commit 4d424d16c6
11 changed files with 209 additions and 6 deletions
@@ -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<void>;
afterExecuteGeneral?: (general: TurnGeneral, result: TurnGeneralExecutionResult) => Promise<void>;
dispatchScenarioEvent?: (targetCode: string, context: TurnCalendarContext) => Promise<void>;
}
export type TurnGeneralExecutionResult = {
@@ -38,12 +39,14 @@ export class InMemoryTurnProcessor implements TurnProcessor {
private readonly tickMinutesOverride?: number;
private readonly beforeExecuteGeneral?: (general: TurnGeneral) => Promise<void>;
private readonly afterExecuteGeneral?: (general: TurnGeneral, result: TurnGeneralExecutionResult) => Promise<void>;
private readonly dispatchScenarioEvent?: (targetCode: string, context: TurnCalendarContext) => Promise<void>;
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<TurnRunResult> {
@@ -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;
}
+12 -2
View File
@@ -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<void> {
@@ -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 = {
@@ -916,6 +916,7 @@ export const createReservedTurnHandler = async (options: {
const createdGenerals: TurnGeneral[] = [];
const createdNations: Nation[] = [];
const commandDeletedTroopIds = new Set<number>();
const destroyedNationIds = new Set<number>();
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,
+1
View File
@@ -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) {
@@ -69,6 +69,7 @@ export type TurnTestHarnessOptions = {
tickMinutes: number;
beforeExecuteGeneral?: InMemoryTurnProcessorOptions['beforeExecuteGeneral'];
afterExecuteGeneral?: InMemoryTurnProcessorOptions['afterExecuteGeneral'];
dispatchScenarioEvent?: InMemoryTurnProcessorOptions['dispatchScenarioEvent'];
};
worldRef?: { current: InMemoryTurnWorld | null };
onActionResolved?: Parameters<typeof createReservedTurnHandler>[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[] = [];
@@ -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();
});
});
@@ -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);