diff --git a/app/game-engine/src/turn/calendarHandlers.ts b/app/game-engine/src/turn/calendarHandlers.ts index f8a532a..20b7f22 100644 --- a/app/game-engine/src/turn/calendarHandlers.ts +++ b/app/game-engine/src/turn/calendarHandlers.ts @@ -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); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index bba692c..4df57bb 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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 diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index bdf02a3..d23d144 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -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(); private readonly troops = new Map(); private readonly diplomacy = new Map(); + private readonly events = new Map(); private readonly dirtyGeneralIds = new Set(); private readonly dirtyCityIds = new Set(); private readonly dirtyNationIds = new Set(); @@ -243,6 +246,7 @@ export class InMemoryTurnWorld { private readonly deletedTroopIds = new Set(); private readonly deletedGeneralIds = new Set(); private readonly deletedNationIds = new Set(); + private readonly deletedEventIds = new Set(); 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); diff --git a/app/game-engine/src/turn/monthlyEventHandler.ts b/app/game-engine/src/turn/monthlyEventHandler.ts new file mode 100644 index 0000000..076550f --- /dev/null +++ b/app/game-engine/src/turn/monthlyEventHandler.ts @@ -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; + +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), + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 0b0a9c3..5b6cfeb 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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(); + 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: `${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 diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 499e80f..eaf8130 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -33,6 +33,15 @@ export interface TurnDiplomacy { meta: Record; } +export interface TurnEvent { + id: number; + targetCode: string; + priority: number; + condition: unknown; + action: unknown; + meta: Record; +} + 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[]; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 5fa9177..5b7ae28 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -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: { diff --git a/app/game-engine/test/monthlyEventHandler.test.ts b/app/game-engine/test/monthlyEventHandler.test.ts new file mode 100644 index 0000000..c4c72a7 --- /dev/null +++ b/app/game-engine/test/monthlyEventHandler.test.ts @@ -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 +): 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([ + [ + '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)' + ); + }); +}); diff --git a/docs/architecture/legacy-engine-events.md b/docs/architecture/legacy-engine-events.md index 50e3ac6..176fbb0 100644 --- a/docs/architecture/legacy-engine-events.md +++ b/docs/architecture/legacy-engine-events.md @@ -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 diff --git a/docs/architecture/todo.md b/docs/architecture/todo.md index f3864b0..8feb8ee 100644 --- a/docs/architecture/todo.md +++ b/docs/architecture/todo.md @@ -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).