refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현
This commit is contained in:
@@ -70,6 +70,9 @@ type WorldClockFields =
|
|||||||
| 'clockTick'
|
| 'clockTick'
|
||||||
| 'clockMode'
|
| 'clockMode'
|
||||||
| 'clockWallAnchor'
|
| 'clockWallAnchor'
|
||||||
|
| 'clockRecoveryStartTick'
|
||||||
|
| 'clockRecoveryEndTick'
|
||||||
|
| 'clockRecoveryStartWallAt'
|
||||||
| 'lastTurnTick'
|
| 'lastTurnTick'
|
||||||
| 'clockPhase'
|
| 'clockPhase'
|
||||||
| 'clockRevision'
|
| 'clockRevision'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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")
|
||||||
|
|||||||
+18
@@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user