feat: 턴 다이아몬드 CLI 추가 및 상태 관리 개선

This commit is contained in:
2025-12-29 07:50:35 +00:00
parent 86c5d5f111
commit e0eb768e2e
6 changed files with 212 additions and 13 deletions
+3 -1
View File
@@ -6,6 +6,7 @@
"scripts": {
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine",
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-engine --watch",
"start": "pnpm run build && node dist/index.js",
"lint": "node -e \"console.log('lint not configured')\"",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit"
@@ -14,7 +15,8 @@
"@sammo-ts/common": "workspace:*",
"@sammo-ts/infra": "workspace:*",
"@sammo-ts/logic": "workspace:*",
"@prisma/client": "^7.2.0"
"@prisma/client": "^7.2.0",
"zod": "^4.2.1"
},
"devDependencies": {
"tsdown": "^0.18.3",
+20
View File
@@ -1,3 +1,8 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runTurnDaemonCli } from './turn/cli.js';
export * from './lifecycle/types.js';
export * from './lifecycle/clock.js';
export * from './lifecycle/inMemoryControlQueue.js';
@@ -14,3 +19,18 @@ export * from './turn/inMemoryStateStore.js';
export * from './turn/inMemoryTurnProcessor.js';
export * from './turn/databaseHooks.js';
export * from './turn/turnDaemon.js';
export * from './turn/cli.js';
const isMain = (): boolean => {
if (!process.argv[1]) {
return false;
}
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
};
if (isMain()) {
runTurnDaemonCli().catch((error) => {
console.error('[turn-daemon] failed to start', error);
process.exitCode = 1;
});
}
+128
View File
@@ -0,0 +1,128 @@
import type { TurnSchedule } from '@sammo-ts/logic';
import type { TurnRunBudget } from '../lifecycle/types.js';
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
import { createTurnDaemonRuntime } from './turnDaemon.js';
export interface TurnDaemonCliOptions {
profile?: string;
databaseUrl?: string;
tickMinutes?: number;
schedule?: TurnSchedule;
budget?: Partial<TurnRunBudget>;
enableDatabaseFlush?: boolean;
env?: NodeJS.ProcessEnv;
}
const DEFAULT_BUDGET: TurnRunBudget = {
budgetMs: 5000,
maxGenerals: 200,
catchUpCap: 1,
};
const parseNumber = (value: string | undefined): number | undefined => {
if (!value) {
return undefined;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return undefined;
}
return parsed;
};
const parseBoolean = (value: string | undefined): boolean | undefined => {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
return false;
}
return undefined;
};
const buildBudgetOverride = (
env: NodeJS.ProcessEnv,
override?: Partial<TurnRunBudget>
): TurnRunBudget | undefined => {
const budgetOverride: Partial<TurnRunBudget> = {
budgetMs: parseNumber(env.TURN_BUDGET_MS),
maxGenerals: parseNumber(env.TURN_MAX_GENERALS),
catchUpCap: parseNumber(env.TURN_CATCH_UP_CAP),
...override,
};
const hasOverride = Object.values(budgetOverride).some(
(value) => value !== undefined
);
if (!hasOverride) {
return undefined;
}
return { ...DEFAULT_BUDGET, ...budgetOverride };
};
export const runTurnDaemonCli = async (
options: TurnDaemonCliOptions = {}
): Promise<void> => {
const env = options.env ?? process.env;
const profile =
options.profile ?? env.TURN_PROFILE ?? env.PROFILE ?? 'che';
const databaseUrl =
options.databaseUrl ?? (await resolveDatabaseUrl({ env }));
const budget = buildBudgetOverride(env, options.budget);
const tickMinutes =
options.tickMinutes ?? parseNumber(env.TURN_TICK_MINUTES);
const enableDatabaseFlush =
options.enableDatabaseFlush ??
parseBoolean(env.TURN_FLUSH_DB) ??
true;
const runtime = await createTurnDaemonRuntime({
profile,
databaseUrl,
defaultBudget: budget,
tickMinutes,
schedule: options.schedule,
enableDatabaseFlush,
});
let closed = false;
const closeOnce = async (): Promise<void> => {
if (closed) {
return;
}
closed = true;
await runtime.close();
};
let stopping = false;
const stop = async (reason: string): Promise<void> => {
if (stopping) {
return;
}
stopping = true;
console.info(`[turn-daemon] stopping: ${reason}`);
await runtime.lifecycle.stop(reason);
await closeOnce();
};
process.on('SIGINT', () => void stop('SIGINT'));
process.on('SIGTERM', () => void stop('SIGTERM'));
const activeTickMinutes =
tickMinutes ??
Math.max(1, Math.round(runtime.world.getState().tickSeconds / 60));
console.info(
`[turn-daemon] started profile=${profile} tickMinutes=${activeTickMinutes}`
);
try {
await runtime.lifecycle.start();
} finally {
await closeOnce();
}
};
+4 -1
View File
@@ -67,13 +67,16 @@ export const createTurnDaemonRuntime = async (
});
const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
const resolvedState = options.tickMinutes
? { ...state, tickSeconds: tickMinutes * 60 }
: state;
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
const worldOptions: InMemoryTurnWorldOptions = {
schedule,
generalTurnHandler: options.generalTurnHandler,
calendarHandler: options.calendarHandler,
};
const world = new InMemoryTurnWorld(state, snapshot, worldOptions);
const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
const stateStore = new InMemoryTurnStateStore(world);
const processor = new InMemoryTurnProcessor(world, { tickMinutes });
+54 -11
View File
@@ -1,4 +1,10 @@
import type { Prisma } from '@prisma/client';
import type {
City as PrismaCity,
Diplomacy as PrismaDiplomacy,
General as PrismaGeneral,
Nation as PrismaNation,
Prisma,
} from '@prisma/client';
import { createPostgresConnector } from '@sammo-ts/infra';
import type {
@@ -9,6 +15,7 @@ import type {
ScenarioMeta,
TriggerValue,
} from '@sammo-ts/logic';
import { z } from 'zod';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
@@ -38,12 +45,43 @@ const normalizeCode = (value: string | null | undefined): string | null => {
return value;
};
const zScenarioStatBlock = z.object({
total: z.number(),
min: z.number(),
max: z.number(),
npcTotal: z.number(),
npcMax: z.number(),
npcMin: z.number(),
chiefMin: z.number(),
});
const zScenarioEnvironment = z.object({
mapName: z.string(),
unitSet: z.string(),
scenarioEffect: z.union([z.string(), z.null()]).optional(),
});
const zScenarioConfig = z.object({
stat: zScenarioStatBlock,
iconPath: z.string(),
map: z.record(z.string(), z.unknown()),
const: z.record(z.string(), z.unknown()),
environment: zScenarioEnvironment,
});
const zScenarioMeta = z.object({
title: z.string(),
startYear: z.number().nullable(),
life: z.number().nullable(),
fiction: z.number().nullable(),
history: z.array(z.string()),
ignoreDefaultEvents: z.boolean(),
});
const parseScenarioMeta = (meta: JsonRecord): ScenarioMeta | undefined => {
const raw = meta.scenarioMeta;
if (isRecord(raw)) {
return raw as ScenarioMeta;
}
return undefined;
const parsed = zScenarioMeta.safeParse(raw);
return parsed.success ? parsed.data : undefined;
};
const parseLastTurnTime = (meta: JsonRecord): Date | null => {
@@ -83,10 +121,15 @@ const alignToPreviousTick = (base: Date, tickMinutes: number): Date => {
return new Date(nextTick.getTime() - tickMinutes * 60_000);
};
const mapScenarioConfig = (raw: Prisma.JsonValue): ScenarioConfig =>
raw as ScenarioConfig;
const mapScenarioConfig = (raw: Prisma.JsonValue): ScenarioConfig => {
const parsed = zScenarioConfig.safeParse(raw);
if (!parsed.success) {
throw new Error(`world_state.config is invalid: ${parsed.error.message}`);
}
return parsed.data;
};
const mapGeneralRow = (row: Prisma.General): TurnGeneral => ({
const mapGeneralRow = (row: PrismaGeneral): TurnGeneral => ({
id: row.id,
name: row.name,
nationId: row.nationId,
@@ -130,7 +173,7 @@ const mapGeneralRow = (row: Prisma.General): TurnGeneral => ({
recentWarTime: row.recentWarTime ?? null,
});
const mapCityRow = (row: Prisma.City): City => ({
const mapCityRow = (row: PrismaCity): City => ({
id: row.id,
name: row.name,
nationId: row.nationId,
@@ -152,7 +195,7 @@ const mapCityRow = (row: Prisma.City): City => ({
meta: asTriggerRecord(row.meta),
});
const mapNationRow = (row: Prisma.Nation): Nation => ({
const mapNationRow = (row: PrismaNation): Nation => ({
id: row.id,
name: row.name,
color: row.color,
@@ -166,7 +209,7 @@ const mapNationRow = (row: Prisma.Nation): Nation => ({
meta: asTriggerRecord(row.meta),
});
const mapDiplomacyRow = (row: Prisma.Diplomacy): ScenarioDiplomacy => ({
const mapDiplomacyRow = (row: PrismaDiplomacy): ScenarioDiplomacy => ({
fromNationId: row.srcNationId,
toNationId: row.destNationId,
state: row.stateCode,
+3
View File
@@ -38,6 +38,9 @@ importers:
'@sammo-ts/logic':
specifier: workspace:*
version: link:../../packages/logic
zod:
specifier: ^4.2.1
version: 4.2.1
devDependencies:
tsdown:
specifier: ^0.18.3