refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현
This commit is contained in:
@@ -93,6 +93,7 @@ export class DatabaseTurnDaemonLease {
|
||||
ON CONFLICT ("profile") DO UPDATE
|
||||
SET
|
||||
"owner_id" = EXCLUDED."owner_id",
|
||||
"clock_ready" = FALSE,
|
||||
"lease_until" = EXCLUDED."lease_until",
|
||||
"fencing_epoch" = CASE
|
||||
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
|
||||
@@ -126,6 +127,14 @@ export class DatabaseTurnDaemonLease {
|
||||
return this.token ? { ...this.token } : null;
|
||||
}
|
||||
|
||||
async markClockReady(): Promise<void> {
|
||||
await this.db.$transaction(async (db) => {
|
||||
await this.assertActive(db);
|
||||
const token = this.getToken()!;
|
||||
await db.turnDaemonLease.update({ where: { profile: token.profile }, data: { clockReady: true } });
|
||||
});
|
||||
}
|
||||
|
||||
isLost(): boolean {
|
||||
return this.lost;
|
||||
}
|
||||
|
||||
@@ -170,6 +170,11 @@ export class TurnDaemonLifecycle {
|
||||
await this.clock.sleepMs(500);
|
||||
continue;
|
||||
}
|
||||
if (gameClock?.mode === 'realtime' && gameClock.startsAt && wallNow < gameClock.startsAt) {
|
||||
this.status.nextTurnTime = gameClock.startsAt.toISOString();
|
||||
await this.clock.sleepMs(Math.min(500, gameClock.startsAt.getTime() - nowMs));
|
||||
continue;
|
||||
}
|
||||
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
|
||||
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
|
||||
if (this.pendingRun) {
|
||||
@@ -219,7 +224,10 @@ export class TurnDaemonLifecycle {
|
||||
continue;
|
||||
}
|
||||
|
||||
const command = await this.controlQueue.waitFor(Math.max(0, nextTurnMs - gameNowMs));
|
||||
const wallDeadline = await this.stateStore.projectGameDeadline?.(nextRunTime);
|
||||
const command = await this.controlQueue.waitFor(
|
||||
Math.max(0, wallDeadline ? wallDeadline.getTime() - nowMs : nextTurnMs - gameNowMs)
|
||||
);
|
||||
if (command) {
|
||||
await this.handleCommand(command);
|
||||
}
|
||||
|
||||
@@ -72,11 +72,13 @@ export interface TurnStateStore {
|
||||
phase?: GameClockPhase;
|
||||
revision?: number;
|
||||
deadlineGeneration?: number;
|
||||
startsAt?: Date;
|
||||
}>;
|
||||
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
|
||||
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
|
||||
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
|
||||
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
||||
projectGameDeadline?(gameTime: Date): Promise<Date>;
|
||||
}
|
||||
|
||||
export interface TurnDaemonControlQueue {
|
||||
|
||||
@@ -571,7 +571,10 @@ const cancelGameInTransaction = async (
|
||||
return { ...resultFromPersisted(created), alreadyApplied: false };
|
||||
};
|
||||
|
||||
export const cancelGame = async (request: GameCancellationRequest): Promise<GameCancellationResult> => {
|
||||
export const cancelGame = async (
|
||||
request: GameCancellationRequest,
|
||||
connectorFactory: typeof createGamePostgresConnector = createGamePostgresConnector
|
||||
): Promise<GameCancellationResult> => {
|
||||
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
|
||||
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
|
||||
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
|
||||
@@ -580,7 +583,7 @@ export const cancelGame = async (request: GameCancellationRequest): Promise<Game
|
||||
earnedPoint: 0,
|
||||
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
||||
});
|
||||
const connector = createGamePostgresConnector({ url: request.databaseUrl });
|
||||
const connector = connectorFactory({ url: request.databaseUrl });
|
||||
await connector.connect();
|
||||
try {
|
||||
return await connector.prisma.$transaction(
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface ScenarioSeedOptions {
|
||||
generalPoolOptions?: GeneralPoolLoaderOptions;
|
||||
resetTables?: boolean;
|
||||
now?: Date;
|
||||
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
|
||||
wallNow?: Date;
|
||||
tickSeconds?: number;
|
||||
gameClockMode?: GameClockMode;
|
||||
installOptions?: ScenarioInstallOptions;
|
||||
@@ -244,8 +246,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const gameClockMode = options.gameClockMode ?? 'realtime';
|
||||
// A realtime season prepared before its formal opening must not consume
|
||||
// wall time while users are only allowed to edit reserved commands.
|
||||
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
|
||||
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
|
||||
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
|
||||
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
|
||||
const openingFloor = cutTurn(requestedOpening, turnTermMinutes);
|
||||
const initialClockWallAnchor =
|
||||
gameClockMode === 'manual'
|
||||
? requestedOpening
|
||||
: new Date(openingFloor.getTime() + (openingFloor < requestedOpening ? tickSeconds * 1_000 : 0));
|
||||
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
|
||||
const initialClock = new GameClock({
|
||||
baseTime: startState.startTime,
|
||||
tick: 0,
|
||||
@@ -317,7 +325,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
||||
starttime: formatDateTime(startState.startTime),
|
||||
turntime: formatDateTime(now),
|
||||
opentime: formatDateTime(now),
|
||||
opentime: formatDateTime(initialClockWallAnchor),
|
||||
lastTurnTime: formatDateTime(now),
|
||||
};
|
||||
|
||||
@@ -344,7 +352,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
}
|
||||
|
||||
worldMeta.hiddenSeed = hiddenSeed;
|
||||
worldMeta.seededAtWall = now.toISOString();
|
||||
worldMeta.seededAtWall = wallNow.toISOString();
|
||||
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
|
||||
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
|
||||
worldMeta.calendarStart = startState.startTime.toISOString();
|
||||
|
||||
@@ -332,7 +332,7 @@ const respondToRaiseInvader = async (options: {
|
||||
reservedTurns,
|
||||
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||
clockWallNow: alignment.resumeWallAt,
|
||||
clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt,
|
||||
});
|
||||
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
|
||||
await handler(
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
buildClockAlignmentPlan,
|
||||
parseClockAlignmentPolicy,
|
||||
parseGameClockPhase,
|
||||
readTurnRecovery,
|
||||
readSerializedTurnRecovery,
|
||||
serializeTurnRecovery,
|
||||
type TurnRecoveryWindow,
|
||||
type ClockAlignmentPolicy,
|
||||
} from '@sammo-ts/common';
|
||||
import {
|
||||
@@ -43,6 +47,8 @@ export interface ClockReconciliationResult {
|
||||
shiftTicks: number;
|
||||
alignedTick: number;
|
||||
resumeWallAt: Date;
|
||||
recovery?: TurnRecoveryWindow | null;
|
||||
resumeAnchor?: Date;
|
||||
}
|
||||
|
||||
interface DbWallRow {
|
||||
@@ -213,6 +219,10 @@ const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigi
|
||||
`);
|
||||
};
|
||||
|
||||
// Gateway는 아직 배포하지 않은 profile의 Prisma 모델로 기존 시계 프로토콜을 처리한다.
|
||||
const supportsRecoveryColumns = (db: GamePrisma.TransactionClient): boolean =>
|
||||
Boolean(db.worldState.fields?.clockRecoveryStartTick);
|
||||
|
||||
const readParticipantSnapshots = async (
|
||||
db: GamePrisma.TransactionClient,
|
||||
worldStateId: number,
|
||||
@@ -224,6 +234,13 @@ const readParticipantSnapshots = async (
|
||||
where: { id: worldStateId },
|
||||
select: {
|
||||
clockTick: true,
|
||||
...(supportsRecoveryColumns(db)
|
||||
? {
|
||||
clockRecoveryStartTick: true,
|
||||
clockRecoveryEndTick: true,
|
||||
clockRecoveryStartWallAt: true,
|
||||
}
|
||||
: {}),
|
||||
clockRevision: true,
|
||||
deadlineGeneration: true,
|
||||
lastTurnTick: true,
|
||||
@@ -290,6 +307,7 @@ const readParticipantSnapshots = async (
|
||||
snapshot('world-clock', 'REBUILD', [
|
||||
{
|
||||
clockTick: world.clockTick,
|
||||
...serializeTurnRecovery(readTurnRecovery(world)),
|
||||
clockRevision: world.clockRevision,
|
||||
deadlineGeneration: world.deadlineGeneration,
|
||||
},
|
||||
@@ -431,6 +449,7 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
|
||||
cutWallAt: Date;
|
||||
rateTicksPerSecond: number;
|
||||
sourceRevision: number;
|
||||
normalTickAtCutWall?: number;
|
||||
policy?: ClockAlignmentPolicy;
|
||||
catchUpTicks?: number;
|
||||
}): Promise<void> => {
|
||||
@@ -475,7 +494,11 @@ export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
|
||||
rateTicksPerSecond: options.rateTicksPerSecond,
|
||||
catchUpTicks: BigInt(catchUpTicks),
|
||||
participantChecksumBefore: aggregateChecksum(participants),
|
||||
detail: asJson({ authority: 'DAEMON', profileName: options.profileName }),
|
||||
detail: asJson({
|
||||
authority: 'DAEMON',
|
||||
profileName: options.profileName,
|
||||
normalTickAtCutWall: Math.max(options.cutTick, options.normalTickAtCutWall ?? options.cutTick),
|
||||
}),
|
||||
},
|
||||
});
|
||||
await persistInitialParticipants(options.db, options.suspensionId, participants);
|
||||
@@ -712,6 +735,7 @@ export const startClockSuspension = async (options: {
|
||||
authority: ClockOperationAuthority;
|
||||
policy?: ClockAlignmentPolicy;
|
||||
catchUpTicks?: number;
|
||||
recoverDurableObservation?: boolean;
|
||||
}): Promise<ClockSuspensionResult> => {
|
||||
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
||||
throw new Error('Clock suspension ID must contain 1-64 characters.');
|
||||
@@ -761,7 +785,13 @@ export const startClockSuspension = async (options: {
|
||||
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
|
||||
throw new Error('Clock suspension requires a fully initialized logical game clock.');
|
||||
}
|
||||
const cutWallAt = await readDbWall(db);
|
||||
if (
|
||||
options.recoverDurableObservation &&
|
||||
(options.authority.kind !== 'DAEMON' || options.source !== 'RECOVERY' || policy !== 'RECOVER_TURNS')
|
||||
) {
|
||||
throw new Error('Only daemon outage recovery may use the durable observation.');
|
||||
}
|
||||
const cutWallAt = options.recoverDurableObservation ? world.clockWallAnchor : await readDbWall(db);
|
||||
const storedTick = safeNumber(world.clockTick, 'world clock tick');
|
||||
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
|
||||
const clock = new GameClock({
|
||||
@@ -769,11 +799,12 @@ export const startClockSuspension = async (options: {
|
||||
tick: storedTick,
|
||||
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||
wallAnchor: world.clockWallAnchor,
|
||||
recovery: readTurnRecovery(world),
|
||||
turnSeconds: world.tickSeconds,
|
||||
phase,
|
||||
revision: sourceRevision,
|
||||
});
|
||||
const cutTick = clock.nowTick(cutWallAt);
|
||||
const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt);
|
||||
await lockParticipants(db, BigInt(cutTick));
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
@@ -797,6 +828,7 @@ export const startClockSuspension = async (options: {
|
||||
detail: asJson({
|
||||
authority: options.authority.kind,
|
||||
profileName: options.authority.profileName,
|
||||
normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)),
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -827,6 +859,7 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
||||
authority?: ClockOperationAuthority;
|
||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
||||
testResumeWallAt?: Date;
|
||||
upgradeMaintenancePolicy?: boolean;
|
||||
}): Promise<ClockReconciliationResult> => {
|
||||
const db = options.db;
|
||||
const worldStateId = await lockWorld(db);
|
||||
@@ -855,6 +888,14 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
||||
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
|
||||
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
|
||||
resumeWallAt: suspension.resumeWallAt,
|
||||
recovery: readSerializedTurnRecovery(suspension.detail),
|
||||
resumeAnchor:
|
||||
suspension.detail &&
|
||||
typeof suspension.detail === 'object' &&
|
||||
!Array.isArray(suspension.detail) &&
|
||||
typeof suspension.detail.resumeAnchor === 'string'
|
||||
? new Date(suspension.detail.resumeAnchor)
|
||||
: (world.clockWallAnchor ?? suspension.resumeWallAt),
|
||||
};
|
||||
}
|
||||
if (suspension.status !== 'SUSPENDED') {
|
||||
@@ -882,14 +923,41 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
||||
throw new Error('A clock reconciliation wall override is allowed only in tests.');
|
||||
}
|
||||
const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db);
|
||||
const legacyUnificationWait = suspension.source === 'UNIFICATION_WAIT' && suspension.policy !== 'TURN_BOUNDARY';
|
||||
const upgradeMaintenance =
|
||||
options.upgradeMaintenancePolicy === true &&
|
||||
supportsRecoveryColumns(db) &&
|
||||
suspension.source === 'MAINTENANCE' &&
|
||||
suspension.policy !== 'RECOVER_TURNS';
|
||||
const effectivePolicy = legacyUnificationWait
|
||||
? 'TURN_BOUNDARY'
|
||||
: upgradeMaintenance
|
||||
? 'RECOVER_TURNS'
|
||||
: parseClockAlignmentPolicy(suspension.policy);
|
||||
const alignmentCutTick = legacyUnificationWait
|
||||
? safeNumber(world.lastTurnTick!, 'unification execution boundary')
|
||||
: cutTick;
|
||||
const plan = buildClockAlignmentPlan({
|
||||
policy: parseClockAlignmentPolicy(suspension.policy),
|
||||
policy: effectivePolicy,
|
||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
||||
cutTick,
|
||||
cutTick: alignmentCutTick,
|
||||
cutWall: suspension.cutWallAt,
|
||||
resumeWall: resumeWallAt,
|
||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
||||
normalTick: (() => {
|
||||
const detail =
|
||||
suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
|
||||
? suspension.detail
|
||||
: {};
|
||||
const normalAtCut = typeof detail.normalTickAtCutWall === 'number' ? detail.normalTickAtCutWall : cutTick;
|
||||
return (
|
||||
normalAtCut +
|
||||
Math.trunc(
|
||||
((resumeWallAt.getTime() - suspension.cutWallAt.getTime()) * suspension.rateTicksPerSecond) / 1_000
|
||||
)
|
||||
);
|
||||
})(),
|
||||
});
|
||||
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
assertShiftFits(before, plan.shiftTicks);
|
||||
@@ -908,8 +976,21 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
||||
targetGeneration,
|
||||
BigInt(plan.shiftTicks),
|
||||
projectionDeltaMilliseconds,
|
||||
resumeWallAt
|
||||
plan.resumeAnchor ?? resumeWallAt
|
||||
);
|
||||
if (supportsRecoveryColumns(db)) {
|
||||
await db.worldState.update({
|
||||
where: { id: worldStateId },
|
||||
data: {
|
||||
clockRecoveryStartTick: plan.recovery ? BigInt(plan.recovery.startTick) : null,
|
||||
clockRecoveryEndTick: plan.recovery ? BigInt(plan.recovery.endTick) : null,
|
||||
clockRecoveryStartWallAt: plan.recovery?.startWallAt ?? null,
|
||||
},
|
||||
});
|
||||
} else if (plan.recovery) {
|
||||
throw new Error('Turn recovery requires an upgraded profile schema and Prisma client.');
|
||||
}
|
||||
|
||||
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
||||
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
|
||||
for (const participant of before) {
|
||||
@@ -967,8 +1048,20 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
||||
where: { id: suspension.id },
|
||||
data: {
|
||||
status: 'RECONCILING',
|
||||
...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}),
|
||||
resumeWallAt,
|
||||
gapTicks: BigInt(plan.gapTicks),
|
||||
catchUpTicks: BigInt(plan.catchUpTicks),
|
||||
detail: asJson({
|
||||
...(suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
|
||||
? suspension.detail
|
||||
: {}),
|
||||
...serializeTurnRecovery(plan.recovery ?? null),
|
||||
resumeAnchor: (plan.resumeAnchor ?? resumeWallAt).toISOString(),
|
||||
...(legacyUnificationWait || upgradeMaintenance
|
||||
? { previousPolicy: suspension.policy, executionBoundaryTick: alignmentCutTick }
|
||||
: {}),
|
||||
}),
|
||||
shiftTicks: BigInt(plan.shiftTicks),
|
||||
alignedTick: BigInt(plan.alignedTick),
|
||||
participantChecksumBefore: aggregateChecksum(before),
|
||||
@@ -986,6 +1079,8 @@ export const reconcileClockSuspensionInTransaction = async (options: {
|
||||
shiftTicks: plan.shiftTicks,
|
||||
alignedTick: plan.alignedTick,
|
||||
resumeWallAt,
|
||||
recovery: plan.recovery ?? null,
|
||||
resumeAnchor: plan.resumeAnchor ?? resumeWallAt,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1024,6 +1119,7 @@ export const reconcileClockSuspension = async (options: {
|
||||
authority: ClockOperationAuthority;
|
||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
||||
testResumeWallAt?: Date;
|
||||
upgradeMaintenancePolicy?: boolean;
|
||||
}): Promise<ClockReconciliationResult> =>
|
||||
runSerializableClockOperation(() =>
|
||||
options.db.$transaction(
|
||||
@@ -1037,6 +1133,7 @@ export const reconcileClockSuspension = async (options: {
|
||||
profileName: options.authority.profileName,
|
||||
authority: options.authority,
|
||||
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
|
||||
upgradeMaintenancePolicy: options.upgradeMaintenancePolicy,
|
||||
});
|
||||
},
|
||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
} from './clockReconciliation.js';
|
||||
import { applyNextClockProjection, type ClockProjectionRedis } from './clockProjectionOutbox.js';
|
||||
import { synchronizeRuntimeClockAuthorityUnderHeldLock } from './runtimeClockAuthoritySync.js';
|
||||
import { prepareRealtimeRecovery } from './prepareRealtimeRecovery.js';
|
||||
|
||||
export interface DatabaseTurnHooks {
|
||||
hooks: TurnDaemonHooks;
|
||||
@@ -73,6 +74,7 @@ export interface DatabaseTurnHooks {
|
||||
close(): Promise<void>;
|
||||
applyClockProjection(redis: ClockProjectionRedis, workerId: string): Promise<boolean>;
|
||||
synchronizeClockAuthority(): Promise<boolean>;
|
||||
prepareRealtimeRecovery(options?: { paused?: boolean }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CommittedReadModelChangeReceipt {
|
||||
@@ -141,6 +143,9 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
|
||||
'clockTick',
|
||||
'clock_tick',
|
||||
'clockWallAnchor',
|
||||
'clockRecoveryStartTick',
|
||||
'clockRecoveryEndTick',
|
||||
'clockRecoveryStartWallAt',
|
||||
'clock_wall_anchor',
|
||||
'heartbeat',
|
||||
'heartbeatAt',
|
||||
@@ -1139,6 +1144,9 @@ export const createDatabaseTurnHooks = async (
|
||||
clockTick: BigInt(state.clockTick ?? 0),
|
||||
clockMode: state.clockMode ?? 'manual',
|
||||
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
|
||||
clockRecoveryStartTick: state.clockRecovery ? BigInt(state.clockRecovery.startTick) : null,
|
||||
clockRecoveryEndTick: state.clockRecovery ? BigInt(state.clockRecovery.endTick) : null,
|
||||
clockRecoveryStartWallAt: state.clockRecovery?.startWallAt ?? null,
|
||||
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
|
||||
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
|
||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
||||
@@ -1260,7 +1268,7 @@ export const createDatabaseTurnHooks = async (
|
||||
}
|
||||
const unificationCutWallAt = unificationSuspensionTransition ? await readClockDatabaseWall(prisma) : null;
|
||||
const unificationCutTick = unificationCutWallAt
|
||||
? world.dateToGameTick(world.getGameNow(unificationCutWallAt))
|
||||
? (state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime))
|
||||
: null;
|
||||
const suspensionPreparation =
|
||||
unificationCutTick !== null
|
||||
@@ -1909,7 +1917,9 @@ export const createDatabaseTurnHooks = async (
|
||||
worldStateId: state.id,
|
||||
profileName: options?.profileName ?? 'default',
|
||||
source: 'UNIFICATION_WAIT',
|
||||
policy: 'TURN_BOUNDARY',
|
||||
cutTick,
|
||||
normalTickAtCutWall: world.getNormalGameTick(suspensionPreparation.cutWallAt),
|
||||
cutWallAt: suspensionPreparation.cutWallAt,
|
||||
rateTicksPerSecond: GAME_TICKS_PER_TURN / state.tickSeconds,
|
||||
sourceRevision: state.clockRevision ?? 1,
|
||||
@@ -2063,6 +2073,25 @@ export const createDatabaseTurnHooks = async (
|
||||
});
|
||||
return clock?.clockPhase === 'RUNNING' || clock?.clockPhase === 'MANUAL';
|
||||
},
|
||||
prepareRealtimeRecovery: async (recoveryOptions) => {
|
||||
const token = options?.turnDaemonLease?.getToken();
|
||||
if (!token) return;
|
||||
await prepareRealtimeRecovery(
|
||||
prisma,
|
||||
{
|
||||
kind: 'DAEMON',
|
||||
profileName: token.profile,
|
||||
ownerId: token.ownerId,
|
||||
fencingEpoch: token.fencingEpoch,
|
||||
},
|
||||
recoveryOptions
|
||||
);
|
||||
await prisma.$transaction(async (transaction) => {
|
||||
await options?.turnDaemonLease?.assertActive(transaction);
|
||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
||||
await synchronizeRuntimeClockAuthorityUnderHeldLock(transaction, world);
|
||||
}, transactionOptions);
|
||||
},
|
||||
synchronizeClockAuthority: () =>
|
||||
prisma.$transaction(async (transaction) => {
|
||||
await options?.turnDaemonLease?.assertActive(transaction);
|
||||
|
||||
@@ -41,6 +41,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
|
||||
phase: ReturnType<InMemoryTurnWorld['getGameClockState']>['phase'];
|
||||
revision: number;
|
||||
deadlineGeneration: number;
|
||||
startsAt: Date;
|
||||
}> {
|
||||
const state = this.world.getGameClockState();
|
||||
return {
|
||||
@@ -49,6 +50,7 @@ export class InMemoryTurnStateStore implements TurnStateStore {
|
||||
phase: state.phase,
|
||||
revision: state.revision,
|
||||
deadlineGeneration: state.deadlineGeneration,
|
||||
startsAt: state.wallAnchor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,4 +69,8 @@ export class InMemoryTurnStateStore implements TurnStateStore {
|
||||
async advanceGameClockTo(target: Date, wallNow: Date): Promise<void> {
|
||||
this.world.advanceGameClockTo(target, wallNow);
|
||||
}
|
||||
|
||||
async projectGameDeadline(gameTime: Date): Promise<Date> {
|
||||
return this.world.projectGameDeadline(gameTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
inferClockPhase,
|
||||
type GameClockMode,
|
||||
type GameClockPhase,
|
||||
type TurnRecoveryWindow,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||
@@ -129,6 +130,7 @@ export interface InMemoryGameClockState {
|
||||
tick: number;
|
||||
mode: GameClockMode;
|
||||
wallAnchor: Date;
|
||||
recovery?: TurnRecoveryWindow | null;
|
||||
lastTurnTick: number;
|
||||
phase: GameClockPhase;
|
||||
revision: number;
|
||||
@@ -147,6 +149,8 @@ export interface DurableClockReconciliationAlignment {
|
||||
alignedTick: number;
|
||||
shiftTicks: number;
|
||||
resumeWallAt: Date;
|
||||
resumeAnchor?: Date;
|
||||
recovery?: TurnRecoveryWindow | null;
|
||||
}
|
||||
|
||||
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
|
||||
@@ -654,6 +658,7 @@ export class InMemoryTurnWorld {
|
||||
tick: this.state.clockTick ?? this.state.lastTurnTick ?? 0,
|
||||
mode: this.state.clockMode ?? 'manual',
|
||||
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
|
||||
recovery: this.state.clockRecovery,
|
||||
turnSeconds: this.state.tickSeconds,
|
||||
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
||||
revision: this.state.clockRevision ?? 1,
|
||||
@@ -684,6 +689,7 @@ export class InMemoryTurnWorld {
|
||||
tick: this.state.clockTick ?? 0,
|
||||
mode: this.state.clockMode ?? 'manual',
|
||||
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
|
||||
recovery: this.state.clockRecovery ? structuredClone(this.state.clockRecovery) : null,
|
||||
lastTurnTick: this.state.lastTurnTick ?? 0,
|
||||
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
||||
revision: this.state.clockRevision ?? 1,
|
||||
@@ -695,6 +701,15 @@ export class InMemoryTurnWorld {
|
||||
return this.getGameClock().now(wallNow);
|
||||
}
|
||||
|
||||
projectGameDeadline(gameTime: Date): Date {
|
||||
const clock = this.getGameClock();
|
||||
return clock.tickToWallDate(clock.dateToTick(gameTime));
|
||||
}
|
||||
|
||||
getNormalGameTick(wallNow: Date): number {
|
||||
return this.getGameClock().normalNowTick(wallNow);
|
||||
}
|
||||
|
||||
promotePreopenAtOpening(wallNow: Date): boolean {
|
||||
const clock = this.getGameClock();
|
||||
if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) {
|
||||
@@ -759,7 +774,8 @@ export class InMemoryTurnWorld {
|
||||
this.state = {
|
||||
...this.state,
|
||||
clockTick: input.alignedTick,
|
||||
clockWallAnchor: new Date(input.resumeWallAt.getTime()),
|
||||
clockWallAnchor: new Date((input.resumeAnchor ?? input.resumeWallAt).getTime()),
|
||||
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
|
||||
lastTurnTick,
|
||||
lastTurnTime,
|
||||
clockPhase: 'RECONCILING',
|
||||
@@ -865,6 +881,7 @@ export class InMemoryTurnWorld {
|
||||
clockTick: input.tick,
|
||||
clockMode: input.mode,
|
||||
clockWallAnchor: new Date(input.wallAnchor.getTime()),
|
||||
clockRecovery: input.recovery ? structuredClone(input.recovery) : null,
|
||||
clockPhase: input.phase,
|
||||
clockRevision: input.revision,
|
||||
deadlineGeneration: input.deadlineGeneration,
|
||||
@@ -918,11 +935,11 @@ export class InMemoryTurnWorld {
|
||||
skippedTurns: number;
|
||||
} | null {
|
||||
const clock = this.getGameClock();
|
||||
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') {
|
||||
if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING' || clock.recovery) {
|
||||
return null;
|
||||
}
|
||||
const currentTick = clock.nowTick(wallNow);
|
||||
const wallAlignedTick = Math.max(currentTick, clock.dateToTick(wallNow));
|
||||
const wallAlignedTick = Math.max(currentTick, clock.normalNowTick(wallNow));
|
||||
const lastTurnTick = this.state.lastTurnTick ?? clock.dateToTick(this.state.lastTurnTime);
|
||||
// 운영 지연은 12턴 미만이면 전부 실행한다. 긴 중단은 완전한 게임 연도
|
||||
// 묶음만 건너뛰어 장수 분·초와 나머지 미처리 턴을 그대로 남긴다.
|
||||
@@ -1181,6 +1198,9 @@ export class InMemoryTurnWorld {
|
||||
lastTurnTime: this.state.lastTurnTime.toISOString(),
|
||||
};
|
||||
}
|
||||
if (previousClock.recovery && wallNow < previousClock.tickToWallDate(previousClock.recovery.endTick)) {
|
||||
throw new Error('복구가 끝난 뒤 기본 턴 길이를 변경할 수 있습니다.');
|
||||
}
|
||||
const currentWallAnchor = this.state.clockWallAnchor ?? previousClock.wallAnchor;
|
||||
const anchorWall = wallNow.getTime() < currentWallAnchor.getTime() ? currentWallAnchor : wallNow;
|
||||
const anchorTick = previousClock.nowTick(anchorWall);
|
||||
@@ -1201,6 +1221,7 @@ export class InMemoryTurnWorld {
|
||||
this.state = {
|
||||
...this.state,
|
||||
tickSeconds: nextTickSeconds,
|
||||
clockRecovery: null,
|
||||
clockBaseTime: nextBaseTime,
|
||||
clockTick: anchorTick,
|
||||
clockWallAnchor: new Date(anchorWall.getTime()),
|
||||
@@ -1670,10 +1691,13 @@ export class InMemoryTurnWorld {
|
||||
};
|
||||
}
|
||||
|
||||
shiftSchedule(deltaMinutes: number, wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
|
||||
shiftSchedule(deltaMinutes: number, _wallNow = new Date()): { shiftedGenerals: number; lastTurnTime: string } {
|
||||
if (!Number.isInteger(deltaMinutes) || deltaMinutes === 0) {
|
||||
throw new Error('Schedule shift must be a non-zero integer number of minutes.');
|
||||
}
|
||||
if ((deltaMinutes * 60) % this.state.tickSeconds !== 0) {
|
||||
throw new Error('일정 이동은 현재 턴 길이의 정수 배수여야 합니다.');
|
||||
}
|
||||
const deltaMs = deltaMinutes * 60_000;
|
||||
const shiftDate = (date: Date): Date => new Date(date.getTime() + deltaMs);
|
||||
const previousClock = this.getGameClock();
|
||||
@@ -1694,6 +1718,7 @@ export class InMemoryTurnWorld {
|
||||
tick: this.state.clockTick ?? 0,
|
||||
mode: this.state.clockMode ?? 'manual',
|
||||
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
|
||||
recovery: this.state.clockRecovery,
|
||||
turnSeconds: this.state.tickSeconds,
|
||||
phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'),
|
||||
revision: this.state.clockRevision ?? 1,
|
||||
@@ -1709,14 +1734,14 @@ export class InMemoryTurnWorld {
|
||||
this.state = {
|
||||
...this.state,
|
||||
clockBaseTime: nextBaseTime,
|
||||
// Rebasing is also the explicit resume checkpoint. Realtime mode
|
||||
// must not replay the operational downtime after an administrator
|
||||
// deliberately delays or accelerates the game schedule.
|
||||
// 가오픈의 anchor는 별도 예약된 정식 오픈이다. 표시 좌표를 옮기는
|
||||
// 작업이 그 미래 경계를 현재 시각으로 당겨 게임을 시작시키면 안 된다.
|
||||
clockWallAnchor: new Date(
|
||||
previousClock.phase === 'PREOPEN' ? previousClock.wallAnchor.getTime() : wallNow.getTime()
|
||||
),
|
||||
// 명시적 이동은 저장 좌표와 실제 실행 anchor를 같은 정수 턴만큼 옮긴다.
|
||||
clockWallAnchor: shiftDate(previousClock.wallAnchor),
|
||||
clockRecovery: this.state.clockRecovery
|
||||
? {
|
||||
...this.state.clockRecovery,
|
||||
startWallAt: shiftDate(this.state.clockRecovery.startWallAt),
|
||||
}
|
||||
: null,
|
||||
lastTurnTime: nextLastTurnTime,
|
||||
meta: nextMeta,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||
import {
|
||||
readClockDatabaseWall,
|
||||
reconcileClockSuspension,
|
||||
startClockSuspension,
|
||||
type ClockOperationAuthority,
|
||||
} from './clockReconciliation.js';
|
||||
|
||||
/** lease를 획득했지만 clock_ready를 공개하기 전, 중단된 복구 또는 새 정전을 처리한다. */
|
||||
export const prepareRealtimeRecovery = async (
|
||||
db: GamePrismaClient,
|
||||
authority: Extract<ClockOperationAuthority, { kind: 'DAEMON' }>,
|
||||
options: { paused?: boolean } = {}
|
||||
): Promise<void> => {
|
||||
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
if (world.clockMode !== 'realtime') return;
|
||||
if (world.clockPhase === 'SUSPENDED') {
|
||||
if (options.paused) return;
|
||||
const pending = await db.clockSuspension.findFirst({
|
||||
where: { worldStateId: world.id, source: 'RECOVERY', policy: 'RECOVER_TURNS', status: 'SUSPENDED' },
|
||||
orderBy: { sourceRevision: 'desc' },
|
||||
});
|
||||
if (pending) await reconcileClockSuspension({ db, suspensionId: pending.id, authority });
|
||||
return;
|
||||
}
|
||||
if (world.clockPhase !== 'RUNNING' || !world.clockWallAnchor || world.clockTick === null) return;
|
||||
const now = await readClockDatabaseWall(db);
|
||||
// 가속 중 정상적인 프로세스 교체는 기존 창을 그대로 재사용한다.
|
||||
// 한 턴 미만의 장애는 잔여 구간 실행만 필요하므로 새 좌표 세대를 만들지 않는다.
|
||||
if (!options.paused && now.getTime() - world.clockWallAnchor.getTime() < world.tickSeconds * 1_000) return;
|
||||
const suspensionId = `recovery-${randomUUID()}`;
|
||||
await startClockSuspension({
|
||||
db,
|
||||
suspensionId,
|
||||
source: 'RECOVERY',
|
||||
policy: 'RECOVER_TURNS',
|
||||
authority,
|
||||
recoverDurableObservation: true,
|
||||
});
|
||||
// 이전 버전의 profile 상태만 PAUSED였던 경우에도 명시적 재개 전에는 실행하지 않는다.
|
||||
if (!options.paused) await reconcileClockSuspension({ db, suspensionId, authority });
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseGameClockPhase } from '@sammo-ts/common';
|
||||
import { parseGameClockPhase, readTurnRecovery, readSerializedTurnRecovery } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
@@ -28,6 +28,9 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
||||
clockTick: true,
|
||||
clockMode: true,
|
||||
clockWallAnchor: true,
|
||||
clockRecoveryStartTick: true,
|
||||
clockRecoveryEndTick: true,
|
||||
clockRecoveryStartWallAt: true,
|
||||
lastTurnTick: true,
|
||||
clockPhase: true,
|
||||
clockRevision: true,
|
||||
@@ -67,6 +70,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
||||
shiftTicks: true,
|
||||
alignedTick: true,
|
||||
resumeWallAt: true,
|
||||
detail: true,
|
||||
},
|
||||
});
|
||||
let expectedRevision = before.revision;
|
||||
@@ -92,6 +96,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
||||
alignedTick: safeNumber(ledger.alignedTick, `clock suspension ${ledger.id} aligned tick`),
|
||||
shiftTicks: safeNumber(ledger.shiftTicks, `clock suspension ${ledger.id} shift ticks`),
|
||||
resumeWallAt: ledger.resumeWallAt,
|
||||
recovery: readSerializedTurnRecovery(ledger.detail),
|
||||
});
|
||||
expectedRevision = targetRevision;
|
||||
}
|
||||
@@ -108,6 +113,7 @@ export const synchronizeRuntimeClockAuthorityUnderHeldLock = async (
|
||||
tick: safeNumber(durable.clockTick, 'durable clock tick'),
|
||||
mode: durable.clockMode === 'manual' ? 'manual' : 'realtime',
|
||||
wallAnchor: durable.clockWallAnchor,
|
||||
recovery: readTurnRecovery(durable),
|
||||
lastTurnTick: safeNumber(durable.lastTurnTick, 'durable last turn tick'),
|
||||
phase: parseGameClockPhase(durable.clockPhase),
|
||||
revision: durableRevision,
|
||||
|
||||
@@ -900,6 +900,17 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
turnDaemonLease: turnDaemonLease ?? undefined,
|
||||
transactionTimeoutMs: options.databaseTransactionTimeoutMs,
|
||||
});
|
||||
try {
|
||||
await dbHooks.prepareRealtimeRecovery({ paused: await gatewayGate?.shouldPause() });
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
dbHooks.close(),
|
||||
reservedTurnStoreHandle?.close(),
|
||||
gatewayGate?.close(),
|
||||
turnDaemonLease?.close(),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
auctionBidder = await createAuctionBidder({
|
||||
databaseUrl: options.databaseUrl,
|
||||
world,
|
||||
@@ -983,6 +994,9 @@ const createTurnDaemonRuntimeWithLease = async (
|
||||
redisConnector = realtimeRuntime.redisConnector;
|
||||
hooks = realtimeRuntime.hooks;
|
||||
stopClockProjectionWorker = realtimeRuntime.stopClockProjectionWorker;
|
||||
// 복구 창이 DB에 저장되고 projection worker가 구성된 뒤에만 독립 worker를 허용한다.
|
||||
// RECONCILING 상태이면 기존 revision/phase fence가 outbox 완료까지 계속 차단한다.
|
||||
await turnDaemonLease?.markClockReady();
|
||||
|
||||
const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
|
||||
const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
WorldSnapshot,
|
||||
GeneralLastTurn,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
|
||||
import type { GameClockMode, GameClockPhase, TurnRecoveryWindow } from '@sammo-ts/common';
|
||||
|
||||
export interface TurnWorldState {
|
||||
id: number;
|
||||
@@ -23,6 +23,7 @@ export interface TurnWorldState {
|
||||
clockTick?: number;
|
||||
clockMode?: GameClockMode;
|
||||
clockWallAnchor?: Date;
|
||||
clockRecovery?: TurnRecoveryWindow | null;
|
||||
lastTurnTick?: number;
|
||||
clockPhase?: GameClockPhase;
|
||||
clockRevision?: number;
|
||||
|
||||
@@ -28,6 +28,7 @@ import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/ite
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
GameClock,
|
||||
readTurnRecovery,
|
||||
asRecord,
|
||||
inferClockPhase,
|
||||
isRecord,
|
||||
@@ -440,14 +441,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
worldState.clockWallAnchor !== null &&
|
||||
worldState.lastTurnTick !== null;
|
||||
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
|
||||
const clockPhase = hasPersistedClock
|
||||
? parseGameClockPhase(worldState.clockPhase)
|
||||
: inferClockPhase(clockMode);
|
||||
const clockPhase = hasPersistedClock ? parseGameClockPhase(worldState.clockPhase) : inferClockPhase(clockMode);
|
||||
const clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision');
|
||||
const deadlineGeneration = toSafeTick(
|
||||
worldState.deadlineGeneration,
|
||||
'world_state.deadline_generation'
|
||||
);
|
||||
const deadlineGeneration = toSafeTick(worldState.deadlineGeneration, 'world_state.deadline_generation');
|
||||
const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
|
||||
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
|
||||
const bootstrapClock = new GameClock({
|
||||
@@ -455,6 +451,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
tick: 0,
|
||||
mode: clockMode,
|
||||
wallAnchor: clockWallAnchor,
|
||||
recovery: readTurnRecovery(worldState),
|
||||
turnSeconds: worldState.tickSeconds,
|
||||
phase: clockPhase,
|
||||
revision: clockRevision,
|
||||
@@ -468,6 +465,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
: toSafeTick(worldState.clockTick, 'world_state.clock_tick'),
|
||||
mode: clockMode,
|
||||
wallAnchor: clockWallAnchor,
|
||||
recovery: readTurnRecovery(worldState),
|
||||
turnSeconds: worldState.tickSeconds,
|
||||
phase: clockPhase,
|
||||
revision: clockRevision,
|
||||
@@ -537,6 +535,7 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
clockTick: gameClock.tick,
|
||||
clockMode,
|
||||
clockWallAnchor: gameClock.wallAnchor,
|
||||
clockRecovery: gameClock.recovery,
|
||||
lastTurnTick,
|
||||
clockPhase,
|
||||
clockRevision,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GameClock } from '@sammo-ts/common';
|
||||
import { GameClock, GAME_TICKS_PER_TURN as T, readTurnRecovery } from '@sammo-ts/common';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
readTurnRuntimeReady,
|
||||
createRedisConnector,
|
||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
||||
GamePrisma,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
|
||||
import { reconcileClockSuspension, startClockSuspension } from '../src/turn/clockReconciliation.js';
|
||||
import { applyNextClockProjection } from '../src/turn/clockProjectionOutbox.js';
|
||||
import { prepareRealtimeRecovery } from '../src/turn/prepareRealtimeRecovery.js';
|
||||
import { DatabaseTurnDaemonLease } from '../src/lifecycle/databaseTurnDaemonLease.js';
|
||||
|
||||
const databaseUrl = process.env.CLOCK_RECONCILIATION_DATABASE_URL;
|
||||
const enabled = Boolean(databaseUrl) && Boolean(process.env.REDIS_URL);
|
||||
@@ -62,6 +65,158 @@ describeIntegration('durable clock reconciliation', () => {
|
||||
await clean();
|
||||
});
|
||||
|
||||
it.each([false, true])('fences outage recovery and reuses its window; repeated outage=%s', async (repeated) => {
|
||||
const profile = 'recovery-startup';
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: profile,
|
||||
currentYear: 199,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 3600,
|
||||
clockBaseTime: new Date('0199-01-01T00:00:00Z'),
|
||||
clockTick: repeated ? BigInt(4 * T) : 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date(Date.now() - 4 * 3_600_000),
|
||||
lastTurnTick: repeated ? BigInt(4 * T) : 0n,
|
||||
...(repeated
|
||||
? {
|
||||
clockRecoveryStartTick: 0n,
|
||||
clockRecoveryEndTick: BigInt(8 * T),
|
||||
clockRecoveryStartWallAt: new Date(Date.now() - 6 * 3_600_000),
|
||||
}
|
||||
: {}),
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
},
|
||||
});
|
||||
const lease = await DatabaseTurnDaemonLease.connect(databaseUrl!, { profile, heartbeat: false });
|
||||
try {
|
||||
const token = await lease.acquire();
|
||||
expect(token).not.toBeNull();
|
||||
expect(await readTurnRuntimeReady(db, 1n)).toBe(false);
|
||||
const authority = {
|
||||
kind: 'DAEMON' as const,
|
||||
profileName: profile,
|
||||
ownerId: token!.ownerId,
|
||||
fencingEpoch: token!.fencingEpoch,
|
||||
};
|
||||
if (!repeated) {
|
||||
await prepareRealtimeRecovery(db, authority, { paused: true });
|
||||
const paused = await db.worldState.findFirstOrThrow();
|
||||
expect(paused.clockPhase).toBe('SUSPENDED');
|
||||
expect(paused.clockTick).toBe(0n);
|
||||
expect((await db.clockSuspension.findFirstOrThrow()).status).toBe('SUSPENDED');
|
||||
await prepareRealtimeRecovery(db, authority, { paused: true });
|
||||
expect((await db.worldState.findFirstOrThrow()).clockPhase).toBe('SUSPENDED');
|
||||
}
|
||||
await prepareRealtimeRecovery(db, authority);
|
||||
const pending = await db.worldState.findFirstOrThrow();
|
||||
expect(pending.clockPhase).toBe('RECONCILING');
|
||||
const recoveredWindow = readTurnRecovery(pending)!;
|
||||
expect(recoveredWindow).not.toBeNull();
|
||||
expect(recoveredWindow.endTick - recoveredWindow.startTick).toBe((repeated ? 12 : 8) * T);
|
||||
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
|
||||
await applyNextClockProjection({ db, redis: redis.client, workerId: profile });
|
||||
await lease.markClockReady();
|
||||
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(true);
|
||||
expect(await readTurnRuntimeReady(db, 1n)).toBe(false);
|
||||
await lease.acquire();
|
||||
expect(await readTurnRuntimeReady(db, pending.clockRevision)).toBe(false);
|
||||
await prepareRealtimeRecovery(db, {
|
||||
kind: 'DAEMON',
|
||||
profileName: profile,
|
||||
ownerId: token!.ownerId,
|
||||
fencingEpoch: token!.fencingEpoch,
|
||||
});
|
||||
const reloaded = await db.worldState.findFirstOrThrow();
|
||||
expect(readTurnRecovery(reloaded)).toEqual(readTurnRecovery(pending));
|
||||
expect(reloaded.clockRevision).toBe(pending.clockRevision);
|
||||
} finally {
|
||||
await lease.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([4, 12, 13, 23, 24])(
|
||||
'persists recovery for %i turns and reloads the same normal boundary',
|
||||
async (turns) => {
|
||||
const now = new Date();
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'turn-recovery',
|
||||
currentYear: 199,
|
||||
currentMonth: 4,
|
||||
tickSeconds: 3600,
|
||||
clockBaseTime: new Date('2026-01-01T00:00:00Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: now,
|
||||
lastTurnTick: 0n,
|
||||
clockPhase: 'RUNNING',
|
||||
clockRevision: 1n,
|
||||
deadlineGeneration: 1n,
|
||||
},
|
||||
});
|
||||
const generalTick = T + 199_020;
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: 26,
|
||||
name: '냥냥',
|
||||
turnTick: BigInt(generalTick),
|
||||
turnTime: new Date('2026-01-01T01:00:19.902Z'),
|
||||
meta: { purchasedPhase: true },
|
||||
},
|
||||
});
|
||||
const authority = { kind: 'OFFLINE' as const, profileName: 'recovery-test', reason: 'fixture' };
|
||||
const suspension = await startClockSuspension({
|
||||
db,
|
||||
suspensionId: 'recovery-test',
|
||||
source: 'MAINTENANCE',
|
||||
policy: turns === 4 ? 'EXACT' : 'RECOVER_TURNS',
|
||||
authority,
|
||||
});
|
||||
const resumedAt = new Date(suspension.cutWallAt.getTime() + turns * 3_600_000);
|
||||
const plan = await reconcileClockSuspension({
|
||||
db,
|
||||
suspensionId: suspension.suspensionId,
|
||||
authority,
|
||||
testResumeWallAt: resumedAt,
|
||||
upgradeMaintenancePolicy: true,
|
||||
});
|
||||
expect(plan.shiftTicks).toBe(Math.floor(turns / 12) * 12 * T);
|
||||
await applyNextClockProjection({ db, redis: redis.client, workerId: 'recovery-test' });
|
||||
const row = await db.worldState.findFirstOrThrow();
|
||||
const recovery = readTurnRecovery(row);
|
||||
const reloaded = new GameClock({
|
||||
baseTime: row.clockBaseTime!,
|
||||
tick: Number(row.clockTick),
|
||||
wallAnchor: row.clockWallAnchor!,
|
||||
mode: 'realtime',
|
||||
turnSeconds: row.tickSeconds,
|
||||
recovery,
|
||||
});
|
||||
expect(row.currentMonth).toBe(4);
|
||||
expect(row.lastTurnTick).toBe(BigInt(plan.shiftTicks));
|
||||
const general = await db.general.findUniqueOrThrow({ where: { id: 26 } });
|
||||
expect(general.turnTick).toBe(BigInt(generalTick + plan.shiftTicks));
|
||||
expect(general.turnTime.toISOString().slice(14)).toBe('00:19.902Z');
|
||||
expect(general.meta).toEqual({ purchasedPhase: true });
|
||||
if (turns % 12 === 0) {
|
||||
expect(recovery).toBeNull();
|
||||
} else {
|
||||
expect(recovery).not.toBeNull();
|
||||
const end = reloaded.tickToWallDate(recovery!.endTick);
|
||||
expect(reloaded.nowTick(end)).toBe(recovery!.endTick);
|
||||
expect(reloaded.executionRate(new Date(end.getTime() - 1))).toBe(2);
|
||||
expect(reloaded.executionRate(end)).toBe(1);
|
||||
expect(reloaded.tickToWallDate(recovery!.endTick + 199_020).getTime() - end.getTime()).toBe(19_902);
|
||||
}
|
||||
const retry = await reconcileClockSuspension({ db, suspensionId: suspension.suspensionId, authority });
|
||||
expect(retry.recovery).toEqual(plan.recovery);
|
||||
expect(retry.catchUpTicks).toBe(plan.catchUpTicks);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([3_142_625, 6 * 3_600_000 + 3_142_625])(
|
||||
'preserves purchased turn phases and the execution cursor after %i ms maintenance and reload',
|
||||
async (gapMilliseconds) => {
|
||||
|
||||
@@ -190,12 +190,12 @@ describe('in-memory scenario general pool availability', () => {
|
||||
const before = world.captureState();
|
||||
const probeAfterOriginalExpiry = new Date(claimedAt.getTime() + 10 * 60_000);
|
||||
|
||||
world.shiftSchedule(15, claimedAt);
|
||||
world.shiftSchedule(20, claimedAt);
|
||||
|
||||
expect(world.captureState().generalPoolEntries).toMatchObject([
|
||||
{
|
||||
id: 1,
|
||||
reservedUntil: new Date(reservedUntil.getTime() + 15 * 60_000),
|
||||
reservedUntil: new Date(reservedUntil.getTime() + 20 * 60_000),
|
||||
reservedUntilTick: GAME_TICKS_PER_TURN / 2,
|
||||
},
|
||||
{ id: 2, reservedUntil: null, reservedUntilTick: null },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
|
||||
import { GAME_TICKS_PER_TURN, planTurnRecovery } from '@sammo-ts/common';
|
||||
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { applyRuntimeClockShift } from '../src/turn/runtimeClockShift.js';
|
||||
import { applyRuntimeGameSettings } from '../src/turn/runtimeGameSettings.js';
|
||||
@@ -83,6 +84,70 @@ const buildWorld = (stateOverride: Partial<TurnWorldState> = {}): InMemoryTurnWo
|
||||
};
|
||||
|
||||
describe('runtime clock shift', () => {
|
||||
it('runs two real monthly cycles per normal interval, survives reload, and returns to one cycle', async () => {
|
||||
const base = new Date('2026-07-30T10:00:00Z');
|
||||
const wallAt = (minutes: number) => new Date(base.getTime() + minutes * 60_000);
|
||||
const plan = planTurnRecovery({
|
||||
observedTick: 0,
|
||||
normalTick: 4 * GAME_TICKS_PER_TURN,
|
||||
wallNow: wallAt(40),
|
||||
turnSeconds: 600,
|
||||
});
|
||||
let world = buildWorld({
|
||||
clockBaseTime: base,
|
||||
clockTick: 0,
|
||||
clockWallAnchor: wallAt(40),
|
||||
clockRecovery: plan.recovery,
|
||||
clockMode: 'realtime',
|
||||
clockPhase: 'RUNNING',
|
||||
lastTurnTick: 0,
|
||||
});
|
||||
for (const [id, milliseconds] of [
|
||||
[1, 19_902],
|
||||
[2, 42_001],
|
||||
]) {
|
||||
world.updateGeneral(id!, {
|
||||
turnTime: new Date(base.getTime() + milliseconds!),
|
||||
turnTick: milliseconds! * 60,
|
||||
});
|
||||
}
|
||||
const executions: Array<[number, number, boolean]> = [];
|
||||
const processorForWorld = () =>
|
||||
new InMemoryTurnProcessor(world, {
|
||||
afterExecuteGeneral: async (general, result) => {
|
||||
executions.push([world.getState().currentMonth, general.id, result.ok]);
|
||||
},
|
||||
});
|
||||
let processor = processorForWorld();
|
||||
for (let minutes = 45; minutes <= 80; minutes += 5) {
|
||||
const now = wallAt(minutes);
|
||||
const target = world.getGameNow(now);
|
||||
world.advanceGameClockTo(target, now);
|
||||
const result = await processor.run(target, { budgetMs: 10_000, maxGenerals: 10, catchUpCap: 1 });
|
||||
expect(result).toMatchObject({ processedGenerals: 2, processedTurns: 1, partial: false });
|
||||
if (minutes === 60) {
|
||||
const snapshot = world.captureState();
|
||||
world = buildWorld();
|
||||
world.restoreState(snapshot);
|
||||
processor = processorForWorld();
|
||||
}
|
||||
}
|
||||
expect(executions).toEqual(
|
||||
Array.from({ length: 8 }, (_, month) => [
|
||||
[month + 1, 1, true],
|
||||
[month + 1, 2, true],
|
||||
]).flat()
|
||||
);
|
||||
expect(world.getState().currentMonth).toBe(9);
|
||||
expect(world.getGeneralById(1)!.turnTime).toEqual(new Date(wallAt(80).getTime() + 19_902));
|
||||
const next = await processor.run(world.getGameNow(wallAt(90)), {
|
||||
budgetMs: 10_000,
|
||||
maxGenerals: 10,
|
||||
catchUpCap: 1,
|
||||
});
|
||||
expect(next).toMatchObject({ processedGenerals: 2, processedTurns: 1 });
|
||||
expect(world.getState().currentMonth).toBe(10);
|
||||
});
|
||||
it('preserves the scenario config when the raw world config is unavailable', () => {
|
||||
const world = buildWorld();
|
||||
|
||||
@@ -99,7 +164,7 @@ describe('runtime clock shift', () => {
|
||||
['accelerates', -15, '2026-07-30T09:45:00.000Z', '2026-07-30T09:55:00.000Z'],
|
||||
['delays', 15, '2026-07-30T10:15:00.000Z', '2026-07-30T10:25:00.000Z'],
|
||||
] as const)('%s the world, every general, checkpoint, and pending auction together', (_, delta, last, general) => {
|
||||
const world = buildWorld();
|
||||
const world = buildWorld({ tickSeconds: 900 });
|
||||
world.setCheckpoint({ turnTime: '2026-07-30T10:10:00.000Z', generalId: 1, year: 190, month: 1 });
|
||||
world.queueNeutralAuction({
|
||||
registrationKey: 'test',
|
||||
@@ -132,7 +197,7 @@ describe('runtime clock shift', () => {
|
||||
expect(world.peekDirtyState().generals.map((entry) => entry.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it.each([0, 1.5, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
|
||||
it.each([0, 1.5, 15, Number.NaN])('rejects an invalid shift without mutation: %s', (delta) => {
|
||||
const world = buildWorld();
|
||||
expect(() => world.shiftSchedule(delta)).toThrow();
|
||||
expect(world.getState().lastTurnTime.toISOString()).toBe('2026-07-30T10:00:00.000Z');
|
||||
@@ -140,7 +205,7 @@ describe('runtime clock shift', () => {
|
||||
});
|
||||
|
||||
it('keeps legacy wall-clock metadata independent from the process timezone', () => {
|
||||
const world = buildWorld();
|
||||
const world = buildWorld({ tickSeconds: 900 });
|
||||
|
||||
world.shiftSchedule(-15);
|
||||
|
||||
@@ -212,7 +277,7 @@ describe('runtime clock shift', () => {
|
||||
expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 });
|
||||
});
|
||||
|
||||
it('preserves the formal wall opening when PREOPEN game display dates are shifted', () => {
|
||||
it('moves the formal opening together with an explicit whole-turn schedule shift', () => {
|
||||
const openAt = new Date('2026-09-06T00:00:00.000Z');
|
||||
const now = new Date('2026-09-05T00:00:00.000Z');
|
||||
const world = buildWorld({
|
||||
@@ -223,11 +288,13 @@ describe('runtime clock shift', () => {
|
||||
lastTurnTick: 0,
|
||||
clockPhase: 'PREOPEN',
|
||||
});
|
||||
world.shiftSchedule(15, now);
|
||||
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: openAt });
|
||||
world.shiftSchedule(20, now);
|
||||
const shiftedOpen = new Date(openAt.getTime() + 20 * 60_000);
|
||||
expect(world.getGameClockState()).toMatchObject({ phase: 'PREOPEN', tick: 0, wallAnchor: shiftedOpen });
|
||||
expect(world.promotePreopenAtOpening(now)).toBe(false);
|
||||
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:15:00.000Z'));
|
||||
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
|
||||
expect(world.getRunnableGameNow(now)).toEqual(new Date('2026-07-30T10:20:00.000Z'));
|
||||
expect(world.promotePreopenAtOpening(openAt)).toBe(false);
|
||||
expect(world.promotePreopenAtOpening(shiftedOpen)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects gameplay commits while the durable clock is suspended', async () => {
|
||||
@@ -309,7 +376,7 @@ describe('runtime clock shift', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('repairs an already accumulated realtime projection lag during a long rebase', () => {
|
||||
it('preserves the normal game-to-wall mapping during a whole-year rebase', () => {
|
||||
const base = new Date('2026-07-30T10:00:00.000Z');
|
||||
const staleAnchor = new Date('2026-07-30T11:00:00.000Z');
|
||||
const resumedAt = new Date('2026-07-30T11:50:00.000Z');
|
||||
@@ -325,7 +392,7 @@ describe('runtime clock shift', () => {
|
||||
|
||||
expect(world.getGameNow(resumedAt).toISOString()).toBe('2026-07-30T11:15:00.000Z');
|
||||
expect(world.rebaseRealtimeBacklog(resumedAt)).toMatchObject({ skippedTurns: 12 });
|
||||
expect(world.getGameNow(resumedAt)).toEqual(resumedAt);
|
||||
expect(world.getGameNow(resumedAt)).toEqual(new Date('2026-07-30T11:15:00.000Z'));
|
||||
});
|
||||
|
||||
it('does not lose realtime elapsed time when an overdue target is committed later', () => {
|
||||
|
||||
@@ -244,7 +244,7 @@ integration('runtime clock shift persistence', () => {
|
||||
type: 'shiftSchedule',
|
||||
requestId,
|
||||
actionId,
|
||||
deltaMinutes: -15,
|
||||
deltaMinutes: -20,
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
@@ -257,29 +257,30 @@ integration('runtime clock shift persistence', () => {
|
||||
await hooks.close();
|
||||
}
|
||||
|
||||
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:45:00.000Z');
|
||||
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:55:00.000Z');
|
||||
expect(world.getState().lastTurnTime.toISOString()).toBe('2099-07-30T09:40:00.000Z');
|
||||
expect(world.getGeneralById(generalIds[0])?.turnTime.toISOString()).toBe('2099-07-30T09:50:00.000Z');
|
||||
expect(await stateStore.loadCheckpoint()).toMatchObject({
|
||||
turnTime: '2099-07-30T09:45:00.000Z',
|
||||
turnTime: '2099-07-30T09:40:00.000Z',
|
||||
generalId: 0,
|
||||
});
|
||||
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:55:00.000Z');
|
||||
// 예정된 재개 경계까지 대기하며, 그 뒤 첫 장수의 시각을 다시 계산한다.
|
||||
expect(lifecycle.getStatus().nextTurnTime).toBe('2099-07-30T09:40:00.000Z');
|
||||
const storedWorld = await db.worldState.findUniqueOrThrow({ where: { id: row.id } });
|
||||
expect(storedWorld.meta).toMatchObject({
|
||||
lastTurnTime: '2099-07-30T09:45:00.000Z',
|
||||
starttime: '2099-06-30 23:45:00',
|
||||
lastTurnTime: '2099-07-30T09:40:00.000Z',
|
||||
starttime: '2099-06-30 23:40:00',
|
||||
});
|
||||
expect(storedWorld.clockTick).toBe(0n);
|
||||
expect(storedWorld.lastTurnTick).toBe(0n);
|
||||
const storedGeneral = await db.general.findUniqueOrThrow({ where: { id: generalIds[1] } });
|
||||
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:05:00.000Z');
|
||||
expect(storedGeneral.turnTime.toISOString()).toBe('2099-07-30T10:00:00.000Z');
|
||||
expect(storedGeneral.turnTick).toBe(BigInt(2 * GAME_TICKS_PER_TURN));
|
||||
const storedAuctions = await db.auction.findMany({
|
||||
where: { id: { in: auctionRows.map((auction) => auction.id) } },
|
||||
});
|
||||
const closeAtById = new Map(storedAuctions.map((auction) => [auction.id, auction.closeAt.toISOString()]));
|
||||
expect(auctionRows.map((auction) => closeAtById.get(auction.id))).toEqual([
|
||||
'2099-07-30T09:45:00.000Z',
|
||||
'2099-07-30T09:40:00.000Z',
|
||||
'2099-07-30T11:00:00.000Z',
|
||||
'2099-07-30T12:00:00.000Z',
|
||||
'2099-07-30T13:00:00.000Z',
|
||||
@@ -291,7 +292,7 @@ integration('runtime clock shift persistence', () => {
|
||||
type: 'shiftSchedule',
|
||||
ok: true,
|
||||
actionId,
|
||||
deltaMinutes: -15,
|
||||
deltaMinutes: -20,
|
||||
shiftedGenerals: 2,
|
||||
shiftedAuctions: 1,
|
||||
},
|
||||
|
||||
@@ -611,7 +611,7 @@ integration('unification finalization transaction', () => {
|
||||
const suspension = await db.clockSuspension.findFirstOrThrow({ where: { worldStateId: worldRow.id } });
|
||||
expect(suspension).toMatchObject({
|
||||
source: 'UNIFICATION_WAIT',
|
||||
policy: 'EXACT',
|
||||
policy: 'TURN_BOUNDARY',
|
||||
status: 'SUSPENDED',
|
||||
sourceRevision: 1n,
|
||||
targetRevision: 2n,
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
tickSeconds: 60,
|
||||
lastTurnTime: liveLastTurnTime,
|
||||
clockBaseTime: new Date('2026-08-19T12:00:00.000Z'),
|
||||
clockTick: 19_980_000_000,
|
||||
clockTick: 19_944_000_000,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: liveWallAnchor,
|
||||
lastTurnTick: 19_944_000_000,
|
||||
@@ -202,7 +202,7 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
actorUserId: recipient.userId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'messageRespond',
|
||||
processingGameTick: 19_980_000_000n,
|
||||
processingGameTick: 19_944_000_000n,
|
||||
})),
|
||||
},
|
||||
messageAction: { updateMany: vi.fn(async () => ({ count: 1 })) },
|
||||
@@ -214,11 +214,11 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
id: 1014,
|
||||
mailbox: recipient.id,
|
||||
type: 'private',
|
||||
time: world.gameTickToDate(19_980_000_000),
|
||||
time: world.gameTickToDate(19_944_000_000),
|
||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
||||
actionType: 'raiseInvader',
|
||||
actionStatus: 'PENDING',
|
||||
createdGameTick: 19_980_000_000n,
|
||||
createdGameTick: 19_944_000_000n,
|
||||
expiresGameTick: null,
|
||||
message,
|
||||
},
|
||||
@@ -256,13 +256,22 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
gapTicks: 108_000_000,
|
||||
catchUpTicks: 0,
|
||||
shiftTicks: 108_000_000,
|
||||
alignedTick: 20_088_000_000,
|
||||
alignedTick: 20_052_000_000,
|
||||
resumeWallAt: acceptedAt,
|
||||
resumeAnchor: new Date('2026-08-20T06:25:00Z'),
|
||||
}),
|
||||
});
|
||||
const processor = new InMemoryTurnProcessor(world);
|
||||
const clock = new ManualClock(acceptedAt.getTime());
|
||||
vi.spyOn(queue, 'waitFor').mockImplementation(async (milliseconds) => {
|
||||
await clock.sleepMs(milliseconds ?? 0);
|
||||
const pending = await queue.drain();
|
||||
for (const command of pending.slice(1)) queue.enqueue(command);
|
||||
return pending[0] ?? null;
|
||||
});
|
||||
const runWallTimes: Date[] = [];
|
||||
const run = vi.fn(async (...args: Parameters<InMemoryTurnProcessor['run']>) => {
|
||||
runWallTimes.push(new Date(clock.nowMs()));
|
||||
const result = await processor.run(...args);
|
||||
if (world.getState().currentMonth === 4) {
|
||||
queue.enqueue({ type: 'shutdown', reason: 'resumed monthly boundary verified' });
|
||||
@@ -307,7 +316,7 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
expect(commandDb.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
expect(commandDb.messageAction.updateMany).toHaveBeenCalledWith({
|
||||
where: { messageId: { in: [1014] }, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedGameTick: 20_088_000_000n },
|
||||
data: { status: 'RESOLVED', resolvedGameTick: 20_052_000_000n },
|
||||
});
|
||||
expect(world.getState()).toMatchObject({
|
||||
currentYear: 226,
|
||||
@@ -324,11 +333,11 @@ describe('HWE-shaped unification invader resume', () => {
|
||||
expect(initialInvaderTurnTimes).toHaveLength(10);
|
||||
expect(Math.min(...initialInvaderTurnTimes)).toBeGreaterThanOrEqual(alignedMonthlyBoundary);
|
||||
expect(Math.max(...initialInvaderTurnTimes)).toBeLessThan(alignedMonthlyBoundary + 60_000);
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls.map(([targetTime]) => targetTime.toISOString())).toEqual([
|
||||
'2026-08-20T06:24:58.611Z',
|
||||
'2026-08-20T06:25:00.000Z',
|
||||
]);
|
||||
expect(lifecycle.getStatus().lastTurnTime).toBe('2026-08-20T06:25:00.000Z');
|
||||
expect(run).toHaveBeenCalled();
|
||||
expect(runWallTimes.every((time) => time >= new Date('2026-08-20T06:25:00Z'))).toBe(true);
|
||||
const nextMonth = addMinutes(liveLastTurnTime, 4);
|
||||
expect(run.mock.calls.every(([targetTime]) => targetTime <= nextMonth)).toBe(true);
|
||||
expect(run.mock.calls.at(-1)![0]).toEqual(nextMonth);
|
||||
expect(lifecycle.getStatus().lastTurnTime).toBe(nextMonth.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user