merge: add monthly event pipeline

This commit is contained in:
2026-07-25 08:24:32 +00:00
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);
@@ -339,6 +339,7 @@ export const createDatabaseTurnHooks = async (
createdNations,
createdTroops,
createdDiplomacy,
deletedEvents,
} = changes;
const reservedTurnChanges = options?.reservedTurns?.peekDirtyState();
@@ -490,6 +491,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
@@ -35,6 +35,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'
@@ -45,8 +54,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
@@ -27,7 +27,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 {
@@ -276,6 +276,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,
@@ -323,26 +339,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: {