diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index 95a80255..d0614d99 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -65,7 +65,15 @@ export type WorldStateMeta = z.infer; type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload>; type PrismaGeneralRow = GamePrisma.GeneralGetPayload>; -type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'; +type WorldClockFields = + | 'clockBaseTime' + | 'clockTick' + | 'clockMode' + | 'clockWallAnchor' + | 'lastTurnTick' + | 'clockPhase' + | 'clockRevision' + | 'deadlineGeneration'; type GeneralClockFields = 'turnTick' | 'recentWarTick'; // Transitional API fixtures may still model the pre-clock row. Runtime Prisma diff --git a/app/game-api/src/services/gameClock.ts b/app/game-api/src/services/gameClock.ts index d64a103d..d2c592f7 100644 --- a/app/game-api/src/services/gameClock.ts +++ b/app/game-api/src/services/gameClock.ts @@ -1,4 +1,10 @@ -import { GameClock, type GameClockMode } from '@sammo-ts/common'; +import { + GameClock, + inferClockPhase, + parseGameClockPhase, + type GameClockMode, + type GameClockPhase, +} from '@sammo-ts/common'; import type { DatabaseClient } from '../context.js'; @@ -7,6 +13,9 @@ export interface CurrentGameTime { wallNow: Date; tick: number | null; mode: GameClockMode | null; + phase?: GameClockPhase | null; + revision?: number | null; + deadlineGeneration?: number | null; running: boolean; startsAt: Date | null; dateToTick(date: Date): number | null; @@ -19,6 +28,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date wallNow, tick: null, mode: null, + phase: null, + revision: null, + deadlineGeneration: null, running: true, startsAt: null, dateToTick: () => null, @@ -32,6 +44,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date clockMode: true, clockWallAnchor: true, tickSeconds: true, + clockPhase: true, + clockRevision: true, + deadlineGeneration: true, }, }); if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) { @@ -40,32 +55,53 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date wallNow, tick: null, mode: null, + phase: null, + revision: null, + deadlineGeneration: null, running: true, startsAt: null, dateToTick: () => null, }; } const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime'; + // During the dual-read migration an older profile (or a rolling-deploy + // fixture) can lack clock_phase. Preserve the existing future-anchor + // PREOPEN contract until every profile has the authoritative column. + const phase = state.clockPhase + ? parseGameClockPhase(state.clockPhase) + : mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime() + ? 'PREOPEN' + : inferClockPhase(mode); const storedTick = Number(state.clockTick); if (!Number.isSafeInteger(storedTick)) { throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`); } + const revision = Number(state.clockRevision ?? 1n); + const deadlineGeneration = Number(state.deadlineGeneration ?? 1n); + if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) { + throw new Error('world_state clock revision or deadline generation is outside the safe integer range.'); + } const clock = new GameClock({ baseTime: state.clockBaseTime, tick: storedTick, mode, wallAnchor: state.clockWallAnchor, turnSeconds: state.tickSeconds, + phase, + revision, }); const tick = clock.nowTick(wallNow); - const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime(); + const running = phase === 'RUNNING' && mode === 'realtime'; return { now: clock.tickToDate(tick), wallNow, tick, mode, + phase, + revision, + deadlineGeneration, running, - startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null, + startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null, dateToTick: (date) => clock.dateToTick(date), }; }; diff --git a/app/game-api/test/gameClock.test.ts b/app/game-api/test/gameClock.test.ts index 8258ece2..8017409e 100644 --- a/app/game-api/test/gameClock.test.ts +++ b/app/game-api/test/gameClock.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { DatabaseClient } from '../src/context.js'; import { loadCurrentGameTime } from '../src/services/gameClock.js'; -const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient => +const buildDatabase = ( + mode: 'realtime' | 'manual' = 'realtime', + phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN' +): DatabaseClient => ({ worldState: { findFirst: vi.fn(async () => ({ @@ -12,6 +15,9 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient clockMode: mode, clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'), tickSeconds: 600, + clockPhase: phase, + clockRevision: 1n, + deadlineGeneration: 1n, })), }, }) as unknown as DatabaseClient; @@ -26,11 +32,15 @@ describe('current game time projection', () => { wallNow: new Date('2026-08-21T10:30:00.000Z'), tick: -108_000_000, mode: 'realtime', + phase: 'PREOPEN', running: false, startsAt: new Date('2026-08-21T11:00:00.000Z'), }); - const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z')); + const opened = await loadCurrentGameTime( + buildDatabase('realtime', 'RUNNING'), + new Date('2026-08-21T11:00:05.000Z') + ); expect(opened).toMatchObject({ now: new Date('2026-08-21T11:00:05.000Z'), tick: 300_000, diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index c3ae3878..99f902de 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -172,7 +172,20 @@ export class TurnDaemonLifecycle { const nowMs = this.clock.nowMs(); const wallNow = new Date(nowMs); - const gameClock = await this.stateStore.loadGameClock?.(wallNow); + let gameClock = await this.stateStore.loadGameClock?.(wallNow); + if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) { + await this.stateStore.promotePreopenAtOpening(wallNow); + gameClock = await this.stateStore.loadGameClock?.(wallNow); + } + if ( + gameClock?.phase && + gameClock.phase !== 'RUNNING' && + gameClock.phase !== 'MANUAL' + ) { + this.status.nextTurnTime = undefined; + await this.clock.sleepMs(500); + continue; + } if (gameClock?.mode === 'manual') { // Ref observes all generals due before one monthly boundary in // a single snapshot. Manual mode advances directly to that diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts index 81ef8936..284790dc 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -7,7 +7,7 @@ import type { TurnRunResult, } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; -import type { GameClockMode } from '@sammo-ts/common'; +import type { GameClockMode, GameClockPhase } from '@sammo-ts/common'; export type { RunReason, @@ -60,7 +60,14 @@ export interface TurnStateStore { loadCheckpoint(): Promise; saveCheckpoint(checkpoint?: TurnCheckpoint): Promise; shouldHaltScheduledRuns?(): Promise; - loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>; + loadGameClock?(wallNow?: Date): Promise<{ + mode: GameClockMode; + now: Date; + phase?: GameClockPhase; + revision?: number; + deadlineGeneration?: number; + }>; + promotePreopenAtOpening?(wallNow: Date): Promise; shouldRebaseRealtimeBacklog?(wallNow: Date): Promise; rebaseRealtimeBacklog?(wallNow: Date): Promise; advanceGameClockTo?(target: Date, wallNow: Date): Promise; diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 67a34404..01e101b5 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -6,7 +6,7 @@ import { type InputJsonValue, type TurnEngineEventCreateManyInput, } from '@sammo-ts/infra'; -import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common'; +import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common'; import { buildScenarioBootstrap, resolveScenarioGeneralDeathMonth, @@ -94,6 +94,17 @@ export const calculateInitialTurnTick = ( return clock.addTicks(baseTick, offsetTicks); }; +export const resolveInitialClockPhase = ( + mode: GameClockMode, + seededAtWall: Date, + scheduledOpenAtWall: Date +): GameClockPhase => { + if (mode === 'manual') { + return 'MANUAL'; + } + return scheduledOpenAtWall.getTime() > seededAtWall.getTime() ? 'PREOPEN' : 'RUNNING'; +}; + const formatDateTime = (date: Date): string => { const pad = (value: number): string => String(value).padStart(2, '0'); return [ @@ -234,14 +245,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom // 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 initialClock = new GameClock({ baseTime: startState.startTime, tick: 0, mode: gameClockMode, wallAnchor: initialClockWallAnchor, turnSeconds: tickSeconds, + phase: initialClockPhase, + revision: 1, }); - const initialClockTick = initialClock.dateToTick(now); + // The formal opening wall instant is always logical tick zero. PREOPEN may + // project signed negative observed ticks, but executable seed schedules are + // derived from this zero coordinate rather than from the seed wall time. + const initialClockTick = 0; const { seed, warnings } = buildScenarioBootstrap({ scenario: scenarioDefinition, @@ -327,6 +344,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom } worldMeta.hiddenSeed = hiddenSeed; + worldMeta.seededAtWall = now.toISOString(); + worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString(); + worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString(); + worldMeta.calendarStart = startState.startTime.toISOString(); if (install?.preopenAt) { worldMeta.preopenAt = formatDateTime(install.preopenAt); @@ -420,6 +441,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom clockMode: gameClockMode, clockWallAnchor: initialClock.wallAnchor, lastTurnTick: BigInt(initialClockTick), + clockPhase: initialClockPhase, + clockRevision: 1n, + deadlineGeneration: 1n, config: asJson({ ...scenarioConfig, ...worldConfig }), meta: asJson(worldMeta), }, @@ -590,13 +614,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom weaponCode: general.weapon ?? 'None', bookCode: general.book ?? 'None', itemCode: general.item ?? 'None', - turnTime: new Date( - now.getTime() + - Math.floor( - (typeof general.meta.initialTurnOffsetMicros === 'number' - ? general.meta.initialTurnOffsetMicros - : 0) / 1_000 - ) + turnTime: initialClock.tickToDate( + calculateInitialTurnTick( + initialClock, + initialClockTick, + typeof general.meta.initialTurnOffsetMicros === 'number' + ? general.meta.initialTurnOffsetMicros + : 0 + ) ), turnTick: BigInt( calculateInitialTurnTick( diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index b9bbdefb..79442707 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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( diff --git a/app/game-engine/src/turn/inMemoryStateStore.ts b/app/game-engine/src/turn/inMemoryStateStore.ts index 2a0722b9..005493bc 100644 --- a/app/game-engine/src/turn/inMemoryStateStore.ts +++ b/app/game-engine/src/turn/inMemoryStateStore.ts @@ -35,13 +35,27 @@ export class InMemoryTurnStateStore implements TurnStateStore { return asNumber(meta.isunited ?? meta.isUnited, 0) >= 2; } - async loadGameClock(wallNow = new Date(Date.now())): Promise<{ mode: 'realtime' | 'manual'; now: Date }> { + async loadGameClock(wallNow = new Date(Date.now())): Promise<{ + mode: 'realtime' | 'manual'; + now: Date; + phase: ReturnType['phase']; + revision: number; + deadlineGeneration: number; + }> { + const state = this.world.getGameClockState(); return { - mode: this.world.getGameClockState().mode, + mode: state.mode, now: this.world.getGameNow(wallNow), + phase: state.phase, + revision: state.revision, + deadlineGeneration: state.deadlineGeneration, }; } + async promotePreopenAtOpening(wallNow: Date): Promise { + return this.world.promotePreopenAtOpening(wallNow); + } + async rebaseRealtimeBacklog(wallNow: Date) { return this.world.rebaseRealtimeBacklog(wallNow); } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index bb605ce9..508d1132 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -10,7 +10,14 @@ import type { UnitSetDefinition, } from '@sammo-ts/logic'; import { getNextTurnAt, readScenarioGeneralPoolClaim } from '@sammo-ts/logic'; -import { GAME_TICKS_PER_TURN, GameClock, type GameClockMode } from '@sammo-ts/common'; +import { + GAME_TICKS_PER_TURN, + GameClock, + assertGameplayCommitAllowed, + inferClockPhase, + type GameClockMode, + type GameClockPhase, +} from '@sammo-ts/common'; import type { TurnCheckpoint } from '../lifecycle/types.js'; import type { @@ -123,6 +130,9 @@ export interface InMemoryGameClockState { mode: GameClockMode; wallAnchor: Date; lastTurnTick: number; + phase: GameClockPhase; + revision: number; + deadlineGeneration: number; } export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle'; @@ -525,6 +535,9 @@ export class InMemoryTurnWorld { constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) { const baseTime = new Date((state.clockBaseTime ?? state.lastTurnTime).getTime()); const mode = state.clockMode ?? 'manual'; + const phase = state.clockPhase ?? inferClockPhase(mode); + const revision = state.clockRevision ?? 1; + const deadlineGeneration = state.deadlineGeneration ?? 1; const wallAnchor = new Date((state.clockWallAnchor ?? state.lastTurnTime).getTime()); const bootstrapClock = new GameClock({ baseTime, @@ -532,6 +545,8 @@ export class InMemoryTurnWorld { mode, wallAnchor, turnSeconds: state.tickSeconds, + phase, + revision, }); const lastTurnTick = state.lastTurnTick ?? bootstrapClock.dateToTick(state.lastTurnTime); const clockTick = state.clockTick ?? lastTurnTick; @@ -541,6 +556,8 @@ export class InMemoryTurnWorld { mode, wallAnchor, turnSeconds: state.tickSeconds, + phase, + revision, }); const lastTurnTime = gameClock.tickToDate(lastTurnTick); this.state = { @@ -550,6 +567,9 @@ export class InMemoryTurnWorld { clockMode: mode, clockWallAnchor: wallAnchor, lastTurnTick, + clockPhase: phase, + clockRevision: revision, + deadlineGeneration, lastTurnTime, meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() }, }; @@ -621,6 +641,8 @@ export class InMemoryTurnWorld { mode: this.state.clockMode ?? 'manual', wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, turnSeconds: this.state.tickSeconds, + phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), + revision: this.state.clockRevision ?? 1, }); } @@ -649,6 +671,9 @@ export class InMemoryTurnWorld { mode: this.state.clockMode ?? 'manual', wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()), lastTurnTick: this.state.lastTurnTick ?? 0, + phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), + revision: this.state.clockRevision ?? 1, + deadlineGeneration: this.state.deadlineGeneration ?? 1, }; } @@ -656,11 +681,26 @@ export class InMemoryTurnWorld { return this.getGameClock().now(wallNow); } + promotePreopenAtOpening(wallNow: Date): boolean { + const clock = this.getGameClock(); + if (clock.phase !== 'PREOPEN' || wallNow.getTime() < clock.wallAnchor.getTime()) { + return false; + } + if (clock.tick !== 0) { + throw new Error(`PREOPEN opening invariant requires clock tick zero, found ${clock.tick}.`); + } + this.state = { + ...this.state, + clockPhase: 'RUNNING', + }; + return true; + } + getRunnableGameNow(wallNow: Date): Date { const clock = this.getGameClock(); // PREOPEN still needs negative game ticks for cooldowns, but executable // turn schedules must not precede the wall-clock opening boundary. - if (clock.mode === 'realtime' && wallNow.getTime() < clock.wallAnchor.getTime()) { + if (clock.phase === 'PREOPEN') { return clock.now(clock.wallAnchor); } return clock.now(wallNow); @@ -676,6 +716,7 @@ export class InMemoryTurnWorld { advanceGameClockTo(target: Date, wallNow: Date): void { const clock = this.getGameClock(); + assertGameplayCommitAllowed(clock.phase); const targetTick = clock.dateToTick(target); // Realtime의 권위 시각은 wall anchor 이후 경과입니다. 밀린 턴을 과거 // target으로 처리한 완료 시각에 anchor를 다시 고정하면, 처리에 걸린 @@ -696,7 +737,7 @@ export class InMemoryTurnWorld { skippedTurns: number; } | null { const clock = this.getGameClock(); - if (clock.mode !== 'realtime') { + if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') { return null; } const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60)); @@ -969,6 +1010,8 @@ export class InMemoryTurnWorld { mode: this.state.clockMode ?? 'manual', wallAnchor: anchorWall, turnSeconds: nextTickSeconds, + phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), + revision: this.state.clockRevision ?? 1, }); const lastTurnTick = this.state.lastTurnTick ?? previousClock.dateToTick(this.state.lastTurnTime); const nextLastTurnTime = nextClock.tickToDate(lastTurnTick); @@ -1470,6 +1513,8 @@ export class InMemoryTurnWorld { mode: this.state.clockMode ?? 'manual', wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, turnSeconds: this.state.tickSeconds, + phase: this.state.clockPhase ?? inferClockPhase(this.state.clockMode ?? 'manual'), + revision: this.state.clockRevision ?? 1, }); const nextLastTurnTime = shiftedClock.tickToDate(this.state.lastTurnTick ?? 0); const nextMeta = { @@ -1605,6 +1650,7 @@ export class InMemoryTurnWorld { } executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution { + assertGameplayCommitAllowed(this.getGameClock().phase); const currentGeneral = this.generals.get(general.id) ?? general; const executionYear = this.state.currentYear; const executionMonth = this.state.currentMonth; @@ -1787,6 +1833,7 @@ export class InMemoryTurnWorld { } async advanceMonth(turnTime: Date): Promise { + assertGameplayCommitAllowed(this.getGameClock().phase); const previousYear = this.state.currentYear; const previousMonth = this.state.currentMonth; let nextYear = previousYear; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index a05e43e3..9a8bd064 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -219,6 +219,8 @@ const resolveRuntimeState = ( mode: state.clockMode ?? 'manual', wallAnchor: state.clockWallAnchor ?? state.lastTurnTime, turnSeconds: state.tickSeconds, + phase: state.clockPhase, + revision: state.clockRevision, }).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0), state.clockTick ?? state.lastTurnTick ?? 0, nextTickSeconds diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 6a41fe27..001e6275 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -11,7 +11,7 @@ import type { WorldSnapshot, GeneralLastTurn, } from '@sammo-ts/logic'; -import type { GameClockMode } from '@sammo-ts/common'; +import type { GameClockMode, GameClockPhase } from '@sammo-ts/common'; export interface TurnWorldState { id: number; @@ -24,6 +24,9 @@ export interface TurnWorldState { clockMode?: GameClockMode; clockWallAnchor?: Date; lastTurnTick?: number; + clockPhase?: GameClockPhase; + clockRevision?: number; + deadlineGeneration?: number; meta: Record; } diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 2a6caabc..8e0ae79b 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -26,7 +26,14 @@ import { normalizeScenarioEffect } from '@sammo-ts/logic'; import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { z } from 'zod'; -import { GameClock, asRecord, isRecord, type GameClockMode } from '@sammo-ts/common'; +import { + GameClock, + asRecord, + inferClockPhase, + isRecord, + parseGameClockPhase, + type GameClockMode, +} from '@sammo-ts/common'; import type { MapLoaderOptions } from '../scenario/mapLoader.js'; import { loadMapDefinitionByName } from '../scenario/mapLoader.js'; @@ -433,6 +440,14 @@ 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 clockRevision = toSafeTick(worldState.clockRevision, 'world_state.clock_revision'); + const deadlineGeneration = toSafeTick( + worldState.deadlineGeneration, + 'world_state.deadline_generation' + ); const clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime; const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime; const bootstrapClock = new GameClock({ @@ -441,6 +456,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) mode: clockMode, wallAnchor: clockWallAnchor, turnSeconds: worldState.tickSeconds, + phase: clockPhase, + revision: clockRevision, }); const legacyLastTurnTick = bootstrapClock.dateToTick(legacyLastTurnTime); const gameClock = new GameClock({ @@ -452,6 +469,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) mode: clockMode, wallAnchor: clockWallAnchor, turnSeconds: worldState.tickSeconds, + phase: clockPhase, + revision: clockRevision, }); const ranksByGeneral = new Map(); @@ -519,6 +538,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) clockMode, clockWallAnchor: gameClock.wallAnchor, lastTurnTick, + clockPhase, + clockRevision, + deadlineGeneration, meta, }, snapshot: { diff --git a/app/game-engine/test/runtimeClockShift.test.ts b/app/game-engine/test/runtimeClockShift.test.ts index bad95498..a3cf08a3 100644 --- a/app/game-engine/test/runtimeClockShift.test.ts +++ b/app/game-engine/test/runtimeClockShift.test.ts @@ -183,17 +183,43 @@ describe('runtime clock shift', () => { clockMode: 'realtime', clockWallAnchor: openAt, lastTurnTick: 0, + clockPhase: 'PREOPEN', }); const preopenAt = new Date('2026-09-02T23:03:00.000Z'); expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime()); expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase); expect(world.getRunnableGameNow(openAt)).toEqual(gameBase); + expect(world.promotePreopenAtOpening(openAt)).toBe(true); expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual( new Date(gameBase.getTime() + 60_000) ); }); + it('promotes PREOPEN only at an opening tick-zero anchor', () => { + const openAt = new Date('2026-09-02T23:30:00.000Z'); + const world = buildWorld({ + clockBaseTime: new Date('2026-07-30T10:00:00.000Z'), + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: openAt, + lastTurnTick: 0, + clockPhase: 'PREOPEN', + }); + + expect(world.promotePreopenAtOpening(new Date(openAt.getTime() - 1))).toBe(false); + expect(world.promotePreopenAtOpening(openAt)).toBe(true); + expect(world.getGameClockState()).toMatchObject({ phase: 'RUNNING', tick: 0 }); + }); + + it('rejects gameplay commits while the durable clock is suspended', async () => { + const world = buildWorld({ clockPhase: 'SUSPENDED', clockMode: 'realtime' }); + + expect(() => world.advanceGameClockTo(new Date(), new Date())).toThrow(/SUSPENDED/); + expect(() => world.executeGeneralTurn(world.listGenerals()[0]!)).toThrow(/SUSPENDED/); + await expect(world.advanceMonth(new Date())).rejects.toThrow(/SUSPENDED/); + }); + it.each([ [5, 6], [10, 3], diff --git a/app/game-engine/test/scenarioSeederClock.test.ts b/app/game-engine/test/scenarioSeederClock.test.ts index 528cab1e..50717b38 100644 --- a/app/game-engine/test/scenarioSeederClock.test.ts +++ b/app/game-engine/test/scenarioSeederClock.test.ts @@ -1,6 +1,6 @@ import { GameClock } from '@sammo-ts/common'; import { describe, expect, test } from 'vitest'; -import { calculateInitialTurnTick } from '../src/scenario/scenarioSeeder.js'; +import { calculateInitialTurnTick, resolveInitialClockPhase } from '../src/scenario/scenarioSeeder.js'; describe('scenario seeder general turn tick', () => { test('preserves Ref-compatible sub-millisecond RNG precision', () => { @@ -17,4 +17,31 @@ describe('scenario seeder general turn tick', () => { expect(calculateInitialTurnTick(clock, baseTick, 235_265_319)).toBe(baseTick + 14_115_919); expect(clock.dateToTick(new Date(now.getTime() + 235_265))).toBe(baseTick + 14_115_900); }); + + test('keeps formal opening at tick zero while PREOPEN projects signed ticks', () => { + const seededAt = new Date('2030-01-01T01:00:00.000Z'); + const openAt = new Date('2030-01-01T02:00:00.000Z'); + const phase = resolveInitialClockPhase('realtime', seededAt, openAt); + const clock = new GameClock({ + baseTime: new Date('0190-01-01T00:00:00.000Z'), + tick: 0, + mode: 'realtime', + wallAnchor: openAt, + turnSeconds: 600, + phase, + }); + + expect(phase).toBe('PREOPEN'); + expect(clock.nowTick(seededAt)).toBe(-6 * 36_000_000); + expect(clock.nowTick(openAt)).toBe(0); + expect(calculateInitialTurnTick(clock, 0, 0)).toBe(0); + expect(calculateInitialTurnTick(clock, 0, 235_265_319)).toBeGreaterThanOrEqual(0); + }); + + test('uses explicit MANUAL and immediate RUNNING phases', () => { + const now = new Date('2030-01-01T01:00:00.000Z'); + + expect(resolveInitialClockPhase('manual', now, new Date('2030-01-02T01:00:00.000Z'))).toBe('MANUAL'); + expect(resolveInitialClockPhase('realtime', now, now)).toBe('RUNNING'); + }); }); diff --git a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts index b3f61aff..a1e856f2 100644 --- a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts +++ b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts @@ -1,6 +1,14 @@ import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; import type { GamePrisma } from '@sammo-ts/infra'; -import { GameClock, asRecord, type GameClockMode } from '@sammo-ts/common'; +import { + GameClock, + asObservedGameInstant, + asRecord, + inferClockPhase, + parseGameClockPhase, + scheduleNotBefore, + type GameClockMode, +} from '@sammo-ts/common'; export interface AdminSeedUser { id: string; @@ -105,14 +113,22 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse const name = await resolveAdminName(prisma, adminUser); const meta = asRecord(worldState.meta); const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null; - const turnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date(); + const fallbackTurnTime = rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date(); + const mode = worldState.clockMode === 'manual' ? 'manual' : 'realtime'; + const phase = worldState.clockPhase + ? parseGameClockPhase(worldState.clockPhase) + : inferClockPhase(mode); const gameClock = new GameClock({ - baseTime: worldState.clockBaseTime ?? turnTime, + baseTime: worldState.clockBaseTime ?? fallbackTurnTime, tick: Number(worldState.clockTick ?? 0n), - mode: worldState.clockMode === 'manual' ? 'manual' : 'realtime', - wallAnchor: worldState.clockWallAnchor ?? turnTime, + mode, + wallAnchor: worldState.clockWallAnchor ?? fallbackTurnTime, turnSeconds: worldState.tickSeconds, + phase, + revision: Number(worldState.clockRevision ?? 1n), }); + const turnTick = scheduleNotBefore(asObservedGameInstant(gameClock.nowTick(new Date())), phase); + const turnTime = gameClock.tickToDate(turnTick); await prisma.general.create({ data: { @@ -130,7 +146,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse specialCode: 'None', special2Code: 'None', turnTime, - turnTick: BigInt(gameClock.dateToTick(turnTime)), + turnTick: BigInt(turnTick), meta: { createdBy: 'admin-seed', killturn: 24, diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index a08dbb67..8ec6a71a 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -39,7 +39,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, gatewaySchemaHead: '20260825000000_add_bulk_release_batches', - gameSchemaHead: '20260824080000_vote_utc_wall_timestamps', + gameSchemaHead: '20260903090000_add_game_clock_reconciliation', }); }); diff --git a/docs/architecture/game-clock-participants.json b/docs/architecture/game-clock-participants.json new file mode 100644 index 00000000..f0835457 --- /dev/null +++ b/docs/architecture/game-clock-participants.json @@ -0,0 +1,205 @@ +{ + "schemaVersion": 1, + "authority": "game-tick", + "tickPerTurn": 36000000, + "policies": ["SHIFT", "KEEP", "REBUILD", "FORBID"], + "coveredFields": [ + "input_event.accepted_game_tick", + "input_event.accepted_clock_revision", + "world_state.clock_tick", + "world_state.last_turn_tick", + "world_state.clock_revision", + "world_state.deadline_generation", + "general.turn_tick", + "general.recent_war_tick", + "select_pool.reserved_until_tick", + "select_npc_token.valid_until_tick", + "select_npc_token.pick_more_from_tick", + "message.time_tick", + "message.valid_until_tick", + "auction.open_tick", + "auction.close_tick", + "vote_poll.start_tick", + "vote_poll.end_tick", + "clock_suspension.source_revision", + "clock_suspension.target_revision", + "clock_suspension.cut_tick", + "clock_suspension.catch_up_ticks", + "clock_suspension.gap_ticks", + "clock_suspension.shift_ticks", + "clock_suspension.aligned_tick", + "clock_projection_outbox.target_revision" + ], + "participants": [ + { + "key": "world-clock", + "policy": "REBUILD", + "authorityFields": ["world_state.clock_tick", "world_state.clock_revision"], + "projectionFields": ["world_state.clock_base_time", "world_state.clock_wall_anchor"], + "owner": "game-engine/clock-operation" + }, + { + "key": "turn-cursor", + "policy": "SHIFT", + "authorityFields": ["world_state.last_turn_tick"], + "projectionFields": ["world_state.meta.lastTurnTime", "in-memory.checkpoint.turnTime"], + "owner": "game-engine/turn-daemon" + }, + { + "key": "general-next-turn", + "policy": "SHIFT", + "authorityFields": ["general.turn_tick"], + "projectionFields": ["general.turn_time"], + "owner": "game-engine/turn-daemon" + }, + { + "key": "general-recent-war-occurrence", + "policy": "KEEP", + "authorityFields": ["general.recent_war_tick"], + "projectionFields": ["general.recent_war_time"], + "owner": "game-engine/battle" + }, + { + "key": "auction-open-occurrence", + "policy": "KEEP", + "authorityFields": ["auction.open_tick"], + "projectionFields": ["auction.created_at"], + "owner": "game-api/auction" + }, + { + "key": "auction-deadline", + "policy": "SHIFT", + "authorityFields": ["auction.close_tick"], + "projectionFields": ["auction.close_at"], + "owner": "game-api/auction-worker" + }, + { + "key": "auction-finalizing-recovery", + "policy": "REBUILD", + "authorityFields": ["auction.status", "world_state.deadline_generation"], + "projectionFields": ["redis.auction.timer"], + "owner": "game-api/auction-worker" + }, + { + "key": "message-occurrence", + "policy": "KEEP", + "authorityFields": ["message.time_tick"], + "projectionFields": ["message.time"], + "owner": "game-engine/message" + }, + { + "key": "message-expiry", + "policy": "SHIFT", + "authorityFields": ["message.valid_until_tick"], + "projectionFields": ["message.valid_until"], + "owner": "game-engine/message" + }, + { + "key": "vote-start-occurrence", + "policy": "KEEP", + "authorityFields": ["vote_poll.start_tick"], + "projectionFields": ["vote_poll.start_at"], + "owner": "game-api/vote" + }, + { + "key": "vote-end-deadline", + "policy": "SHIFT", + "authorityFields": ["vote_poll.end_tick"], + "projectionFields": ["vote_poll.end_at"], + "owner": "game-api/vote" + }, + { + "key": "select-pool-reservation", + "policy": "SHIFT", + "authorityFields": ["select_pool.reserved_until_tick"], + "projectionFields": ["select_pool.reserved_until"], + "owner": "game-engine/select-pool" + }, + { + "key": "npc-selection-window", + "policy": "SHIFT", + "authorityFields": ["select_npc_token.valid_until_tick", "select_npc_token.pick_more_from_tick"], + "projectionFields": ["select_npc_token.valid_until", "select_npc_token.pick_more_from"], + "owner": "game-engine/npc-selection" + }, + { + "key": "accepted-command-coordinate", + "policy": "KEEP", + "authorityFields": ["input_event.accepted_game_tick", "input_event.accepted_clock_revision"], + "projectionFields": [], + "owner": "game-api/input-event" + }, + { + "key": "tournament-deadlines", + "policy": "REBUILD", + "authorityFields": ["redis.tournament.state.nextTick", "redis.tournament.state.bettingCloseTick"], + "projectionFields": ["redis.tournament.state.nextAt", "redis.tournament.state.bettingCloseAt"], + "owner": "game-api/tournament-worker", + "migration": "Redis-only legacy dates must dual-write ticks before exact reconciliation is enabled." + }, + { + "key": "movable-json-rule-anchors", + "policy": "FORBID", + "authorityFields": ["world_state.meta.turntime", "world_state.meta.starttime", "world_state.meta.tnmt_time"], + "projectionFields": [], + "owner": "game-engine/world-meta", + "migration": "Register typed columns or explicit participant adapters before exact reconciliation can complete." + }, + { + "key": "unification-wait", + "policy": "FORBID", + "authorityFields": ["world_state.meta.isunited", "world_state.meta.lastTurnTime"], + "projectionFields": [], + "owner": "game-engine/unification", + "migration": "Replace the lastTurnTime workaround with a durable UNIFICATION_WAIT suspension." + }, + { + "key": "clock-operation-ledger", + "policy": "KEEP", + "authorityFields": [ + "clock_suspension.source_revision", + "clock_suspension.target_revision", + "clock_suspension.cut_tick", + "clock_suspension.catch_up_ticks", + "clock_suspension.gap_ticks", + "clock_suspension.shift_ticks", + "clock_suspension.aligned_tick", + "clock_projection_outbox.target_revision" + ], + "projectionFields": [], + "owner": "game-engine/clock-operation" + } + ], + "redis": [ + { + "keyPattern": "sammo:{profile}:clock:active-revision", + "policy": "REBUILD", + "status": "planned" + }, + { + "keyPattern": "sammo:{profile}:auction:timer", + "policy": "REBUILD", + "status": "implemented-without-clock-revision-fence" + }, + { + "keyPattern": "sammo:{profile}:tournament:state", + "policy": "REBUILD", + "status": "legacy-date-dual-write-required" + } + ], + "wallOnly": [ + "input_event.created_at", + "input_event.processing_at", + "input_event.completed_at", + "input_event.lease_until", + "turn_daemon_lease.lease_until", + "turn_daemon_lease.heartbeat_at", + "clock_suspension.cut_wall_at", + "clock_suspension.resume_wall_at", + "clock_projection_outbox.available_at", + "clock_projection_outbox.locked_at", + "clock_projection_outbox.applied_at", + "*.created_at", + "*.updated_at" + ] +} diff --git a/docs/architecture/game-clock-reconciliation.md b/docs/architecture/game-clock-reconciliation.md new file mode 100644 index 00000000..0cfd8ea2 --- /dev/null +++ b/docs/architecture/game-clock-reconciliation.md @@ -0,0 +1,107 @@ +# Game clock reconciliation + +## Product contract + +Gameplay time is an integer `GameTick`; one turn is permanently `36,000,000` +ticks. Wall time is an observation and operational-control input, never the +authority for gameplay ordering. A long suspension advances the observed game +coordinate to the resume wall instant without replaying skipped turns, monthly +events, RNG, auctions, or tournaments. Every movable future schedule is shifted +by the same exact tick delta, including the sub-turn remainder. + +The clock state is stored in `world_state`: + +- `clock_phase` gates gameplay commits. +- `clock_revision` identifies the coordinate conversion generation. +- `deadline_generation` fences worker deadlines rebuilt from that generation. +- `clock_tick` and `clock_wall_anchor` form the durable observed-time snapshot. +- `last_turn_tick` is the execution cursor and is independent from occurrence + history. + +The phases are `PREOPEN`, `RUNNING`, `SUSPENDED`, `RECONCILING`, `MANUAL`, and +`COMPLETED`. `PREOPEN` alone permits signed negative observed ticks and floors +executable schedules at zero. `RUNNING` never projects below its durable tick +when wall time moves backward. `SUSPENDED`, `RECONCILING`, and `COMPLETED` do not +permit turn or monthly commits. `MANUAL` moves only through explicit engine +progression. + +## Durable operation + +A suspension begins under the turn-daemon fence and schema-scoped clock lock. +It records the cut tick, database wall instant, rate, source revision, and +participant checksum in `clock_suspension`. Resume reads the database wall +instant and builds an exact plan: + +```text +gapTicks = max(0, ticksBetween(cutWall, resumeWall, rateAtCut)) +shiftTicks = gapTicks - catchUpTicks +alignedTick = cutTick + gapTicks +deadlineAfter = deadlineBefore + shiftTicks +``` + +Planned maintenance, delayed opening, and unification wait use zero catch-up. +The compatibility-only complete-turn behavior is named +`LEGACY_COMPLETE_TURNS`; it is not the exact policy. + +Every participant writes its `SHIFT`, `KEEP`, `REBUILD`, or `FORBID` decision, +row count, and before/after checksum to `clock_reconciliation_participant`. +The authoritative registry is +[`game-clock-participants.json`](./game-clock-participants.json). The +architecture gate rejects a new tick/revision field that is absent from that +inventory. + +## DB to Redis boundary + +The database transaction leaves the phase `RECONCILING` and creates exactly one +`clock_projection_outbox` row for the target revision. An outbox worker rebuilds +auction and tournament projections and writes +`sammo:{profile}:clock:active-revision` last. Only after checksum verification +may the database transition to `RUNNING` for the same target revision and +deadline generation. + +Workers must compare DB revision, Redis active revision, phase, and deadline +generation before dequeue and again in their final database transaction. Due +pop is one Redis operation: verify revision/phase, read `-inf..nowTick`, and +remove the claimed members. A failed Redis rebuild therefore leaves the game in +`RECONCILING`; process liveness alone is not readiness. + +## Lock order + +All mutation paths use this order: + +```text +turn-daemon fencing row +-> game-clock:operation advisory transaction lock +-> general-access:persistence advisory transaction lock (only if needed) +-> world_state FOR UPDATE +-> participant rows/tables in registry order +-> DB commit +-> Redis outbox projection +``` + +The ordinary turn flush already validates phase, revision, and deadline +generation after taking this lock prefix. Clock operation participants will be +added without changing that prefix. + +## Opening invariant + +Both production and direct seeding use the same scenario seeder. It stores +`clock_tick = 0`, `last_turn_tick = 0`, and the scheduled opening as +`clock_wall_anchor`. The metadata names `seededAtWall`, `scheduledOpenAtWall`, +`projectedGameDateAtOpening`, and `calendarStart` separately. Precreated general +turn ticks are calculated from zero and therefore cannot be negative. At the +wall anchor the in-memory phase promotion refuses any PREOPEN clock whose stored +tick is not exactly zero. + +## Compatibility and migration + +This branch begins with dual-read defaults for callers and fixtures built before +the new columns. Database migration backfills manual profiles as `MANUAL`, +future anchored realtime profiles as `PREOPEN`, and other profiles as +`RUNNING`. Existing DateTime columns remain projections while tick columns are +authoritative. + +Exact reconciliation stays disabled while any registry participant is +`FORBID`. In particular, Redis-only tournament dates and unification wait must +be moved to durable tick/revision contracts before the operation can reach +`RUNNING`. Removing these guards to make a partial operation pass is prohibited. diff --git a/docs/developer/game-clock-reconciliation-plan.md b/docs/developer/game-clock-reconciliation-plan.md new file mode 100644 index 00000000..937711d3 --- /dev/null +++ b/docs/developer/game-clock-reconciliation-plan.md @@ -0,0 +1,81 @@ +# Game clock reconciliation implementation plan + +Baseline: `main@b91dcbcaaac5acd4c7349cd3ed0996c547f58756` + +Branch: `test/game-clock-reconciliation-20260903` + +This plan is the status source for the long-running user test branch. A checked +item means code and focused automated evidence exist on this branch; it does not +mean deployment or production validation. + +## Milestone 1 - authority and inventory + +- [x] Branded `GameTick`, `ObservedGameInstant`, `ScheduleInstant`, + `WallInstant`, and `ClockRevision` boundaries. +- [x] Explicit clock phase and monotonic RUNNING projection. +- [x] Exact alignment arithmetic preserving millisecond/sub-turn remainder. +- [x] Opening tick zero and PREOPEN executable floor in the shared seeder. +- [x] Schema columns for phase, revision, and deadline generation. +- [x] Suspension, participant-checksum, and Redis projection outbox tables. +- [x] Machine-readable DB/Redis/JSON participant inventory and architecture gate. +- [x] Turn flush lock prefix and phase/revision/generation fence. +- [ ] Empty and upgraded database migration execution evidence. + +## Milestone 2 - exact DB reconciliation + +- [ ] Suspension start command with DB wall time and idempotent source revision. +- [ ] Exact resume plan transaction with deterministic participant lock order. +- [ ] SHIFT adapters for cursor, generals, active auctions, message expiry, vote + end, select pool, and NPC selection windows. +- [ ] KEEP checksum adapters for occurrences and history. +- [ ] Explicit `LEGACY_COMPLETE_TURNS` and bounded `CATCH_UP` policies. +- [ ] Property tests for remaining distance, ordering, and history invariants. +- [ ] 24-hour and 65m17.250s PostgreSQL integration evidence. + +## Milestone 3 - revisioned Redis and workers + +- [ ] Projection outbox claimer/retry/recovery state machine. +- [ ] Redis active revision and atomic due-pop script. +- [ ] Auction OPEN/FINALIZING revision and generation fence. +- [ ] Tournament durable tick dual-write and projection rebuild. +- [ ] DB-commit/Redis-failure restart tests and readiness integration. + +## Milestone 4 - command and lifecycle workflows + +- [ ] All durable input events record accepted tick and accepted revision. +- [ ] Processing converts accepted coordinates across revisions or fails closed. +- [ ] Gateway pause/resume/open orchestration writes the DB clock phase. +- [ ] Unification wait becomes a durable `UNIFICATION_WAIT` suspension. +- [ ] Alignment, optional rate change, invader IDs/RNG, creation, first schedule, + outbox, verification, and RUNNING transition form one retry-safe workflow. +- [ ] Multi-host drift and general-access/clock-operation deadlock tests. + +## Milestone 5 - test-branch release gate + +- [ ] Full typecheck, architecture, lint, unit, build, and non-conditional + integration suites. +- [ ] Dedicated PostgreSQL/Redis conditional integration suite with skip count + recorded. +- [ ] Recovery runbook exercised from each incomplete status. +- [ ] Admin status/readiness exposes revision, phase, participant checksums, and + incomplete outbox state. +- [ ] User-test deployment evidence is recorded separately from Git push. +- [ ] All `FORBID` inventory entries are removed by typed migrations or proven + inactive preconditions. + +## Evidence log + +### 2026-09-03 - authority foundation + +- `pnpm test:bootstrap`: dependency installation, Prisma generation, and package + preparation passed in the dedicated worktree. +- `CI=1 TURBO_CONCURRENCY=1 pnpm typecheck`: 21/21 tasks passed. +- `CI=1 TURBO_CONCURRENCY=1 pnpm test`: 12/12 package tasks passed. Conditional + suites remain classified separately and are not integration evidence. +- `CI=1 TURBO_CONCURRENCY=1 pnpm build`: 26/26 tasks passed. +- `TURBO_CONCURRENCY=1 pnpm lint`: passed with 36 pre-existing frontend + warnings and no errors. +- `pnpm check:architecture`: package boundaries passed; 21 authoritative clock + fields and 18 participants were registered. +- Migration SQL was generated, formatted, validated, and registered as the + release manifest head. Empty/upgraded PostgreSQL execution is still pending. diff --git a/docs/developer/game-clock-recovery.md b/docs/developer/game-clock-recovery.md new file mode 100644 index 00000000..a48097a2 --- /dev/null +++ b/docs/developer/game-clock-recovery.md @@ -0,0 +1,40 @@ +# Game clock reconciliation recovery + +This runbook is intentionally fail-closed. Do not force a profile to `RUNNING` +or delete an outbox row merely because its process is alive. + +## Observe + +Read only the target game schema. Record `world_state.clock_phase`, +`clock_revision`, `deadline_generation`, the latest `clock_suspension`, all its +participant checksums, and the matching `clock_projection_outbox`. Compare that +target revision with `sammo:{profile}:clock:active-revision`. Never print DB or +Redis credentials. + +## Status meaning + +- `SUSPENDED`: the cut is durable; no alignment DB transaction has committed. +- `RECONCILING` with `PENDING`/`FAILED` outbox: DB schedules moved, Redis is not + authoritative yet, and gameplay must remain stopped. +- `RECONCILING` with `APPLIED` outbox: verify Redis active revision and all + participant checksums before finalizing. +- `RUNNING`: DB revision, deadline generation, and Redis active revision must + agree. A mismatch is an incident and workers must not dequeue. + +## Retry + +Retry the same suspension ID and target revision through the clock-operation +service. The service must re-read participant checksums and either return the +already-applied result or resume the pending outbox. Never create a replacement +revision to hide a failed target revision. + +## Rollback + +There is no blind inverse update. Before enabling exact reconciliation in an +environment, keep the normal database backup required for schema migrations. +If participant verification shows an unexpected mutation, stop the profile, +retain the ledger/outbox evidence, and restore the whole game schema from that +backup. Redis projections are then rebuilt from the restored DB revision. + +The implementation-plan release gate remains open until these steps have an +automated fixture and an operator-facing status endpoint. diff --git a/package.json b/package.json index 54611a3b..de9a7e98 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check", "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check", "check:legacy:scenario": "SAMMO_REQUIRE_REF_SOURCE=1 pnpm --filter @sammo-ts/game-engine test monthlyCatalogCoverage.test.ts scenarioLoader.test.ts scenarioComposition.test.ts", - "check:architecture": "node tools/check-package-boundaries.mjs", + "check:architecture": "node tools/check-package-boundaries.mjs && node tools/check-game-clock-participants.mjs", "check:typescript-toolchain": "node tools/check-typescript-toolchain.mjs", "test:architecture": "node --test tools/check-package-boundaries.test.mjs", "test:image-sync": "node --test tools/sync-image-repository.test.mjs", diff --git a/packages/common/src/time/GameClock.ts b/packages/common/src/time/GameClock.ts index e6f98415..1468aa98 100644 --- a/packages/common/src/time/GameClock.ts +++ b/packages/common/src/time/GameClock.ts @@ -2,6 +2,31 @@ export const GAME_TICKS_PER_TURN = 36_000_000; export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER; export type GameClockMode = 'realtime' | 'manual'; +export type GameClockPhase = 'PREOPEN' | 'RUNNING' | 'SUSPENDED' | 'RECONCILING' | 'MANUAL' | 'COMPLETED'; +export type ClockAlignmentPolicy = 'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP'; + +declare const gameTickBrand: unique symbol; +declare const observedGameInstantBrand: unique symbol; +declare const scheduleInstantBrand: unique symbol; +declare const clockRevisionBrand: unique symbol; +declare const wallInstantBrand: unique symbol; + +export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' }; +export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' }; +export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' }; +export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' }; +export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' }; + +export interface ExactClockAlignmentPlan { + policy: 'EXACT'; + sourceRevision: ClockRevision; + targetRevision: ClockRevision; + cutTick: GameTick; + gapTicks: GameTick; + catchUpTicks: GameTick; + shiftTicks: GameTick; + alignedTick: GameTick; +} export interface GameClockState { baseTime: Date; @@ -9,6 +34,8 @@ export interface GameClockState { mode: GameClockMode; wallAnchor: Date; turnSeconds: number; + phase?: GameClockPhase; + revision?: number; } const requireSafeTick = (tick: number): number => { @@ -18,6 +45,108 @@ const requireSafeTick = (tick: number): number => { return tick; }; +export const asGameTick = (tick: number): GameTick => requireSafeTick(tick) as GameTick; + +export const asObservedGameInstant = (tick: number): ObservedGameInstant => + requireSafeTick(tick) as ObservedGameInstant; + +export const asScheduleInstant = (tick: number): ScheduleInstant => requireSafeTick(tick) as ScheduleInstant; + +export const asClockRevision = (revision: number): ClockRevision => { + if (!Number.isSafeInteger(revision) || revision < 1) { + throw new Error(`Clock revision must be a positive safe integer: ${revision}`); + } + return revision as ClockRevision; +}; + +export const asWallInstant = (instant: Date): WallInstant => { + if (Number.isNaN(instant.getTime())) { + throw new Error('Wall instant must be a valid date.'); + } + return new Date(instant.getTime()) as WallInstant; +}; + +export const inferClockPhase = (mode: GameClockMode): GameClockPhase => (mode === 'manual' ? 'MANUAL' : 'RUNNING'); + +const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [ + 'PREOPEN', + 'RUNNING', + 'SUSPENDED', + 'RECONCILING', + 'MANUAL', + 'COMPLETED', +]; + +export const parseGameClockPhase = (value: string): GameClockPhase => { + if ((GAME_CLOCK_PHASES as readonly string[]).includes(value)) { + return value as GameClockPhase; + } + throw new Error(`Unknown game clock phase: ${value}`); +}; + +export const scheduleNotBefore = (instant: ObservedGameInstant, phase: GameClockPhase): ScheduleInstant => { + if (phase === 'PREOPEN') { + return asScheduleInstant(Math.max(0, instant)); + } + return asScheduleInstant(instant); +}; + +export const createDeadline = ( + instant: ObservedGameInstant, + durationTicks: GameTick, + phase: GameClockPhase +): ScheduleInstant => scheduleNotBefore(asObservedGameInstant(requireSafeTick(instant + durationTicks)), phase); + +export const assertGameplayCommitAllowed = (phase: GameClockPhase): void => { + if (phase !== 'RUNNING' && phase !== 'MANUAL') { + throw new Error(`Gameplay commit is forbidden while the game clock phase is ${phase}.`); + } +}; + +export const buildExactClockAlignmentPlan = (input: { + sourceRevision: number; + cutTick: number; + cutWall: Date; + resumeWall: Date; + ticksPerSecond: number; + catchUpTicks?: number; +}): ExactClockAlignmentPlan => { + const sourceRevision = asClockRevision(input.sourceRevision); + const cutTick = asGameTick(input.cutTick); + const cutWall = asWallInstant(input.cutWall); + const resumeWall = asWallInstant(input.resumeWall); + if (!Number.isSafeInteger(input.ticksPerSecond) || input.ticksPerSecond <= 0) { + throw new Error(`ticksPerSecond must be a positive safe integer: ${input.ticksPerSecond}`); + } + const elapsedMilliseconds = Math.max(0, resumeWall.getTime() - cutWall.getTime()); + if (!Number.isSafeInteger(elapsedMilliseconds)) { + throw new Error('Clock suspension wall gap is outside the safe integer range.'); + } + const wholeSeconds = Math.trunc(elapsedMilliseconds / 1_000); + const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000; + const gapTicks = asGameTick( + requireSafeTick( + wholeSeconds * input.ticksPerSecond + + Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000) + ) + ); + const catchUpTicks = asGameTick(input.catchUpTicks ?? 0); + if (catchUpTicks < 0 || catchUpTicks > gapTicks) { + throw new Error(`catchUpTicks must be between 0 and the wall gap (${gapTicks}): ${catchUpTicks}`); + } + const shiftTicks = asGameTick(gapTicks - catchUpTicks); + return { + policy: 'EXACT', + sourceRevision, + targetRevision: asClockRevision(sourceRevision + 1), + cutTick, + gapTicks, + catchUpTicks, + shiftTicks, + alignedTick: asGameTick(cutTick + gapTicks), + }; +}; + const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => { const wholeSeconds = Math.floor(tick / ticksPerSecond); const remainingTicks = tick - wholeSeconds * ticksPerSecond; @@ -35,6 +164,8 @@ export class GameClock { readonly wallAnchor: Date; readonly turnSeconds: number; readonly ticksPerSecond: number; + readonly phase: GameClockPhase; + readonly revision: ClockRevision; constructor(state: GameClockState) { if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) { @@ -55,6 +186,8 @@ export class GameClock { this.wallAnchor = new Date(state.wallAnchor.getTime()); this.turnSeconds = state.turnSeconds; this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds; + this.phase = state.phase ?? inferClockPhase(state.mode); + this.revision = asClockRevision(state.revision ?? 1); } static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date { @@ -76,14 +209,19 @@ export class GameClock { } nowTick(wallNow: Date): number { - if (this.mode === 'manual') { + if (this.mode === 'manual' || this.phase === 'MANUAL' || this.phase === 'COMPLETED') { return this.tick; } const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow); // A future realtime anchor represents the formal opening at anchor tick. // Before that instant Ref exposes the elapsed offset as a negative tick, // which lets PREOPEN-only actions keep their logical cooldowns moving. - return this.addTicks(this.tick, elapsedTicks); + const projectedTick = this.addTicks(this.tick, elapsedTicks); + // PREOPEN is the only phase where the opening anchor may project a + // signed negative coordinate. Once a realtime game is running, an NTP + // rewind must never make the observed coordinate decrease below the + // last durable clock snapshot. + return this.phase === 'PREOPEN' ? projectedTick : Math.max(this.tick, projectedTick); } now(wallNow: Date): Date { diff --git a/packages/common/test/gameClock.test.ts b/packages/common/test/gameClock.test.ts index 8d410955..9af0d7aa 100644 --- a/packages/common/test/gameClock.test.ts +++ b/packages/common/test/gameClock.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { GAME_TICKS_PER_TURN, GameClock, MAX_SAFE_GAME_TICK } from '../src/time/GameClock.js'; +import { + GAME_TICKS_PER_TURN, + GameClock, + MAX_SAFE_GAME_TICK, + asGameTick, + asObservedGameInstant, + buildExactClockAlignmentPlan, + createDeadline, + scheduleNotBefore, +} from '../src/time/GameClock.js'; describe('GameClock', () => { const baseTime = new Date('2042-01-01T00:00:00.000Z'); @@ -42,12 +51,68 @@ describe('GameClock', () => { mode: 'realtime', wallAnchor: new Date('2026-01-01T01:00:00.000Z'), turnSeconds: 3_600, + phase: 'PREOPEN', }); expect(clock.nowTick(new Date('2026-01-01T00:30:00.000Z'))).toBe(-GAME_TICKS_PER_TURN / 2); expect(clock.nowTick(new Date('2026-01-01T01:00:00.000Z'))).toBe(0); }); + it('does not rewind a RUNNING realtime tick when wall time moves backward', () => { + const clock = new GameClock({ + baseTime, + tick: GAME_TICKS_PER_TURN, + mode: 'realtime', + wallAnchor: new Date('2026-01-01T01:00:00.000Z'), + turnSeconds: 3_600, + phase: 'RUNNING', + revision: 7, + }); + + expect(clock.nowTick(new Date('2026-01-01T00:59:55.000Z'))).toBe(GAME_TICKS_PER_TURN); + expect(clock.revision).toBe(7); + }); + + it('floors PREOPEN executable schedules at opening tick zero', () => { + const observed = asObservedGameInstant(-GAME_TICKS_PER_TURN / 2); + + expect(scheduleNotBefore(observed, 'PREOPEN')).toBe(0); + expect(createDeadline(observed, asGameTick(GAME_TICKS_PER_TURN / 4), 'PREOPEN')).toBe(0); + expect(scheduleNotBefore(observed, 'RUNNING')).toBe(observed); + }); + + it('preserves a 65 minute 17.250 second sub-turn suspension remainder exactly', () => { + const plan = buildExactClockAlignmentPlan({ + sourceRevision: 11, + cutTick: 123_456, + cutWall: new Date('2026-01-01T00:00:00.000Z'), + resumeWall: new Date('2026-01-01T01:05:17.250Z'), + ticksPerSecond: 10_000, + }); + + expect(plan).toMatchObject({ + sourceRevision: 11, + targetRevision: 12, + gapTicks: 39_172_500, + shiftTicks: 39_172_500, + alignedTick: 39_295_956, + }); + expect(plan.shiftTicks % GAME_TICKS_PER_TURN).toBe(3_172_500); + }); + + it('aligns a 24 hour exact maintenance without catch-up', () => { + const plan = buildExactClockAlignmentPlan({ + sourceRevision: 1, + cutTick: 2 * GAME_TICKS_PER_TURN, + cutWall: new Date('2026-01-01T00:00:00.000Z'), + resumeWall: new Date('2026-01-02T00:00:00.000Z'), + ticksPerSecond: 10_000, + }); + + expect(plan.gapTicks).toBe(24 * GAME_TICKS_PER_TURN); + expect(plan.alignedTick).toBe(26 * GAME_TICKS_PER_TURN); + }); + it('projects near the safe tick boundary without unsafe intermediate multiplication', () => { const clock = new GameClock({ baseTime: new Date(0), diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index 4a71cdd0..196523dc 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -63,21 +63,23 @@ enum InputEventTarget { } model InputEvent { - sequence BigInt @id @default(autoincrement()) - requestId String @unique @map("request_id") - target InputEventTarget - eventType String @map("event_type") - payload Json @default(dbgenerated("'{}'::jsonb")) - actorUserId String? @map("actor_user_id") - status InputEventStatus @default(PENDING) - result Json? - error String? - attempts Int @default(0) - lockedBy String? @map("locked_by") - leaseUntil DateTime? @map("lease_until") - createdAt DateTime @default(now()) @map("created_at") - processingAt DateTime? @map("processing_at") - completedAt DateTime? @map("completed_at") + sequence BigInt @id @default(autoincrement()) + requestId String @unique @map("request_id") + target InputEventTarget + eventType String @map("event_type") + payload Json @default(dbgenerated("'{}'::jsonb")) + actorUserId String? @map("actor_user_id") + acceptedGameTick BigInt? @map("accepted_game_tick") + acceptedClockRevision BigInt? @map("accepted_clock_revision") + status InputEventStatus @default(PENDING) + result Json? + error String? + attempts Int @default(0) + lockedBy String? @map("locked_by") + leaseUntil DateTime? @map("lease_until") + createdAt DateTime @default(now()) @map("created_at") + processingAt DateTime? @map("processing_at") + completedAt DateTime? @map("completed_at") @@index([target, status, sequence]) @@map("input_event") @@ -145,25 +147,101 @@ model TurnDaemonLease { } model WorldState { - id Int @id @default(autoincrement()) - scenarioCode String @map("scenario_code") - currentYear Int @map("current_year") - currentMonth Int @map("current_month") - tickSeconds Int @map("tick_seconds") - clockBaseTime DateTime? @map("clock_base_time") - clockTick BigInt? @map("clock_tick") - clockMode String @default("realtime") @map("clock_mode") - clockWallAnchor DateTime? @map("clock_wall_anchor") - lastTurnTick BigInt? @map("last_turn_tick") - config Json @default(dbgenerated("'{}'::jsonb")) - meta Json @default(dbgenerated("'{}'::jsonb")) - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + scenarioCode String @map("scenario_code") + currentYear Int @map("current_year") + currentMonth Int @map("current_month") + tickSeconds Int @map("tick_seconds") + clockBaseTime DateTime? @map("clock_base_time") + clockTick BigInt? @map("clock_tick") + clockMode String @default("realtime") @map("clock_mode") + clockWallAnchor DateTime? @map("clock_wall_anchor") + lastTurnTick BigInt? @map("last_turn_tick") + clockPhase String @default("RUNNING") @map("clock_phase") + clockRevision BigInt @default(1) @map("clock_revision") + deadlineGeneration BigInt @default(1) @map("deadline_generation") + config Json @default(dbgenerated("'{}'::jsonb")) + meta Json @default(dbgenerated("'{}'::jsonb")) + updatedAt DateTime @updatedAt @map("updated_at") - trafficPeriods TrafficPeriod[] + trafficPeriods TrafficPeriod[] + clockSuspensions ClockSuspension[] + clockProjectionOutbox ClockProjectionOutbox[] @@map("world_state") } +model ClockSuspension { + id String @id @db.VarChar(64) + worldStateId Int @map("world_state_id") + source String + policy String + status String @default("SUSPENDED") + sourceRevision BigInt @map("source_revision") + targetRevision BigInt @map("target_revision") + cutTick BigInt @map("cut_tick") + cutWallAt DateTime @map("cut_wall_at") @db.Timestamp(3) + resumeWallAt DateTime? @map("resume_wall_at") @db.Timestamp(3) + rateTicksPerSecond Int @map("rate_ticks_per_second") + catchUpTicks BigInt @default(0) @map("catch_up_ticks") + gapTicks BigInt? @map("gap_ticks") + shiftTicks BigInt? @map("shift_ticks") + alignedTick BigInt? @map("aligned_tick") + participantChecksumBefore String? @map("participant_checksum_before") + participantChecksumAfter String? @map("participant_checksum_after") + detail Json @default(dbgenerated("'{}'::jsonb")) + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3) + + worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade) + participants ClockReconciliationParticipant[] + projectionOutbox ClockProjectionOutbox[] + + @@unique([worldStateId, targetRevision]) + @@index([status, createdAt]) + @@map("clock_suspension") +} + +model ClockReconciliationParticipant { + suspensionId String @map("suspension_id") @db.VarChar(64) + participantKey String @map("participant_key") @db.VarChar(96) + policy String + beforeChecksum String @map("before_checksum") @db.VarChar(128) + afterChecksum String @map("after_checksum") @db.VarChar(128) + affectedCount Int @default(0) @map("affected_count") + detail Json @default(dbgenerated("'{}'::jsonb")) + + suspension ClockSuspension @relation(fields: [suspensionId], references: [id], onDelete: Cascade) + + @@id([suspensionId, participantKey]) + @@map("clock_reconciliation_participant") +} + +model ClockProjectionOutbox { + id BigInt @id @default(autoincrement()) + worldStateId Int @map("world_state_id") + suspensionId String? @map("suspension_id") @db.VarChar(64) + targetRevision BigInt @map("target_revision") + status String @default("PENDING") + payload Json @default(dbgenerated("'{}'::jsonb")) + checksum String @db.VarChar(128) + attempts Int @default(0) + availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3) + lockedAt DateTime? @map("locked_at") @db.Timestamp(3) + lockedBy String? @map("locked_by") @db.VarChar(128) + appliedAt DateTime? @map("applied_at") @db.Timestamp(3) + lastError String? @map("last_error") + createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3) + updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3) + + worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade) + suspension ClockSuspension? @relation(fields: [suspensionId], references: [id], onDelete: Cascade) + + @@unique([worldStateId, targetRevision]) + @@index([status, availableAt, id]) + @@map("clock_projection_outbox") +} + model Nation { id Int @id name String diff --git a/packages/infra/prisma/migrations/20260903090000_add_game_clock_reconciliation/migration.sql b/packages/infra/prisma/migrations/20260903090000_add_game_clock_reconciliation/migration.sql new file mode 100644 index 00000000..31a24ce7 --- /dev/null +++ b/packages/infra/prisma/migrations/20260903090000_add_game_clock_reconciliation/migration.sql @@ -0,0 +1,81 @@ +ALTER TABLE world_state + ADD COLUMN clock_phase TEXT NOT NULL DEFAULT 'RUNNING', + ADD COLUMN clock_revision BIGINT NOT NULL DEFAULT 1, + ADD COLUMN deadline_generation BIGINT NOT NULL DEFAULT 1; + +UPDATE world_state +SET clock_phase = CASE + WHEN clock_mode = 'manual' THEN 'MANUAL' + WHEN clock_wall_anchor IS NOT NULL AND clock_wall_anchor > CURRENT_TIMESTAMP THEN 'PREOPEN' + ELSE 'RUNNING' + END; + +ALTER TABLE input_event + ADD COLUMN accepted_game_tick BIGINT, + ADD COLUMN accepted_clock_revision BIGINT; + +CREATE TABLE clock_suspension ( + id VARCHAR(64) PRIMARY KEY, + world_state_id INTEGER NOT NULL REFERENCES world_state(id) ON DELETE CASCADE, + source TEXT NOT NULL, + policy TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'SUSPENDED', + source_revision BIGINT NOT NULL, + target_revision BIGINT NOT NULL, + cut_tick BIGINT NOT NULL, + cut_wall_at TIMESTAMP(3) NOT NULL, + resume_wall_at TIMESTAMP(3), + rate_ticks_per_second INTEGER NOT NULL, + catch_up_ticks BIGINT NOT NULL DEFAULT 0, + gap_ticks BIGINT, + shift_ticks BIGINT, + aligned_tick BIGINT, + participant_checksum_before VARCHAR(128), + participant_checksum_after VARCHAR(128), + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + updated_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + CONSTRAINT clock_suspension_world_revision_key UNIQUE (world_state_id, target_revision), + CONSTRAINT clock_suspension_revision_step CHECK (target_revision = source_revision + 1), + CONSTRAINT clock_suspension_nonnegative_catchup CHECK (catch_up_ticks >= 0), + CONSTRAINT clock_suspension_positive_rate CHECK (rate_ticks_per_second > 0) +); + +CREATE INDEX clock_suspension_status_created_at_idx ON clock_suspension(status, created_at); + +CREATE TABLE clock_reconciliation_participant ( + suspension_id VARCHAR(64) NOT NULL REFERENCES clock_suspension(id) ON DELETE CASCADE, + participant_key VARCHAR(96) NOT NULL, + policy TEXT NOT NULL, + before_checksum VARCHAR(128) NOT NULL, + after_checksum VARCHAR(128) NOT NULL, + affected_count INTEGER NOT NULL DEFAULT 0, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (suspension_id, participant_key), + CONSTRAINT clock_reconciliation_participant_policy_check CHECK (policy IN ('SHIFT', 'KEEP', 'REBUILD', 'FORBID')), + CONSTRAINT clock_reconciliation_participant_count_check CHECK (affected_count >= 0) +); + +CREATE TABLE clock_projection_outbox ( + id BIGSERIAL PRIMARY KEY, + world_state_id INTEGER NOT NULL REFERENCES world_state(id) ON DELETE CASCADE, + suspension_id VARCHAR(64) REFERENCES clock_suspension(id) ON DELETE CASCADE, + target_revision BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'PENDING', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + checksum VARCHAR(128) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + available_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + locked_at TIMESTAMP(3), + locked_by VARCHAR(128), + applied_at TIMESTAMP(3), + last_error TEXT, + created_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + updated_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'), + CONSTRAINT clock_projection_outbox_world_revision_key UNIQUE (world_state_id, target_revision), + CONSTRAINT clock_projection_outbox_status_check CHECK (status IN ('PENDING', 'APPLYING', 'APPLIED', 'FAILED')), + CONSTRAINT clock_projection_outbox_attempts_check CHECK (attempts >= 0) +); + +CREATE INDEX clock_projection_outbox_status_available_at_id_idx + ON clock_projection_outbox(status, available_at, id); diff --git a/packages/infra/src/gameSchemaAdvisoryLock.ts b/packages/infra/src/gameSchemaAdvisoryLock.ts index ebb0efc0..7cd82616 100644 --- a/packages/infra/src/gameSchemaAdvisoryLock.ts +++ b/packages/infra/src/gameSchemaAdvisoryLock.ts @@ -8,6 +8,8 @@ interface TryLockRow { /** Serializes score/traffic writers whose table lock order otherwise differs by entry point. */ export const GENERAL_ACCESS_PERSISTENCE_LOCK = 'general-access:persistence'; +/** Serializes phase/revision changes with every gameplay flush in one game schema. */ +export const CLOCK_OPERATION_PERSISTENCE_LOCK = 'game-clock:operation'; const lockKeySql = (logicalKey: string): GamePrisma.Sql => GamePrisma.sql`hashtextextended(current_schema() || chr(31) || ${logicalKey}, 0)`; diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 7eb2b1b8..44aad37e 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -16,6 +16,9 @@ export interface TurnEngineWorldStateRow { clockMode: string; clockWallAnchor: Date | null; lastTurnTick: bigint | null; + clockPhase: string; + clockRevision: bigint; + deadlineGeneration: bigint; config: JsonValue; meta: JsonValue; updatedAt?: Date | null; @@ -181,6 +184,9 @@ export interface TurnEngineWorldStateUpdateInput { clockMode: string; clockWallAnchor: Date; lastTurnTick: bigint; + clockPhase: string; + clockRevision: bigint; + deadlineGeneration: bigint; config: InputJsonValue; meta: InputJsonValue; } @@ -195,6 +201,9 @@ export interface TurnEngineWorldStateCreateInput { clockMode: string; clockWallAnchor: Date; lastTurnTick: bigint; + clockPhase: string; + clockRevision: bigint; + deadlineGeneration: bigint; config: InputJsonValue; meta: InputJsonValue; } diff --git a/release-manifest.json b/release-manifest.json index 5e0bb889..cc0a6058 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -2,6 +2,6 @@ "formatVersion": 1, "controllerProtocol": 2, "gatewaySchemaHead": "20260825000000_add_bulk_release_batches", - "gameSchemaHead": "20260824080000_vote_utc_wall_timestamps", + "gameSchemaHead": "20260903090000_add_game_clock_reconciliation", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] } diff --git a/tools/check-game-clock-participants.mjs b/tools/check-game-clock-participants.mjs new file mode 100644 index 00000000..4aa20ed9 --- /dev/null +++ b/tools/check-game-clock-participants.mjs @@ -0,0 +1,75 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const schemaPath = path.join(root, 'packages/infra/prisma/game.prisma'); +const inventoryPath = path.join(root, 'docs/architecture/game-clock-participants.json'); +const [schema, inventoryText] = await Promise.all([ + readFile(schemaPath, 'utf8'), + readFile(inventoryPath, 'utf8'), +]); +const inventory = JSON.parse(inventoryText); +const covered = new Set(inventory.coveredFields ?? []); +const policies = new Set(inventory.policies ?? []); +const failures = []; + +if (inventory.tickPerTurn !== 36_000_000) { + failures.push(`tickPerTurn must remain 36000000, found ${inventory.tickPerTurn}`); +} + +const discovered = []; +for (const modelMatch of schema.matchAll(/model\s+(\w+)\s*\{([\s\S]*?)\n\}/g)) { + const [, modelName, body] = modelMatch; + const table = body.match(/@@map\("([^"]+)"\)/)?.[1] ?? modelName; + for (const fieldMatch of body.matchAll(/^\s*(\w+)\s+BigInt\??[^\n]*@map\("([^"]+)"\)/gm)) { + const databaseField = fieldMatch[2]; + if ( + databaseField.endsWith('_tick') || + databaseField === 'clock_revision' || + databaseField === 'deadline_generation' || + (table.startsWith('clock_') && databaseField.endsWith('_revision')) + ) { + discovered.push(`${table}.${databaseField}`); + } + } +} + +for (const field of discovered) { + if (!covered.has(field)) { + failures.push(`unregistered authoritative clock field: ${field}`); + } +} +for (const participant of inventory.participants ?? []) { + if (!policies.has(participant.policy)) { + failures.push(`participant ${participant.key} has unknown policy ${participant.policy}`); + } + if (!participant.owner || !Array.isArray(participant.authorityFields)) { + failures.push(`participant ${participant.key} is missing owner or authorityFields`); + } +} +for (const requiredKey of [ + 'world-clock', + 'turn-cursor', + 'general-next-turn', + 'auction-deadline', + 'message-expiry', + 'vote-end-deadline', + 'select-pool-reservation', + 'npc-selection-window', + 'tournament-deadlines', + 'unification-wait', +]) { + if (!(inventory.participants ?? []).some((participant) => participant.key === requiredKey)) { + failures.push(`required participant is missing: ${requiredKey}`); + } +} + +if (failures.length > 0) { + console.error(failures.join('\n')); + process.exitCode = 1; +} else { + console.log( + `Validated ${discovered.length} authoritative clock fields and ${inventory.participants.length} participants.` + ); +}