diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 44acc14..e96f493 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -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", diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index 051b733..4263b4b 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -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; + }); +} diff --git a/app/game-engine/src/turn/cli.ts b/app/game-engine/src/turn/cli.ts new file mode 100644 index 0000000..d0da412 --- /dev/null +++ b/app/game-engine/src/turn/cli.ts @@ -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; + 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 | undefined => { + const budgetOverride: Partial = { + 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 => { + 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 => { + if (closed) { + return; + } + closed = true; + await runtime.close(); + }; + + let stopping = false; + const stop = async (reason: string): Promise => { + 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(); + } +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index d2901dc..bf83eed 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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 }); diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 418485f..9abe487 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8136715..94e9aae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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