feat: 게임 엔진 생명주기 및 스케줄링 관련 클래스 추가
This commit is contained in:
@@ -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';
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
if (ms <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
|
||||||
|
|
||||||
|
type Waiter = {
|
||||||
|
deadlineMs: number | null;
|
||||||
|
resolve: (command: TurnDaemonCommand | null) => void;
|
||||||
|
timeoutId?: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<TurnDaemonCommand[]> {
|
||||||
|
if (this.queue.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const drained = this.queue.slice();
|
||||||
|
this.queue.length = 0;
|
||||||
|
return drained;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> | 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<void> {
|
||||||
|
if (!this.loopPromise) {
|
||||||
|
this.loopPromise = this.runLoop();
|
||||||
|
}
|
||||||
|
return this.loopPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(reason?: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const commands = await this.controlQueue.drain();
|
||||||
|
for (const command of commands) {
|
||||||
|
await this.handleCommand(command);
|
||||||
|
if (this.stopping) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForResume(): Promise<void> {
|
||||||
|
const command = await this.controlQueue.waitUntil(null);
|
||||||
|
if (command) {
|
||||||
|
await this.handleCommand(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCommand(command: TurnDaemonCommand): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnSchedule {
|
||||||
|
getNextTurnTime(lastTurnTime: Date): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnProcessor {
|
||||||
|
run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise<TurnRunResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnStateStore {
|
||||||
|
loadLastTurnTime(): Promise<Date>;
|
||||||
|
saveLastTurnTime(turnTime: Date): Promise<void>;
|
||||||
|
loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
|
||||||
|
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnDaemonControlQueue {
|
||||||
|
enqueue(command: TurnDaemonCommand): void;
|
||||||
|
drain(): Promise<TurnDaemonCommand[]>;
|
||||||
|
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>;
|
||||||
|
getDepth(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnDaemonHooks {
|
||||||
|
flushChanges?(result: TurnRunResult): Promise<void>;
|
||||||
|
publishEvents?(result: TurnRunResult): Promise<void>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user