feat: 게임 시계 reconciliation 권위 기반 추가

This commit is contained in:
2026-09-03 08:44:43 +00:00
parent b91dcbcaaa
commit ae7d55ef47
29 changed files with 1265 additions and 81 deletions
+61 -14
View File
@@ -1,5 +1,6 @@
import {
acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma,
@@ -122,6 +123,10 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clock_base_time',
'clockMode',
'clock_mode',
'clockPhase',
'clock_phase',
'clockRevision',
'clock_revision',
'clockTick',
'clock_tick',
'clockWallAnchor',
@@ -135,6 +140,8 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'last_turn_tick',
'lastTurnTime',
'last_turn_time',
'deadlineGeneration',
'deadline_generation',
'lease',
'leaseOwner',
'lease_owner',
@@ -1122,9 +1129,20 @@ export const createDatabaseTurnHooks = async (
clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)),
clockPhase: state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL'),
clockRevision: BigInt(state.clockRevision ?? 1),
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
config: asJson(world.getWorldConfig()),
meta: asJson(state.meta),
};
const writesGeneralAccess =
accessScoreResetGeneralIds.length > 0 ||
lifecycleEvents.length > 0 ||
deletedGenerals.length > 0 ||
generals.some(
(general) =>
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
);
const persist = async (
prisma: GamePrisma.TransactionClient
): Promise<{
@@ -1157,6 +1175,49 @@ export const createDatabaseTurnHooks = async (
// world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch.
await options?.turnDaemonLease?.assertActive(prisma);
await acquireGameSchemaAdvisoryXactLock(prisma, CLOCK_OPERATION_PERSISTENCE_LOCK);
if (writesGeneralAccess) {
// General-access API writers take this lock before touching
// world rows. Clock operations use the same global order.
await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK);
}
const persistedClock = await prisma.$queryRaw<
Array<{
clock_phase: string;
clock_revision: bigint;
deadline_generation: bigint;
opening_reached: boolean;
}>
>(GamePrisma.sql`
SELECT clock_phase,
clock_revision,
deadline_generation,
clock_wall_anchor <= CURRENT_TIMESTAMP AS opening_reached
FROM world_state
WHERE id = ${state.id}
FOR UPDATE
`);
const durableClock = persistedClock[0];
if (!durableClock) {
throw new Error(`world_state ${state.id} is missing during a fenced turn flush.`);
}
const expectedPhase = state.clockPhase ?? (state.clockMode === 'realtime' ? 'RUNNING' : 'MANUAL');
const expectedRevision = BigInt(state.clockRevision ?? 1);
const expectedGeneration = BigInt(state.deadlineGeneration ?? 1);
const openingPhaseTransition =
durableClock.clock_phase === 'PREOPEN' &&
expectedPhase === 'RUNNING' &&
durableClock.opening_reached;
if (
(!openingPhaseTransition && durableClock.clock_phase !== expectedPhase) ||
durableClock.clock_revision !== expectedRevision ||
durableClock.deadline_generation !== expectedGeneration
) {
throw new Error(
`Game clock fence changed before flush: expected ${expectedPhase}@${expectedRevision}/${expectedGeneration}, ` +
`found ${durableClock.clock_phase}@${durableClock.clock_revision}/${durableClock.deadline_generation}.`
);
}
let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey =
@@ -1323,20 +1384,6 @@ export const createDatabaseTurnHooks = async (
const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle');
const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle');
const writesGeneralAccess =
accessScoreResetGeneralIds.length > 0 ||
lifecycleEvents.length > 0 ||
deletedGenerals.length > 0 ||
generals.some(
(general) =>
typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal)
);
if (writesGeneralAccess) {
// API access writers acquire this before traffic/access rows.
// Match that order before lifecycle and monthly score writes.
await acquireGameSchemaAdvisoryXactLock(prisma, GENERAL_ACCESS_PERSISTENCE_LOCK);
}
await persistInheritancePointAdjustments(beforeLifecycleAdjustments);
await persistInheritanceLogs(beforeLifecycleLogs);
await persistGeneralLifecycleEvents(