refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현

This commit is contained in:
2026-09-06 10:04:33 +00:00
parent 63da0921cb
commit d3443ec7f5
53 changed files with 1467 additions and 160 deletions
+3
View File
@@ -70,6 +70,9 @@ type WorldClockFields =
| 'clockTick' | 'clockTick'
| 'clockMode' | 'clockMode'
| 'clockWallAnchor' | 'clockWallAnchor'
| 'clockRecoveryStartTick'
| 'clockRecoveryEndTick'
| 'clockRecoveryStartWallAt'
| 'lastTurnTick' | 'lastTurnTick'
| 'clockPhase' | 'clockPhase'
| 'clockRevision' | 'clockRevision'
+1
View File
@@ -74,6 +74,7 @@ export const lobbyRouter = router({
clockMode: gameTime.mode ?? 'realtime', clockMode: gameTime.mode ?? 'realtime',
clockRunning: gameTime.running, clockRunning: gameTime.running,
clockStartsAt: gameTime.startsAt?.toISOString() ?? null, clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
clockRecovery: gameTime.recovery ?? null,
turnEngineRunning, turnEngineRunning,
otherTextInfo: worldState.meta.otherTextInfo ?? '', otherTextInfo: worldState.meta.otherTextInfo ?? '',
npcMode: worldState.config.npcMode ?? 0, npcMode: worldState.config.npcMode ?? 0,
+25 -3
View File
@@ -1,5 +1,6 @@
import { import {
GameClock, GameClock,
readTurnRecovery,
inferClockPhase, inferClockPhase,
parseGameClockPhase, parseGameClockPhase,
type GameClockMode, type GameClockMode,
@@ -7,6 +8,7 @@ import {
} from '@sammo-ts/common'; } from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js'; import type { DatabaseClient } from '../context.js';
import { readTurnRuntimeReady } from '@sammo-ts/infra';
export interface CurrentGameTime { export interface CurrentGameTime {
now: Date; now: Date;
@@ -17,6 +19,8 @@ export interface CurrentGameTime {
revision?: number | null; revision?: number | null;
deadlineGeneration?: number | null; deadlineGeneration?: number | null;
running: boolean; running: boolean;
runtimeReady?: boolean;
recovery?: { startsAt: string; endsAt: string } | null;
startsAt: Date | null; startsAt: Date | null;
dateToTick(date: Date): number | null; dateToTick(date: Date): number | null;
} }
@@ -43,6 +47,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockTick: true, clockTick: true,
clockMode: true, clockMode: true,
clockWallAnchor: true, clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
tickSeconds: true, tickSeconds: true,
clockPhase: true, clockPhase: true,
clockRevision: true, clockRevision: true,
@@ -86,12 +93,18 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
tick: storedTick, tick: storedTick,
mode, mode,
wallAnchor: state.clockWallAnchor, wallAnchor: state.clockWallAnchor,
recovery: readTurnRecovery(state),
turnSeconds: state.tickSeconds, turnSeconds: state.tickSeconds,
phase, phase,
revision, revision,
}); });
const tick = clock.nowTick(wallNow); const runtimeReady =
const running = phase === 'RUNNING' && mode === 'realtime'; phase !== 'RUNNING' ||
mode !== 'realtime' ||
state.clockRecoveryStartTick === undefined ||
(await readTurnRuntimeReady(db, state.clockRevision));
const tick = runtimeReady ? clock.nowTick(wallNow) : clock.tick;
const running = runtimeReady && phase === 'RUNNING' && mode === 'realtime' && wallNow >= clock.wallAnchor;
return { return {
now: clock.tickToDate(tick), now: clock.tickToDate(tick),
wallNow, wallNow,
@@ -101,7 +114,16 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
revision, revision,
deadlineGeneration, deadlineGeneration,
running, running,
startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null, runtimeReady,
recovery:
clock.recovery && clock.tickToWallDate(clock.recovery.endTick) > wallNow
? {
startsAt: clock.recovery.startWallAt.toISOString(),
endsAt: clock.tickToWallDate(clock.recovery.endTick).toISOString(),
}
: null,
startsAt:
phase === 'PREOPEN' || (phase === 'RUNNING' && wallNow < clock.wallAnchor) ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date), dateToTick: (date) => clock.dateToTick(date),
}; };
}; };
@@ -39,6 +39,7 @@ const ensureRedisClockFence = async (
allowedPhases: readonly GameClockPhase[] allowedPhases: readonly GameClockPhase[]
): Promise<ActiveRedisClockFence | null> => { ): Promise<ActiveRedisClockFence | null> => {
if ( if (
gameTime.runtimeReady === false ||
!gameTime.phase || !gameTime.phase ||
!allowedPhases.includes(gameTime.phase) || !allowedPhases.includes(gameTime.phase) ||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') || (gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
@@ -68,6 +69,7 @@ export const ensureActiveRedisClockFence = async (
profileName: string, profileName: string,
gameTime: CurrentGameTime gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> => { ): Promise<ActiveRedisClockFence | null> => {
if (!gameTime.running) return null;
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']); return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
}; };
+35
View File
@@ -23,6 +23,41 @@ const buildDatabase = (
}) as unknown as DatabaseClient; }) as unknown as DatabaseClient;
describe('current game time projection', () => { describe('current game time projection', () => {
it('keeps independent workers frozen until this clock revision has a ready daemon', async () => {
const row = {
clockBaseTime: new Date('2026-09-06T00:00:00Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: new Date('2026-09-06T00:00:00Z'),
tickSeconds: 3600,
clockPhase: 'RUNNING',
clockRevision: 2n,
deadlineGeneration: 2n,
clockRecoveryStartTick: 0n,
clockRecoveryEndTick: 288_000_000n,
clockRecoveryStartWallAt: new Date('2026-09-06T04:00:00Z'),
};
let ready = false;
const db = {
worldState: { findFirst: vi.fn(async () => row) },
$queryRaw: vi.fn(async () => [{ ready }]),
} as unknown as DatabaseClient;
const now = new Date('2026-09-06T05:00:00Z');
expect(await loadCurrentGameTime(db, now)).toMatchObject({ tick: 0, running: false, runtimeReady: false });
ready = true;
expect(await loadCurrentGameTime(db, now)).toMatchObject({
tick: 72_000_000,
running: true,
runtimeReady: true,
recovery: { startsAt: '2026-09-06T04:00:00.000Z', endsAt: '2026-09-06T08:00:00.000Z' },
});
});
it('holds an invader restart until its future turn boundary', async () => {
const db = buildDatabase('realtime', 'RUNNING');
const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z'));
expect(result).toMatchObject({ tick: 0, running: false, startsAt: new Date('2026-08-21T11:00:00Z') });
});
it('projects negative realtime ticks until the future opening anchor', async () => { it('projects negative realtime ticks until the future opening anchor', async () => {
const db = buildDatabase(); const db = buildDatabase();
@@ -93,6 +93,7 @@ export class DatabaseTurnDaemonLease {
ON CONFLICT ("profile") DO UPDATE ON CONFLICT ("profile") DO UPDATE
SET SET
"owner_id" = EXCLUDED."owner_id", "owner_id" = EXCLUDED."owner_id",
"clock_ready" = FALSE,
"lease_until" = EXCLUDED."lease_until", "lease_until" = EXCLUDED."lease_until",
"fencing_epoch" = CASE "fencing_epoch" = CASE
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id" WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
@@ -126,6 +127,14 @@ export class DatabaseTurnDaemonLease {
return this.token ? { ...this.token } : null; return this.token ? { ...this.token } : null;
} }
async markClockReady(): Promise<void> {
await this.db.$transaction(async (db) => {
await this.assertActive(db);
const token = this.getToken()!;
await db.turnDaemonLease.update({ where: { profile: token.profile }, data: { clockReady: true } });
});
}
isLost(): boolean { isLost(): boolean {
return this.lost; return this.lost;
} }
@@ -170,6 +170,11 @@ export class TurnDaemonLifecycle {
await this.clock.sleepMs(500); await this.clock.sleepMs(500);
continue; continue;
} }
if (gameClock?.mode === 'realtime' && gameClock.startsAt && wallNow < gameClock.startsAt) {
this.status.nextTurnTime = gameClock.startsAt.toISOString();
await this.clock.sleepMs(Math.min(500, gameClock.startsAt.getTime() - nowMs));
continue;
}
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다. // 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다. // 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) { if (this.pendingRun) {
@@ -219,7 +224,10 @@ export class TurnDaemonLifecycle {
continue; continue;
} }
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs)); const wallDeadline = await this.stateStore.projectGameDeadline?.(nextRunTime);
const command = await this.controlQueue.waitFor(
Math.max(0, wallDeadline ? wallDeadline.getTime() - nowMs : nextTurnMs - gameNowMs)
);
if (command) { if (command) {
await this.handleCommand(command); await this.handleCommand(command);
} }
+2
View File
@@ -72,11 +72,13 @@ export interface TurnStateStore {
phase?: GameClockPhase; phase?: GameClockPhase;
revision?: number; revision?: number;
deadlineGeneration?: number; deadlineGeneration?: number;
startsAt?: Date;
}>; }>;
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>; promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>; shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>; rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>; advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
projectGameDeadline?(gameTime: Date): Promise<Date>;
} }
export interface TurnDaemonControlQueue { export interface TurnDaemonControlQueue {
@@ -571,7 +571,10 @@ const cancelGameInTransaction = async (
return { ...resultFromPersisted(created), alreadyApplied: false }; return { ...resultFromPersisted(created), alreadyApplied: false };
}; };
export const cancelGame = async (request: GameCancellationRequest): Promise<GameCancellationResult> => { export const cancelGame = async (
request: GameCancellationRequest,
connectorFactory: typeof createGamePostgresConnector = createGamePostgresConnector
): Promise<GameCancellationResult> => {
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.'); if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.'); if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.'); if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
@@ -580,7 +583,7 @@ export const cancelGame = async (request: GameCancellationRequest): Promise<Game
earnedPoint: 0, earnedPoint: 0,
earnedPointRetentionPercent: request.earnedPointRetentionPercent, earnedPointRetentionPercent: request.earnedPointRetentionPercent,
}); });
const connector = createGamePostgresConnector({ url: request.databaseUrl }); const connector = connectorFactory({ url: request.databaseUrl });
await connector.connect(); await connector.connect();
try { try {
return await connector.prisma.$transaction( return await connector.prisma.$transaction(
+12 -4
View File
@@ -68,6 +68,8 @@ export interface ScenarioSeedOptions {
generalPoolOptions?: GeneralPoolLoaderOptions; generalPoolOptions?: GeneralPoolLoaderOptions;
resetTables?: boolean; resetTables?: boolean;
now?: Date; now?: Date;
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
wallNow?: Date;
tickSeconds?: number; tickSeconds?: number;
gameClockMode?: GameClockMode; gameClockMode?: GameClockMode;
installOptions?: ScenarioInstallOptions; installOptions?: ScenarioInstallOptions;
@@ -244,8 +246,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const gameClockMode = options.gameClockMode ?? 'realtime'; const gameClockMode = options.gameClockMode ?? 'realtime';
// A realtime season prepared before its formal opening must not consume // A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands. // wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now; const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor); const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
const openingFloor = cutTurn(requestedOpening, turnTermMinutes);
const initialClockWallAnchor =
gameClockMode === 'manual'
? requestedOpening
: new Date(openingFloor.getTime() + (openingFloor < requestedOpening ? tickSeconds * 1_000 : 0));
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
const initialClock = new GameClock({ const initialClock = new GameClock({
baseTime: startState.startTime, baseTime: startState.startTime,
tick: 0, tick: 0,
@@ -317,7 +325,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2, develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime), starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now), turntime: formatDateTime(now),
opentime: formatDateTime(now), opentime: formatDateTime(initialClockWallAnchor),
lastTurnTime: formatDateTime(now), lastTurnTime: formatDateTime(now),
}; };
@@ -344,7 +352,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
} }
worldMeta.hiddenSeed = hiddenSeed; worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.toISOString(); worldMeta.seededAtWall = wallNow.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString(); worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString(); worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString(); worldMeta.calendarStart = startState.startTime.toISOString();
@@ -332,7 +332,7 @@ const respondToRaiseInvader = async (options: {
reservedTurns, reservedTurns,
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()), env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
loadArchivedNationMaxId: options.loadArchivedNationMaxId, loadArchivedNationMaxId: options.loadArchivedNationMaxId,
clockWallNow: alignment.resumeWallAt, clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt,
}); });
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} }; const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
await handler( await handler(
+103 -6
View File
@@ -7,6 +7,10 @@ import {
buildClockAlignmentPlan, buildClockAlignmentPlan,
parseClockAlignmentPolicy, parseClockAlignmentPolicy,
parseGameClockPhase, parseGameClockPhase,
readTurnRecovery,
readSerializedTurnRecovery,
serializeTurnRecovery,
type TurnRecoveryWindow,
type ClockAlignmentPolicy, type ClockAlignmentPolicy,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
import { import {
@@ -43,6 +47,8 @@ export interface ClockReconciliationResult {
shiftTicks: number; shiftTicks: number;
alignedTick: number; alignedTick: number;
resumeWallAt: Date; resumeWallAt: Date;
recovery?: TurnRecoveryWindow | null;
resumeAnchor?: Date;
} }
interface DbWallRow { interface DbWallRow {
@@ -213,6 +219,10 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigi
`); `);
}; };
// Gateway는 아직 배포하지 않은 profile의 Prisma 모델로 기존 시계 프로토콜을 처리한다.
const supportsRecoveryColumns = (db: GamePrisma.TransactionClient): boolean =>
Boolean(db.worldState.fields?.clockRecoveryStartTick);
const readParticipantSnapshots = async ( const readParticipantSnapshots = async (
db: GamePrisma.TransactionClient, db: GamePrisma.TransactionClient,
worldStateId: number, worldStateId: number,
@@ -224,6 +234,13 @@ const readParticipantSnapshots = async (
where: { id: worldStateId }, where: { id: worldStateId },
select: { select: {
clockTick: true, clockTick: true,
...(supportsRecoveryColumns(db)
? {
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
}
: {}),
clockRevision: true, clockRevision: true,
deadlineGeneration: true, deadlineGeneration: true,
lastTurnTick: true, lastTurnTick: true,
@@ -290,6 +307,7 @@ const readParticipantSnapshots = async (
snapshot('world-clock', 'REBUILD', [ snapshot('world-clock', 'REBUILD', [
{ {
clockTick: world.clockTick, clockTick: world.clockTick,
...serializeTurnRecovery(readTurnRecovery(world)),
clockRevision: world.clockRevision, clockRevision: world.clockRevision,
deadlineGeneration: world.deadlineGeneration, deadlineGeneration: world.deadlineGeneration,
}, },
@@ -431,6 +449,7 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
cutWallAt: Date; cutWallAt: Date;
rateTicksPerSecond: number; rateTicksPerSecond: number;
sourceRevision: number; sourceRevision: number;
normalTickAtCutWall?: number;
policy?: ClockAlignmentPolicy; policy?: ClockAlignmentPolicy;
catchUpTicks?: number; catchUpTicks?: number;
}): Promise<void> => { }): Promise<void> => {
@@ -475,7 +494,11 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
rateTicksPerSecond: options.rateTicksPerSecond, rateTicksPerSecond: options.rateTicksPerSecond,
catchUpTicks: BigInt(catchUpTicks), catchUpTicks: BigInt(catchUpTicks),
participantChecksumBefore: aggregateChecksum(participants), participantChecksumBefore: aggregateChecksum(participants),
detail: asJson({ authority: 'DAEMON', profileName: options.profileName }), detail: asJson({
authority: 'DAEMON',
profileName: options.profileName,
normalTickAtCutWall: Math.max(options.cutTick, options.normalTickAtCutWall ?? options.cutTick),
}),
}, },
}); });
await persistInitialParticipants(options.db, options.suspensionId, participants); await persistInitialParticipants(options.db, options.suspensionId, participants);
@@ -712,6 +735,7 @@ export const startClockSuspension = async (options: {
authority: ClockOperationAuthority; authority: ClockOperationAuthority;
policy?: ClockAlignmentPolicy; policy?: ClockAlignmentPolicy;
catchUpTicks?: number; catchUpTicks?: number;
recoverDurableObservation?: boolean;
}): Promise<ClockSuspensionResult> => { }): Promise<ClockSuspensionResult> => {
if (!options.suspensionId.trim() || options.suspensionId.length > 64) { if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
throw new Error('Clock suspension ID must contain 1-64 characters.'); throw new Error('Clock suspension ID must contain 1-64 characters.');
@@ -761,7 +785,13 @@ export const startClockSuspension = async (options: {
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) { if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
throw new Error('Clock suspension requires a fully initialized logical game clock.'); throw new Error('Clock suspension requires a fully initialized logical game clock.');
} }
const cutWallAt = await readDbWall(db); if (
options.recoverDurableObservation &&
(options.authority.kind !== 'DAEMON' || options.source !== 'RECOVERY' || policy !== 'RECOVER_TURNS')
) {
throw new Error('Only daemon outage recovery may use the durable observation.');
}
const cutWallAt = options.recoverDurableObservation ? world.clockWallAnchor : await readDbWall(db);
const storedTick = safeNumber(world.clockTick, 'world clock tick'); const storedTick = safeNumber(world.clockTick, 'world clock tick');
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision'); const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
const clock = new GameClock({ const clock = new GameClock({
@@ -769,11 +799,12 @@ export const startClockSuspension = async (options: {
tick: storedTick, tick: storedTick,
mode: world.clockMode === 'manual' ? 'manual' : 'realtime', mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: world.clockWallAnchor, wallAnchor: world.clockWallAnchor,
recovery: readTurnRecovery(world),
turnSeconds: world.tickSeconds, turnSeconds: world.tickSeconds,
phase, phase,
revision: sourceRevision, revision: sourceRevision,
}); });
const cutTick = clock.nowTick(cutWallAt); const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt);
await lockParticipants(db, BigInt(cutTick)); await lockParticipants(db, BigInt(cutTick));
await db.worldState.update({ await db.worldState.update({
where: { id: worldStateId }, where: { id: worldStateId },
@@ -797,6 +828,7 @@ export const startClockSuspension = async (options: {
detail: asJson({ detail: asJson({
authority: options.authority.kind, authority: options.authority.kind,
profileName: options.authority.profileName, profileName: options.authority.profileName,
normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)),
}), }),
}, },
}); });
@@ -827,6 +859,7 @@ export const reconcileClockSuspensionInTransaction = async (options: {
authority?: ClockOperationAuthority; authority?: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */ /** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date; testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> => { }): Promise<ClockReconciliationResult> => {
const db = options.db; const db = options.db;
const worldStateId = await lockWorld(db); const worldStateId = await lockWorld(db);
@@ -855,6 +888,14 @@ export const reconcileClockSuspensionInTransaction = async (options: {
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'), shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'), alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
resumeWallAt: suspension.resumeWallAt, resumeWallAt: suspension.resumeWallAt,
recovery: readSerializedTurnRecovery(suspension.detail),
resumeAnchor:
suspension.detail &&
typeof suspension.detail === 'object' &&
!Array.isArray(suspension.detail) &&
typeof suspension.detail.resumeAnchor === 'string'
? new Date(suspension.detail.resumeAnchor)
: (world.clockWallAnchor ?? suspension.resumeWallAt),
}; };
} }
if (suspension.status !== 'SUSPENDED') { if (suspension.status !== 'SUSPENDED') {
@@ -882,14 +923,41 @@ export const reconcileClockSuspensionInTransaction = async (options: {
throw new Error('A clock reconciliation wall override is allowed only in tests.'); throw new Error('A clock reconciliation wall override is allowed only in tests.');
} }
const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db); const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db);
const legacyUnificationWait = suspension.source === 'UNIFICATION_WAIT' && suspension.policy !== 'TURN_BOUNDARY';
const upgradeMaintenance =
options.upgradeMaintenancePolicy === true &&
supportsRecoveryColumns(db) &&
suspension.source === 'MAINTENANCE' &&
suspension.policy !== 'RECOVER_TURNS';
const effectivePolicy = legacyUnificationWait
? 'TURN_BOUNDARY'
: upgradeMaintenance
? 'RECOVER_TURNS'
: parseClockAlignmentPolicy(suspension.policy);
const alignmentCutTick = legacyUnificationWait
? safeNumber(world.lastTurnTick!, 'unification execution boundary')
: cutTick;
const plan = buildClockAlignmentPlan({ const plan = buildClockAlignmentPlan({
policy: parseClockAlignmentPolicy(suspension.policy), policy: effectivePolicy,
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'), sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
cutTick, cutTick: alignmentCutTick,
cutWall: suspension.cutWallAt, cutWall: suspension.cutWallAt,
resumeWall: resumeWallAt, resumeWall: resumeWallAt,
ticksPerSecond: suspension.rateTicksPerSecond, ticksPerSecond: suspension.rateTicksPerSecond,
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'), catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
normalTick: (() => {
const detail =
suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
? suspension.detail
: {};
const normalAtCut = typeof detail.normalTickAtCutWall === 'number' ? detail.normalTickAtCutWall : cutTick;
return (
normalAtCut +
Math.trunc(
((resumeWallAt.getTime() - suspension.cutWallAt.getTime()) * suspension.rateTicksPerSecond) / 1_000
)
);
})(),
}); });
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick); const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
assertShiftFits(before, plan.shiftTicks); assertShiftFits(before, plan.shiftTicks);
@@ -908,8 +976,21 @@ export const reconcileClockSuspensionInTransaction = async (options: {
targetGeneration, targetGeneration,
BigInt(plan.shiftTicks), BigInt(plan.shiftTicks),
projectionDeltaMilliseconds, projectionDeltaMilliseconds,
resumeWallAt plan.resumeAnchor ?? resumeWallAt
); );
if (supportsRecoveryColumns(db)) {
await db.worldState.update({
where: { id: worldStateId },
data: {
clockRecoveryStartTick: plan.recovery ? BigInt(plan.recovery.startTick) : null,
clockRecoveryEndTick: plan.recovery ? BigInt(plan.recovery.endTick) : null,
clockRecoveryStartWallAt: plan.recovery?.startWallAt ?? null,
},
});
} else if (plan.recovery) {
throw new Error('Turn recovery requires an upgraded profile schema and Prisma client.');
}
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick); const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
const afterByKey = new Map(after.map((participant) => [participant.key, participant])); const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
for (const participant of before) { for (const participant of before) {
@@ -967,8 +1048,20 @@ export const reconcileClockSuspensionInTransaction = async (options: {
where: { id: suspension.id }, where: { id: suspension.id },
data: { data: {
status: 'RECONCILING', status: 'RECONCILING',
...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}),
resumeWallAt, resumeWallAt,
gapTicks: BigInt(plan.gapTicks), gapTicks: BigInt(plan.gapTicks),
catchUpTicks: BigInt(plan.catchUpTicks),
detail: asJson({
...(suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
? suspension.detail
: {}),
...serializeTurnRecovery(plan.recovery ?? null),
resumeAnchor: (plan.resumeAnchor ?? resumeWallAt).toISOString(),
...(legacyUnificationWait || upgradeMaintenance
? { previousPolicy: suspension.policy, executionBoundaryTick: alignmentCutTick }
: {}),
}),
shiftTicks: BigInt(plan.shiftTicks), shiftTicks: BigInt(plan.shiftTicks),
alignedTick: BigInt(plan.alignedTick), alignedTick: BigInt(plan.alignedTick),
participantChecksumBefore: aggregateChecksum(before), participantChecksumBefore: aggregateChecksum(before),
@@ -986,6 +1079,8 @@ export const reconcileClockSuspensionInTransaction = async (options: {
shiftTicks: plan.shiftTicks, shiftTicks: plan.shiftTicks,
alignedTick: plan.alignedTick, alignedTick: plan.alignedTick,
resumeWallAt, resumeWallAt,
recovery: plan.recovery ?? null,
resumeAnchor: plan.resumeAnchor ?? resumeWallAt,
}; };
}; };
@@ -1024,6 +1119,7 @@ export const reconcileClockSuspension = async (options: {
authority: ClockOperationAuthority; authority: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */ /** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date; testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> => }): Promise<ClockReconciliationResult> =>
runSerializableClockOperation(() => runSerializableClockOperation(() =>
options.db.$transaction( options.db.$transaction(
@@ -1037,6 +1133,7 @@ export const reconcileClockSuspension = async (options: {
profileName: options.authority.profileName, profileName: options.authority.profileName,
authority: options.authority, authority: options.authority,
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}), ...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
upgradeMaintenancePolicy: options.upgradeMaintenancePolicy,
}); });
}, },
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 } { isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
+30 -1
View File
@@ -65,6 +65,7 @@ import {
} from './clockReconciliation.js'; } from './clockReconciliation.js';
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js'; import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js'; import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks { export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks; hooks: TurnDaemonHooks;
@@ -73,6 +74,7 @@ export interface DatabaseTurnHooks {
close(): Promise<void>; close(): Promise<void>;
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>; applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
synchronizeClockAuthority(): Promise<boolean>; synchronizeClockAuthority(): Promise<boolean>;
prepareRealtimeRecovery(options?: { paused?: boolean }): Promise<void>;
} }
export interface CommittedReadModelChangeReceipt { export interface CommittedReadModelChangeReceipt {
@@ -141,6 +143,9 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clockTick', 'clockTick',
'clock_tick', 'clock_tick',
'clockWallAnchor', 'clockWallAnchor',
'clockRecoveryStartTick',
'clockRecoveryEndTick',
'clockRecoveryStartWallAt',
'clock_wall_anchor', 'clock_wall_anchor',
'heartbeat', 'heartbeat',
'heartbeatAt', 'heartbeatAt',
@@ -1139,6 +1144,9 @@ export const createDatabaseTurnHooks = async (
clockTick: BigInt(state.clockTick ?? 0), clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'manual', clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime, clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
clockRecoveryStartTick: state.clockRecovery ? BigInt(state.clockRecovery.startTick) : null,
clockRecoveryEndTick: state.clockRecovery ? BigInt(state.clockRecovery.endTick) : null,
clockRecoveryStartWallAt: state.clockRecovery?.startWallAt ?? null,
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)), lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'), clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
clockRevision: BigInt(state.clockRevision ?? 1), clockRevision: BigInt(state.clockRevision ?? 1),
@@ -1260,7 +1268,7 @@ export const createDatabaseTurnHooks = async (
} }
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null; const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
const unificationCutTick = unificationCutWallAt const unificationCutTick = unificationCutWallAt
? world.dateToGameTick(world.getGameNow(unificationCutWallAt)) ? (state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime))
: null; : null;
const suspensionPreparation = const suspensionPreparation =
unificationCutTick !== null unificationCutTick !== null
@@ -1909,7 +1917,9 @@ export const createDatabaseTurnHooks = async (
worldStateId: state.id, worldStateId: state.id,
profileName: options?.profileName ?? 'default', profileName: options?.profileName ?? 'default',
source: 'UNIFICATION_WAIT', source: 'UNIFICATION_WAIT',
policy: 'TURN_BOUNDARY',
cutTick, cutTick,
normalTickAtCutWall: world.getNormalGameTick(suspensionPreparation.cutWallAt),
cutWallAt: suspensionPreparation.cutWallAt, cutWallAt: suspensionPreparation.cutWallAt,
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds, rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
sourceRevision: state.clockRevision ?? 1, sourceRevision: state.clockRevision ?? 1,
@@ -2063,6 +2073,25 @@ export const createDatabaseTurnHooks = async (
}); });
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL'; return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
}, },
prepareRealtimeRecovery: async (recoveryOptions) => {
const token = options?.turnDaemonLease?.getToken();
if (!token) return;
await prepareRealtimeRecovery(
prisma,
{
kind: 'DAEMON',
profileName: token.profile,
ownerId: token.ownerId,
fencingEpoch: token.fencingEpoch,
},
recoveryOptions
);
await prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
}, transactionOptions);
},
synchronizeClockAuthority: () => synchronizeClockAuthority: () =>
prisma.$transaction(async (transaction) => { prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction); await options?.turnDaemonLease?.assertActive(transaction);
@@ -41,6 +41,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase']; phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase'];
revision: number; revision: number;
deadlineGeneration: number; deadlineGeneration: number;
startsAt: Date;
}> { }> {
const state = this.world.getGameClockState(); const state = this.world.getGameClockState();
return { return {
@@ -49,6 +50,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
phase: state.phase, phase: state.phase,
revision: state.revision, revision: state.revision,
deadlineGeneration: state.deadlineGeneration, deadlineGeneration: state.deadlineGeneration,
startsAt: state.wallAnchor,
}; };
} }
@@ -67,4 +69,8 @@ export class InMemoryTurnStateStore implements TurnStateStore {
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> { async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
this.world.advanceGameClockTo(target, wallNow); this.world.advanceGameClockTo(target, wallNow);
} }
async projectGameDeadline(gameTime: Date): Promise<Date> {
return this.world.projectGameDeadline(gameTime);
}
} }
+37 -12
View File
@@ -17,6 +17,7 @@ import {
inferClockPhase, inferClockPhase,
type GameClockMode, type GameClockMode,
type GameClockPhase, type GameClockPhase,
type TurnRecoveryWindow,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
import type { TurnCheckpoint } from '../lifecycle/types.js'; import type { TurnCheckpoint } from '../lifecycle/types.js';
@@ -129,6 +130,7 @@ export interface InMemoryGameClockState {
tick: number; tick: number;
mode: GameClockMode; mode: GameClockMode;
wallAnchor: Date; wallAnchor: Date;
recovery?: TurnRecoveryWindow | null;
lastTurnTick: number; lastTurnTick: number;
phase: GameClockPhase; phase: GameClockPhase;
revision: number; revision: number;
@@ -147,6 +149,8 @@ export interface DurableClockReconciliationAlignment {
alignedTick: number; alignedTick: number;
shiftTicks: number; shiftTicks: number;
resumeWallAt: Date; resumeWallAt: Date;
resumeAnchor?: Date;
recovery?: TurnRecoveryWindow | null;
} }
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle'; export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
@@ -654,6 +658,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0, tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
mode: this.state.clockMode ?? 'manual', mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
recovery: this.state.clockRecovery,
turnSeconds: this.state.tickSeconds, turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1, revision: this.state.clockRevision ?? 1,
@@ -684,6 +689,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? 0, tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual', mode: this.state.clockMode ?? 'manual',
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()), wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
recovery: this.state.clockRecovery ? structuredClone(this.state.clockRecovery) : null,
lastTurnTick: this.state.lastTurnTick ?? 0, lastTurnTick: this.state.lastTurnTick ?? 0,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1, revision: this.state.clockRevision ?? 1,
@@ -695,6 +701,15 @@ export class InMemoryTurnWorld {
return this.getGameClock().now(wallNow); return this.getGameClock().now(wallNow);
} }
projectGameDeadline(gameTime: Date): Date {
const clock = this.getGameClock();
return clock.tickToWallDate(clock.dateToTick(gameTime));
}
getNormalGameTick(wallNow: Date): number {
return this.getGameClock().normalNowTick(wallNow);
}
promotePreopenAtOpening(wallNow: Date): boolean { promotePreopenAtOpening(wallNow: Date): boolean {
const clock = this.getGameClock(); const clock = this.getGameClock();
if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) { if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) {
@@ -759,7 +774,8 @@ export class InMemoryTurnWorld {
this.state = { this.state = {
...this.state, ...this.state,
clockTick: input.alignedTick, clockTick: input.alignedTick,
clockWallAnchor: new Date(input.resumeWallAt.getTime()), clockWallAnchor: new Date((input.resumeAnchor ?? input.resumeWallAt).getTime()),
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
lastTurnTick, lastTurnTick,
lastTurnTime, lastTurnTime,
clockPhase: 'RECONCILING', clockPhase: 'RECONCILING',
@@ -865,6 +881,7 @@ export class InMemoryTurnWorld {
clockTick: input.tick, clockTick: input.tick,
clockMode: input.mode, clockMode: input.mode,
clockWallAnchor: new Date(input.wallAnchor.getTime()), clockWallAnchor: new Date(input.wallAnchor.getTime()),
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
clockPhase: input.phase, clockPhase: input.phase,
clockRevision: input.revision, clockRevision: input.revision,
deadlineGeneration: input.deadlineGeneration, deadlineGeneration: input.deadlineGeneration,
@@ -918,11 +935,11 @@ export class InMemoryTurnWorld {
skippedTurns: number; skippedTurns: number;
} | null { } | null {
const clock = this.getGameClock(); const clock = this.getGameClock();
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') { if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING' || clock.recovery) {
return null; return null;
} }
const currentTick = clock.nowTick(wallNow); const currentTick = clock.nowTick(wallNow);
const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow)); const wallAlignedTick = Math.max(currentTick, clock.normalNowTick(wallNow));
const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime); const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime);
// 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도 // 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도
// 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다. // 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다.
@@ -1181,6 +1198,9 @@ export class InMemoryTurnWorld {
lastTurnTime: this.state.lastTurnTime.toISOString(), lastTurnTime: this.state.lastTurnTime.toISOString(),
}; };
} }
if (previousClock.recovery && wallNow < previousClock.tickToWallDate(previousClock.recovery.endTick)) {
throw new Error('복구가 끝난 뒤 기본 턴 길이를 변경할 수 있습니다.');
}
const currentWallAnchor = this.state.clockWallAnchor ?? previousClock.wallAnchor; const currentWallAnchor = this.state.clockWallAnchor ?? previousClock.wallAnchor;
const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow; const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow;
const anchorTick = previousClock.nowTick(anchorWall); const anchorTick = previousClock.nowTick(anchorWall);
@@ -1201,6 +1221,7 @@ export class InMemoryTurnWorld {
this.state = { this.state = {
...this.state, ...this.state,
tickSeconds: nextTickSeconds, tickSeconds: nextTickSeconds,
clockRecovery: null,
clockBaseTime: nextBaseTime, clockBaseTime: nextBaseTime,
clockTick: anchorTick, clockTick: anchorTick,
clockWallAnchor: new Date(anchorWall.getTime()), clockWallAnchor: new Date(anchorWall.getTime()),
@@ -1670,10 +1691,13 @@ export class InMemoryTurnWorld {
}; };
} }
shiftSchedule(deltaMinutes: number, wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } { shiftSchedule(deltaMinutes: number, _wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) { if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) {
throw new Error('Schedule shift must be a non-zero integer number of minutes.'); throw new Error('Schedule shift must be a non-zero integer number of minutes.');
} }
if ((deltaMinutes * 60) % this.state.tickSeconds !== 0) {
throw new Error('일정 이동은 현재 턴 길이의 정수 배수여야 합니다.');
}
const deltaMs = deltaMinutes * 60_000; const deltaMs = deltaMinutes * 60_000;
const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs); const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs);
const previousClock = this.getGameClock(); const previousClock = this.getGameClock();
@@ -1694,6 +1718,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? 0, tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual', mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
recovery: this.state.clockRecovery,
turnSeconds: this.state.tickSeconds, turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1, revision: this.state.clockRevision ?? 1,
@@ -1709,14 +1734,14 @@ export class InMemoryTurnWorld {
this.state = { this.state = {
...this.state, ...this.state,
clockBaseTime: nextBaseTime, clockBaseTime: nextBaseTime,
// Rebasing is also the explicit resume checkpoint. Realtime mode // 명시적 이동은 저장 좌표와 실제 실행 anchor를 같은 정수 턴만큼 옮긴다.
// must not replay the operational downtime after an administrator clockWallAnchor: shiftDate(previousClock.wallAnchor),
// deliberately delays or accelerates the game schedule. clockRecovery: this.state.clockRecovery
// 가오픈의 anchor는 별도 예약된 정식 오픈이다. 표시 좌표를 옮기는 ? {
// 작업이 그 미래 경계를 현재 시각으로 당겨 게임을 시작시키면 안 된다. ...this.state.clockRecovery,
clockWallAnchor: new Date( startWallAt: shiftDate(this.state.clockRecovery.startWallAt),
previousClock.phase === 'PREOPEN' ? previousClock.wallAnchor.getTime() : wallNow.getTime() }
), : null,
lastTurnTime: nextLastTurnTime, lastTurnTime: nextLastTurnTime,
meta: nextMeta, meta: nextMeta,
}; };
@@ -0,0 +1,43 @@
import { randomUUID } from 'node:crypto';
import type { GamePrismaClient } from '@sammo-ts/infra';
import {
readClockDatabaseWall,
reconcileClockSuspension,
startClockSuspension,
type ClockOperationAuthority,
} from './clockReconciliation.js';
/** lease를 획득했지만 clock_ready를 공개하기 전, 중단된 복구 또는 새 정전을 처리한다. */
export const prepareRealtimeRecovery = async (
db: GamePrismaClient,
authority: Extract<ClockOperationAuthority, { kind: 'DAEMON' }>,
options: { paused?: boolean } = {}
): Promise<void> => {
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
if (world.clockMode !== 'realtime') return;
if (world.clockPhase === 'SUSPENDED') {
if (options.paused) return;
const pending = await db.clockSuspension.findFirst({
where: { worldStateId: world.id, source: 'RECOVERY', policy: 'RECOVER_TURNS', status: 'SUSPENDED' },
orderBy: { sourceRevision: 'desc' },
});
if (pending) await reconcileClockSuspension({ db, suspensionId: pending.id, authority });
return;
}
if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return;
const now = await readClockDatabaseWall(db);
// 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다.
// 한 턴 미만의 장애는 잔여 구간 실행만 필요하므로 새 좌표 세대를 만들지 않는다.
if (!options.paused && now.getTime() - world.clockWallAnchor.getTime() < world.tickSeconds * 1_000) return;
const suspensionId = `recovery-${randomUUID()}`;
await startClockSuspension({
db,
suspensionId,
source: 'RECOVERY',
policy: 'RECOVER_TURNS',
authority,
recoverDurableObservation: true,
});
// 이전 버전의 profile 상태만 PAUSED였던 경우에도 명시적 재개 전에는 실행하지 않는다.
if (!options.paused) await reconcileClockSuspension({ db, suspensionId, authority });
};
@@ -1,4 +1,4 @@
import { parseGameClockPhase } from '@sammo-ts/common'; import { parseGameClockPhase, readTurnRecovery, readSerializedTurnRecovery } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra'; import type { GamePrisma } from '@sammo-ts/infra';
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -28,6 +28,9 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
clockTick: true, clockTick: true,
clockMode: true, clockMode: true,
clockWallAnchor: true, clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
lastTurnTick: true, lastTurnTick: true,
clockPhase: true, clockPhase: true,
clockRevision: true, clockRevision: true,
@@ -67,6 +70,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
shiftTicks: true, shiftTicks: true,
alignedTick: true, alignedTick: true,
resumeWallAt: true, resumeWallAt: true,
detail: true,
}, },
}); });
let expectedRevision = before.revision; let expectedRevision = before.revision;
@@ -92,6 +96,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`), alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`), shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
resumeWallAt: ledger.resumeWallAt, resumeWallAt: ledger.resumeWallAt,
recovery: readSerializedTurnRecovery(ledger.detail),
}); });
expectedRevision = targetRevision; expectedRevision = targetRevision;
} }
@@ -108,6 +113,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
tick: safeNumber(durable.clockTick, 'durable clock tick'), tick: safeNumber(durable.clockTick, 'durable clock tick'),
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime', mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: durable.clockWallAnchor, wallAnchor: durable.clockWallAnchor,
recovery: readTurnRecovery(durable),
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'), lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
phase: parseGameClockPhase(durable.clockPhase), phase: parseGameClockPhase(durable.clockPhase),
revision: durableRevision, revision: durableRevision,
+14
View File
@@ -900,6 +900,17 @@ const createTurnDaemonRuntimeWithLease = async (
turnDaemonLease: turnDaemonLease ?? undefined, turnDaemonLease: turnDaemonLease ?? undefined,
transactionTimeoutMs: options.databaseTransactionTimeoutMs, transactionTimeoutMs: options.databaseTransactionTimeoutMs,
}); });
try {
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
} catch (error) {
await Promise.allSettled([
dbHooks.close(),
reservedTurnStoreHandle?.close(),
gatewayGate?.close(),
turnDaemonLease?.close(),
]);
throw error;
}
auctionBidder = await createAuctionBidder({ auctionBidder = await createAuctionBidder({
databaseUrl: options.databaseUrl, databaseUrl: options.databaseUrl,
world, world,
@@ -983,6 +994,9 @@ const createTurnDaemonRuntimeWithLease = async (
redisConnector = realtimeRuntime.redisConnector; redisConnector = realtimeRuntime.redisConnector;
hooks = realtimeRuntime.hooks; hooks = realtimeRuntime.hooks;
stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker; stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker;
// 복구 창이 DB에 저장되고 projection worker가 구성된 뒤에만 독립 worker를 허용한다.
// RECONCILING 상태이면 기존 revision/phase fence가 outbox 완료까지 계속 차단한다.
await turnDaemonLease?.markClockReady();
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null; const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null; const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
+2 -1
View File
@@ -11,7 +11,7 @@ import type {
WorldSnapshot, WorldSnapshot,
GeneralLastTurn, GeneralLastTurn,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common'; import type { GameClockMode, GameClockPhase, TurnRecoveryWindow } from '@sammo-ts/common';
export interface TurnWorldState { export interface TurnWorldState {
id: number; id: number;
@@ -23,6 +23,7 @@ export interface TurnWorldState {
clockTick?: number; clockTick?: number;
clockMode?: GameClockMode; clockMode?: GameClockMode;
clockWallAnchor?: Date; clockWallAnchor?: Date;
clockRecovery?: TurnRecoveryWindow | null;
lastTurnTick?: number; lastTurnTick?: number;
clockPhase?: GameClockPhase; clockPhase?: GameClockPhase;
clockRevision?: number; clockRevision?: number;
+6 -7
View File
@@ -28,6 +28,7 @@ import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/ite
import { z } from 'zod'; import { z } from 'zod';
import { import {
GameClock, GameClock,
readTurnRecovery,
asRecord, asRecord,
inferClockPhase, inferClockPhase,
isRecord, isRecord,
@@ -440,14 +441,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
worldState.clockWallAnchor !== null && worldState.clockWallAnchor !== null &&
worldState.lastTurnTick !== null; worldState.lastTurnTick !== null;
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual'; const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
const clockPhase = hasPersistedClock const clockPhase = hasPersistedClock ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(clockMode);
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(clockMode);
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision'); const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
const deadlineGeneration = toSafeTick( const deadlineGeneration = toSafeTick(worldState.deadlineGeneration, 'world_state.deadline_generation');
worldState.deadlineGeneration,
'world_state.deadline_generation'
);
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime; const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime; const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
const bootstrapClock = new GameClock({ const bootstrapClock = new GameClock({
@@ -455,6 +451,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
tick: 0, tick: 0,
mode: clockMode, mode: clockMode,
wallAnchor: clockWallAnchor, wallAnchor: clockWallAnchor,
recovery: readTurnRecovery(worldState),
turnSeconds: worldState.tickSeconds, turnSeconds: worldState.tickSeconds,
phase: clockPhase, phase: clockPhase,
revision: clockRevision, revision: clockRevision,
@@ -468,6 +465,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'), : toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
mode: clockMode, mode: clockMode,
wallAnchor: clockWallAnchor, wallAnchor: clockWallAnchor,
recovery: readTurnRecovery(worldState),
turnSeconds: worldState.tickSeconds, turnSeconds: worldState.tickSeconds,
phase: clockPhase, phase: clockPhase,
revision: clockRevision, revision: clockRevision,
@@ -537,6 +535,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
clockTick: gameClock.tick, clockTick: gameClock.tick,
clockMode, clockMode,
clockWallAnchor: gameClock.wallAnchor, clockWallAnchor: gameClock.wallAnchor,
clockRecovery: gameClock.recovery,
lastTurnTick, lastTurnTick,
clockPhase, clockPhase,
clockRevision, clockRevision,
@@ -1,8 +1,9 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { GameClock } from '@sammo-ts/common'; import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
readTurnRuntimeReady,
createRedisConnector, createRedisConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK, GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma, GamePrisma,
@@ -13,6 +14,8 @@ import {
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js'; import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js'; import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js';
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL; const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL); const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
@@ -62,6 +65,158 @@ describeIntegration('durable clock reconciliation', () => {
await clean(); await clean();
}); });
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
const profile = 'recovery-startup';
await db.worldState.create({
data: {
scenarioCode: profile,
currentYear: 199,
currentMonth: 4,
tickSeconds: 3600,
clockBaseTime: new Date('0199-01-01T00:00:00Z'),
clockTick: repeated ? BigInt(4 * T) : 0n,
clockMode: 'realtime',
clockWallAnchor: new Date(Date.now() - 4 * 3_600_000),
lastTurnTick: repeated ? BigInt(4 * T) : 0n,
...(repeated
? {
clockRecoveryStartTick: 0n,
clockRecoveryEndTick: BigInt(8 * T),
clockRecoveryStartWallAt: new Date(Date.now() - 6 * 3_600_000),
}
: {}),
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
try {
const token = await lease.acquire();
expect(token).not.toBeNull();
expect(await readTurnRuntimeReady(db, 1n)).toBe(false);
const authority = {
kind: 'DAEMON' as const,
profileName: profile,
ownerId: token!.ownerId,
fencingEpoch: token!.fencingEpoch,
};
if (!repeated) {
await prepareRealtimeRecovery(db, authority, { paused: true });
const paused = await db.worldState.findFirstOrThrow();
expect(paused.clockPhase).toBe('SUSPENDED');
expect(paused.clockTick).toBe(0n);
expect((await db.clockSuspension.findFirstOrThrow()).status).toBe('SUSPENDED');
await prepareRealtimeRecovery(db, authority, { paused: true });
expect((await db.worldState.findFirstOrThrow()).clockPhase).toBe('SUSPENDED');
}
await prepareRealtimeRecovery(db, authority);
const pending = await db.worldState.findFirstOrThrow();
expect(pending.clockPhase).toBe('RECONCILING');
const recoveredWindow = readTurnRecovery(pending)!;
expect(recoveredWindow).not.toBeNull();
expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 12 : 8) * T);
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
await applyNextClockProjection({ db, redis: redis.client, workerId: profile });
await lease.markClockReady();
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(true);
expect(await readTurnRuntimeReady(db, 1n)).toBe(false);
await lease.acquire();
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
await prepareRealtimeRecovery(db, {
kind: 'DAEMON',
profileName: profile,
ownerId: token!.ownerId,
fencingEpoch: token!.fencingEpoch,
});
const reloaded = await db.worldState.findFirstOrThrow();
expect(readTurnRecovery(reloaded)).toEqual(readTurnRecovery(pending));
expect(reloaded.clockRevision).toBe(pending.clockRevision);
} finally {
await lease.close();
}
});
it.each([4, 12, 13, 23, 24])(
'persists recovery for %i turns and reloads the same normal boundary',
async (turns) => {
const now = new Date();
await db.worldState.create({
data: {
scenarioCode: 'turn-recovery',
currentYear: 199,
currentMonth: 4,
tickSeconds: 3600,
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
clockTick: 0n,
clockMode: 'realtime',
clockWallAnchor: now,
lastTurnTick: 0n,
clockPhase: 'RUNNING',
clockRevision: 1n,
deadlineGeneration: 1n,
},
});
const generalTick = T + 199_020;
await db.general.create({
data: {
id: 26,
name: '냥냥',
turnTick: BigInt(generalTick),
turnTime: new Date('2026-01-01T01:00:19.902Z'),
meta: { purchasedPhase: true },
},
});
const authority = { kind: 'OFFLINE' as const, profileName: 'recovery-test', reason: 'fixture' };
const suspension = await startClockSuspension({
db,
suspensionId: 'recovery-test',
source: 'MAINTENANCE',
policy: turns === 4 ? 'EXACT' : 'RECOVER_TURNS',
authority,
});
const resumedAt = new Date(suspension.cutWallAt.getTime() + turns * 3_600_000);
const plan = await reconcileClockSuspension({
db,
suspensionId: suspension.suspensionId,
authority,
testResumeWallAt: resumedAt,
upgradeMaintenancePolicy: true,
});
expect(plan.shiftTicks).toBe(Math.floor(turns / 12) * 12 * T);
await applyNextClockProjection({ db, redis: redis.client, workerId: 'recovery-test' });
const row = await db.worldState.findFirstOrThrow();
const recovery = readTurnRecovery(row);
const reloaded = new GameClock({
baseTime: row.clockBaseTime!,
tick: Number(row.clockTick),
wallAnchor: row.clockWallAnchor!,
mode: 'realtime',
turnSeconds: row.tickSeconds,
recovery,
});
expect(row.currentMonth).toBe(4);
expect(row.lastTurnTick).toBe(BigInt(plan.shiftTicks));
const general = await db.general.findUniqueOrThrow({ where: { id: 26 } });
expect(general.turnTick).toBe(BigInt(generalTick + plan.shiftTicks));
expect(general.turnTime.toISOString().slice(14)).toBe('00:19.902Z');
expect(general.meta).toEqual({ purchasedPhase: true });
if (turns % 12 === 0) {
expect(recovery).toBeNull();
} else {
expect(recovery).not.toBeNull();
const end = reloaded.tickToWallDate(recovery!.endTick);
expect(reloaded.nowTick(end)).toBe(recovery!.endTick);
expect(reloaded.executionRate(new Date(end.getTime() - 1))).toBe(2);
expect(reloaded.executionRate(end)).toBe(1);
expect(reloaded.tickToWallDate(recovery!.endTick + 199_020).getTime() - end.getTime()).toBe(19_902);
}
const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority });
expect(retry.recovery).toEqual(plan.recovery);
expect(retry.catchUpTicks).toBe(plan.catchUpTicks);
}
);
it.each([3_142_625, 6 * 3_600_000 + 3_142_625])( it.each([3_142_625, 6 * 3_600_000 + 3_142_625])(
'preserves purchased turn phases and the execution cursor after %i ms maintenance and reload', 'preserves purchased turn phases and the execution cursor after %i ms maintenance and reload',
async (gapMilliseconds) => { async (gapMilliseconds) => {
@@ -190,12 +190,12 @@ describe('in-memory scenario general pool availability', () => {
const before = world.captureState(); const before = world.captureState();
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000); const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
world.shiftSchedule(15, claimedAt); world.shiftSchedule(20, claimedAt);
expect(world.captureState().generalPoolEntries).toMatchObject([ expect(world.captureState().generalPoolEntries).toMatchObject([
{ {
id: 1, id: 1,
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000), reservedUntil: new Date(reservedUntil.getTime() + 20 * 60_000),
reservedUntilTick: GAME_TICKS_PER_TURN / 2, reservedUntilTick: GAME_TICKS_PER_TURN / 2,
}, },
{ id: 2, reservedUntil: null, reservedUntilTick: null }, { id: 2, reservedUntil: null, reservedUntilTick: null },
+78 -11
View File
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { GamePrismaClient } from '@sammo-ts/infra'; import type { GamePrismaClient } from '@sammo-ts/infra';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { GAME_TICKS_PER_TURN, planTurnRecovery } from '@sammo-ts/common';
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js'; import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js'; import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
@@ -83,6 +84,70 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
}; };
describe('runtime clock shift', () => { describe('runtime clock shift', () => {
it('runs two real monthly cycles per normal interval, survives reload, and returns to one cycle', async () => {
const base = new Date('2026-07-30T10:00:00Z');
const wallAt = (minutes: number) => new Date(base.getTime() + minutes * 60_000);
const plan = planTurnRecovery({
observedTick: 0,
normalTick: 4 * GAME_TICKS_PER_TURN,
wallNow: wallAt(40),
turnSeconds: 600,
});
let world = buildWorld({
clockBaseTime: base,
clockTick: 0,
clockWallAnchor: wallAt(40),
clockRecovery: plan.recovery,
clockMode: 'realtime',
clockPhase: 'RUNNING',
lastTurnTick: 0,
});
for (const [id, milliseconds] of [
[1, 19_902],
[2, 42_001],
]) {
world.updateGeneral(id!, {
turnTime: new Date(base.getTime() + milliseconds!),
turnTick: milliseconds! * 60,
});
}
const executions: Array<[number, number, boolean]> = [];
const processorForWorld = () =>
new InMemoryTurnProcessor(world, {
afterExecuteGeneral: async (general, result) => {
executions.push([world.getState().currentMonth, general.id, result.ok]);
},
});
let processor = processorForWorld();
for (let minutes = 45; minutes <= 80; minutes += 5) {
const now = wallAt(minutes);
const target = world.getGameNow(now);
world.advanceGameClockTo(target, now);
const result = await processor.run(target, { budgetMs: 10_000, maxGenerals: 10, catchUpCap: 1 });
expect(result).toMatchObject({ processedGenerals: 2, processedTurns: 1, partial: false });
if (minutes === 60) {
const snapshot = world.captureState();
world = buildWorld();
world.restoreState(snapshot);
processor = processorForWorld();
}
}
expect(executions).toEqual(
Array.from({ length: 8 }, (_, month) => [
[month + 1, 1, true],
[month + 1, 2, true],
]).flat()
);
expect(world.getState().currentMonth).toBe(9);
expect(world.getGeneralById(1)!.turnTime).toEqual(new Date(wallAt(80).getTime() + 19_902));
const next = await processor.run(world.getGameNow(wallAt(90)), {
budgetMs: 10_000,
maxGenerals: 10,
catchUpCap: 1,
});
expect(next).toMatchObject({ processedGenerals: 2, processedTurns: 1 });
expect(world.getState().currentMonth).toBe(10);
});
it('preserves the scenario config when the raw world config is unavailable', () => { it('preserves the scenario config when the raw world config is unavailable', () => {
const world = buildWorld(); const world = buildWorld();
@@ -99,7 +164,7 @@ describe('runtime clock shift', () => {
['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'], ['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'],
['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'], ['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'],
] as const)('%s the world, every general, checkpoint, and pending auction together', (_, delta, last, general) => { ] as const)('%s the world, every general, checkpoint, and pending auction together', (_, delta, last, general) => {
const world = buildWorld(); const world = buildWorld({ tickSeconds: 900 });
world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 }); world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 });
world.queueNeutralAuction({ world.queueNeutralAuction({
registrationKey: 'test', registrationKey: 'test',
@@ -132,7 +197,7 @@ describe('runtime clock shift', () => {
expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]); expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]);
}); });
it.each([0, 1.5, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => { it.each([0, 1.5, 15, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
const world = buildWorld(); const world = buildWorld();
expect(() => world.shiftSchedule(delta)).toThrow(); expect(() => world.shiftSchedule(delta)).toThrow();
expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z'); expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z');
@@ -140,7 +205,7 @@ describe('runtime clock shift', () => {
}); });
it('keeps legacy wall-clock metadata independent from the process timezone', () => { it('keeps legacy wall-clock metadata independent from the process timezone', () => {
const world = buildWorld(); const world = buildWorld({ tickSeconds: 900 });
world.shiftSchedule(-15); world.shiftSchedule(-15);
@@ -212,7 +277,7 @@ describe('runtime clock shift', () => {
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 }); expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
}); });
it('preserves the formal wall opening when PREOPEN game display dates are shifted', () => { it('moves the formal opening together with an explicit whole-turn schedule shift', () => {
const openAt = new Date('2026-09-06T00:00:00.000Z'); const openAt = new Date('2026-09-06T00:00:00.000Z');
const now = new Date('2026-09-05T00:00:00.000Z'); const now = new Date('2026-09-05T00:00:00.000Z');
const world = buildWorld({ const world = buildWorld({
@@ -223,11 +288,13 @@ describe('runtime clock shift', () => {
lastTurnTick: 0, lastTurnTick: 0,
clockPhase: 'PREOPEN', clockPhase: 'PREOPEN',
}); });
world.shiftSchedule(15, now); world.shiftSchedule(20, now);
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: openAt }); const shiftedOpen = new Date(openAt.getTime() + 20 * 60_000);
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: shiftedOpen });
expect(world.promotePreopenAtOpening(now)).toBe(false); expect(world.promotePreopenAtOpening(now)).toBe(false);
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:15:00.000Z')); expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:20:00.000Z'));
expect(world.promotePreopenAtOpening(openAt)).toBe(true); expect(world.promotePreopenAtOpening(openAt)).toBe(false);
expect(world.promotePreopenAtOpening(shiftedOpen)).toBe(true);
}); });
it('rejects gameplay commits while the durable clock is suspended', async () => { it('rejects gameplay commits while the durable clock is suspended', async () => {
@@ -309,7 +376,7 @@ describe('runtime clock shift', () => {
}); });
}); });
it('repairs an already accumulated realtime projection lag during a long rebase', () => { it('preserves the normal game-to-wall mapping during a whole-year rebase', () => {
const base = new Date('2026-07-30T10:00:00.000Z'); const base = new Date('2026-07-30T10:00:00.000Z');
const staleAnchor = new Date('2026-07-30T11:00:00.000Z'); const staleAnchor = new Date('2026-07-30T11:00:00.000Z');
const resumedAt = new Date('2026-07-30T11:50:00.000Z'); const resumedAt = new Date('2026-07-30T11:50:00.000Z');
@@ -325,7 +392,7 @@ describe('runtime clock shift', () => {
expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z'); expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z');
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 }); expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
expect(world.getGameNow(resumedAt)).toEqual(resumedAt); expect(world.getGameNow(resumedAt)).toEqual(new Date('2026-07-30T11:15:00.000Z'));
}); });
it('does not lose realtime elapsed time when an overdue target is committed later', () => { it('does not lose realtime elapsed time when an overdue target is committed later', () => {
@@ -244,7 +244,7 @@ integration('runtime clock shift persistence', () => {
type: 'shiftSchedule', type: 'shiftSchedule',
requestId, requestId,
actionId, actionId,
deltaMinutes: -15, deltaMinutes: -20,
} as GamePrisma.InputJsonValue, } as GamePrisma.InputJsonValue,
}, },
}); });
@@ -257,29 +257,30 @@ integration('runtime clock shift persistence', () => {
await hooks.close(); await hooks.close();
} }
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45:00.000Z'); expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:40:00.000Z');
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:55:00.000Z'); expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:50:00.000Z');
expect(await stateStore.loadCheckpoint()).toMatchObject({ expect(await stateStore.loadCheckpoint()).toMatchObject({
turnTime: '2099-07-30T09:45:00.000Z', turnTime: '2099-07-30T09:40:00.000Z',
generalId: 0, generalId: 0,
}); });
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z'); // 예정된 재개 경계까지 대기하며, 그 뒤 첫 장수의 시각을 다시 계산한다.
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:40:00.000Z');
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } }); const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
expect(storedWorld.meta).toMatchObject({ expect(storedWorld.meta).toMatchObject({
lastTurnTime: '2099-07-30T09:45:00.000Z', lastTurnTime: '2099-07-30T09:40:00.000Z',
starttime: '2099-06-30 23:45:00', starttime: '2099-06-30 23:40:00',
}); });
expect(storedWorld.clockTick).toBe(0n); expect(storedWorld.clockTick).toBe(0n);
expect(storedWorld.lastTurnTick).toBe(0n); expect(storedWorld.lastTurnTick).toBe(0n);
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } }); const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z'); expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:00:00.000Z');
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN)); expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
const storedAuctions = await db.auction.findMany({ const storedAuctions = await db.auction.findMany({
where: { id: { in: auctionRows.map((auction) => auction.id) } }, where: { id: { in: auctionRows.map((auction) => auction.id) } },
}); });
const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()])); const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()]));
expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([ expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([
'2099-07-30T09:45:00.000Z', '2099-07-30T09:40:00.000Z',
'2099-07-30T11:00:00.000Z', '2099-07-30T11:00:00.000Z',
'2099-07-30T12:00:00.000Z', '2099-07-30T12:00:00.000Z',
'2099-07-30T13:00:00.000Z', '2099-07-30T13:00:00.000Z',
@@ -291,7 +292,7 @@ integration('runtime clock shift persistence', () => {
type: 'shiftSchedule', type: 'shiftSchedule',
ok: true, ok: true,
actionId, actionId,
deltaMinutes: -15, deltaMinutes: -20,
shiftedGenerals: 2, shiftedGenerals: 2,
shiftedAuctions: 1, shiftedAuctions: 1,
}, },
@@ -611,7 +611,7 @@ integration('unification finalization transaction', () => {
const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } }); const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } });
expect(suspension).toMatchObject({ expect(suspension).toMatchObject({
source: 'UNIFICATION_WAIT', source: 'UNIFICATION_WAIT',
policy: 'EXACT', policy: 'TURN_BOUNDARY',
status: 'SUSPENDED', status: 'SUSPENDED',
sourceRevision: 1n, sourceRevision: 1n,
targetRevision: 2n, targetRevision: 2n,
@@ -143,7 +143,7 @@ describe('HWE-shaped unification invader resume', () => {
tickSeconds: 60, tickSeconds: 60,
lastTurnTime: liveLastTurnTime, lastTurnTime: liveLastTurnTime,
clockBaseTime: new Date('2026-08-19T12:00:00.000Z'), clockBaseTime: new Date('2026-08-19T12:00:00.000Z'),
clockTick: 19_980_000_000, clockTick: 19_944_000_000,
clockMode: 'realtime', clockMode: 'realtime',
clockWallAnchor: liveWallAnchor, clockWallAnchor: liveWallAnchor,
lastTurnTick: 19_944_000_000, lastTurnTick: 19_944_000_000,
@@ -202,7 +202,7 @@ describe('HWE-shaped unification invader resume', () => {
actorUserId: recipient.userId, actorUserId: recipient.userId,
target: 'ENGINE', target: 'ENGINE',
eventType: 'messageRespond', eventType: 'messageRespond',
processingGameTick: 19_980_000_000n, processingGameTick: 19_944_000_000n,
})), })),
}, },
messageAction: { updateMany: vi.fn(async () => ({ count: 1 })) }, messageAction: { updateMany: vi.fn(async () => ({ count: 1 })) },
@@ -214,11 +214,11 @@ describe('HWE-shaped unification invader resume', () => {
id: 1014, id: 1014,
mailbox: recipient.id, mailbox: recipient.id,
type: 'private', type: 'private',
time: world.gameTickToDate(19_980_000_000), time: world.gameTickToDate(19_944_000_000),
validUntil: new Date('9999-12-31T00:00:00.000Z'), validUntil: new Date('9999-12-31T00:00:00.000Z'),
actionType: 'raiseInvader', actionType: 'raiseInvader',
actionStatus: 'PENDING', actionStatus: 'PENDING',
createdGameTick: 19_980_000_000n, createdGameTick: 19_944_000_000n,
expiresGameTick: null, expiresGameTick: null,
message, message,
}, },
@@ -256,13 +256,22 @@ describe('HWE-shaped unification invader resume', () => {
gapTicks: 108_000_000, gapTicks: 108_000_000,
catchUpTicks: 0, catchUpTicks: 0,
shiftTicks: 108_000_000, shiftTicks: 108_000_000,
alignedTick: 20_088_000_000, alignedTick: 20_052_000_000,
resumeWallAt: acceptedAt, resumeWallAt: acceptedAt,
resumeAnchor: new Date('2026-08-20T06:25:00Z'),
}), }),
}); });
const processor = new InMemoryTurnProcessor(world); const processor = new InMemoryTurnProcessor(world);
const clock = new ManualClock(acceptedAt.getTime()); const clock = new ManualClock(acceptedAt.getTime());
vi.spyOn(queue, 'waitFor').mockImplementation(async (milliseconds) => {
await clock.sleepMs(milliseconds ?? 0);
const pending = await queue.drain();
for (const command of pending.slice(1)) queue.enqueue(command);
return pending[0] ?? null;
});
const runWallTimes: Date[] = [];
const run = vi.fn(async (...args: Parameters<InMemoryTurnProcessor['run']>) => { const run = vi.fn(async (...args: Parameters<InMemoryTurnProcessor['run']>) => {
runWallTimes.push(new Date(clock.nowMs()));
const result = await processor.run(...args); const result = await processor.run(...args);
if (world.getState().currentMonth === 4) { if (world.getState().currentMonth === 4) {
queue.enqueue({ type: 'shutdown', reason: 'resumed monthly boundary verified' }); queue.enqueue({ type: 'shutdown', reason: 'resumed monthly boundary verified' });
@@ -307,7 +316,7 @@ describe('HWE-shaped unification invader resume', () => {
expect(commandDb.$queryRaw).toHaveBeenCalledTimes(2); expect(commandDb.$queryRaw).toHaveBeenCalledTimes(2);
expect(commandDb.messageAction.updateMany).toHaveBeenCalledWith({ expect(commandDb.messageAction.updateMany).toHaveBeenCalledWith({
where: { messageId: { in: [1014] }, status: 'PENDING' }, where: { messageId: { in: [1014] }, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedGameTick: 20_088_000_000n }, data: { status: 'RESOLVED', resolvedGameTick: 20_052_000_000n },
}); });
expect(world.getState()).toMatchObject({ expect(world.getState()).toMatchObject({
currentYear: 226, currentYear: 226,
@@ -324,11 +333,11 @@ describe('HWE-shaped unification invader resume', () => {
expect(initialInvaderTurnTimes).toHaveLength(10); expect(initialInvaderTurnTimes).toHaveLength(10);
expect(Math.min(...initialInvaderTurnTimes)).toBeGreaterThanOrEqual(alignedMonthlyBoundary); expect(Math.min(...initialInvaderTurnTimes)).toBeGreaterThanOrEqual(alignedMonthlyBoundary);
expect(Math.max(...initialInvaderTurnTimes)).toBeLessThan(alignedMonthlyBoundary + 60_000); expect(Math.max(...initialInvaderTurnTimes)).toBeLessThan(alignedMonthlyBoundary + 60_000);
expect(run).toHaveBeenCalledTimes(2); expect(run).toHaveBeenCalled();
expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([ expect(runWallTimes.every((time) => time >= new Date('2026-08-20T06:25:00Z'))).toBe(true);
'2026-08-20T06:24:58.611Z', const nextMonth = addMinutes(liveLastTurnTime, 4);
'2026-08-20T06:25:00.000Z', expect(run.mock.calls.every(([targetTime]) => targetTime <= nextMonth)).toBe(true);
]); expect(run.mock.calls.at(-1)![0]).toEqual(nextMonth);
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-20T06:25:00.000Z'); expect(lifecycle.getStatus().lastTurnTime).toBe(nextMonth.toISOString());
}); });
}); });
@@ -46,6 +46,7 @@ type NavigationFixture = {
clockMode?: 'realtime' | 'manual'; clockMode?: 'realtime' | 'manual';
clockRunning?: boolean; clockRunning?: boolean;
clockStartsAt?: string | null; clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
turnEngineRunning?: boolean | null; turnEngineRunning?: boolean | null;
cityDefence?: number; cityDefence?: number;
cityState?: number; cityState?: number;
@@ -636,6 +637,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
clockMode: state.clockMode ?? 'realtime', clockMode: state.clockMode ?? 'realtime',
clockRunning: state.clockRunning ?? true, clockRunning: state.clockRunning ?? true,
clockStartsAt: state.clockStartsAt ?? null, clockStartsAt: state.clockStartsAt ?? null,
clockRecovery: state.clockRecovery ?? null,
turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning, turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning,
scenarioTitle: state.scenarioTitle ?? '', scenarioTitle: state.scenarioTitle ?? '',
autorunUser: state.autorunUser ?? null, autorunUser: state.autorunUser ?? null,
@@ -6078,3 +6080,63 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
}) })
.toBe(1); .toBe(1);
}); });
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
test(`turn recovery returns to normal speed at the boundary (${viewport.width}px)`, async ({ page }) => {
const start = new Date('2026-09-06T07:59:50Z');
await page.clock.install({ time: start });
await page.setViewportSize(viewport);
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 1,
npcMode: 1,
generalMeCalls: 0,
operations: [],
serverTime: '2026-09-06T07:59:40Z',
serverWallTime: start.toISOString(),
clockMode: 'realtime',
clockRunning: true,
turnEngineRunning: true,
clockRecovery: { startsAt: '2026-09-06T04:00:00Z', endsAt: '2026-09-06T08:00:00Z' },
};
await installFixture(page, state);
await page.goto('./');
await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 });
await page.evaluate(() => document.fonts.ready);
const status = page.locator('.execution-status:visible');
await expect(status).toContainText('복구 2배속');
const root = process.env.TURN_RECOVERY_ARTIFACT_DIR;
const measure = () =>
status.evaluate((element) => ({
rect: element.getBoundingClientRect().toJSON(),
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
font: getComputedStyle(element).font,
lineHeight: getComputedStyle(element).lineHeight,
text: element.textContent,
}));
const before = await measure();
expect(before.scrollWidth).toBeLessThanOrEqual(before.clientWidth + 1);
if (root) {
await mkdir(root, { recursive: true });
await page.screenshot({ path: resolve(root, `recovering-${viewport.width}.png`), fullPage: true });
}
await page.clock.runFor(20_000);
await expect(status).not.toContainText('복구 2배속');
await expect(status).toContainText('17:00');
const after = await measure();
expect(after.scrollWidth).toBeLessThanOrEqual(after.clientWidth + 1);
if (root) {
await page.screenshot({ path: resolve(root, `normal-${viewport.width}.png`), fullPage: true });
await writeFile(
resolve(root, `geometry-${viewport.width}.json`),
JSON.stringify({ viewport, before, after }, null, 2)
);
}
});
}
@@ -29,6 +29,7 @@ const props = defineProps<{
clockMode?: 'realtime' | 'manual'; clockMode?: 'realtime' | 'manual';
clockRunning?: boolean; clockRunning?: boolean;
clockStartsAt?: string | null; clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
autorunLimit?: number | null; autorunLimit?: number | null;
storageKey?: string; storageKey?: string;
mapData?: CommandMapData | null; mapData?: CommandMapData | null;
@@ -60,11 +61,7 @@ const labelMap = computed(() => {
}); });
const firstReservedMonth = computed( const firstReservedMonth = computed(
() => () => (props.currentYear ?? 0) * 12 + (props.currentMonth ?? 1) - 1 + (props.general?.nextTurnMonthOffset ?? 0)
(props.currentYear ?? 0) * 12 +
(props.currentMonth ?? 1) -
1 +
(props.general?.nextTurnMonthOffset ?? 0)
); );
const rows = computed<ReservedCommandRow[]>(() => { const rows = computed<ReservedCommandRow[]>(() => {
@@ -119,7 +116,7 @@ const updateServerClock = () => {
currentServerTime.value = '--:--:--'; currentServerTime.value = '--:--:--';
return; return;
} }
const { clientElapsedMs, time: projectedTime } = projectServerClock(serverClockSample); const { clientElapsedMs, time: projectedTime, rate } = projectServerClock(serverClockSample);
currentServerTime.value = formatLocalTimeSeconds(projectedTime); currentServerTime.value = formatLocalTimeSeconds(projectedTime);
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) { if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs; const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs;
@@ -127,15 +124,30 @@ const updateServerClock = () => {
updateServerClock, updateServerClock,
untilStartMs > 0 untilStartMs > 0
? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS) ? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS)
: 1_000 - projectedTime.getMilliseconds() : (1_000 - projectedTime.getMilliseconds()) / rate
); );
} }
}; };
watch( watch(
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const, () =>
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => { [
serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt }); props.serverTime,
props.serverWallTime,
props.clockMode,
props.clockRunning,
props.clockStartsAt,
props.clockRecovery,
] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock(); updateServerClock();
}, },
{ immediate: true } { immediate: true }
@@ -16,6 +16,7 @@ const props = defineProps<{
clockMode?: 'realtime' | 'manual'; clockMode?: 'realtime' | 'manual';
clockRunning?: boolean; clockRunning?: boolean;
clockStartsAt?: string | null; clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
turnEngineRunning?: boolean | null; turnEngineRunning?: boolean | null;
status: { status: {
onlineUserCount: number; onlineUserCount: number;
@@ -34,6 +35,7 @@ const props = defineProps<{
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage)); const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
const currentServerTime = ref('기록 없음'); const currentServerTime = ref('기록 없음');
const hasServerClock = ref(false); const hasServerClock = ref(false);
const recovering = ref(false);
const turnEngineStopped = computed(() => props.turnEngineRunning === false); const turnEngineStopped = computed(() => props.turnEngineRunning === false);
const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean'); const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean');
const serverClockTitle = computed(() => { const serverClockTitle = computed(() => {
@@ -57,6 +59,7 @@ const updateServerClock = () => {
const now = Date.now(); const now = Date.now();
const projection = projectServerClock(serverClockSample, now); const projection = projectServerClock(serverClockSample, now);
recovering.value = projection.rate === 2;
currentServerTime.value = formatServerDateTime(projection.time, { currentServerTime.value = formatServerDateTime(projection.time, {
format: 'monthDayTime', format: 'monthDayTime',
fallback: '기록 없음', fallback: '기록 없음',
@@ -67,16 +70,37 @@ const updateServerClock = () => {
const nextDelays: number[] = []; const nextDelays: number[] = [];
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) { if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs; const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs;
nextDelays.push(untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time)); nextDelays.push(
untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time) / projection.rate
);
}
for (const boundary of [serverClockSample.recoveryStartDelayMs, serverClockSample.recoveryEndDelayMs]) {
if (boundary !== undefined && boundary > projection.clientElapsedMs)
nextDelays.push(boundary - projection.clientElapsedMs);
} }
if (nextDelays.length === 0) return; if (nextDelays.length === 0) return;
serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays))); serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays)));
}; };
watch( watch(
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const, () =>
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => { [
serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt }); props.serverTime,
props.serverWallTime,
props.clockMode,
props.clockRunning,
props.clockStartsAt,
props.clockRecovery,
] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock(); updateServerClock();
}, },
{ immediate: true } { immediate: true }
@@ -100,7 +124,7 @@ onUnmounted(() => {
}" }"
:title="serverClockTitle" :title="serverClockTitle"
> >
현재 시각: {{ currentServerTime }} 현재 시각: {{ currentServerTime }}<span v-if="recovering"> · 복구 2배속</span>
</div> </div>
<div class="status-row tournament-status"> <div class="status-row tournament-status">
<RouterLink to="/tournament"> <RouterLink to="/tournament">
@@ -4,6 +4,7 @@ export type ServerClockProjectionInput = {
clockMode?: 'realtime' | 'manual'; clockMode?: 'realtime' | 'manual';
clockRunning?: boolean; clockRunning?: boolean;
clockStartsAt?: string | null; clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
}; };
export type SampledServerClock = { export type SampledServerClock = {
@@ -11,6 +12,8 @@ export type SampledServerClock = {
sampledClientTimeMs: number; sampledClientTimeMs: number;
clockMode: 'realtime' | 'manual'; clockMode: 'realtime' | 'manual';
startDelayMs: number | null; startDelayMs: number | null;
recoveryStartDelayMs?: number;
recoveryEndDelayMs?: number;
}; };
const parseInstant = (value?: string | null): number | null => { const parseInstant = (value?: string | null): number | null => {
@@ -40,11 +43,20 @@ export const sampleServerClock = (
: null; : null;
} }
const wallSample = parseInstant(input.serverWallTime);
const recoveryStart = parseInstant(input.clockRecovery?.startsAt);
const recoveryEnd = parseInstant(input.clockRecovery?.endsAt);
return { return {
serverTimeMs, serverTimeMs,
sampledClientTimeMs, sampledClientTimeMs,
clockMode: input.clockMode ?? 'realtime', clockMode: input.clockMode ?? 'realtime',
startDelayMs, startDelayMs,
...(wallSample !== null && recoveryStart !== null && recoveryEnd !== null && recoveryEnd > recoveryStart
? {
recoveryStartDelayMs: recoveryStart - wallSample,
recoveryEndDelayMs: recoveryEnd - wallSample,
}
: {}),
}; };
}; };
@@ -55,9 +67,25 @@ export const projectServerClock = (sample: SampledServerClock, clientTimeMs = Da
? 0 ? 0
: Math.max(0, clientElapsedMs - sample.startDelayMs); : Math.max(0, clientElapsedMs - sample.startDelayMs);
const accelerationMs =
sample.startDelayMs === null || sample.clockMode === 'manual'
? 0
: Math.max(
0,
Math.min(clientElapsedMs, sample.recoveryEndDelayMs ?? 0) -
Math.max(0, sample.recoveryStartDelayMs ?? 0)
);
const rate =
sample.startDelayMs !== null &&
sample.clockMode !== 'manual' &&
clientElapsedMs >= (sample.recoveryStartDelayMs ?? Infinity) &&
clientElapsedMs < (sample.recoveryEndDelayMs ?? -Infinity)
? 2
: 1;
return { return {
clientElapsedMs, clientElapsedMs,
time: new Date(sample.serverTimeMs + elapsedGameMs), rate,
time: new Date(sample.serverTimeMs + elapsedGameMs + accelerationMs),
}; };
}; };
+3
View File
@@ -311,6 +311,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode" :clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning" :clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt" :clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:turn-engine-running="lobbyInfo?.turnEngineRunning" :turn-engine-running="lobbyInfo?.turnEngineRunning"
/> />
</div> </div>
@@ -365,6 +366,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode" :clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning" :clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt" :clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:autorun-limit="reservedGeneralAutorunLimit" :autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap" :map-data="worldMap"
:map-layout="mapLayout" :map-layout="mapLayout"
@@ -547,6 +549,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode" :clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning" :clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt" :clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:autorun-limit="reservedGeneralAutorunLimit" :autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap" :map-data="worldMap"
:map-layout="mapLayout" :map-layout="mapLayout"
@@ -49,3 +49,22 @@ void test('holds a preopen clock until its wall-clock start delay passes', () =>
void test('rejects an invalid server clock sample', () => { void test('rejects an invalid server clock sample', () => {
assert.equal(sampleServerClock({ serverTime: 'not-a-time' }, 10_000), null); assert.equal(sampleServerClock({ serverTime: 'not-a-time' }, 10_000), null);
}); });
void test('a single browser sample accelerates only inside the recovery window and rejoins normal time', () => {
const sample = sampleServerClock(
{
serverTime: '2026-09-06T00:20:00Z',
serverWallTime: '2026-09-06T04:20:00Z',
clockRunning: true,
clockRecovery: { startsAt: '2026-09-06T05:00:00Z', endsAt: '2026-09-06T09:00:00Z' },
},
0
);
assert.ok(sample);
const minute = 60_000;
assert.equal(projectServerClock(sample, 40 * minute).time.toISOString(), '2026-09-06T01:00:00.000Z');
assert.equal(projectServerClock(sample, 100 * minute).time.toISOString(), '2026-09-06T03:00:00.000Z');
assert.equal(projectServerClock(sample, 280 * minute).time.toISOString(), '2026-09-06T09:00:00.000Z');
assert.equal(projectServerClock(sample, 340 * minute).time.toISOString(), '2026-09-06T10:00:00.000Z');
assert.equal(projectServerClock(sample, 280 * minute).rate, 1);
});
+28
View File
@@ -2285,6 +2285,22 @@ export const adminRouter = router({
message: 'preopenAt and openAt are required for RESERVED status.', message: 'preopenAt and openAt are required for RESERVED status.',
}); });
} }
const current = await ctx.profiles.getProfile(input.profileName);
if (!current) throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
if (current.currentScenario !== null && current.status !== input.status) {
const clockAction =
input.status === 'RUNNING'
? 'RESUME'
: current.status === 'RUNNING' && (input.status === 'PAUSED' || input.status === 'STOPPED')
? 'SUSPEND'
: null;
if (clockAction)
await ctx.orchestrator.transitionProfileClock(
input.profileName,
clockAction,
'operator setStatus'
);
}
const result = await ctx.profiles.updateStatus(input.profileName, input.status, { const result = await ctx.profiles.updateStatus(input.profileName, input.status, {
preopenAt: input.preopenAt, preopenAt: input.preopenAt,
openAt: input.openAt, openAt: input.openAt,
@@ -2696,6 +2712,18 @@ export const adminRouter = router({
}); });
} }
if (input.action === 'ACCELERATE' || input.action === 'DELAY') {
const [settings] = (await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? [];
if (!settings || input.durationMinutes! % settings.turnTermMinutes !== 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: settings
? `일정 이동은 현재 턴 길이(${settings.turnTermMinutes}분)의 정수 배로 입력해 주세요.`
: '현재 턴 길이를 확인할 수 없습니다.',
});
}
}
if ( if (
input.action === 'ACCELERATE' || input.action === 'ACCELERATE' ||
input.action === 'DELAY' || input.action === 'DELAY' ||
@@ -1,6 +1,7 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { stripVTControlCharacters } from 'node:util'; import { stripVTControlCharacters } from 'node:util';
@@ -1237,7 +1238,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!profile || profile.currentScenario === null) { if (!profile || profile.currentScenario === null) {
throw new Error(`Profile clock is unavailable: ${profileName}`); throw new Error(`Profile clock is unavailable: ${profileName}`);
} }
const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) }); const { connectorFactory, supportsTurnRecovery } = await this.resolveProfileClockAdapter(profile);
const postgres = connectorFactory({ url: this.resolveProfileDatabaseUrl(profile) });
const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)); const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env));
await postgres.connect(); await postgres.connect();
await redis.connect(); await redis.connect();
@@ -1265,6 +1267,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true }, select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
}); });
if (!world) throw new Error(`Profile has no world_state: ${profileName}`); if (!world) throw new Error(`Profile has no world_state: ${profileName}`);
if (['PREOPEN', 'MANUAL', 'COMPLETED'].includes(world.clockPhase)) {
return { phase: world.clockPhase, revision: clockRevisionAsNumber(world.clockRevision) };
}
if (action === 'SUSPEND') { if (action === 'SUSPEND') {
let suspension = await postgres.prisma.clockSuspension.findFirst({ let suspension = await postgres.prisma.clockSuspension.findFirst({
@@ -1281,8 +1286,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
suspensionId: `gateway-maintenance-${suffix}`, suspensionId: `gateway-maintenance-${suffix}`,
source: 'MAINTENANCE', source: 'MAINTENANCE',
// 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다. // 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다.
// 관측 시계만 재개하고 정상 엔진이 미처리 턴을 따라잡게 한다. // 완전한 12턴은 정수 이동하고 잔여 지연은 복구 구간에서 두 배속으로 실행한다.
policy: 'PRESERVE_SCHEDULE', policy: supportsTurnRecovery ? 'RECOVER_TURNS' : 'PRESERVE_SCHEDULE',
authority, authority,
}); });
suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({ suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({
@@ -1326,7 +1331,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return { phase: 'SUSPENDED', revision: clockRevisionAsNumber(world.clockRevision) }; return { phase: 'SUSPENDED', revision: clockRevisionAsNumber(world.clockRevision) };
} }
if (world.clockPhase === 'SUSPENDED') { if (world.clockPhase === 'SUSPENDED') {
await reconcileClockSuspension({ db: postgres.prisma, suspensionId: suspension.id, authority }); await reconcileClockSuspension({
db: postgres.prisma,
suspensionId: suspension.id,
authority,
upgradeMaintenancePolicy: supportsTurnRecovery,
});
} else if (world.clockPhase !== 'RECONCILING') { } else if (world.clockPhase !== 'RECONCILING') {
throw new Error(`Cannot resume profile clock from ${world.clockPhase}.`); throw new Error(`Cannot resume profile clock from ${world.clockPhase}.`);
} }
@@ -1353,7 +1363,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.promoteProfileOpeningOverride(profile); await this.promoteProfileOpeningOverride(profile);
return; return;
} }
const postgres = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) }); const { connectorFactory } = await this.resolveProfileClockAdapter(profile);
const postgres = connectorFactory({ url: this.resolveProfileDatabaseUrl(profile) });
const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)); const redis = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env));
await postgres.connect(); await postgres.connect();
await redis.connect(); await redis.connect();
@@ -1695,6 +1706,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!gatewayProfileCapabilities(profile.status).operatorResumable) { if (!gatewayProfileCapabilities(profile.status).operatorResumable) {
throw new Error(`Profile status ${profile.status} cannot be started by an operator.`); throw new Error(`Profile status ${profile.status} cannot be started by an operator.`);
} }
await this.transitionProfileClock(profile.profileName, 'RESUME', operation.reason ?? 'operator START');
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.'); await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.');
const updated = await updateOperationProfile( const updated = await updateOperationProfile(
{ {
@@ -1735,6 +1747,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') { if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') {
throw new Error(`Profile status ${profile.status} cannot be stopped by an operator.`); throw new Error(`Profile status ${profile.status} cannot be stopped by an operator.`);
} }
await this.transitionProfileClock(profile.profileName, 'SUSPEND', operation.reason ?? 'operator STOP');
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.'); await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.');
await updateOperationProfile({ status: 'STOPPED' }, () => await updateOperationProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED') this.repository.updateStatus(profile.profileName, 'STOPPED')
@@ -1979,14 +1992,19 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
'settlement', 'settlement',
'기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.' '기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.'
); );
const result = await this.cancelGame({ const result = await this.cancelGame(
cancellationId: operation.id, {
databaseUrl, cancellationId: operation.id,
cancelledBy: operation.requestedBy, databaseUrl,
reason: operation.reason ?? '', cancelledBy: operation.requestedBy,
...options, reason: operation.reason ?? '',
cancelledAt: this.now(), ...options,
}); cancelledAt: this.now(),
},
this.cancelGame === defaultCancelGame
? (await this.resolveProfileClockAdapter(profile)).connectorFactory
: undefined
);
cancellationCommitted = true; cancellationCommitted = true;
await assertLease(); await assertLease();
await updateClaimedProfile({ await updateClaimedProfile({
@@ -2493,11 +2511,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`); throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`);
} }
await appendLog('seed', '시나리오 초기 데이터 생성을 완료했습니다.'); await appendLog('seed', '시나리오 초기 데이터 생성을 완료했습니다.');
// 실제 seed가 정한 경계를 공개 시간표와 scheduler에도 사용한다.
const openingConnector = createGamePostgresConnector({ url: seedInfo.databaseUrl });
let effectiveOpenAt = openAt;
try {
await openingConnector.connect();
const clock = await openingConnector.prisma.worldState.findFirstOrThrow({
select: { clockMode: true, clockWallAnchor: true },
});
if (clock.clockMode === 'realtime' && clock.clockWallAnchor) effectiveOpenAt = clock.clockWallAnchor;
} finally {
await openingConnector.disconnect();
}
await this.clearTournamentRuntimeState(profile.profileName); await this.clearTournamentRuntimeState(profile.profileName);
await assertLease?.(); await assertLease?.();
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
const now = this.now(); const now = this.now();
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt); const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, effectiveOpenAt);
const publishedProfile = await updateClaimedProfile( const publishedProfile = await updateClaimedProfile(
{ {
currentScenario: String(scenarioId), currentScenario: String(scenarioId),
@@ -2508,8 +2539,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
buildLastUsedAt: completedAt, buildLastUsedAt: completedAt,
buildCompletedAt: completedAt, buildCompletedAt: completedAt,
buildError: null, buildError: null,
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, preopenAt: preopenAt
openAt: openAt ? openAt.toISOString() : null, ? preopenAt.toISOString()
: effectiveOpenAt
? effectiveOpenAt.toISOString()
: null,
openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null, scheduledStartAt: action.scheduledAt ?? null,
...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}), ...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}),
}, },
@@ -2523,8 +2558,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId)); await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
} }
return this.repository.updateStatus(profile.profileName, desiredStatus, { return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null, preopenAt: preopenAt
openAt: openAt ? openAt.toISOString() : null, ? preopenAt.toISOString()
: effectiveOpenAt
? effectiveOpenAt.toISOString()
: null,
openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null, scheduledStartAt: action.scheduledAt ?? null,
}); });
} }
@@ -2802,6 +2841,25 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} }
} }
private async resolveProfileClockAdapter(profile: GatewayProfileRecord): Promise<{
connectorFactory: typeof createGamePostgresConnector;
supportsTurnRecovery: boolean;
}> {
// Gateway와 profile은 독립 배포된다. 이전 profile에는 당시 Prisma 모델과
// 기존 즉시 따라잡기 정책을 사용하고, 새 profile만 복구 창을 저장한다.
const profileWorkspace = profile.buildWorkspace ?? this.processConfig.workspaceRoot;
const manifest = await readReleaseManifest(profileWorkspace);
const supportsTurnRecovery = manifest.gameSchemaHead >= '20260906090000_add_turn_recovery_window';
const connectorFactory = supportsTurnRecovery
? createGamePostgresConnector
: (
(await import(pathToFileURL(path.join(profileWorkspace, 'packages/infra/dist/index.js')).href)) as {
createGamePostgresConnector: typeof createGamePostgresConnector;
}
).createGamePostgresConnector;
return { connectorFactory, supportsTurnRecovery };
}
private resolveProfileDatabaseUrl(profile: GatewayProfileRecord): string { private resolveProfileDatabaseUrl(profile: GatewayProfileRecord): string {
return resolveGatewayPostgresConfigFromEnv(this.processConfig.baseEnv ?? process.env, profile.profile).url; return resolveGatewayPostgresConfigFromEnv(this.processConfig.baseEnv ?? process.env, profile.profile).url;
} }
@@ -65,6 +65,7 @@ export const runProfileSeedCli = async (env: NodeJS.ProcessEnv = process.env): P
tickSeconds: request.tickSeconds, tickSeconds: request.tickSeconds,
gameClockMode: process.env.GAME_CLOCK_MODE === 'manual' ? 'manual' : 'realtime', gameClockMode: process.env.GAME_CLOCK_MODE === 'manual' ? 'manual' : 'realtime',
now: new Date(request.now), now: new Date(request.now),
wallNow: new Date(),
installOptions: request.installOptions installOptions: request.installOptions
? { ? {
...request.installOptions, ...request.installOptions,
@@ -22,6 +22,7 @@ export interface SeedProfileDatabaseOptions {
tickSeconds?: number; tickSeconds?: number;
gameClockMode?: GameClockMode; gameClockMode?: GameClockMode;
now?: Date; now?: Date;
wallNow?: Date;
installOptions?: ScenarioInstallOptions; installOptions?: ScenarioInstallOptions;
scenarioOptions?: Parameters<typeof seedScenarioToDatabase>[0]['scenarioOptions']; scenarioOptions?: Parameters<typeof seedScenarioToDatabase>[0]['scenarioOptions'];
mapOptions?: Parameters<typeof seedScenarioToDatabase>[0]['mapOptions']; mapOptions?: Parameters<typeof seedScenarioToDatabase>[0]['mapOptions'];
@@ -115,9 +116,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse
const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null; const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null;
const fallbackTurnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date(); const fallbackTurnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date();
const mode = worldState.clockMode === 'manual' ? 'manual' : 'realtime'; const mode = worldState.clockMode === 'manual' ? 'manual' : 'realtime';
const phase = worldState.clockPhase const phase = worldState.clockPhase ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(mode);
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(mode);
const gameClock = new GameClock({ const gameClock = new GameClock({
baseTime: worldState.clockBaseTime ?? fallbackTurnTime, baseTime: worldState.clockBaseTime ?? fallbackTurnTime,
tick: Number(worldState.clockTick ?? 0n), tick: Number(worldState.clockTick ?? 0n),
@@ -164,6 +163,7 @@ export const seedProfileDatabase = async (options: SeedProfileDatabaseOptions) =
tickSeconds: options.tickSeconds, tickSeconds: options.tickSeconds,
gameClockMode: options.gameClockMode, gameClockMode: options.gameClockMode,
now: options.now, now: options.now,
wallNow: options.wallNow,
installOptions: options.installOptions, installOptions: options.installOptions,
scenarioOptions: options.scenarioOptions, scenarioOptions: options.scenarioOptions,
mapOptions: options.mapOptions, mapOptions: options.mapOptions,
+23 -4
View File
@@ -1566,13 +1566,32 @@ describe('admin runtime clock action API', () => {
expect(harness.updatedStatuses).toEqual([]); expect(harness.updatedStatuses).toEqual([]);
}); });
it('routes direct profile status changes through clock suspension', async () => {
const harness = await buildCaller(unusedCreateOperation, { initialProfileStatus: 'RUNNING' });
await harness.caller.admin.profiles.setStatus({ profileName: 'che:2', status: 'PAUSED' });
expect(harness.lifecycle[0]).toBe('clock:SUSPEND');
expect(harness.updatedStatuses).toEqual(['PAUSED']);
});
it('rejects schedule movement that changes within-turn phases', async () => {
const harness = await buildCaller(unusedCreateOperation);
await expect(
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'ACCELERATE',
durationMinutes: 15,
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(harness.createdRuntimeActions).toEqual([]);
});
it('creates a first-class clock action owned by the authenticated administrator', async () => { it('creates a first-class clock action owned by the authenticated administrator', async () => {
const harness = await buildCaller(unusedCreateOperation); const harness = await buildCaller(unusedCreateOperation);
const result = await harness.caller.admin.profiles.requestAction({ const result = await harness.caller.admin.profiles.requestAction({
profileName: 'che:2', profileName: 'che:2',
action: 'ACCELERATE', action: 'ACCELERATE',
durationMinutes: 15, durationMinutes: 20,
reason: '운영 일정 조정', reason: '운영 일정 조정',
}); });
@@ -1580,7 +1599,7 @@ describe('admin runtime clock action API', () => {
ok: true, ok: true,
action: { action: {
action: 'ACCELERATE', action: 'ACCELERATE',
durationMinutes: 15, durationMinutes: 20,
status: 'REQUESTED', status: 'REQUESTED',
}, },
}); });
@@ -1589,7 +1608,7 @@ describe('admin runtime clock action API', () => {
profileName: 'che:2', profileName: 'che:2',
action: 'ACCELERATE', action: 'ACCELERATE',
payload: {}, payload: {},
durationMinutes: 15, durationMinutes: 20,
reason: '운영 일정 조정', reason: '운영 일정 조정',
requestedBy: harness.admin.id, requestedBy: harness.admin.id,
}, },
@@ -1605,7 +1624,7 @@ describe('admin runtime clock action API', () => {
harness.caller.admin.profiles.requestAction({ harness.caller.admin.profiles.requestAction({
profileName: 'che:2', profileName: 'che:2',
action: 'DELAY', action: 'DELAY',
durationMinutes: 5, durationMinutes: 20,
}) })
).rejects.toMatchObject({ ).rejects.toMatchObject({
code: 'CONFLICT', code: 'CONFLICT',
@@ -193,6 +193,10 @@ const createHarness = (
scheduleIntervalMs: 60_000, scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000, buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000, adminActionIntervalMs: 60_000,
transitionProfileClock: async (_profileName, action) => {
lifecycle.push(`clock:${action}`);
return { phase: action === 'SUSPEND' ? 'SUSPENDED' : 'RUNNING', revision: 2 };
},
now: options.now, now: options.now,
cancelGame: options.cancelGame, cancelGame: options.cancelGame,
promoteProfileOpening: options.promoteProfileOpening promoteProfileOpening: options.promoteProfileOpening
@@ -218,6 +222,13 @@ const createHarness = (
}; };
describe('GatewayOrchestrator first-class operations', () => { describe('GatewayOrchestrator first-class operations', () => {
it.each(['START', 'STOP'] as const)('routes %s through durable clock transition', async (action) => {
const harness = createHarness(buildOperation(action));
await harness.orchestrator.runOperationsNow();
expect(harness.lifecycle[0]).toBe(`clock:${action === 'START' ? 'RESUME' : 'SUSPEND'}`);
expect(harness.completions).toEqual(['SUCCEEDED']);
});
it('stops runtime, settles once, and seals a cancelled profile', async () => { it('stops runtime, settles once, and seals a cancelled profile', async () => {
const operation: GatewayOperationRecord = { const operation: GatewayOperationRecord = {
id: '88888888-8888-4888-8888-888888888888', id: '88888888-8888-4888-8888-888888888888',
@@ -48,14 +48,14 @@ describeDatabase('selected workspace profile seed CLI', () => {
JSON.stringify({ JSON.stringify({
scenarioId: 1010, scenarioId: 1010,
tickSeconds: 60, tickSeconds: 60,
now: '2036-03-03T00:00:00.000Z', now: '2036-03-03T02:10:30.000Z',
installOptions: { installOptions: {
serverId: 'selected-cli-seed', serverId: 'selected-cli-seed',
firstGameIdx: 0, firstGameIdx: 0,
installOperationId: 'selected-cli-operation', installOperationId: 'selected-cli-operation',
installCommitSha: 'selected-cli-commit', installCommitSha: 'selected-cli-commit',
preopenAt: '2036-03-03T01:00:00.000Z', preopenAt: '2036-03-03T01:00:00.000Z',
openAt: '2036-03-03T02:00:00.000Z', openAt: '2036-03-03T02:10:30.000Z',
}, },
adminUser: { adminUser: {
id: 'selected-cli-admin', id: 'selected-cli-admin',
@@ -71,7 +71,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
const world = await connector.prisma.worldState.findFirstOrThrow(); const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({ expect(world).toMatchObject({
scenarioCode: '1010', scenarioCode: '1010',
clockWallAnchor: new Date('2036-03-03T02:00:00.000Z'), clockWallAnchor: new Date('2036-03-03T02:11:00.000Z'),
clockPhase: 'PREOPEN',
meta: { meta: {
firstGameIdx: 0, firstGameIdx: 0,
gameIdx: completedGameCount, gameIdx: completedGameCount,
@@ -83,6 +84,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
where: { userId: 'selected-cli-admin' }, where: { userId: 'selected-cli-admin' },
}); });
expect(adminGeneral).toMatchObject({ meta: { createdBy: 'admin-seed' } }); expect(adminGeneral).toMatchObject({ meta: { createdBy: 'admin-seed' } });
expect(adminGeneral.turnTick).toBeGreaterThanOrEqual(0n);
expect(adminGeneral.turnTick).toBeLessThan(36_000_000n);
const history = await connector.prisma.gameHistory.findUniqueOrThrow({ const history = await connector.prisma.gameHistory.findUniqueOrThrow({
where: { serverId: 'selected-cli-seed' }, where: { serverId: 'selected-cli-seed' },
}); });
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches', gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gameSchemaHead: '20260903201500_complete_invader_game_clock', gameSchemaHead: '20260906090000_add_turn_recovery_window',
}); });
}); });
+14 -3
View File
@@ -42,7 +42,9 @@
"clock_suspension.gap_ticks", "clock_suspension.gap_ticks",
"clock_suspension.shift_ticks", "clock_suspension.shift_ticks",
"clock_suspension.aligned_tick", "clock_suspension.aligned_tick",
"clock_projection_outbox.target_revision" "clock_projection_outbox.target_revision",
"world_state.clock_recovery_start_tick",
"world_state.clock_recovery_end_tick"
], ],
"wallTimeFields": [ "wallTimeFields": [
"input_event.created_at", "input_event.created_at",
@@ -85,8 +87,17 @@
{ {
"key": "world-clock", "key": "world-clock",
"policy": "REBUILD", "policy": "REBUILD",
"authorityFields": ["world_state.clock_tick", "world_state.clock_revision"], "authorityFields": [
"projectionFields": ["world_state.clock_base_time", "world_state.clock_wall_anchor"], "world_state.clock_tick",
"world_state.clock_revision",
"world_state.clock_recovery_start_tick",
"world_state.clock_recovery_end_tick"
],
"projectionFields": [
"world_state.clock_base_time",
"world_state.clock_wall_anchor",
"world_state.clock_recovery_start_wall_at"
],
"owner": "game-engine/clock-operation" "owner": "game-engine/clock-operation"
}, },
{ {
+53 -27
View File
@@ -5,11 +5,9 @@
Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000` Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000`
ticks. Wall time is separately authoritative for account, community, audit, ticks. Wall time is separately authoritative for account, community, audit,
lease, retry, notification, and operational rules. It is never projected into a lease, retry, notification, and operational rules. It is never projected into a
game deadline. A long suspension advances the observed game coordinate to the game deadline. A suspension keeps the normal schedule and all within-turn phases. Whole
resume wall instant without replaying skipped complete turns, monthly events, 12-turn blocks of outage are moved without executing gameplay; the remaining
RNG, auctions, or tournaments. Every movable future GAME schedule is shifted by one to eleven turns are executed at twice normal speed. Wall occurrences and
the same tick delta. Exact alignment includes the sub-turn remainder; Gateway
maintenance preserves schedules and delegates catch-up to the engine as described below. WALL occurrences and
deadlines are outside that operation. deadlines are outside that operation.
The clock state is stored in `world_state`: The clock state is stored in `world_state`:
@@ -33,7 +31,7 @@ progression.
A suspension begins under the turn-daemon fence and schema-scoped clock lock. A suspension begins under the turn-daemon fence and schema-scoped clock lock.
It records the cut tick, database wall instant, rate, source revision, and It records the cut tick, database wall instant, rate, source revision, and
participant checksum in `clock_suspension`. Resume reads the database wall participant checksum in `clock_suspension`. Resume reads the database wall
instant and builds an exact plan: instant and builds a policy plan. The historical `EXACT` policy uses:
```text ```text
gapTicks = max(0, ticksBetween(cutWall, resumeWall, rateAtCut)) gapTicks = max(0, ticksBetween(cutWall, resumeWall, rateAtCut))
@@ -42,29 +40,57 @@ alignedTick = cutTick + gapTicks
deadlineAfter = deadlineBefore + shiftTicks deadlineAfter = deadlineBefore + shiftTicks
``` ```
From 2026-09-06, Gateway maintenance suspension uses `PRESERVE_SCHEDULE`. From 2026-09-06, maintenance and crash recovery use `RECOVER_TURNS`.
Resume advances only the observed tick/anchor and revision; it does not move This supersedes the earlier same-day `PRESERVE_SCHEDULE` immediate catch-up
execution cursors, general schedules, auction/message deadlines, or their policy. The base turn length does not change. One turn remains 36,000,000
minute/second phases. The ordinary engine then processes overdue generals in ticks; a persisted `TurnRecoveryWindow` changes only the wall execution rate.
time order, bounded by one monthly boundary per pass, followed by that monthly
transition. Completed turns are not recreated.
The Core product policy for long realtime downtime is now independent of turn - Count complete overdue turns from the durable observation to the normal
length: fewer than 12 overdue turns are executed normally. At 12 or more turns, timeline. Skip only `floor(overdueTurns / 12) * 12` turns, moving future
only complete blocks of 12 are skipped. For example, a 13-turn backlog shifts schedules and the execution cursor by the same integer delta.
schedules by 12 turns and executes the remaining turn; 23 skips 12 and executes - Execute the sub-turn remainder immediately through the ordinary engine.
11; 24 skips 24. Skipping never advances gameplay years, resources, RNG, or Reach the next normal turn boundary at normal speed, then execute the
commands. The existing fenced backlog flush shifts the cursor and schedules remaining one to eleven turns of backlog at 2x speed.
together, retaining all sub-turn phases. Explicit operator schedule movement - Join the original schedule at the recorded end boundary and return to 1x.
remains a separate action. Four hours of backlog on a 60-minute server needs four hours at 2x; it runs
eight turns in that time. Resources, RNG, commands and monthly handlers run
normally for those turns, in the existing chronological order.
- Purchased within-turn offsets remain logical offsets. Their wall offsets
compress during recovery and return to the original minutes/seconds after
the end boundary. The API and browser expose the recovery interval.
This supersedes the short-lived maintenance `LEGACY_COMPLETE_TURNS` selection, `clock_recovery_start_tick`, `clock_recovery_end_tick`, and
which skipped every complete suspended turn without the 12-turn policy. Legacy `clock_recovery_start_wall_at` are an all-or-none durable window. Flush/reload
policy values remain readable for existing ledgers. Unification wait, delayed preserves it; a short restart reuses it. A new long outage replans against the
opening, and explicit `EXACT` callers keep their distinct exact alignment original normal timeline. Game-date epoch and wall epoch may differ; never
contract. Applied historical ledgers are not rewritten by deployment. Both convert the real wall instant using `dateToTick` to compute normal time.
Gateway (maintenance selection) and game engine (12-turn backlog handling) must
be deployed to activate the new behavior fully. Before a newly leased daemon permits independent workers to advance time, it
prepares recovery under the clock lock. `turn_daemon_lease.clock_ready` starts
false and becomes true only after durable recovery and projection-worker
setup. A paused profile is durably suspended during upgrade and stays suspended
until explicitly resumed. API/worker clock reads require a live ready lease at the read revision;
RECONCILING remains fenced until the Redis outbox is applied.
Planned realtime opening rounds upward to a turn boundary. The seed CLI passes
actual wall time separately from the requested game-calendar baseline, and
Gateway publishes the stored opening anchor for both display and scheduling. Unification wait
uses `TURN_BOUNDARY`, cuts at the completed monthly cursor, and resumes at the
next normal boundary without replaying the intentional waiting period. An old
pending unification ledger is upgraded on resume; applied history stays intact.
Explicit operator movement accepts signed whole turns only. A future resume
anchor gates execution until that wall instant. Base-rate changes are rejected
while a recovery window is active.
Legacy policy values stay readable for historical ledgers. Gateway and game
profiles deploy independently: the Gateway loads the profile workspace's Prisma
connector for profiles without the recovery migration and retains the existing
`PRESERVE_SCHEDULE` maintenance behavior there. It never writes new columns or
an accelerated recovery window through that legacy model. New profiles use the
new connector and `RECOVER_TURNS`, including upgrading a pending old maintenance
ledger on resume. Applied historical operations remain unchanged. DB-preserving
DEPLOY upgrades each profile independently; other profiles need not restart.
The release manifest declares `20260906090000_add_turn_recovery_window`.
Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision, Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision,
row count, and before/after checksum to `clock_reconciliation_participant`. row count, and before/after checksum to `clock_reconciliation_participant`.
+10
View File
@@ -18,6 +18,16 @@ effect; those are two facts, never one fallback clock.
`createdAt`/`updatedAt` fields remain wall audit timestamps unless this inventory `createdAt`/`updatedAt` fields remain wall audit timestamps unless this inventory
explicitly calls them game projections. explicitly calls them game projections.
## Recovery execution rate
A turn always contains 36,000,000 GAME ticks. The configured normal duration
remains unchanged during outage recovery. A persisted boundary-to-boundary
window projects GAME time at 2x wall speed, then 1x after rejoining the normal
schedule. The window and its wall anchor belong to clock authority; they are
not gameplay deadlines or wall audit occurrences. See
[reconciliation](./game-clock-reconciliation.md) for the 12-turn skip rule,
planned-start boundaries, readiness fencing and deployment compatibility.
## Game database inventory ## Game database inventory
PREOPEN user commands are executable even though scheduled turns are stopped. PREOPEN user commands are executable even though scheduled turns are stopped.
+1
View File
@@ -1,6 +1,7 @@
export * from './rng.js'; export * from './rng.js';
export * from './time/Clock.js'; export * from './time/Clock.js';
export * from './time/GameClock.js'; export * from './time/GameClock.js';
export * from './time/TurnRecovery.js';
export * from './time/ServerDateTime.js'; export * from './time/ServerDateTime.js';
export * from './util/BytesLike.js'; export * from './util/BytesLike.js';
export * from './util/convertBytesLikeToArrayBuffer.js'; export * from './util/convertBytesLikeToArrayBuffer.js';
+99 -6
View File
@@ -1,11 +1,21 @@
export const GAME_TICKS_PER_TURN = 36_000_000; import {
observeTurnRecovery,
nextTurnBoundary,
planTurnRecovery,
projectRecoveryDeadline,
validateTurnRecovery,
type TurnRecoveryWindow,
} from './TurnRecovery.js';
import { GAME_TICKS_PER_TURN, asGameTick, type GameTick } from './gameTimeUnits.js';
export { GAME_TICKS_PER_TURN, asGameTick, type GameTick } from './gameTimeUnits.js';
export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER; export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER;
export type GameClockMode = 'realtime' | 'manual'; export type GameClockMode = 'realtime' | 'manual';
export type GameClockPhase = 'PREOPEN' | 'RUNNING' | 'SUSPENDED' | 'RECONCILING' | 'MANUAL' | 'COMPLETED'; export type GameClockPhase = 'PREOPEN' | 'RUNNING' | 'SUSPENDED' | 'RECONCILING' | 'MANUAL' | 'COMPLETED';
export type ClockAlignmentPolicy = 'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP' | 'PRESERVE_SCHEDULE'; export type ClockAlignmentPolicy =
'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP' | 'PRESERVE_SCHEDULE' | 'RECOVER_TURNS' | 'TURN_BOUNDARY';
declare const gameTickBrand: unique symbol;
declare const observedGameInstantBrand: unique symbol; declare const observedGameInstantBrand: unique symbol;
declare const scheduleInstantBrand: unique symbol; declare const scheduleInstantBrand: unique symbol;
declare const clockRevisionBrand: unique symbol; declare const clockRevisionBrand: unique symbol;
@@ -13,7 +23,6 @@ declare const deadlineGenerationBrand: unique symbol;
declare const wallInstantBrand: unique symbol; declare const wallInstantBrand: unique symbol;
declare const monotonicDurationBrand: unique symbol; declare const monotonicDurationBrand: unique symbol;
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' }; export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' };
export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' }; export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' };
export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' }; export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' };
@@ -31,6 +40,8 @@ export interface ClockAlignmentPlan {
catchUpTicks: GameTick; catchUpTicks: GameTick;
shiftTicks: GameTick; shiftTicks: GameTick;
alignedTick: GameTick; alignedTick: GameTick;
recovery?: TurnRecoveryWindow | null;
resumeAnchor?: Date;
} }
export interface GameClockState { export interface GameClockState {
@@ -41,6 +52,7 @@ export interface GameClockState {
turnSeconds: number; turnSeconds: number;
phase?: GameClockPhase; phase?: GameClockPhase;
revision?: number; revision?: number;
recovery?: TurnRecoveryWindow | null;
} }
const requireSafeTick = (tick: number): number => { const requireSafeTick = (tick: number): number => {
@@ -50,8 +62,6 @@ const requireSafeTick = (tick: number): number => {
return tick; return tick;
}; };
export const asGameTick = (tick: number): GameTick => requireSafeTick(tick) as GameTick;
export const asObservedGameInstant = (tick: number): ObservedGameInstant => export const asObservedGameInstant = (tick: number): ObservedGameInstant =>
requireSafeTick(tick) as ObservedGameInstant; requireSafeTick(tick) as ObservedGameInstant;
@@ -108,6 +118,8 @@ const CLOCK_ALIGNMENT_POLICIES: readonly ClockAlignmentPolicy[] = [
'LEGACY_COMPLETE_TURNS', 'LEGACY_COMPLETE_TURNS',
'CATCH_UP', 'CATCH_UP',
'PRESERVE_SCHEDULE', 'PRESERVE_SCHEDULE',
'RECOVER_TURNS',
'TURN_BOUNDARY',
]; ];
export const parseClockAlignmentPolicy = (value: string): ClockAlignmentPolicy => { export const parseClockAlignmentPolicy = (value: string): ClockAlignmentPolicy => {
@@ -192,7 +204,56 @@ export const buildClockAlignmentPlan = (input: {
resumeWall: Date; resumeWall: Date;
ticksPerSecond: number; ticksPerSecond: number;
catchUpTicks?: number; catchUpTicks?: number;
normalTick?: number;
}): ClockAlignmentPlan => { }): ClockAlignmentPlan => {
if (input.policy === 'TURN_BOUNDARY') {
if (input.cutTick % GAME_TICKS_PER_TURN !== 0 || (input.catchUpTicks ?? 0) !== 0) {
throw new Error('Planned resume requires a suspended turn boundary and no catch-up.');
}
const exact = buildAlignmentPlan({ ...input, catchUpTicks: 0 });
const normalTick = input.normalTick ?? exact.alignedTick;
const alignedTick = nextTurnBoundary(Math.max(input.cutTick, normalTick));
return {
...exact,
alignedTick,
shiftTicks: asGameTick(alignedTick - input.cutTick),
catchUpTicks: asGameTick(0),
resumeAnchor: new Date(
input.resumeWall.getTime() + Math.ceil(((alignedTick - normalTick) * 1_000) / input.ticksPerSecond)
),
recovery: null,
};
}
if (input.policy === 'RECOVER_TURNS') {
if ((input.catchUpTicks ?? 0) !== 0)
throw new Error('Turn recovery derives its backlog from the saved observation.');
const exact = buildAlignmentPlan({ ...input, catchUpTicks: 0 });
const recovery = planTurnRecovery({
observedTick: input.cutTick,
normalTick: input.normalTick ?? exact.alignedTick,
wallNow: input.resumeWall,
turnSeconds: GAME_TICKS_PER_TURN / input.ticksPerSecond,
});
const shiftTicks = asGameTick(recovery.skippedTurns * GAME_TICKS_PER_TURN);
return {
...exact,
shiftTicks,
catchUpTicks: asGameTick(Math.max(0, (input.normalTick ?? exact.alignedTick) - input.cutTick - shiftTicks)),
alignedTick: recovery.initialTick,
recovery: recovery.recovery,
...(recovery.initialTick > (input.normalTick ?? exact.alignedTick)
? {
resumeAnchor: new Date(
input.resumeWall.getTime() +
Math.ceil(
((recovery.initialTick - (input.normalTick ?? exact.alignedTick)) * 1_000) /
input.ticksPerSecond
)
),
}
: {}),
};
}
if (input.policy === 'PRESERVE_SCHEDULE') { if (input.policy === 'PRESERVE_SCHEDULE') {
if ((input.catchUpTicks ?? 0) !== 0) { if ((input.catchUpTicks ?? 0) !== 0) {
throw new Error('PRESERVE_SCHEDULE derives catch-up from the complete wall gap.'); throw new Error('PRESERVE_SCHEDULE derives catch-up from the complete wall gap.');
@@ -239,6 +300,7 @@ export class GameClock {
readonly ticksPerSecond: number; readonly ticksPerSecond: number;
readonly phase: GameClockPhase; readonly phase: GameClockPhase;
readonly revision: ClockRevision; readonly revision: ClockRevision;
readonly recovery: TurnRecoveryWindow | null;
constructor(state: GameClockState) { constructor(state: GameClockState) {
if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) { if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) {
@@ -261,6 +323,10 @@ export class GameClock {
this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds; this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds;
this.phase = state.phase ?? inferClockPhase(state.mode); this.phase = state.phase ?? inferClockPhase(state.mode);
this.revision = asClockRevision(state.revision ?? 1); this.revision = asClockRevision(state.revision ?? 1);
this.recovery = state.recovery
? { ...state.recovery, startWallAt: new Date(state.recovery.startWallAt) }
: null;
if (this.recovery) validateTurnRecovery(this.recovery);
} }
static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date { static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date {
@@ -291,6 +357,9 @@ export class GameClock {
) { ) {
return this.tick; return this.tick;
} }
if (this.recovery && this.phase === 'RUNNING') {
return Math.max(this.tick, observeTurnRecovery(this.recovery, wallNow, this.ticksPerSecond));
}
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow); const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
// A future realtime anchor represents the formal opening at anchor tick. // A future realtime anchor represents the formal opening at anchor tick.
// Before that instant Ref exposes the elapsed offset as a negative tick, // Before that instant Ref exposes the elapsed offset as a negative tick,
@@ -307,6 +376,30 @@ export class GameClock {
return this.tickToDate(this.nowTick(wallNow)); return this.tickToDate(this.nowTick(wallNow));
} }
/** 가속·대기와 별개로 유저가 익숙한 기존 시간표의 현재 좌표를 구한다. */
normalNowTick(wallNow: Date): number {
if (this.recovery) {
return this.addTicks(
(this.recovery.startTick + this.recovery.endTick) / 2,
this.ticksBetween(this.recovery.startWallAt, wallNow)
);
}
return this.addTicks(this.tick, this.ticksBetween(this.wallAnchor, wallNow));
}
/** tickToDate는 안정된 게임 좌표이며 이 메서드만 실제 실행 예정 시각을 반환한다. */
tickToWallDate(tick: number): Date {
return this.recovery
? projectRecoveryDeadline(this.recovery, tick, this.ticksPerSecond)
: new Date(this.wallAnchor.getTime() + tickOffsetMilliseconds(tick - this.tick, this.ticksPerSecond));
}
executionRate(wallNow: Date): 1 | 2 {
if (!this.recovery || this.phase !== 'RUNNING' || this.mode !== 'realtime') return 1;
const end = projectRecoveryDeadline(this.recovery, this.recovery.endTick, this.ticksPerSecond);
return wallNow >= this.recovery.startWallAt && wallNow < end ? 2 : 1;
}
dateToTick(date: Date): number { dateToTick(date: Date): number {
return requireSafeTick(this.ticksBetween(this.baseTime, date)); return requireSafeTick(this.ticksBetween(this.baseTime, date));
} }
+145
View File
@@ -0,0 +1,145 @@
import { asGameTick, GAME_TICKS_PER_TURN, type GameTick } from './gameTimeUnits.js';
/** 정상 시간표는 바꾸지 않고, 정수 턴의 지연만 두 배 속도로 소진한다. */
export interface TurnRecoveryWindow {
startTick: GameTick;
endTick: GameTick;
startWallAt: Date;
}
export const readTurnRecovery = (row: {
clockRecoveryStartTick?: bigint | number | null;
clockRecoveryEndTick?: bigint | number | null;
clockRecoveryStartWallAt?: Date | null;
}): TurnRecoveryWindow | null => {
const values = [row.clockRecoveryStartTick, row.clockRecoveryEndTick, row.clockRecoveryStartWallAt];
if (values.every((value) => value == null)) return null;
if (values.some((value) => value == null)) throw new Error('Incomplete durable turn recovery window.');
const window = {
startTick: asGameTick(Number(row.clockRecoveryStartTick)),
endTick: asGameTick(Number(row.clockRecoveryEndTick)),
startWallAt: new Date(row.clockRecoveryStartWallAt!),
};
validateTurnRecovery(window);
return window;
};
export const serializeTurnRecovery = (window: TurnRecoveryWindow | null) => ({
clockRecoveryStartTick: window?.startTick ?? null,
clockRecoveryEndTick: window?.endTick ?? null,
clockRecoveryStartWallAt: window?.startWallAt.toISOString() ?? null,
});
export const readSerializedTurnRecovery = (value: unknown): TurnRecoveryWindow | null => {
if (value == null) return null;
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid serialized recovery window.');
const row = value as Record<string, unknown>;
if (row.clockRecoveryStartTick == null && row.clockRecoveryEndTick == null && row.clockRecoveryStartWallAt == null)
return null;
if (
typeof row.clockRecoveryStartTick !== 'number' ||
typeof row.clockRecoveryEndTick !== 'number' ||
typeof row.clockRecoveryStartWallAt !== 'string'
) {
throw new Error('Incomplete serialized recovery window.');
}
return readTurnRecovery({
clockRecoveryStartTick: row.clockRecoveryStartTick,
clockRecoveryEndTick: row.clockRecoveryEndTick,
clockRecoveryStartWallAt: new Date(row.clockRecoveryStartWallAt),
});
};
export interface TurnRecoveryPlan {
skippedTurns: number;
recoveryTurns: number;
initialTick: GameTick;
recovery: TurnRecoveryWindow | null;
}
export const nextTurnBoundary = (tick: number): GameTick => {
asGameTick(tick);
return asGameTick(Math.ceil(tick / GAME_TICKS_PER_TURN) * GAME_TICKS_PER_TURN);
};
/** 운영자 이동은 정수 턴으로만 받는다. 과거 실행의 취소를 뜻하지 않는다. */
export const turnShiftTicks = (turns: number): GameTick => {
if (!Number.isSafeInteger(turns)) throw new Error('Schedule movement requires an integer number of turns.');
return asGameTick(turns * GAME_TICKS_PER_TURN);
};
/**
* observedTick은 , normalTick은 .
* , .
* skip은 cursor에 .
*/
export const planTurnRecovery = (input: {
observedTick: number;
normalTick: number;
wallNow: Date;
turnSeconds: number;
}): TurnRecoveryPlan => {
const { observedTick, normalTick, wallNow, turnSeconds } = input;
asGameTick(observedTick);
asGameTick(normalTick);
if (!Number.isInteger(turnSeconds) || turnSeconds <= 0 || GAME_TICKS_PER_TURN % turnSeconds !== 0) {
throw new Error('Recovery requires a representable positive turn length.');
}
if (!Number.isFinite(wallNow.getTime())) throw new Error('Recovery wall instant is invalid.');
const overdueTurns = Math.max(0, Math.floor((normalTick - observedTick) / GAME_TICKS_PER_TURN));
const skippedTurns = Math.floor(overdueTurns / 12) * 12;
const recoveryTurns = overdueTurns % 12;
const initialTick = asGameTick(
Math.max(observedTick + turnShiftTicks(skippedTurns), normalTick - turnShiftTicks(recoveryTurns))
);
if (recoveryTurns === 0) return { skippedTurns, recoveryTurns, initialTick, recovery: null };
const boundary = nextTurnBoundary(normalTick);
const startWallAt = new Date(
wallNow.getTime() + Math.ceil(((boundary - normalTick) * turnSeconds * 1_000) / GAME_TICKS_PER_TURN)
);
return {
skippedTurns,
recoveryTurns,
initialTick,
recovery: {
startTick: asGameTick(boundary - turnShiftTicks(recoveryTurns)),
endTick: asGameTick(boundary + turnShiftTicks(recoveryTurns)),
startWallAt,
},
};
};
export const validateTurnRecovery = (window: TurnRecoveryWindow): void => {
asGameTick(window.startTick);
asGameTick(window.endTick);
const span = window.endTick - window.startTick;
if (
!Number.isFinite(window.startWallAt.getTime()) ||
window.startTick % GAME_TICKS_PER_TURN !== 0 ||
window.endTick % GAME_TICKS_PER_TURN !== 0 ||
span <= 0 ||
span % (2 * GAME_TICKS_PER_TURN) !== 0 ||
span >= 24 * GAME_TICKS_PER_TURN
)
throw new Error('Recovery must join turn boundaries after one to eleven turns at double speed.');
};
/** 경계 전에는 정상 속도, 복구 구간은 두 배, 합류 경계 이후는 정상 속도이다. */
export const observeTurnRecovery = (window: TurnRecoveryWindow, wallNow: Date, ticksPerSecond: number): GameTick => {
validateTurnRecovery(window);
const elapsed = asGameTick(
Math.trunc(((wallNow.getTime() - window.startWallAt.getTime()) * ticksPerSecond) / 1_000)
);
const halfSpan = (window.endTick - window.startTick) / 2;
return asGameTick(window.startTick + elapsed + Math.max(0, Math.min(elapsed, halfSpan)));
};
/** 게임 좌표의 예정 시각을 사용자에게 표시할 실제 실행 시각으로 투영한다. */
export const projectRecoveryDeadline = (window: TurnRecoveryWindow, tick: number, ticksPerSecond: number): Date => {
validateTurnRecovery(window);
asGameTick(tick);
const offset = tick - window.startTick;
const span = window.endTick - window.startTick;
const elapsed = offset < 0 ? offset : offset <= span ? offset / 2 : offset - span / 2;
return new Date(window.startWallAt.getTime() + Math.ceil((elapsed * 1_000) / ticksPerSecond));
};
@@ -0,0 +1,9 @@
export const GAME_TICKS_PER_TURN = 36_000_000;
declare const gameTickBrand: unique symbol;
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
export const asGameTick = (tick: number): GameTick => {
if (!Number.isSafeInteger(tick)) throw new Error(`Game tick must be a safe integer: ${tick}`);
return tick as GameTick;
};
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { GAME_TICKS_PER_TURN as T, GameClock, buildClockAlignmentPlan } from '../src/time/GameClock.js';
import {
nextTurnBoundary,
observeTurnRecovery,
planTurnRecovery,
projectRecoveryDeadline,
turnShiftTicks,
} from '../src/time/TurnRecovery.js';
const base = Date.parse('2026-09-06T00:00:00Z');
const wall = (hours: number) => new Date(base + hours * 3_600_000);
describe('turn-aligned double-speed recovery', () => {
it('reloads midway through acceleration without restarting the recovery duration', () => {
const { recovery } = planTurnRecovery({
observedTick: 0,
normalTick: 4 * T,
wallNow: wall(4),
turnSeconds: 3600,
});
const reloaded = new GameClock({
baseTime: wall(0),
tick: 2 * T,
wallAnchor: wall(5),
mode: 'realtime',
turnSeconds: 3600,
recovery,
});
expect(reloaded.nowTick(wall(6))).toBe(4 * T);
expect(reloaded.nowTick(wall(8))).toBe(8 * T);
expect(reloaded.nowTick(wall(9))).toBe(9 * T);
expect(reloaded.normalNowTick(wall(6))).toBe(6 * T);
expect(reloaded.executionRate(wall(7))).toBe(2);
expect(reloaded.executionRate(wall(8))).toBe(1);
});
it('resumes a planned wait at a whole-turn boundary without changing purchased phase', () => {
const plan = buildClockAlignmentPlan({
policy: 'TURN_BOUNDARY',
sourceRevision: 1,
cutTick: 0,
cutWall: wall(0),
resumeWall: wall(4 + 1 / 3),
ticksPerSecond: 10_000,
});
expect(plan.shiftTicks).toBe(5 * T);
expect(plan.alignedTick).toBe(5 * T);
expect(plan.resumeAnchor).toEqual(wall(5));
expect((199_020 + plan.shiftTicks) % T).toBe(199_020);
});
it('preserves a normal schedule whose game epoch differs from the real opening date', () => {
const plan = buildClockAlignmentPlan({
policy: 'RECOVER_TURNS',
sourceRevision: 1,
cutTick: 0,
cutWall: wall(100),
resumeWall: wall(104),
ticksPerSecond: 10_000,
normalTick: 4 * T,
});
expect(plan.shiftTicks).toBe(0);
const clock = new GameClock({
baseTime: wall(0),
tick: plan.alignedTick,
wallAnchor: wall(104),
turnSeconds: 3600,
mode: 'realtime',
recovery: plan.recovery,
});
expect(clock.nowTick(wall(108))).toBe(8 * T);
expect(clock.tickToWallDate(8 * T + 199_020)).toEqual(new Date(wall(108).getTime() + 19_902));
});
it.each([0, 1, 4, 11, 12, 13, 23, 24, 28])('preserves twelve-turn blocks for %i overdue turns', (turns) => {
const plan = planTurnRecovery({
observedTick: 0,
normalTick: turns * T,
wallNow: wall(turns),
turnSeconds: 3600,
});
expect(plan.skippedTurns).toBe(Math.floor(turns / 12) * 12);
expect(plan.recoveryTurns).toBe(turns % 12);
expect(plan.initialTick).toBe(plan.skippedTurns * T);
if (!plan.recovery) return;
const end = turns + plan.recoveryTurns;
expect(observeTurnRecovery(plan.recovery, wall(end), 10_000)).toBe(end * T);
expect(observeTurnRecovery(plan.recovery, wall(end + 1), 10_000)).toBe((end + 1) * T);
});
it('executes four delayed turns over four hours and meets the original eight-hour boundary', () => {
const { recovery } = planTurnRecovery({
observedTick: 0,
normalTick: 4 * T,
wallNow: wall(4),
turnSeconds: 3600,
});
expect(recovery).not.toBeNull();
expect(observeTurnRecovery(recovery!, wall(4), 10_000)).toBe(0);
expect(observeTurnRecovery(recovery!, wall(5), 10_000)).toBe(2 * T);
expect(observeTurnRecovery(recovery!, wall(8), 10_000)).toBe(8 * T);
expect(observeTurnRecovery(recovery!, wall(9), 10_000)).toBe(9 * T);
});
it('retains the fractional phase and begins acceleration at the next boundary', () => {
const plan = planTurnRecovery({
observedTick: 0,
normalTick: 4 * T + T / 3,
wallNow: wall(4 + 1 / 3),
turnSeconds: 3600,
});
expect(plan.initialTick).toBe(T / 3);
expect(plan.recovery!.startWallAt).toEqual(wall(5));
expect(observeTurnRecovery(plan.recovery!, wall(4.5), 10_000)).toBe(T / 2);
expect(observeTurnRecovery(plan.recovery!, wall(5), 10_000)).toBe(T);
expect(observeTurnRecovery(plan.recovery!, wall(9), 10_000)).toBe(9 * T);
});
it('preserves purchased phase coordinates while projecting compressed wall deadlines', () => {
const { recovery } = planTurnRecovery({
observedTick: 0,
normalTick: 4 * T,
wallNow: wall(4),
turnSeconds: 3600,
});
const phase = 199_020; // 00:19.902 at normal speed
expect(projectRecoveryDeadline(recovery!, phase, 10_000).getTime()).toBe(wall(4).getTime() + 9951);
expect(projectRecoveryDeadline(recovery!, 8 * T + phase, 10_000).getTime()).toBe(wall(8).getTime() + 19902);
});
it('does not rewind a persisted observation when wall time moves backwards', () => {
const plan = planTurnRecovery({ observedTick: 5 * T, normalTick: 4 * T, wallNow: wall(4), turnSeconds: 3600 });
expect(plan.initialTick).toBe(5 * T);
expect(plan.recovery).toBeNull();
});
it('accepts signed whole-turn shifts and rejects fractional movement', () => {
expect(turnShiftTicks(-4)).toBe(-4 * T);
expect(turnShiftTicks(12)).toBe(12 * T);
expect(() => turnShiftTicks(0.5)).toThrow();
expect(nextTurnBoundary(4 * T + 1)).toBe(5 * T);
expect(nextTurnBoundary(4 * T)).toBe(4 * T);
});
});
+4
View File
@@ -145,6 +145,7 @@ model TurnDaemonLease {
ownerId String @map("owner_id") ownerId String @map("owner_id")
leaseUntil DateTime @map("lease_until") leaseUntil DateTime @map("lease_until")
fencingEpoch BigInt @default(1) @map("fencing_epoch") fencingEpoch BigInt @default(1) @map("fencing_epoch")
clockReady Boolean @default(false) @map("clock_ready")
heartbeatAt DateTime @default(now()) @map("heartbeat_at") heartbeatAt DateTime @default(now()) @map("heartbeat_at")
@@map("turn_daemon_lease") @@map("turn_daemon_lease")
@@ -160,6 +161,9 @@ model WorldState {
clockTick BigInt? @map("clock_tick") clockTick BigInt? @map("clock_tick")
clockMode String @default("realtime") @map("clock_mode") clockMode String @default("realtime") @map("clock_mode")
clockWallAnchor DateTime? @map("clock_wall_anchor") clockWallAnchor DateTime? @map("clock_wall_anchor")
clockRecoveryStartTick BigInt? @map("clock_recovery_start_tick")
clockRecoveryEndTick BigInt? @map("clock_recovery_end_tick")
clockRecoveryStartWallAt DateTime? @map("clock_recovery_start_wall_at")
lastTurnTick BigInt? @map("last_turn_tick") lastTurnTick BigInt? @map("last_turn_tick")
clockPhase String @default("RUNNING") @map("clock_phase") clockPhase String @default("RUNNING") @map("clock_phase")
clockRevision BigInt @default(1) @map("clock_revision") clockRevision BigInt @default(1) @map("clock_revision")
@@ -0,0 +1,18 @@
ALTER TABLE "turn_daemon_lease" ADD COLUMN "clock_ready" BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE "world_state"
ADD COLUMN "clock_recovery_start_tick" BIGINT,
ADD COLUMN "clock_recovery_end_tick" BIGINT,
ADD COLUMN "clock_recovery_start_wall_at" TIMESTAMP(3),
ADD CONSTRAINT "world_state_turn_recovery_window_check" CHECK (
("clock_recovery_start_tick" IS NULL AND "clock_recovery_end_tick" IS NULL AND "clock_recovery_start_wall_at" IS NULL)
OR (
"clock_recovery_start_tick" IS NOT NULL AND "clock_recovery_end_tick" IS NOT NULL AND "clock_recovery_start_wall_at" IS NOT NULL
AND "clock_recovery_start_tick" BETWEEN -9007199254740991 AND 9007199254740991
AND "clock_recovery_end_tick" BETWEEN -9007199254740991 AND 9007199254740991
AND "clock_recovery_start_tick" % 36000000 = 0
AND "clock_recovery_end_tick" % 36000000 = 0
AND "clock_recovery_end_tick" - "clock_recovery_start_tick" BETWEEN 72000000 AND 792000000
AND ("clock_recovery_end_tick" - "clock_recovery_start_tick") % 72000000 = 0
)
);
+26 -2
View File
@@ -1,4 +1,4 @@
import { GameClock, inferClockPhase, parseGameClockPhase } from '@sammo-ts/common'; import { GameClock, readTurnRecovery, inferClockPhase, parseGameClockPhase } from '@sammo-ts/common';
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js'; import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
import { acquireGameSchemaAdvisoryXactLock, CLOCK_OPERATION_PERSISTENCE_LOCK } from './gameSchemaAdvisoryLock.js'; import { acquireGameSchemaAdvisoryXactLock, CLOCK_OPERATION_PERSISTENCE_LOCK } from './gameSchemaAdvisoryLock.js';
@@ -9,6 +9,21 @@ interface DbWallRow {
wallNow: Date; wallNow: Date;
} }
/** 새 daemon의 시계 복구가 끝나기 전에는 독립 worker가 시간을 진행하지 않는다. */
export const readTurnRuntimeReady = async (
db: Pick<GamePrismaClient, '$queryRaw'>,
revision: bigint
): Promise<boolean> => {
const [row] = await db.$queryRaw<Array<{ ready: boolean }>>(GamePrisma.sql`
SELECT EXISTS (
SELECT 1 FROM turn_daemon_lease, world_state
WHERE clock_ready = TRUE AND lease_until > timezone('UTC', clock_timestamp())
AND clock_revision = ${revision}
) AS ready
`);
return row?.ready === true;
};
export interface InputEventClockCoordinate { export interface InputEventClockCoordinate {
wallAt: Date; wallAt: Date;
gameAt: Date; gameAt: Date;
@@ -38,6 +53,9 @@ export const readInputEventClockCoordinate = async (
clockTick: true, clockTick: true,
clockMode: true, clockMode: true,
clockWallAnchor: true, clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
tickSeconds: true, tickSeconds: true,
clockPhase: true, clockPhase: true,
clockRevision: true, clockRevision: true,
@@ -60,11 +78,17 @@ export const readInputEventClockCoordinate = async (
tick, tick,
mode, mode,
wallAnchor: world.clockWallAnchor, wallAnchor: world.clockWallAnchor,
recovery: readTurnRecovery(world),
turnSeconds: world.tickSeconds, turnSeconds: world.tickSeconds,
phase, phase,
revision, revision,
}); });
const observedTick = clock.nowTick(wall.wallNow); const ready =
phase !== 'RUNNING' ||
mode !== 'realtime' ||
world.clockRecoveryStartTick === undefined ||
(await readTurnRuntimeReady(db, world.clockRevision));
const observedTick = ready ? clock.nowTick(wall.wallNow) : clock.tick;
return { return {
wallAt: wall.wallNow, wallAt: wall.wallNow,
gameAt: clock.tickToDate(observedTick), gameAt: clock.tickToDate(observedTick),
+9
View File
@@ -15,6 +15,9 @@ export interface TurnEngineWorldStateRow {
clockTick: bigint | null; clockTick: bigint | null;
clockMode: string; clockMode: string;
clockWallAnchor: Date | null; clockWallAnchor: Date | null;
clockRecoveryStartTick?: bigint | null;
clockRecoveryEndTick?: bigint | null;
clockRecoveryStartWallAt?: Date | null;
lastTurnTick: bigint | null; lastTurnTick: bigint | null;
clockPhase: string; clockPhase: string;
clockRevision: bigint; clockRevision: bigint;
@@ -183,6 +186,9 @@ export interface TurnEngineWorldStateUpdateInput {
clockTick: bigint; clockTick: bigint;
clockMode: string; clockMode: string;
clockWallAnchor: Date; clockWallAnchor: Date;
clockRecoveryStartTick?: bigint | null;
clockRecoveryEndTick?: bigint | null;
clockRecoveryStartWallAt?: Date | null;
lastTurnTick: bigint; lastTurnTick: bigint;
clockPhase: string; clockPhase: string;
clockRevision: bigint; clockRevision: bigint;
@@ -200,6 +206,9 @@ export interface TurnEngineWorldStateCreateInput {
clockTick: bigint; clockTick: bigint;
clockMode: string; clockMode: string;
clockWallAnchor: Date; clockWallAnchor: Date;
clockRecoveryStartTick?: bigint | null;
clockRecoveryEndTick?: bigint | null;
clockRecoveryStartWallAt?: Date | null;
lastTurnTick: bigint; lastTurnTick: bigint;
clockPhase: string; clockPhase: string;
clockRevision: bigint; clockRevision: bigint;
+1 -1
View File
@@ -2,6 +2,6 @@
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches", "gatewaySchemaHead": "20260825000000_add_bulk_release_batches",
"gameSchemaHead": "20260903201500_complete_invader_game_clock", "gameSchemaHead": "20260906090000_add_turn_recovery_window",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }