diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index c5fcc1d..59316fa 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -1,6 +1,6 @@ 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'; +export * from './lifecycle/getNextTickTime.js'; export * from './scenario/scenarioLoader.js'; diff --git a/app/game-engine/src/lifecycle/getNextTickTime.ts b/app/game-engine/src/lifecycle/getNextTickTime.ts new file mode 100644 index 0000000..bc839a7 --- /dev/null +++ b/app/game-engine/src/lifecycle/getNextTickTime.ts @@ -0,0 +1,18 @@ +const MINUTES_TO_MS = 60_000; + +const getCutTurnBase = (time: Date): Date => + new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0); + +export const getNextTickTime = (lastTurnTime: Date, turnTermMinutes: number): Date => { + if (turnTermMinutes <= 0) { + throw new Error('turnTermMinutes must be positive'); + } + + // 월 기준 턴 그리드에 맞춰 다음 틱 경계를 계산한다. + const base = getCutTurnBase(lastTurnTime); + const elapsedMinutes = Math.floor( + (lastTurnTime.getTime() - base.getTime()) / MINUTES_TO_MS + ); + const alignedMinutes = elapsedMinutes - (elapsedMinutes % turnTermMinutes); + return new Date(base.getTime() + (alignedMinutes + turnTermMinutes) * MINUTES_TO_MS); +}; diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 8b2ded2..caa8301 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -6,7 +6,7 @@ import type { TurnDaemonStatus, TurnRunBudget, TurnRunResult, - TurnSchedule, + NextTickTimeResolver, TurnStateStore, TurnProcessor, Clock, @@ -27,7 +27,7 @@ export interface TurnDaemonLifecycleOptions { export interface TurnDaemonLifecycleDeps { clock: Clock; controlQueue: TurnDaemonControlQueue; - schedule: TurnSchedule; + getNextTickTime: NextTickTimeResolver; stateStore: TurnStateStore; processor: TurnProcessor; hooks?: TurnDaemonHooks; @@ -37,7 +37,7 @@ export class TurnDaemonLifecycle { // 턴 데몬의 생명주기를 관리하는 루프. private readonly clock: Clock; private readonly controlQueue: TurnDaemonControlQueue; - private readonly schedule: TurnSchedule; + private readonly getNextTickTime: NextTickTimeResolver; private readonly stateStore: TurnStateStore; private readonly processor: TurnProcessor; private readonly hooks?: TurnDaemonHooks; @@ -51,7 +51,7 @@ export class TurnDaemonLifecycle { constructor(deps: TurnDaemonLifecycleDeps, options: TurnDaemonLifecycleOptions) { this.clock = deps.clock; this.controlQueue = deps.controlQueue; - this.schedule = deps.schedule; + this.getNextTickTime = deps.getNextTickTime; this.stateStore = deps.stateStore; this.processor = deps.processor; this.hooks = deps.hooks; @@ -120,16 +120,16 @@ export class TurnDaemonLifecycle { continue; } - const nextTurnTime = this.getNextTurnTime(); - if (!nextTurnTime) { + const nextRunTime = await this.resolveNextRunTime(); + if (!nextRunTime) { await this.clock.sleepMs(200); continue; } const nowMs = this.clock.nowMs(); - const nextTurnMs = nextTurnTime.getTime(); + const nextTurnMs = nextRunTime.getTime(); if (nowMs >= nextTurnMs) { - await this.runOnce({ reason: 'schedule', targetTime: nextTurnTime }); + await this.runOnce({ reason: 'schedule', targetTime: nextRunTime }); continue; } @@ -145,14 +145,25 @@ export class TurnDaemonLifecycle { const checkpoint = await this.stateStore.loadCheckpoint(); this.status.lastTurnTime = lastTurnTime.toISOString(); this.status.checkpoint = checkpoint; - this.status.nextTurnTime = this.schedule.getNextTurnTime(lastTurnTime).toISOString(); + await this.resolveNextRunTime(); } - private getNextTurnTime(): Date | null { + private async resolveNextRunTime(): Promise { if (!this.status.lastTurnTime) { + this.status.nextTurnTime = undefined; return null; } - return this.schedule.getNextTurnTime(new Date(this.status.lastTurnTime)); + + const lastTurnTime = new Date(this.status.lastTurnTime); + const nextGeneralTurnTime = await this.stateStore.loadNextGeneralTurnTime(); + const nextTickTime = this.getNextTickTime(lastTurnTime); + // 가장 빠른 장수 턴과 현재 틱 경계 중 먼저 오는 시각을 선택한다. + const nextTurnTime = nextGeneralTurnTime && nextGeneralTurnTime.getTime() <= nextTickTime.getTime() + ? nextGeneralTurnTime + : nextTickTime; + + this.status.nextTurnTime = nextTurnTime.toISOString(); + return nextTurnTime; } private async drainCommands(): Promise { @@ -219,16 +230,15 @@ export class TurnDaemonLifecycle { await this.stateStore.saveCheckpoint(result.checkpoint); await this.hooks?.flushChanges?.(result); await this.hooks?.publishEvents?.(result); - this.applyRunResult(result, startMs); + await this.applyRunResult(result, startMs); this.status.state = 'idle'; } - private applyRunResult(result: TurnRunResult, startMs: number): void { + private async applyRunResult(result: TurnRunResult, startMs: number): Promise { 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(); + await this.resolveNextRunTime(); } } diff --git a/app/game-engine/src/lifecycle/turnSchedule.ts b/app/game-engine/src/lifecycle/turnSchedule.ts deleted file mode 100644 index 22bb863..0000000 --- a/app/game-engine/src/lifecycle/turnSchedule.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 index b564d88..58fd90b 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -45,9 +45,7 @@ export type TurnDaemonCommand = export type { Clock } from '@sammo-ts/common'; -export interface TurnSchedule { - getNextTurnTime(lastTurnTime: Date): Date; -} +export type NextTickTimeResolver = (lastTurnTime: Date) => Date; export interface TurnProcessor { run(targetTime: Date, budget: TurnRunBudget, checkpoint?: TurnCheckpoint): Promise; @@ -55,6 +53,8 @@ export interface TurnProcessor { export interface TurnStateStore { loadLastTurnTime(): Promise; + // 월드에서 관리하는 턴 대기열의 선두(가장 이른 장수 턴 시간)를 조회한다. + loadNextGeneralTurnTime(): Promise; saveLastTurnTime(turnTime: Date): Promise; loadCheckpoint(): Promise; saveCheckpoint(checkpoint?: TurnCheckpoint): Promise; diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index 22bf7b7..8a0a8c1 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -1,25 +1,53 @@ import { describe, expect, it, vi } from 'vitest'; import { - FixedIntervalSchedule, InMemoryControlQueue, ManualClock, TurnDaemonLifecycle, + getNextTickTime, type TurnProcessor, type TurnRunResult, type TurnStateStore, } from '../src/index.js'; -describe('TurnDaemonLifecycle', () => { - it('runs once when requestRun is enqueued', async () => { - const clock = new ManualClock(0); - const controlQueue = new InMemoryControlQueue(); - const schedule = new FixedIntervalSchedule(1000); +const addMinutes = (time: Date, minutes: number): Date => + new Date(time.getTime() + minutes * 60_000); +describe('TurnDaemonLifecycle', () => { + it('runs scheduled turn based on queue front and checkpoint context', async () => { + const turnTermMinutes = 10; + const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0); + const generalTurnQueue = [ + addMinutes(lastTurnTime, 5), + addMinutes(lastTurnTime, 20), + ]; + const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes); + const expectedRunTimeMs = Math.min( + nextTickTime.getTime(), + generalTurnQueue[0]!.getTime() + ); + const checkpoint = { + turnTime: lastTurnTime.toISOString(), + generalId: 101, + year: 203, + month: 4, + }; + const clock = new ManualClock(addMinutes(lastTurnTime, 30).getTime()); + const controlQueue = new InMemoryControlQueue(); + const getNextTickTimeResolver = (currentLastTurnTime: Date) => + getNextTickTime(currentLastTurnTime, turnTermMinutes); + + let hasRun = false; const stateStore: TurnStateStore = { - loadLastTurnTime: async () => new Date(0), + loadLastTurnTime: async () => new Date(lastTurnTime.getTime()), + loadNextGeneralTurnTime: async () => { + if (hasRun) { + return null; + } + return generalTurnQueue[0] ? new Date(generalTurnQueue[0].getTime()) : null; + }, saveLastTurnTime: async () => {}, - loadCheckpoint: async () => undefined, + loadCheckpoint: async () => checkpoint, saveCheckpoint: async () => {}, }; @@ -28,28 +56,30 @@ describe('TurnDaemonLifecycle', () => { resolveRun = resolve; }); const processor: TurnProcessor = { - run: vi.fn(async (): Promise => { + run: vi.fn(async (targetTime): Promise => { resolveRun?.(); + hasRun = true; return { - lastTurnTime: new Date(0).toISOString(), - processedGenerals: 0, + lastTurnTime: targetTime.toISOString(), + processedGenerals: 2, processedTurns: 1, durationMs: 0, partial: false, + checkpoint, }; }), }; + const budget = { budgetMs: 1000, maxGenerals: 10, catchUpCap: 1 }; const lifecycle = new TurnDaemonLifecycle( - { clock, controlQueue, schedule, stateStore, processor }, + { clock, controlQueue, getNextTickTime: getNextTickTimeResolver, stateStore, processor }, { profile: 'test', - defaultBudget: { budgetMs: 1000, maxGenerals: 10, catchUpCap: 1 }, + defaultBudget: budget, }, ); const loop = lifecycle.start(); - lifecycle.requestRun('manual'); await Promise.race([ runCalled, new Promise((_, reject) => { @@ -58,6 +88,95 @@ describe('TurnDaemonLifecycle', () => { ]); expect(processor.run).toHaveBeenCalledTimes(1); + const runMock = processor.run as ReturnType; + const [targetTime, budgetArg, checkpointArg] = runMock.mock.calls[0] ?? []; + expect((targetTime as Date).getTime()).toBe(expectedRunTimeMs); + expect(budgetArg).toEqual(budget); + expect(checkpointArg).toEqual(checkpoint); + + await lifecycle.stop('test done'); + await loop; + }); + + it('runs scheduled turn when tick boundary arrives before queue front', async () => { + const turnTermMinutes = 10; + const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0); + const generalTurnQueue = [ + addMinutes(lastTurnTime, 15), + addMinutes(lastTurnTime, 30), + ]; + const nextTickTime = getNextTickTime(lastTurnTime, turnTermMinutes); + const expectedRunTimeMs = Math.min( + nextTickTime.getTime(), + generalTurnQueue[0]!.getTime() + ); + const checkpoint = { + turnTime: lastTurnTime.toISOString(), + generalId: 102, + year: 203, + month: 4, + }; + const clock = new ManualClock(addMinutes(lastTurnTime, 30).getTime()); + const controlQueue = new InMemoryControlQueue(); + const getNextTickTimeResolver = (currentLastTurnTime: Date) => + getNextTickTime(currentLastTurnTime, turnTermMinutes); + + let hasRun = false; + const stateStore: TurnStateStore = { + loadLastTurnTime: async () => new Date(lastTurnTime.getTime()), + loadNextGeneralTurnTime: async () => { + if (hasRun) { + return null; + } + return generalTurnQueue[0] ? new Date(generalTurnQueue[0].getTime()) : null; + }, + saveLastTurnTime: async () => {}, + loadCheckpoint: async () => checkpoint, + saveCheckpoint: async () => {}, + }; + + let resolveRun: (() => void) | null = null; + const runCalled = new Promise((resolve) => { + resolveRun = resolve; + }); + const processor: TurnProcessor = { + run: vi.fn(async (targetTime): Promise => { + resolveRun?.(); + hasRun = true; + return { + lastTurnTime: targetTime.toISOString(), + processedGenerals: 2, + processedTurns: 1, + durationMs: 0, + partial: false, + checkpoint, + }; + }), + }; + + const budget = { budgetMs: 1000, maxGenerals: 10, catchUpCap: 1 }; + const lifecycle = new TurnDaemonLifecycle( + { clock, controlQueue, getNextTickTime: getNextTickTimeResolver, stateStore, processor }, + { + profile: 'test', + defaultBudget: budget, + }, + ); + + const loop = lifecycle.start(); + await Promise.race([ + runCalled, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('run was not called')), 50); + }), + ]); + + expect(processor.run).toHaveBeenCalledTimes(1); + const runMock = processor.run as ReturnType; + const [targetTime, budgetArg, checkpointArg] = runMock.mock.calls[0] ?? []; + expect((targetTime as Date).getTime()).toBe(expectedRunTimeMs); + expect(budgetArg).toEqual(budget); + expect(checkpointArg).toEqual(checkpoint); await lifecycle.stop('test done'); await loop;