diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index cb0ff5c..4e5a570 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -1 +1,5 @@ -export {}; +export * from './lifecycle/types.js'; +export * from './lifecycle/clock.js'; +export * from './lifecycle/inMemoryControlQueue.js'; +export * from './lifecycle/turnSchedule.js'; +export * from './lifecycle/turnDaemonLifecycle.js'; diff --git a/app/game-engine/src/lifecycle/clock.ts b/app/game-engine/src/lifecycle/clock.ts new file mode 100644 index 0000000..02d4395 --- /dev/null +++ b/app/game-engine/src/lifecycle/clock.ts @@ -0,0 +1,15 @@ +import type { Clock } from './types.js'; + +export class SystemClock implements Clock { + // 시스템 시간을 기준으로 동작하는 기본 시계. + nowMs(): number { + return Date.now(); + } + + async sleepMs(ms: number): Promise { + if (ms <= 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/app/game-engine/src/lifecycle/inMemoryControlQueue.ts b/app/game-engine/src/lifecycle/inMemoryControlQueue.ts new file mode 100644 index 0000000..67478ee --- /dev/null +++ b/app/game-engine/src/lifecycle/inMemoryControlQueue.ts @@ -0,0 +1,62 @@ +import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js'; + +type Waiter = { + deadlineMs: number | null; + resolve: (command: TurnDaemonCommand | null) => void; + timeoutId?: ReturnType; +}; + +export class InMemoryControlQueue implements TurnDaemonControlQueue { + // 단일 프로세스 내에서 쓰는 간단한 제어 큐. + private queue: TurnDaemonCommand[] = []; + private waiters: Waiter[] = []; + + enqueue(command: TurnDaemonCommand): void { + const waiter = this.waiters.shift(); + if (waiter) { + if (waiter.timeoutId) { + clearTimeout(waiter.timeoutId); + } + waiter.resolve(command); + return; + } + this.queue.push(command); + } + + async drain(): Promise { + if (this.queue.length === 0) { + return []; + } + const drained = this.queue.slice(); + this.queue.length = 0; + return drained; + } + + async waitUntil(deadlineMs: number | null): Promise { + if (this.queue.length > 0) { + return this.queue.shift() ?? null; + } + return new Promise((resolve) => { + const waiter: Waiter = { deadlineMs, resolve }; + if (deadlineMs !== null) { + const delay = Math.max(0, deadlineMs - Date.now()); + waiter.timeoutId = setTimeout(() => { + this.removeWaiter(waiter); + resolve(null); + }, delay); + } + this.waiters.push(waiter); + }); + } + + getDepth(): number { + return this.queue.length; + } + + private removeWaiter(waiter: Waiter): void { + const index = this.waiters.indexOf(waiter); + if (index >= 0) { + this.waiters.splice(index, 1); + } + } +} diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts new file mode 100644 index 0000000..8b2ded2 --- /dev/null +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -0,0 +1,234 @@ +import type { + RunReason, + TurnDaemonCommand, + TurnDaemonControlQueue, + TurnDaemonHooks, + TurnDaemonStatus, + TurnRunBudget, + TurnRunResult, + TurnSchedule, + TurnStateStore, + TurnProcessor, + Clock, + TurnCheckpoint, +} from './types.js'; + +type PendingRun = { + reason: RunReason; + targetTime?: Date; + budget?: TurnRunBudget; +}; + +export interface TurnDaemonLifecycleOptions { + profile: string; + defaultBudget: TurnRunBudget; +} + +export interface TurnDaemonLifecycleDeps { + clock: Clock; + controlQueue: TurnDaemonControlQueue; + schedule: TurnSchedule; + stateStore: TurnStateStore; + processor: TurnProcessor; + hooks?: TurnDaemonHooks; +} + +export class TurnDaemonLifecycle { + // 턴 데몬의 생명주기를 관리하는 루프. + private readonly clock: Clock; + private readonly controlQueue: TurnDaemonControlQueue; + private readonly schedule: TurnSchedule; + private readonly stateStore: TurnStateStore; + private readonly processor: TurnProcessor; + private readonly hooks?: TurnDaemonHooks; + private readonly options: TurnDaemonLifecycleOptions; + + private status: TurnDaemonStatus; + private pendingRun: PendingRun | null = null; + private stopping = false; + private loopPromise: Promise | null = null; + + constructor(deps: TurnDaemonLifecycleDeps, options: TurnDaemonLifecycleOptions) { + this.clock = deps.clock; + this.controlQueue = deps.controlQueue; + this.schedule = deps.schedule; + this.stateStore = deps.stateStore; + this.processor = deps.processor; + this.hooks = deps.hooks; + this.options = options; + this.status = { + state: 'idle', + running: false, + paused: false, + queueDepth: 0, + }; + } + + start(): Promise { + if (!this.loopPromise) { + this.loopPromise = this.runLoop(); + } + return this.loopPromise; + } + + async stop(reason?: string): Promise { + this.controlQueue.enqueue({ type: 'shutdown', reason }); + if (this.loopPromise) { + await this.loopPromise; + } + } + + requestRun(reason: RunReason, targetTime?: Date, budget?: TurnRunBudget): void { + this.controlQueue.enqueue({ + type: 'run', + reason, + targetTime: targetTime ? targetTime.toISOString() : undefined, + budget, + }); + } + + pause(reason?: string): void { + this.controlQueue.enqueue({ type: 'pause', reason }); + } + + resume(reason?: string): void { + this.controlQueue.enqueue({ type: 'resume', reason }); + } + + getStatus(): TurnDaemonStatus { + return { + ...this.status, + queueDepth: this.controlQueue.getDepth(), + }; + } + + private async runLoop(): Promise { + await this.initializeState(); + while (!this.stopping) { + await this.drainCommands(); + if (this.stopping) { + break; + } + if (this.status.paused) { + await this.waitForResume(); + continue; + } + + if (this.pendingRun) { + await this.runOnce(this.pendingRun); + this.pendingRun = null; + continue; + } + + const nextTurnTime = this.getNextTurnTime(); + if (!nextTurnTime) { + await this.clock.sleepMs(200); + continue; + } + + const nowMs = this.clock.nowMs(); + const nextTurnMs = nextTurnTime.getTime(); + if (nowMs >= nextTurnMs) { + await this.runOnce({ reason: 'schedule', targetTime: nextTurnTime }); + continue; + } + + const command = await this.controlQueue.waitUntil(nextTurnMs); + if (command) { + await this.handleCommand(command); + } + } + } + + private async initializeState(): Promise { + const lastTurnTime = await this.stateStore.loadLastTurnTime(); + const checkpoint = await this.stateStore.loadCheckpoint(); + this.status.lastTurnTime = lastTurnTime.toISOString(); + this.status.checkpoint = checkpoint; + this.status.nextTurnTime = this.schedule.getNextTurnTime(lastTurnTime).toISOString(); + } + + private getNextTurnTime(): Date | null { + if (!this.status.lastTurnTime) { + return null; + } + return this.schedule.getNextTurnTime(new Date(this.status.lastTurnTime)); + } + + private async drainCommands(): Promise { + const commands = await this.controlQueue.drain(); + for (const command of commands) { + await this.handleCommand(command); + if (this.stopping) { + return; + } + } + } + + private async waitForResume(): Promise { + const command = await this.controlQueue.waitUntil(null); + if (command) { + await this.handleCommand(command); + } + } + + private async handleCommand(command: TurnDaemonCommand): Promise { + switch (command.type) { + case 'pause': + this.status.paused = true; + this.status.state = 'paused'; + return; + case 'resume': + this.status.paused = false; + this.status.state = 'idle'; + return; + case 'shutdown': + this.status.state = 'stopping'; + this.stopping = true; + return; + case 'run': + this.pendingRun = { + reason: command.reason, + targetTime: command.targetTime ? new Date(command.targetTime) : undefined, + budget: command.budget, + }; + this.status.pendingReason = command.reason; + return; + } + } + + private async runOnce(pending: PendingRun): Promise { + const startMs = this.clock.nowMs(); + this.status.state = 'running'; + this.status.running = true; + this.status.pendingReason = pending.reason; + + const targetTime = pending.targetTime ?? new Date(startMs); + const budget = pending.budget ?? this.options.defaultBudget; + const checkpoint = this.status.checkpoint; + let result: TurnRunResult; + + try { + result = await this.processor.run(targetTime, budget, checkpoint); + } finally { + this.status.running = false; + } + + this.status.state = 'flushing'; + await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime)); + await this.stateStore.saveCheckpoint(result.checkpoint); + await this.hooks?.flushChanges?.(result); + await this.hooks?.publishEvents?.(result); + this.applyRunResult(result, startMs); + this.status.state = 'idle'; + } + + private applyRunResult(result: TurnRunResult, startMs: number): void { + this.status.lastRunAt = new Date(startMs).toISOString(); + this.status.lastDurationMs = Math.max(0, this.clock.nowMs() - startMs); + this.status.lastTurnTime = result.lastTurnTime; + this.status.checkpoint = result.checkpoint; + const nextTurnTime = this.schedule.getNextTurnTime(new Date(result.lastTurnTime)); + this.status.nextTurnTime = nextTurnTime.toISOString(); + } +} diff --git a/app/game-engine/src/lifecycle/turnSchedule.ts b/app/game-engine/src/lifecycle/turnSchedule.ts new file mode 100644 index 0000000..22bb863 --- /dev/null +++ b/app/game-engine/src/lifecycle/turnSchedule.ts @@ -0,0 +1,17 @@ +import type { TurnSchedule } from './types.js'; + +export class FixedIntervalSchedule implements TurnSchedule { + // 일정 간격으로 턴을 진행하는 스케줄러. + private intervalMs: number; + + constructor(intervalMs: number) { + if (intervalMs <= 0) { + throw new Error('intervalMs must be positive'); + } + this.intervalMs = intervalMs; + } + + getNextTurnTime(lastTurnTime: Date): Date { + return new Date(lastTurnTime.getTime() + this.intervalMs); + } +} diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts new file mode 100644 index 0000000..1f6b91c --- /dev/null +++ b/app/game-engine/src/lifecycle/types.ts @@ -0,0 +1,76 @@ +export type TurnDaemonState = 'idle' | 'running' | 'flushing' | 'paused' | 'stopping'; + +export type RunReason = 'schedule' | 'manual' | 'poke'; + +export interface TurnRunBudget { + budgetMs: number; + maxGenerals: number; + catchUpCap: number; +} + +export interface TurnCheckpoint { + turnTime: string; + generalId?: number; + year: number; + month: number; +} + +export interface TurnRunResult { + lastTurnTime: string; + processedGenerals: number; + processedTurns: number; + durationMs: number; + partial: boolean; + checkpoint?: TurnCheckpoint; +} + +export interface TurnDaemonStatus { + state: TurnDaemonState; + running: boolean; + paused: boolean; + lastRunAt?: string; + lastDurationMs?: number; + lastTurnTime?: string; + nextTurnTime?: string; + pendingReason?: RunReason; + queueDepth: number; + checkpoint?: TurnCheckpoint; +} + +export type TurnDaemonCommand = + | { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget } + | { type: 'pause'; reason?: string } + | { type: 'resume'; reason?: string } + | { type: 'shutdown'; reason?: string }; + +export interface Clock { + nowMs(): number; + sleepMs(ms: number): Promise; +} + +export interface TurnSchedule { + getNextTurnTime(lastTurnTime: Date): Date; +} + +export interface TurnProcessor { + run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise; +} + +export interface TurnStateStore { + loadLastTurnTime(): Promise; + saveLastTurnTime(turnTime: Date): Promise; + loadCheckpoint(): Promise; + saveCheckpoint(checkpoint?: TurnCheckpoint): Promise; +} + +export interface TurnDaemonControlQueue { + enqueue(command: TurnDaemonCommand): void; + drain(): Promise; + waitUntil(deadlineMs: number | null): Promise; + getDepth(): number; +} + +export interface TurnDaemonHooks { + flushChanges?(result: TurnRunResult): Promise; + publishEvents?(result: TurnRunResult): Promise; +}