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'
| 'clockMode'
| 'clockWallAnchor'
| 'clockRecoveryStartTick'
| 'clockRecoveryEndTick'
| 'clockRecoveryStartWallAt'
| 'lastTurnTick'
| 'clockPhase'
| 'clockRevision'
+1
View File
@@ -74,6 +74,7 @@ export const lobbyRouter = router({
clockMode: gameTime.mode ?? 'realtime',
clockRunning: gameTime.running,
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
clockRecovery: gameTime.recovery ?? null,
turnEngineRunning,
otherTextInfo: worldState.meta.otherTextInfo ?? '',
npcMode: worldState.config.npcMode ?? 0,
+25 -3
View File
@@ -1,5 +1,6 @@
import {
GameClock,
readTurnRecovery,
inferClockPhase,
parseGameClockPhase,
type GameClockMode,
@@ -7,6 +8,7 @@ import {
} from '@sammo-ts/common';
import type { DatabaseClient } from '../context.js';
import { readTurnRuntimeReady } from '@sammo-ts/infra';
export interface CurrentGameTime {
now: Date;
@@ -17,6 +19,8 @@ export interface CurrentGameTime {
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean;
runtimeReady?: boolean;
recovery?: { startsAt: string; endsAt: string } | null;
startsAt: Date | null;
dateToTick(date: Date): number | null;
}
@@ -43,6 +47,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockTick: true,
clockMode: true,
clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
tickSeconds: true,
clockPhase: true,
clockRevision: true,
@@ -86,12 +93,18 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
tick: storedTick,
mode,
wallAnchor: state.clockWallAnchor,
recovery: readTurnRecovery(state),
turnSeconds: state.tickSeconds,
phase,
revision,
});
const tick = clock.nowTick(wallNow);
const running = phase === 'RUNNING' && mode === 'realtime';
const runtimeReady =
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 {
now: clock.tickToDate(tick),
wallNow,
@@ -101,7 +114,16 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
revision,
deadlineGeneration,
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),
};
};
@@ -39,6 +39,7 @@ const ensureRedisClockFence = async (
allowedPhases: readonly GameClockPhase[]
): Promise<ActiveRedisClockFence | null> => {
if (
gameTime.runtimeReady === false ||
!gameTime.phase ||
!allowedPhases.includes(gameTime.phase) ||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
@@ -68,6 +69,7 @@ export const ensureActiveRedisClockFence = async (
profileName: string,
gameTime: CurrentGameTime
): Promise<ActiveRedisClockFence | null> => {
if (!gameTime.running) return null;
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
};
+35
View File
@@ -23,6 +23,41 @@ const buildDatabase = (
}) as unknown as DatabaseClient;
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 () => {
const db = buildDatabase();
@@ -93,6 +93,7 @@ export class DatabaseTurnDaemonLease {
ON CONFLICT ("profile") DO UPDATE
SET
"owner_id" = EXCLUDED."owner_id",
"clock_ready" = FALSE,
"lease_until" = EXCLUDED."lease_until",
"fencing_epoch" = CASE
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
@@ -126,6 +127,14 @@ export class DatabaseTurnDaemonLease {
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 {
return this.lost;
}
@@ -170,6 +170,11 @@ export class TurnDaemonLifecycle {
await this.clock.sleepMs(500);
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를 통과해야 한다.
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
if (this.pendingRun) {
@@ -219,7 +224,10 @@ export class TurnDaemonLifecycle {
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) {
await this.handleCommand(command);
}
+2
View File
@@ -72,11 +72,13 @@ export interface TurnStateStore {
phase?: GameClockPhase;
revision?: number;
deadlineGeneration?: number;
startsAt?: Date;
}>;
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
projectGameDeadline?(gameTime: Date): Promise<Date>;
}
export interface TurnDaemonControlQueue {
@@ -571,7 +571,10 @@ const cancelGameInTransaction = async (
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 (!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.');
@@ -580,7 +583,7 @@ export const cancelGame = async (request: GameCancellationRequest): Promise<Game
earnedPoint: 0,
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
});
const connector = createGamePostgresConnector({ url: request.databaseUrl });
const connector = connectorFactory({ url: request.databaseUrl });
await connector.connect();
try {
return await connector.prisma.$transaction(
+12 -4
View File
@@ -68,6 +68,8 @@ export interface ScenarioSeedOptions {
generalPoolOptions?: GeneralPoolLoaderOptions;
resetTables?: boolean;
now?: Date;
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
wallNow?: Date;
tickSeconds?: number;
gameClockMode?: GameClockMode;
installOptions?: ScenarioInstallOptions;
@@ -244,8 +246,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
const gameClockMode = options.gameClockMode ?? 'realtime';
// A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
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({
baseTime: startState.startTime,
tick: 0,
@@ -317,7 +325,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
starttime: formatDateTime(startState.startTime),
turntime: formatDateTime(now),
opentime: formatDateTime(now),
opentime: formatDateTime(initialClockWallAnchor),
lastTurnTime: formatDateTime(now),
};
@@ -344,7 +352,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
}
worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.toISOString();
worldMeta.seededAtWall = wallNow.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString();
@@ -332,7 +332,7 @@ const respondToRaiseInvader = async (options: {
reservedTurns,
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
clockWallNow: alignment.resumeWallAt,
clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt,
});
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
await handler(
+103 -6
View File
@@ -7,6 +7,10 @@ import {
buildClockAlignmentPlan,
parseClockAlignmentPolicy,
parseGameClockPhase,
readTurnRecovery,
readSerializedTurnRecovery,
serializeTurnRecovery,
type TurnRecoveryWindow,
type ClockAlignmentPolicy,
} from '@sammo-ts/common';
import {
@@ -43,6 +47,8 @@ export interface ClockReconciliationResult {
shiftTicks: number;
alignedTick: number;
resumeWallAt: Date;
recovery?: TurnRecoveryWindow | null;
resumeAnchor?: Date;
}
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 (
db: GamePrisma.TransactionClient,
worldStateId: number,
@@ -224,6 +234,13 @@ const readParticipantSnapshots = async (
where: { id: worldStateId },
select: {
clockTick: true,
...(supportsRecoveryColumns(db)
? {
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
}
: {}),
clockRevision: true,
deadlineGeneration: true,
lastTurnTick: true,
@@ -290,6 +307,7 @@ const readParticipantSnapshots = async (
snapshot('world-clock', 'REBUILD', [
{
clockTick: world.clockTick,
...serializeTurnRecovery(readTurnRecovery(world)),
clockRevision: world.clockRevision,
deadlineGeneration: world.deadlineGeneration,
},
@@ -431,6 +449,7 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
cutWallAt: Date;
rateTicksPerSecond: number;
sourceRevision: number;
normalTickAtCutWall?: number;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
}): Promise<void> => {
@@ -475,7 +494,11 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
rateTicksPerSecond: options.rateTicksPerSecond,
catchUpTicks: BigInt(catchUpTicks),
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);
@@ -712,6 +735,7 @@ export const startClockSuspension = async (options: {
authority: ClockOperationAuthority;
policy?: ClockAlignmentPolicy;
catchUpTicks?: number;
recoverDurableObservation?: boolean;
}): Promise<ClockSuspensionResult> => {
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
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) {
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 sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
const clock = new GameClock({
@@ -769,11 +799,12 @@ export const startClockSuspension = async (options: {
tick: storedTick,
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: world.clockWallAnchor,
recovery: readTurnRecovery(world),
turnSeconds: world.tickSeconds,
phase,
revision: sourceRevision,
});
const cutTick = clock.nowTick(cutWallAt);
const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt);
await lockParticipants(db, BigInt(cutTick));
await db.worldState.update({
where: { id: worldStateId },
@@ -797,6 +828,7 @@ export const startClockSuspension = async (options: {
detail: asJson({
authority: options.authority.kind,
profileName: options.authority.profileName,
normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)),
}),
},
});
@@ -827,6 +859,7 @@ export const reconcileClockSuspensionInTransaction = async (options: {
authority?: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> => {
const db = options.db;
const worldStateId = await lockWorld(db);
@@ -855,6 +888,14 @@ export const reconcileClockSuspensionInTransaction = async (options: {
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
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') {
@@ -882,14 +923,41 @@ export const reconcileClockSuspensionInTransaction = async (options: {
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 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({
policy: parseClockAlignmentPolicy(suspension.policy),
policy: effectivePolicy,
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
cutTick,
cutTick: alignmentCutTick,
cutWall: suspension.cutWallAt,
resumeWall: resumeWallAt,
ticksPerSecond: suspension.rateTicksPerSecond,
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);
assertShiftFits(before, plan.shiftTicks);
@@ -908,8 +976,21 @@ export const reconcileClockSuspensionInTransaction = async (options: {
targetGeneration,
BigInt(plan.shiftTicks),
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 afterByKey = new Map(after.map((participant) => [participant.key, participant]));
for (const participant of before) {
@@ -967,8 +1048,20 @@ export const reconcileClockSuspensionInTransaction = async (options: {
where: { id: suspension.id },
data: {
status: 'RECONCILING',
...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}),
resumeWallAt,
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),
alignedTick: BigInt(plan.alignedTick),
participantChecksumBefore: aggregateChecksum(before),
@@ -986,6 +1079,8 @@ export const reconcileClockSuspensionInTransaction = async (options: {
shiftTicks: plan.shiftTicks,
alignedTick: plan.alignedTick,
resumeWallAt,
recovery: plan.recovery ?? null,
resumeAnchor: plan.resumeAnchor ?? resumeWallAt,
};
};
@@ -1024,6 +1119,7 @@ export const reconcileClockSuspension = async (options: {
authority: ClockOperationAuthority;
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
testResumeWallAt?: Date;
upgradeMaintenancePolicy?: boolean;
}): Promise<ClockReconciliationResult> =>
runSerializableClockOperation(() =>
options.db.$transaction(
@@ -1037,6 +1133,7 @@ export const reconcileClockSuspension = async (options: {
profileName: options.authority.profileName,
authority: options.authority,
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
upgradeMaintenancePolicy: options.upgradeMaintenancePolicy,
});
},
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
+30 -1
View File
@@ -65,6 +65,7 @@ import {
} from './clockReconciliation.js';
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -73,6 +74,7 @@ export interface DatabaseTurnHooks {
close(): Promise<void>;
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
synchronizeClockAuthority(): Promise<boolean>;
prepareRealtimeRecovery(options?: { paused?: boolean }): Promise<void>;
}
export interface CommittedReadModelChangeReceipt {
@@ -141,6 +143,9 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clockTick',
'clock_tick',
'clockWallAnchor',
'clockRecoveryStartTick',
'clockRecoveryEndTick',
'clockRecoveryStartWallAt',
'clock_wall_anchor',
'heartbeat',
'heartbeatAt',
@@ -1139,6 +1144,9 @@ export const createDatabaseTurnHooks = async (
clockTick: BigInt(state.clockTick ?? 0),
clockMode: state.clockMode ?? 'manual',
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)),
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
clockRevision: BigInt(state.clockRevision ?? 1),
@@ -1260,7 +1268,7 @@ export const createDatabaseTurnHooks = async (
}
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
const unificationCutTick = unificationCutWallAt
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
? (state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime))
: null;
const suspensionPreparation =
unificationCutTick !== null
@@ -1909,7 +1917,9 @@ export const createDatabaseTurnHooks = async (
worldStateId: state.id,
profileName: options?.profileName ?? 'default',
source: 'UNIFICATION_WAIT',
policy: 'TURN_BOUNDARY',
cutTick,
normalTickAtCutWall: world.getNormalGameTick(suspensionPreparation.cutWallAt),
cutWallAt: suspensionPreparation.cutWallAt,
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
sourceRevision: state.clockRevision ?? 1,
@@ -2063,6 +2073,25 @@ export const createDatabaseTurnHooks = async (
});
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: () =>
prisma.$transaction(async (transaction) => {
await options?.turnDaemonLease?.assertActive(transaction);
@@ -41,6 +41,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase'];
revision: number;
deadlineGeneration: number;
startsAt: Date;
}> {
const state = this.world.getGameClockState();
return {
@@ -49,6 +50,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
phase: state.phase,
revision: state.revision,
deadlineGeneration: state.deadlineGeneration,
startsAt: state.wallAnchor,
};
}
@@ -67,4 +69,8 @@ export class InMemoryTurnStateStore implements TurnStateStore {
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
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,
type GameClockMode,
type GameClockPhase,
type TurnRecoveryWindow,
} from '@sammo-ts/common';
import type { TurnCheckpoint } from '../lifecycle/types.js';
@@ -129,6 +130,7 @@ export interface InMemoryGameClockState {
tick: number;
mode: GameClockMode;
wallAnchor: Date;
recovery?: TurnRecoveryWindow | null;
lastTurnTick: number;
phase: GameClockPhase;
revision: number;
@@ -147,6 +149,8 @@ export interface DurableClockReconciliationAlignment {
alignedTick: number;
shiftTicks: number;
resumeWallAt: Date;
resumeAnchor?: Date;
recovery?: TurnRecoveryWindow | null;
}
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
@@ -654,6 +658,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
recovery: this.state.clockRecovery,
turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -684,6 +689,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
recovery: this.state.clockRecovery ? structuredClone(this.state.clockRecovery) : null,
lastTurnTick: this.state.lastTurnTick ?? 0,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -695,6 +701,15 @@ export class InMemoryTurnWorld {
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 {
const clock = this.getGameClock();
if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) {
@@ -759,7 +774,8 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
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,
lastTurnTime,
clockPhase: 'RECONCILING',
@@ -865,6 +881,7 @@ export class InMemoryTurnWorld {
clockTick: input.tick,
clockMode: input.mode,
clockWallAnchor: new Date(input.wallAnchor.getTime()),
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
clockPhase: input.phase,
clockRevision: input.revision,
deadlineGeneration: input.deadlineGeneration,
@@ -918,11 +935,11 @@ export class InMemoryTurnWorld {
skippedTurns: number;
} | null {
const clock = this.getGameClock();
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') {
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING' || clock.recovery) {
return null;
}
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);
// 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도
// 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다.
@@ -1181,6 +1198,9 @@ export class InMemoryTurnWorld {
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 anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow;
const anchorTick = previousClock.nowTick(anchorWall);
@@ -1201,6 +1221,7 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
tickSeconds: nextTickSeconds,
clockRecovery: null,
clockBaseTime: nextBaseTime,
clockTick: anchorTick,
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) {
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 shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs);
const previousClock = this.getGameClock();
@@ -1694,6 +1718,7 @@ export class InMemoryTurnWorld {
tick: this.state.clockTick ?? 0,
mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
recovery: this.state.clockRecovery,
turnSeconds: this.state.tickSeconds,
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
revision: this.state.clockRevision ?? 1,
@@ -1709,14 +1734,14 @@ export class InMemoryTurnWorld {
this.state = {
...this.state,
clockBaseTime: nextBaseTime,
// Rebasing is also the explicit resume checkpoint. Realtime mode
// must not replay the operational downtime after an administrator
// deliberately delays or accelerates the game schedule.
// 가오픈의 anchor는 별도 예약된 정식 오픈이다. 표시 좌표를 옮기는
// 작업이 그 미래 경계를 현재 시각으로 당겨 게임을 시작시키면 안 된다.
clockWallAnchor: new Date(
previousClock.phase === 'PREOPEN' ? previousClock.wallAnchor.getTime() : wallNow.getTime()
),
// 명시적 이동은 저장 좌표와 실제 실행 anchor를 같은 정수 턴만큼 옮긴다.
clockWallAnchor: shiftDate(previousClock.wallAnchor),
clockRecovery: this.state.clockRecovery
? {
...this.state.clockRecovery,
startWallAt: shiftDate(this.state.clockRecovery.startWallAt),
}
: null,
lastTurnTime: nextLastTurnTime,
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 { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -28,6 +28,9 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
clockTick: true,
clockMode: true,
clockWallAnchor: true,
clockRecoveryStartTick: true,
clockRecoveryEndTick: true,
clockRecoveryStartWallAt: true,
lastTurnTick: true,
clockPhase: true,
clockRevision: true,
@@ -67,6 +70,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
shiftTicks: true,
alignedTick: true,
resumeWallAt: true,
detail: true,
},
});
let expectedRevision = before.revision;
@@ -92,6 +96,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
resumeWallAt: ledger.resumeWallAt,
recovery: readSerializedTurnRecovery(ledger.detail),
});
expectedRevision = targetRevision;
}
@@ -108,6 +113,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
tick: safeNumber(durable.clockTick, 'durable clock tick'),
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
wallAnchor: durable.clockWallAnchor,
recovery: readTurnRecovery(durable),
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
phase: parseGameClockPhase(durable.clockPhase),
revision: durableRevision,
+14
View File
@@ -900,6 +900,17 @@ const createTurnDaemonRuntimeWithLease = async (
turnDaemonLease: turnDaemonLease ?? undefined,
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({
databaseUrl: options.databaseUrl,
world,
@@ -983,6 +994,9 @@ const createTurnDaemonRuntimeWithLease = async (
redisConnector = realtimeRuntime.redisConnector;
hooks = realtimeRuntime.hooks;
stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker;
// 복구 창이 DB에 저장되고 projection worker가 구성된 뒤에만 독립 worker를 허용한다.
// RECONCILING 상태이면 기존 revision/phase fence가 outbox 완료까지 계속 차단한다.
await turnDaemonLease?.markClockReady();
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
+2 -1
View File
@@ -11,7 +11,7 @@ import type {
WorldSnapshot,
GeneralLastTurn,
} from '@sammo-ts/logic';
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
import type { GameClockMode, GameClockPhase, TurnRecoveryWindow } from '@sammo-ts/common';
export interface TurnWorldState {
id: number;
@@ -23,6 +23,7 @@ export interface TurnWorldState {
clockTick?: number;
clockMode?: GameClockMode;
clockWallAnchor?: Date;
clockRecovery?: TurnRecoveryWindow | null;
lastTurnTick?: number;
clockPhase?: GameClockPhase;
clockRevision?: number;
+6 -7
View File
@@ -28,6 +28,7 @@ import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/ite
import { z } from 'zod';
import {
GameClock,
readTurnRecovery,
asRecord,
inferClockPhase,
isRecord,
@@ -440,14 +441,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
worldState.clockWallAnchor !== null &&
worldState.lastTurnTick !== null;
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
const clockPhase = hasPersistedClock
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(clockMode);
const clockPhase = hasPersistedClock ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(clockMode);
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
const deadlineGeneration = toSafeTick(
worldState.deadlineGeneration,
'world_state.deadline_generation'
);
const deadlineGeneration = toSafeTick(worldState.deadlineGeneration, 'world_state.deadline_generation');
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
const bootstrapClock = new GameClock({
@@ -455,6 +451,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
tick: 0,
mode: clockMode,
wallAnchor: clockWallAnchor,
recovery: readTurnRecovery(worldState),
turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
@@ -468,6 +465,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
mode: clockMode,
wallAnchor: clockWallAnchor,
recovery: readTurnRecovery(worldState),
turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
@@ -537,6 +535,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
clockTick: gameClock.tick,
clockMode,
clockWallAnchor: gameClock.wallAnchor,
clockRecovery: gameClock.recovery,
lastTurnTick,
clockPhase,
clockRevision,
@@ -1,8 +1,9 @@
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 {
createGamePostgresConnector,
readTurnRuntimeReady,
createRedisConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
@@ -13,6 +14,8 @@ import {
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.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 enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
@@ -62,6 +65,158 @@ describeIntegration('durable clock reconciliation', () => {
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])(
'preserves purchased turn phases and the execution cursor after %i ms maintenance and reload',
async (gapMilliseconds) => {
@@ -190,12 +190,12 @@ describe('in-memory scenario general pool availability', () => {
const before = world.captureState();
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
world.shiftSchedule(15, claimedAt);
world.shiftSchedule(20, claimedAt);
expect(world.captureState().generalPoolEntries).toMatchObject([
{
id: 1,
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
reservedUntil: new Date(reservedUntil.getTime() + 20 * 60_000),
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
},
{ id: 2, reservedUntil: null, reservedUntilTick: null },
+78 -11
View File
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
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 { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
@@ -83,6 +84,70 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
};
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', () => {
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'],
['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) => {
const world = buildWorld();
const world = buildWorld({ tickSeconds: 900 });
world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 });
world.queueNeutralAuction({
registrationKey: 'test',
@@ -132,7 +197,7 @@ describe('runtime clock shift', () => {
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();
expect(() => world.shiftSchedule(delta)).toThrow();
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', () => {
const world = buildWorld();
const world = buildWorld({ tickSeconds: 900 });
world.shiftSchedule(-15);
@@ -212,7 +277,7 @@ describe('runtime clock shift', () => {
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 now = new Date('2026-09-05T00:00:00.000Z');
const world = buildWorld({
@@ -223,11 +288,13 @@ describe('runtime clock shift', () => {
lastTurnTick: 0,
clockPhase: 'PREOPEN',
});
world.shiftSchedule(15, now);
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: openAt });
world.shiftSchedule(20, now);
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.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:15:00.000Z'));
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:20:00.000Z'));
expect(world.promotePreopenAtOpening(openAt)).toBe(false);
expect(world.promotePreopenAtOpening(shiftedOpen)).toBe(true);
});
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 staleAnchor = new Date('2026-07-30T11:00: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.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', () => {
@@ -244,7 +244,7 @@ integration('runtime clock shift persistence', () => {
type: 'shiftSchedule',
requestId,
actionId,
deltaMinutes: -15,
deltaMinutes: -20,
} as GamePrisma.InputJsonValue,
},
});
@@ -257,29 +257,30 @@ integration('runtime clock shift persistence', () => {
await hooks.close();
}
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45:00.000Z');
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:55: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:50:00.000Z');
expect(await stateStore.loadCheckpoint()).toMatchObject({
turnTime: '2099-07-30T09:45:00.000Z',
turnTime: '2099-07-30T09:40:00.000Z',
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 } });
expect(storedWorld.meta).toMatchObject({
lastTurnTime: '2099-07-30T09:45:00.000Z',
starttime: '2099-06-30 23:45:00',
lastTurnTime: '2099-07-30T09:40:00.000Z',
starttime: '2099-06-30 23:40:00',
});
expect(storedWorld.clockTick).toBe(0n);
expect(storedWorld.lastTurnTick).toBe(0n);
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));
const storedAuctions = await db.auction.findMany({
where: { id: { in: auctionRows.map((auction) => auction.id) } },
});
const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()]));
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-30T12:00:00.000Z',
'2099-07-30T13:00:00.000Z',
@@ -291,7 +292,7 @@ integration('runtime clock shift persistence', () => {
type: 'shiftSchedule',
ok: true,
actionId,
deltaMinutes: -15,
deltaMinutes: -20,
shiftedGenerals: 2,
shiftedAuctions: 1,
},
@@ -611,7 +611,7 @@ integration('unification finalization transaction', () => {
const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } });
expect(suspension).toMatchObject({
source: 'UNIFICATION_WAIT',
policy: 'EXACT',
policy: 'TURN_BOUNDARY',
status: 'SUSPENDED',
sourceRevision: 1n,
targetRevision: 2n,
@@ -143,7 +143,7 @@ describe('HWE-shaped unification invader resume', () => {
tickSeconds: 60,
lastTurnTime: liveLastTurnTime,
clockBaseTime: new Date('2026-08-19T12:00:00.000Z'),
clockTick: 19_980_000_000,
clockTick: 19_944_000_000,
clockMode: 'realtime',
clockWallAnchor: liveWallAnchor,
lastTurnTick: 19_944_000_000,
@@ -202,7 +202,7 @@ describe('HWE-shaped unification invader resume', () => {
actorUserId: recipient.userId,
target: 'ENGINE',
eventType: 'messageRespond',
processingGameTick: 19_980_000_000n,
processingGameTick: 19_944_000_000n,
})),
},
messageAction: { updateMany: vi.fn(async () => ({ count: 1 })) },
@@ -214,11 +214,11 @@ describe('HWE-shaped unification invader resume', () => {
id: 1014,
mailbox: recipient.id,
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'),
actionType: 'raiseInvader',
actionStatus: 'PENDING',
createdGameTick: 19_980_000_000n,
createdGameTick: 19_944_000_000n,
expiresGameTick: null,
message,
},
@@ -256,13 +256,22 @@ describe('HWE-shaped unification invader resume', () => {
gapTicks: 108_000_000,
catchUpTicks: 0,
shiftTicks: 108_000_000,
alignedTick: 20_088_000_000,
alignedTick: 20_052_000_000,
resumeWallAt: acceptedAt,
resumeAnchor: new Date('2026-08-20T06:25:00Z'),
}),
});
const processor = new InMemoryTurnProcessor(world);
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']>) => {
runWallTimes.push(new Date(clock.nowMs()));
const result = await processor.run(...args);
if (world.getState().currentMonth === 4) {
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.messageAction.updateMany).toHaveBeenCalledWith({
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({
currentYear: 226,
@@ -324,11 +333,11 @@ describe('HWE-shaped unification invader resume', () => {
expect(initialInvaderTurnTimes).toHaveLength(10);
expect(Math.min(...initialInvaderTurnTimes)).toBeGreaterThanOrEqual(alignedMonthlyBoundary);
expect(Math.max(...initialInvaderTurnTimes)).toBeLessThan(alignedMonthlyBoundary + 60_000);
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([
'2026-08-20T06:24:58.611Z',
'2026-08-20T06:25:00.000Z',
]);
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-20T06:25:00.000Z');
expect(run).toHaveBeenCalled();
expect(runWallTimes.every((time) => time >= new Date('2026-08-20T06:25:00Z'))).toBe(true);
const nextMonth = addMinutes(liveLastTurnTime, 4);
expect(run.mock.calls.every(([targetTime]) => targetTime <= nextMonth)).toBe(true);
expect(run.mock.calls.at(-1)![0]).toEqual(nextMonth);
expect(lifecycle.getStatus().lastTurnTime).toBe(nextMonth.toISOString());
});
});
@@ -46,6 +46,7 @@ type NavigationFixture = {
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
turnEngineRunning?: boolean | null;
cityDefence?: number;
cityState?: number;
@@ -636,6 +637,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
clockMode: state.clockMode ?? 'realtime',
clockRunning: state.clockRunning ?? true,
clockStartsAt: state.clockStartsAt ?? null,
clockRecovery: state.clockRecovery ?? null,
turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning,
scenarioTitle: state.scenarioTitle ?? '',
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);
});
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';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
autorunLimit?: number | null;
storageKey?: string;
mapData?: CommandMapData | null;
@@ -60,11 +61,7 @@ const labelMap = 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[]>(() => {
@@ -119,7 +116,7 @@ const updateServerClock = () => {
currentServerTime.value = '--:--:--';
return;
}
const { clientElapsedMs, time: projectedTime } = projectServerClock(serverClockSample);
const { clientElapsedMs, time: projectedTime, rate } = projectServerClock(serverClockSample);
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs;
@@ -127,15 +124,30 @@ const updateServerClock = () => {
updateServerClock,
untilStartMs > 0
? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS)
: 1_000 - projectedTime.getMilliseconds()
: (1_000 - projectedTime.getMilliseconds()) / rate
);
}
};
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();
},
{ immediate: true }
@@ -16,6 +16,7 @@ const props = defineProps<{
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
turnEngineRunning?: boolean | null;
status: {
onlineUserCount: number;
@@ -34,6 +35,7 @@ const props = defineProps<{
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
const currentServerTime = ref('기록 없음');
const hasServerClock = ref(false);
const recovering = ref(false);
const turnEngineStopped = computed(() => props.turnEngineRunning === false);
const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean');
const serverClockTitle = computed(() => {
@@ -57,6 +59,7 @@ const updateServerClock = () => {
const now = Date.now();
const projection = projectServerClock(serverClockSample, now);
recovering.value = projection.rate === 2;
currentServerTime.value = formatServerDateTime(projection.time, {
format: 'monthDayTime',
fallback: '기록 없음',
@@ -67,16 +70,37 @@ const updateServerClock = () => {
const nextDelays: number[] = [];
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
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;
serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays)));
};
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();
},
{ immediate: true }
@@ -100,7 +124,7 @@ onUnmounted(() => {
}"
:title="serverClockTitle"
>
현재 시각: {{ currentServerTime }}
현재 시각: {{ currentServerTime }}<span v-if="recovering"> · 복구 2배속</span>
</div>
<div class="status-row tournament-status">
<RouterLink to="/tournament">
@@ -4,6 +4,7 @@ export type ServerClockProjectionInput = {
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
};
export type SampledServerClock = {
@@ -11,6 +12,8 @@ export type SampledServerClock = {
sampledClientTimeMs: number;
clockMode: 'realtime' | 'manual';
startDelayMs: number | null;
recoveryStartDelayMs?: number;
recoveryEndDelayMs?: number;
};
const parseInstant = (value?: string | null): number | null => {
@@ -40,11 +43,20 @@ export const sampleServerClock = (
: null;
}
const wallSample = parseInstant(input.serverWallTime);
const recoveryStart = parseInstant(input.clockRecovery?.startsAt);
const recoveryEnd = parseInstant(input.clockRecovery?.endsAt);
return {
serverTimeMs,
sampledClientTimeMs,
clockMode: input.clockMode ?? 'realtime',
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
: 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 {
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-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:turn-engine-running="lobbyInfo?.turnEngineRunning"
/>
</div>
@@ -365,6 +366,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@@ -547,6 +549,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
: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', () => {
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.',
});
}
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, {
preopenAt: input.preopenAt,
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 (
input.action === 'ACCELERATE' ||
input.action === 'DELAY' ||
@@ -1,6 +1,7 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { stripVTControlCharacters } from 'node:util';
@@ -1237,7 +1238,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!profile || profile.currentScenario === null) {
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));
await postgres.connect();
await redis.connect();
@@ -1265,6 +1267,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
});
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') {
let suspension = await postgres.prisma.clockSuspension.findFirst({
@@ -1281,8 +1286,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
suspensionId: `gateway-maintenance-${suffix}`,
source: 'MAINTENANCE',
// 운영 중단은 생성 때 구매한 턴 구간과 장수 간 실행 순서를 보존한다.
// 관측 시계만 재개하고 정상 엔진이 미처리 턴을 따라잡게 한다.
policy: 'PRESERVE_SCHEDULE',
// 완전한 12턴은 정수 이동하고 잔여 지연은 복구 구간에서 두 배속으로 실행한다.
policy: supportsTurnRecovery ? 'RECOVER_TURNS' : 'PRESERVE_SCHEDULE',
authority,
});
suspension = await postgres.prisma.clockSuspension.findUniqueOrThrow({
@@ -1326,7 +1331,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return { phase: 'SUSPENDED', revision: clockRevisionAsNumber(world.clockRevision) };
}
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') {
throw new Error(`Cannot resume profile clock from ${world.clockPhase}.`);
}
@@ -1353,7 +1363,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.promoteProfileOpeningOverride(profile);
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));
await postgres.connect();
await redis.connect();
@@ -1695,6 +1706,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!gatewayProfileCapabilities(profile.status).operatorResumable) {
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를 시작합니다.');
const updated = await updateOperationProfile(
{
@@ -1735,6 +1747,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') {
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 updateOperationProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
@@ -1979,14 +1992,19 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
'settlement',
'기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.'
);
const result = await this.cancelGame({
cancellationId: operation.id,
databaseUrl,
cancelledBy: operation.requestedBy,
reason: operation.reason ?? '',
...options,
cancelledAt: this.now(),
});
const result = await this.cancelGame(
{
cancellationId: operation.id,
databaseUrl,
cancelledBy: operation.requestedBy,
reason: operation.reason ?? '',
...options,
cancelledAt: this.now(),
},
this.cancelGame === defaultCancelGame
? (await this.resolveProfileClockAdapter(profile)).connectorFactory
: undefined
);
cancellationCommitted = true;
await assertLease();
await updateClaimedProfile({
@@ -2493,11 +2511,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
throw new Error(`Selected profile seed failed: ${seedResult.output.slice(-4000)}`);
}
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 assertLease?.();
const completedAt = this.now().toISOString();
const now = this.now();
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt);
const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, effectiveOpenAt);
const publishedProfile = await updateClaimedProfile(
{
currentScenario: String(scenarioId),
@@ -2508,8 +2539,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
buildLastUsedAt: completedAt,
buildCompletedAt: completedAt,
buildError: null,
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
openAt: openAt ? openAt.toISOString() : null,
preopenAt: preopenAt
? preopenAt.toISOString()
: effectiveOpenAt
? effectiveOpenAt.toISOString()
: null,
openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : null,
scheduledStartAt: action.scheduledAt ?? null,
...(releaseSource ? { meta: writeProfileReleaseSource(profile.meta, releaseSource) } : {}),
},
@@ -2523,8 +2558,12 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
await this.repository.updateCurrentScenario(profile.profileName, String(scenarioId));
}
return this.repository.updateStatus(profile.profileName, desiredStatus, {
preopenAt: preopenAt ? preopenAt.toISOString() : openAt ? openAt.toISOString() : null,
openAt: openAt ? openAt.toISOString() : null,
preopenAt: preopenAt
? preopenAt.toISOString()
: effectiveOpenAt
? effectiveOpenAt.toISOString()
: null,
openAt: effectiveOpenAt ? effectiveOpenAt.toISOString() : 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 {
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,
gameClockMode: process.env.GAME_CLOCK_MODE === 'manual' ? 'manual' : 'realtime',
now: new Date(request.now),
wallNow: new Date(),
installOptions: request.installOptions
? {
...request.installOptions,
@@ -22,6 +22,7 @@ export interface SeedProfileDatabaseOptions {
tickSeconds?: number;
gameClockMode?: GameClockMode;
now?: Date;
wallNow?: Date;
installOptions?: ScenarioInstallOptions;
scenarioOptions?: Parameters<typeof seedScenarioToDatabase>[0]['scenarioOptions'];
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 fallbackTurnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date();
const mode = worldState.clockMode === 'manual' ? 'manual' : 'realtime';
const phase = worldState.clockPhase
? parseGameClockPhase(worldState.clockPhase)
: inferClockPhase(mode);
const phase = worldState.clockPhase ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(mode);
const gameClock = new GameClock({
baseTime: worldState.clockBaseTime ?? fallbackTurnTime,
tick: Number(worldState.clockTick ?? 0n),
@@ -164,6 +163,7 @@ export const seedProfileDatabase = async (options: SeedProfileDatabaseOptions) =
tickSeconds: options.tickSeconds,
gameClockMode: options.gameClockMode,
now: options.now,
wallNow: options.wallNow,
installOptions: options.installOptions,
scenarioOptions: options.scenarioOptions,
mapOptions: options.mapOptions,
+23 -4
View File
@@ -1566,13 +1566,32 @@ describe('admin runtime clock action API', () => {
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 () => {
const harness = await buildCaller(unusedCreateOperation);
const result = await harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'ACCELERATE',
durationMinutes: 15,
durationMinutes: 20,
reason: '운영 일정 조정',
});
@@ -1580,7 +1599,7 @@ describe('admin runtime clock action API', () => {
ok: true,
action: {
action: 'ACCELERATE',
durationMinutes: 15,
durationMinutes: 20,
status: 'REQUESTED',
},
});
@@ -1589,7 +1608,7 @@ describe('admin runtime clock action API', () => {
profileName: 'che:2',
action: 'ACCELERATE',
payload: {},
durationMinutes: 15,
durationMinutes: 20,
reason: '운영 일정 조정',
requestedBy: harness.admin.id,
},
@@ -1605,7 +1624,7 @@ describe('admin runtime clock action API', () => {
harness.caller.admin.profiles.requestAction({
profileName: 'che:2',
action: 'DELAY',
durationMinutes: 5,
durationMinutes: 20,
})
).rejects.toMatchObject({
code: 'CONFLICT',
@@ -193,6 +193,10 @@ const createHarness = (
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
transitionProfileClock: async (_profileName, action) => {
lifecycle.push(`clock:${action}`);
return { phase: action === 'SUSPEND' ? 'SUSPENDED' : 'RUNNING', revision: 2 };
},
now: options.now,
cancelGame: options.cancelGame,
promoteProfileOpening: options.promoteProfileOpening
@@ -218,6 +222,13 @@ const createHarness = (
};
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 () => {
const operation: GatewayOperationRecord = {
id: '88888888-8888-4888-8888-888888888888',
@@ -48,14 +48,14 @@ describeDatabase('selected workspace profile seed CLI', () => {
JSON.stringify({
scenarioId: 1010,
tickSeconds: 60,
now: '2036-03-03T00:00:00.000Z',
now: '2036-03-03T02:10:30.000Z',
installOptions: {
serverId: 'selected-cli-seed',
firstGameIdx: 0,
installOperationId: 'selected-cli-operation',
installCommitSha: 'selected-cli-commit',
preopenAt: '2036-03-03T01:00:00.000Z',
openAt: '2036-03-03T02:00:00.000Z',
openAt: '2036-03-03T02:10:30.000Z',
},
adminUser: {
id: 'selected-cli-admin',
@@ -71,7 +71,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
const world = await connector.prisma.worldState.findFirstOrThrow();
expect(world).toMatchObject({
scenarioCode: '1010',
clockWallAnchor: new Date('2036-03-03T02:00:00.000Z'),
clockWallAnchor: new Date('2036-03-03T02:11:00.000Z'),
clockPhase: 'PREOPEN',
meta: {
firstGameIdx: 0,
gameIdx: completedGameCount,
@@ -83,6 +84,8 @@ describeDatabase('selected workspace profile seed CLI', () => {
where: { userId: 'selected-cli-admin' },
});
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({
where: { serverId: 'selected-cli-seed' },
});
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gameSchemaHead: '20260903201500_complete_invader_game_clock',
gameSchemaHead: '20260906090000_add_turn_recovery_window',
});
});