feat: 턴 다이아몬드 CLI 추가 및 상태 관리 개선
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user