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,
|
||||
|
||||
Reference in New Issue
Block a user