feat: run persisted monthly events

This commit is contained in:
2026-07-25 08:23:59 +00:00
parent 88afcb5465
commit fb1074c5e8
10 changed files with 494 additions and 35 deletions
@@ -8,6 +8,11 @@ export const composeCalendarHandlers = (
return undefined;
}
return {
beforeMonthChanged: (context) => {
for (const handler of resolved) {
handler.beforeMonthChanged?.(context);
}
},
onMonthChanged: (context) => {
for (const handler of resolved) {
handler.onMonthChanged?.(context);
@@ -336,6 +336,7 @@ export const createDatabaseTurnHooks = async (
createdNations,
createdTroops,
createdDiplomacy,
deletedEvents,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -487,6 +488,11 @@ export const createDatabaseTurnHooks = async (
where: { id: { in: deletedNations } },
});
}
if (deletedEvents.length > 0) {
await prisma.event.deleteMany({
where: { id: { in: deletedEvents } },
});
}
await Promise.all([
...generals
+36 -9
View File
@@ -2,7 +2,7 @@ import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop,
import { getNextTurnAt } from '@sammo-ts/logic';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type { TurnDiplomacy, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import {
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
buildDefaultDiplomacy,
@@ -58,7 +58,8 @@ export interface TurnCalendarContext {
}
export interface TurnCalendarHandler {
// 월/연 변경에 따른 후처리를 끼워 넣기 위한 확장 포인트.
// 레거시 PRE_MONTH는 날짜 변경 전, MONTH는 날짜 변경 후에 실행된다.
beforeMonthChanged?(context: TurnCalendarContext): void;
onMonthChanged?(context: TurnCalendarContext): void;
onYearChanged?(context: TurnCalendarContext): void;
}
@@ -85,6 +86,7 @@ export interface TurnWorldChanges {
createdNations: Nation[];
createdTroops: Troop[];
createdDiplomacy: TurnDiplomacy[];
deletedEvents: number[];
}
const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
@@ -231,6 +233,7 @@ export class InMemoryTurnWorld {
private readonly nations = new Map<number, Nation>();
private readonly troops = new Map<number, Troop>();
private readonly diplomacy = new Map<string, TurnDiplomacy>();
private readonly events = new Map<number, TurnEvent>();
private readonly dirtyGeneralIds = new Set<number>();
private readonly dirtyCityIds = new Set<number>();
private readonly dirtyNationIds = new Set<number>();
@@ -243,6 +246,7 @@ export class InMemoryTurnWorld {
private readonly deletedTroopIds = new Set<number>();
private readonly deletedGeneralIds = new Set<number>();
private readonly deletedNationIds = new Set<number>();
private readonly deletedEventIds = new Set<number>();
private readonly deletedNationSnapshots: Array<{
nation: Nation;
generalIds: number[];
@@ -287,6 +291,9 @@ export class InMemoryTurnWorld {
meta: { ...entry.meta },
});
}
for (const event of snapshot.events) {
this.events.set(event.id, { ...event, meta: { ...event.meta } });
}
this.ensureDiplomacyMatrix();
}
@@ -350,6 +357,21 @@ export class InMemoryTurnWorld {
}));
}
listEvents(targetCode?: string): TurnEvent[] {
return Array.from(this.events.values())
.filter((event) => targetCode === undefined || event.targetCode.toLowerCase() === targetCode.toLowerCase())
.sort((left, right) => right.priority - left.priority || left.id - right.id)
.map((event) => ({ ...event, meta: { ...event.meta } }));
}
removeEvent(id: number): boolean {
if (!this.events.delete(id)) {
return false;
}
this.deletedEventIds.add(id);
return true;
}
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null {
if (srcNationId === destNationId) {
return null;
@@ -691,6 +713,15 @@ export class InMemoryTurnWorld {
nextYear = previousYear + 1;
}
const context: TurnCalendarContext = {
previousYear,
previousMonth,
currentYear: nextYear,
currentMonth: nextMonth,
turnTime,
};
this.calendarHandler?.beforeMonthChanged?.(context);
const meta = {
...this.state.meta,
lastTurnTime: turnTime.toISOString(),
@@ -703,13 +734,6 @@ export class InMemoryTurnWorld {
meta,
};
const context: TurnCalendarContext = {
previousYear,
previousMonth,
currentYear: nextYear,
currentMonth: nextMonth,
turnTime,
};
this.advanceDiplomacyMonth();
this.calendarHandler?.onMonthChanged?.(context);
if (nextYear !== previousYear) {
@@ -748,6 +772,7 @@ export class InMemoryTurnWorld {
const deletedTroops = Array.from(this.deletedTroopIds);
const deletedGenerals = Array.from(this.deletedGeneralIds);
const deletedNations = Array.from(this.deletedNationIds);
const deletedEvents = Array.from(this.deletedEventIds);
const deletedNationSnapshots = this.deletedNationSnapshots.slice();
const logs = this.logs.slice();
const messages = this.messages.slice();
@@ -768,6 +793,7 @@ export class InMemoryTurnWorld {
createdNations,
createdTroops,
createdDiplomacy,
deletedEvents,
};
}
@@ -788,6 +814,7 @@ export class InMemoryTurnWorld {
for (const id of changes.deletedTroops) this.deletedTroopIds.delete(id);
for (const id of changes.deletedGenerals) this.deletedGeneralIds.delete(id);
for (const id of changes.deletedNations) this.deletedNationIds.delete(id);
for (const id of changes.deletedEvents) this.deletedEventIds.delete(id);
this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length);
this.logs.splice(0, changes.logs.length);
this.messages.splice(0, changes.messages.length);
@@ -0,0 +1,174 @@
import type { InMemoryTurnWorld, TurnCalendarContext, TurnCalendarHandler } from './inMemoryWorld.js';
import type { TurnEvent } from './types.js';
export interface MonthlyEventEnvironment {
year: number;
month: number;
startyear: number;
currentEventID: number;
turnTime: Date;
}
export type MonthlyEventActionHandler = (
args: readonly unknown[],
environment: MonthlyEventEnvironment,
event: TurnEvent
) => void;
export type MonthlyEventActionRegistry = ReadonlyMap<string, MonthlyEventActionHandler>;
const COMPARATORS = new Set(['==', '!=', '<', '>', '<=', '>=']);
const compare = (left: readonly (number | null)[], operator: string, right: readonly (number | null)[]): boolean => {
const normalize = (values: readonly (number | null)[]): string =>
values.map((value) => (value === null ? '' : String(value).padStart(12, '0'))).join(':');
const lhs = normalize(left);
const rhs = normalize(right);
switch (operator) {
case '==':
return lhs === rhs;
case '!=':
return lhs !== rhs;
case '<':
return lhs < rhs;
case '>':
return lhs > rhs;
case '<=':
return lhs <= rhs;
case '>=':
return lhs >= rhs;
default:
return false;
}
};
const readNullableInteger = (value: unknown, label: string): number | null => {
if (value === null) {
return null;
}
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new Error(`${label} must be an integer or null.`);
}
return value;
};
const evaluateCondition = (
raw: unknown,
environment: MonthlyEventEnvironment,
remainingNationCount: number
): boolean => {
if (typeof raw === 'boolean') {
return raw;
}
if (!Array.isArray(raw) || typeof raw[0] !== 'string') {
throw new Error('Event condition must be a boolean or condition tuple.');
}
const name = raw[0];
const normalized = name.toLowerCase();
if (normalized === 'not') {
if (raw.length !== 2) {
throw new Error('not condition requires exactly one operand.');
}
return !evaluateCondition(raw[1], environment, remainingNationCount);
}
if (normalized === 'and') {
return raw.slice(1).every((condition) => evaluateCondition(condition, environment, remainingNationCount));
}
if (normalized === 'or') {
return raw.slice(1).some((condition) => evaluateCondition(condition, environment, remainingNationCount));
}
if (normalized === 'xor') {
return raw
.slice(1)
.reduce(
(value, condition) => value !== evaluateCondition(condition, environment, remainingNationCount),
false
);
}
if (name === 'Date' || name === 'DateRelative') {
const operator = raw[1];
if (typeof operator !== 'string' || !COMPARATORS.has(operator)) {
throw new Error(`${name} condition has an invalid comparator.`);
}
const year = readNullableInteger(raw[2], `${name}.year`);
const month = readNullableInteger(raw[3], `${name}.month`);
if (year === null && month === null) {
throw new Error(`${name} condition requires year or month.`);
}
const currentYear = name === 'DateRelative' ? environment.year - environment.startyear : environment.year;
return compare([year === null ? null : currentYear, month === null ? null : environment.month], operator, [
year,
month,
]);
}
if (name === 'RemainNation') {
const operator = raw[1];
const count = raw[2];
if (typeof operator !== 'string' || !COMPARATORS.has(operator) || typeof count !== 'number') {
throw new Error('RemainNation condition is invalid.');
}
return compare([remainingNationCount], operator, [Math.floor(count)]);
}
throw new Error(`Unsupported event condition: ${name}`);
};
const parseActions = (raw: unknown): Array<{ name: string; args: readonly unknown[] }> => {
if (!Array.isArray(raw)) {
throw new Error('Event action list must be an array.');
}
return raw.map((action) => {
if (!Array.isArray(action) || typeof action[0] !== 'string') {
throw new Error('Event action must be a tuple beginning with its name.');
}
return { name: action[0], args: action.slice(1) };
});
};
export const createMonthlyEventHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
startYear: number;
actions?: MonthlyEventActionRegistry;
}): TurnCalendarHandler => {
const dispatch = (targetCode: 'pre_month' | 'month', context: TurnCalendarContext): void => {
const world = options.getWorld();
if (!world) {
return;
}
const year = targetCode === 'pre_month' ? context.previousYear : context.currentYear;
const month = targetCode === 'pre_month' ? context.previousMonth : context.currentMonth;
const remainingNationCount = world.listNations().filter((nation) => nation.id > 0).length;
for (const event of world.listEvents(targetCode)) {
const environment: MonthlyEventEnvironment = {
year,
month,
startyear: options.startYear,
currentEventID: event.id,
turnTime: context.turnTime,
};
if (!evaluateCondition(event.condition, environment, remainingNationCount)) {
continue;
}
for (const action of parseActions(event.action)) {
if (action.name === 'DeleteEvent') {
world.removeEvent(event.id);
continue;
}
const handler = options.actions?.get(action.name);
if (!handler) {
throw new Error(`Unsupported monthly event action: ${action.name} (eventId=${event.id})`);
}
handler(action.args, environment, event);
}
}
};
return {
beforeMonthChanged: (context) => dispatch('pre_month', context),
onMonthChanged: (context) => dispatch('month', context),
};
};
+69 -2
View File
@@ -1,4 +1,4 @@
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
import { LogCategory, LogScope, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
@@ -33,6 +33,7 @@ import { createAuctionBidder } from '../auction/bidder.js';
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
import { createYearbookHandler } from './yearbookHandler.js';
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from './monthlyEventHandler.js';
export interface TurnDaemonRuntimeOptions {
profile: string;
@@ -125,6 +126,71 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
scenarioConfig: snapshot.scenarioConfig,
nationTraits: nationTraitMap,
});
const hasEventAction = (name: string): boolean =>
snapshot.events.some(
(event) =>
Array.isArray(event.action) &&
event.action.some((action) => Array.isArray(action) && action[0] === name)
);
const eventActions = new Map<string, MonthlyEventActionHandler>();
eventActions.set('ProcessIncome', (_args, environment) => {
incomeHandler.onMonthChanged?.({
previousYear: environment.month === 1 ? environment.year - 1 : environment.year,
previousMonth: environment.month === 1 ? 12 : environment.month - 1,
currentYear: environment.year,
currentMonth: environment.month,
turnTime: environment.turnTime,
});
});
eventActions.set('NoticeToHistoryLog', (args) => {
const text = args[0];
if (typeof text !== 'string' || !worldRef) {
return;
}
worldRef.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
text,
});
});
eventActions.set('NewYear', (_args, environment) => {
if (!worldRef) {
return;
}
for (const general of worldRef.listGenerals()) {
const belong = general.meta.belong;
worldRef.updateGeneral(general.id, {
age: general.age + 1,
meta: {
...general.meta,
...(general.nationId !== 0
? { belong: typeof belong === 'number' && Number.isFinite(belong) ? belong + 1 : 1 }
: {}),
},
});
}
worldRef.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
text: `<C>${environment.year}</>년이 되었습니다.`,
});
});
eventActions.set('ResetOfficerLock', () => {
if (!worldRef) {
return;
}
for (const nation of worldRef.listNations()) {
worldRef.updateNation(nation.id, { meta: { ...nation.meta, chief_set: 0 } });
}
for (const city of worldRef.listCities()) {
worldRef.updateCity(city.id, { meta: { ...city.meta, officer_set: 0 } });
}
});
const monthlyEventHandler = createMonthlyEventHandler({
getWorld: () => worldRef,
startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
actions: eventActions,
});
const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({
getWorld: () => worldRef,
});
@@ -144,9 +210,10 @@ export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions)
getWorld: () => worldRef,
});
const calendarHandler = composeCalendarHandlers(
monthlyEventHandler,
options.calendarHandler ?? unification?.handler,
nationTurnMonthlyHandler,
incomeHandler,
hasEventAction('ProcessIncome') ? null : incomeHandler,
frontStateHandler,
tournamentAutoStartHandler,
yearbookHandler.handler
+11 -2
View File
@@ -33,6 +33,15 @@ export interface TurnDiplomacy {
meta: Record<string, unknown>;
}
export interface TurnEvent {
id: number;
targetCode: string;
priority: number;
condition: unknown;
action: unknown;
meta: Record<string, unknown>;
}
export interface TurnWorldSnapshot extends Omit<
WorldSnapshot,
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
@@ -43,8 +52,8 @@ export interface TurnWorldSnapshot extends Omit<
map: MapDefinition;
unitSet?: UnitSetDefinition;
diplomacy: TurnDiplomacy[];
events: unknown[];
initialEvents: unknown[];
events: TurnEvent[];
initialEvents: TurnEvent[];
generals: TurnGeneral[];
cities: City[];
nations: Nation[];
+19 -21
View File
@@ -26,7 +26,7 @@ import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
import type { TurnDiplomacy, TurnGeneral, TurnWorldLoadResult } from './types.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
import { readDiplomacyMeta } from '@sammo-ts/logic';
interface TurnWorldLoaderOptions {
@@ -262,6 +262,22 @@ const mapDiplomacyRow = (row: TurnEngineDiplomacyRow): TurnDiplomacy => {
};
};
const mapEventRow = (row: {
id: number;
targetCode: string;
priority: number;
condition: JsonValue;
action: JsonValue;
meta: JsonValue;
}): TurnEvent => ({
id: row.id,
targetCode: row.targetCode,
priority: row.priority,
condition: row.condition,
action: row.action,
meta: asRecord(row.meta),
});
const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({
id: row.troopLeaderId,
nationId: row.nationId,
@@ -309,26 +325,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
const fallbackBase = resolveFallbackTurnTimeBase(generals, worldState.updatedAt ?? null);
const lastTurnTime = parseLastTurnTime(meta) ?? alignToPreviousTick(fallbackBase, tickMinutes);
const events = eventRows
.filter((row) => row.targetCode !== 'initial')
.map((row) => ({
id: row.id,
targetCode: row.targetCode,
priority: row.priority,
condition: row.condition,
action: row.action,
meta: row.meta,
}));
const initialEvents = eventRows
.filter((row) => row.targetCode === 'initial')
.map((row) => ({
id: row.id,
targetCode: row.targetCode,
priority: row.priority,
condition: row.condition,
action: row.action,
meta: row.meta,
}));
const events = eventRows.filter((row) => row.targetCode !== 'initial').map(mapEventRow);
const initialEvents = eventRows.filter((row) => row.targetCode === 'initial').map(mapEventRow);
return {
state: {
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';
import type { MapDefinition } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createMonthlyEventHandler, type MonthlyEventActionHandler } from '../src/turn/monthlyEventHandler.js';
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const map: MapDefinition = {
id: 'event-test',
name: 'event-test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
const buildWorld = (
events: TurnWorldSnapshot['events'],
actions: Map<string, MonthlyEventActionHandler>
): InMemoryTurnWorld => {
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 12,
tickSeconds: 600,
lastTurnTime: new Date('0189-12-01T00:00:00.000Z'),
meta: {},
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: map.id, unitSet: 'default' },
},
scenarioMeta: {
title: 'event test',
startYear: 189,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
map,
diplomacy: [],
events,
initialEvents: [],
generals: [],
cities: [],
nations: [],
troops: [],
};
let world: InMemoryTurnWorld | null = null;
const handler = createMonthlyEventHandler({
getWorld: () => world,
startYear: 189,
actions,
});
world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
calendarHandler: handler,
});
return world;
};
describe('monthly event pipeline', () => {
it('runs PRE_MONTH before the date change and MONTH after it in priority/id order', () => {
const trace: string[] = [];
const actions = new Map<string, MonthlyEventActionHandler>([
[
'Trace',
(args, environment) => {
trace.push(`${String(args[0])}:${environment.year}-${environment.month}`);
},
],
]);
const world = buildWorld(
[
{
id: 2,
targetCode: 'month',
priority: 10,
condition: true,
action: [['Trace', 'month-low']],
meta: {},
},
{
id: 3,
targetCode: 'month',
priority: 20,
condition: ['DateRelative', '==', 1, 1],
action: [['Trace', 'month-high']],
meta: {},
},
{
id: 1,
targetCode: 'pre_month',
priority: 0,
condition: ['Date', '==', 189, 12],
action: [['Trace', 'pre']],
meta: {},
},
],
actions
);
world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
expect(trace).toEqual(['pre:189-12', 'month-high:190-1', 'month-low:190-1']);
});
it('supports logic conditions and persists DeleteEvent through dirty state', () => {
const world = buildWorld(
[
{
id: 7,
targetCode: 'month',
priority: 0,
condition: ['and', ['Date', '==', null, 1], ['RemainNation', '==', 0]],
action: [['DeleteEvent']],
meta: {},
},
],
new Map()
);
world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'));
expect(world.listEvents('month')).toEqual([]);
expect(world.peekDirtyState().deletedEvents).toEqual([7]);
world.acknowledgeDirtyState(world.peekDirtyState());
expect(world.peekDirtyState().deletedEvents).toEqual([]);
});
it('fails explicitly when a scenario action has not been migrated', () => {
const world = buildWorld(
[
{
id: 9,
targetCode: 'month',
priority: 0,
condition: true,
action: [['RaiseInvader']],
meta: {},
},
],
new Map()
);
expect(() => world.advanceMonth(new Date('0190-01-01T00:00:00.000Z'))).toThrow(
'Unsupported monthly event action: RaiseInvader (eventId=9)'
);
});
});
+16
View File
@@ -40,6 +40,22 @@ Indexes: `(target, priority, id)` for dispatch ordering. Both `condition` and
Events are used inside the monthly pipeline and in special moments like
city occupation (`EventTarget::OCCUPY_CITY`, called by some commands).
### Rewrite runtime status
`app/game-engine/src/turn/monthlyEventHandler.ts` now dispatches persisted
`event` rows for `pre_month` and `month`. It preserves the legacy
`priority DESC, id ASC` order, evaluates `Date`, `DateRelative`, `RemainNation`,
and the boolean logic operators, and records `DeleteEvent` through the normal
turn dirty-state transaction.
The calendar calls `pre_month` before changing the world date and `month`
after changing it. Action names are resolved through an explicit registry.
An unported action stops the turn with its action name and event id instead of
being silently ignored. The runtime registry currently covers
`ProcessIncome`, `NoticeToHistoryLog`, `NewYear`, and `ResetOfficerLock`;
the remaining legacy action catalog must be migrated before full event-action
parity can be claimed.
## Condition and Action DSL
`Event\Condition::build()` and `Event\Action::build()` decode JSON arrays into
+5 -1
View File
@@ -23,7 +23,11 @@ Move items into the main docs once they are finalized.
- [AI suggestion] Implement command continuation/alternate flow (LastTurn/term stack/pre/post requirements/next_execute) and integrate into reserved turn processing.
- [AI suggestion] Integrate trigger preprocessing + attempt/execute phases into turn resolution (parity with legacy trigger composition).
- [AI suggestion] Add AI/autorun and inactivity (killturn/block) handling with NPC takeover/deletion policies.
- [AI suggestion] Add an event execution pipeline (initial + monthly + conditional) tied to tick/month transitions with deterministic RNG seeding.
- [AI suggestion] Complete the event action registry beyond the implemented
`pre_month`/`month` dispatcher (`ProcessIncome`, `NoticeToHistoryLog`,
`NewYear`, `ResetOfficerLock`). Add initial and non-month targets plus the
remaining NPC, invader, disaster, supply, nation-level, betting, and
inheritance actions with legacy-compatible deterministic RNG.
- [AI suggestion] Add monthly economy/city/nation updates and hook them into the turn calendar handler.
- [AI suggestion] Implement diplomacy/state transitions and monthly/command-based updates beyond read-only maps.
- [AI suggestion] Integrate war/battle pipeline into turn processing (troop movement/war resolution hooks, not just isolated sim jobs).