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
+9 -1
View File
@@ -65,7 +65,15 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>; type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>; type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick'; type WorldClockFields =
| 'clockBaseTime'
| 'clockTick'
| 'clockMode'
| 'clockWallAnchor'
| 'lastTurnTick'
| 'clockPhase'
| 'clockRevision'
| 'deadlineGeneration';
type GeneralClockFields = 'turnTick' | 'recentWarTick'; type GeneralClockFields = 'turnTick' | 'recentWarTick';
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma // Transitional API fixtures may still model the pre-clock row. Runtime Prisma
+39 -3
View File
@@ -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'; import type { DatabaseClient } from '../context.js';
@@ -7,6 +13,9 @@ export interface CurrentGameTime {
wallNow: Date; wallNow: Date;
tick: number | null; tick: number | null;
mode: GameClockMode | null; mode: GameClockMode | null;
phase?: GameClockPhase | null;
revision?: number | null;
deadlineGeneration?: number | null;
running: boolean; running: boolean;
startsAt: Date | null; startsAt: Date | null;
dateToTick(date: Date): number | null; dateToTick(date: Date): number | null;
@@ -19,6 +28,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow, wallNow,
tick: null, tick: null,
mode: null, mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true, running: true,
startsAt: null, startsAt: null,
dateToTick: () => null, dateToTick: () => null,
@@ -32,6 +44,9 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
clockMode: true, clockMode: true,
clockWallAnchor: true, clockWallAnchor: true,
tickSeconds: true, tickSeconds: true,
clockPhase: true,
clockRevision: true,
deadlineGeneration: true,
}, },
}); });
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) { if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
@@ -40,32 +55,53 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
wallNow, wallNow,
tick: null, tick: null,
mode: null, mode: null,
phase: null,
revision: null,
deadlineGeneration: null,
running: true, running: true,
startsAt: null, startsAt: null,
dateToTick: () => null, dateToTick: () => null,
}; };
} }
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime'; 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); const storedTick = Number(state.clockTick);
if (!Number.isSafeInteger(storedTick)) { if (!Number.isSafeInteger(storedTick)) {
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`); 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({ const clock = new GameClock({
baseTime: state.clockBaseTime, baseTime: state.clockBaseTime,
tick: storedTick, tick: storedTick,
mode, mode,
wallAnchor: state.clockWallAnchor, wallAnchor: state.clockWallAnchor,
turnSeconds: state.tickSeconds, turnSeconds: state.tickSeconds,
phase,
revision,
}); });
const tick = clock.nowTick(wallNow); const tick = clock.nowTick(wallNow);
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime(); const running = phase === 'RUNNING' && mode === 'realtime';
return { return {
now: clock.tickToDate(tick), now: clock.tickToDate(tick),
wallNow, wallNow,
tick, tick,
mode, mode,
phase,
revision,
deadlineGeneration,
running, running,
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null, startsAt: phase === 'PREOPEN' ? state.clockWallAnchor : null,
dateToTick: (date) => clock.dateToTick(date), dateToTick: (date) => clock.dateToTick(date),
}; };
}; };
+12 -2
View File
@@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js'; import type { DatabaseClient } from '../src/context.js';
import { loadCurrentGameTime } from '../src/services/gameClock.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: { worldState: {
findFirst: vi.fn(async () => ({ findFirst: vi.fn(async () => ({
@@ -12,6 +15,9 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
clockMode: mode, clockMode: mode,
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'), clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
tickSeconds: 600, tickSeconds: 600,
clockPhase: phase,
clockRevision: 1n,
deadlineGeneration: 1n,
})), })),
}, },
}) as unknown as DatabaseClient; }) as unknown as DatabaseClient;
@@ -26,11 +32,15 @@ describe('current game time projection', () => {
wallNow: new Date('2026-08-21T10:30:00.000Z'), wallNow: new Date('2026-08-21T10:30:00.000Z'),
tick: -108_000_000, tick: -108_000_000,
mode: 'realtime', mode: 'realtime',
phase: 'PREOPEN',
running: false, running: false,
startsAt: new Date('2026-08-21T11:00:00.000Z'), 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({ expect(opened).toMatchObject({
now: new Date('2026-08-21T11:00:05.000Z'), now: new Date('2026-08-21T11:00:05.000Z'),
tick: 300_000, tick: 300_000,
@@ -172,7 +172,20 @@ export class TurnDaemonLifecycle {
const nowMs = this.clock.nowMs(); const nowMs = this.clock.nowMs();
const wallNow = new Date(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') { if (gameClock?.mode === 'manual') {
// Ref observes all generals due before one monthly boundary in // Ref observes all generals due before one monthly boundary in
// a single snapshot. Manual mode advances directly to that // a single snapshot. Manual mode advances directly to that
+9 -2
View File
@@ -7,7 +7,7 @@ import type {
TurnRunResult, TurnRunResult,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra'; import type { GamePrisma } from '@sammo-ts/infra';
import type { GameClockMode } from '@sammo-ts/common'; import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
export type { export type {
RunReason, RunReason,
@@ -60,7 +60,14 @@ export interface TurnStateStore {
loadCheckpoint(): Promise<TurnCheckpoint | undefined>; loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>; saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
shouldHaltScheduledRuns?(): Promise<boolean>; shouldHaltScheduledRuns?(): Promise<boolean>;
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<boolean>;
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>; shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>; rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>; advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
+34 -9
View File
@@ -6,7 +6,7 @@ import {
type InputJsonValue, type InputJsonValue,
type TurnEngineEventCreateManyInput, type TurnEngineEventCreateManyInput,
} from '@sammo-ts/infra'; } 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 { import {
buildScenarioBootstrap, buildScenarioBootstrap,
resolveScenarioGeneralDeathMonth, resolveScenarioGeneralDeathMonth,
@@ -94,6 +94,17 @@ export const calculateInitialTurnTick = (
return clock.addTicks(baseTick, offsetTicks); 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 formatDateTime = (date: Date): string => {
const pad = (value: number): string => String(value).padStart(2, '0'); const pad = (value: number): string => String(value).padStart(2, '0');
return [ return [
@@ -234,14 +245,20 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
// A realtime season prepared before its formal opening must not consume // A realtime season prepared before its formal opening must not consume
// wall time while users are only allowed to edit reserved commands. // wall time while users are only allowed to edit reserved commands.
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now; const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
const initialClockPhase = resolveInitialClockPhase(gameClockMode, now, initialClockWallAnchor);
const initialClock = new GameClock({ const initialClock = new GameClock({
baseTime: startState.startTime, baseTime: startState.startTime,
tick: 0, tick: 0,
mode: gameClockMode, mode: gameClockMode,
wallAnchor: initialClockWallAnchor, wallAnchor: initialClockWallAnchor,
turnSeconds: tickSeconds, 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({ const { seed, warnings } = buildScenarioBootstrap({
scenario: scenarioDefinition, scenario: scenarioDefinition,
@@ -327,6 +344,10 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
} }
worldMeta.hiddenSeed = hiddenSeed; worldMeta.hiddenSeed = hiddenSeed;
worldMeta.seededAtWall = now.toISOString();
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
worldMeta.calendarStart = startState.startTime.toISOString();
if (install?.preopenAt) { if (install?.preopenAt) {
worldMeta.preopenAt = formatDateTime(install.preopenAt); worldMeta.preopenAt = formatDateTime(install.preopenAt);
@@ -420,6 +441,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
clockMode: gameClockMode, clockMode: gameClockMode,
clockWallAnchor: initialClock.wallAnchor, clockWallAnchor: initialClock.wallAnchor,
lastTurnTick: BigInt(initialClockTick), lastTurnTick: BigInt(initialClockTick),
clockPhase: initialClockPhase,
clockRevision: 1n,
deadlineGeneration: 1n,
config: asJson({ ...scenarioConfig, ...worldConfig }), config: asJson({ ...scenarioConfig, ...worldConfig }),
meta: asJson(worldMeta), meta: asJson(worldMeta),
}, },
@@ -590,13 +614,14 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
weaponCode: general.weapon ?? 'None', weaponCode: general.weapon ?? 'None',
bookCode: general.book ?? 'None', bookCode: general.book ?? 'None',
itemCode: general.item ?? 'None', itemCode: general.item ?? 'None',
turnTime: new Date( turnTime: initialClock.tickToDate(
now.getTime() + calculateInitialTurnTick(
Math.floor( initialClock,
(typeof general.meta.initialTurnOffsetMicros === 'number' initialClockTick,
? general.meta.initialTurnOffsetMicros typeof general.meta.initialTurnOffsetMicros === 'number'
: 0) / 1_000 ? general.meta.initialTurnOffsetMicros
) : 0
)
), ),
turnTick: BigInt( turnTick: BigInt(
calculateInitialTurnTick( calculateInitialTurnTick(
+61 -14
View File
@@ -1,5 +1,6 @@
import { import {
acquireGameSchemaAdvisoryXactLock, acquireGameSchemaAdvisoryXactLock,
CLOCK_OPERATION_PERSISTENCE_LOCK,
createGamePostgresConnector, createGamePostgresConnector,
GENERAL_ACCESS_PERSISTENCE_LOCK, GENERAL_ACCESS_PERSISTENCE_LOCK,
GamePrisma, GamePrisma,
@@ -122,6 +123,10 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'clock_base_time', 'clock_base_time',
'clockMode', 'clockMode',
'clock_mode', 'clock_mode',
'clockPhase',
'clock_phase',
'clockRevision',
'clock_revision',
'clockTick', 'clockTick',
'clock_tick', 'clock_tick',
'clockWallAnchor', 'clockWallAnchor',
@@ -135,6 +140,8 @@ const CLOCK_ONLY_WORLD_META_KEYS = new Set([
'last_turn_tick', 'last_turn_tick',
'lastTurnTime', 'lastTurnTime',
'last_turn_time', 'last_turn_time',
'deadlineGeneration',
'deadline_generation',
'lease', 'lease',
'leaseOwner', 'leaseOwner',
'lease_owner', 'lease_owner',
@@ -1122,9 +1129,20 @@ export const createDatabaseTurnHooks = async (
clockMode: state.clockMode ?? 'manual', clockMode: state.clockMode ?? 'manual',
clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime, clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(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()), config: asJson(world.getWorldConfig()),
meta: asJson(state.meta), 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 ( const persist = async (
prisma: GamePrisma.TransactionClient prisma: GamePrisma.TransactionClient
): Promise<{ ): Promise<{
@@ -1157,6 +1175,49 @@ export const createDatabaseTurnHooks = async (
// world mutation. A stale daemon can finish calculating, but it can // world mutation. A stale daemon can finish calculating, but it can
// never commit after another owner has advanced the epoch. // never commit after another owner has advanced the epoch.
await options?.turnDaemonLease?.assertActive(prisma); 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; let neutralAuctionsToCreate = pendingNeutralAuctions;
if (pendingNeutralAuctions.length > 0) { if (pendingNeutralAuctions.length > 0) {
const latestRegistrationKey = const latestRegistrationKey =
@@ -1323,20 +1384,6 @@ export const createDatabaseTurnHooks = async (
const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle'); const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle');
const afterLifecycleLogs = 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 persistInheritancePointAdjustments(beforeLifecycleAdjustments);
await persistInheritanceLogs(beforeLifecycleLogs); await persistInheritanceLogs(beforeLifecycleLogs);
await persistGeneralLifecycleEvents( await persistGeneralLifecycleEvents(
+16 -2
View File
@@ -35,13 +35,27 @@ export class InMemoryTurnStateStore implements TurnStateStore {
return asNumber(meta.isunited ?? meta.isUnited, 0) >= 2; 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<InMemoryTurnWorld['getGameClockState']>['phase'];
revision: number;
deadlineGeneration: number;
}> {
const state = this.world.getGameClockState();
return { return {
mode: this.world.getGameClockState().mode, mode: state.mode,
now: this.world.getGameNow(wallNow), now: this.world.getGameNow(wallNow),
phase: state.phase,
revision: state.revision,
deadlineGeneration: state.deadlineGeneration,
}; };
} }
async promotePreopenAtOpening(wallNow: Date): Promise<boolean> {
return this.world.promotePreopenAtOpening(wallNow);
}
async rebaseRealtimeBacklog(wallNow: Date) { async rebaseRealtimeBacklog(wallNow: Date) {
return this.world.rebaseRealtimeBacklog(wallNow); return this.world.rebaseRealtimeBacklog(wallNow);
} }
+50 -3
View File
@@ -10,7 +10,14 @@ import type {
UnitSetDefinition, UnitSetDefinition,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { getNextTurnAt, readScenarioGeneralPoolClaim } 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 { TurnCheckpoint } from '../lifecycle/types.js';
import type { import type {
@@ -123,6 +130,9 @@ export interface InMemoryGameClockState {
mode: GameClockMode; mode: GameClockMode;
wallAnchor: Date; wallAnchor: Date;
lastTurnTick: number; lastTurnTick: number;
phase: GameClockPhase;
revision: number;
deadlineGeneration: number;
} }
export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle'; export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle';
@@ -525,6 +535,9 @@ export class InMemoryTurnWorld {
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) { constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
const baseTime = new Date((state.clockBaseTime ?? state.lastTurnTime).getTime()); const baseTime = new Date((state.clockBaseTime ?? state.lastTurnTime).getTime());
const mode = state.clockMode ?? 'manual'; 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 wallAnchor = new Date((state.clockWallAnchor ?? state.lastTurnTime).getTime());
const bootstrapClock = new GameClock({ const bootstrapClock = new GameClock({
baseTime, baseTime,
@@ -532,6 +545,8 @@ export class InMemoryTurnWorld {
mode, mode,
wallAnchor, wallAnchor,
turnSeconds: state.tickSeconds, turnSeconds: state.tickSeconds,
phase,
revision,
}); });
const lastTurnTick = state.lastTurnTick ?? bootstrapClock.dateToTick(state.lastTurnTime); const lastTurnTick = state.lastTurnTick ?? bootstrapClock.dateToTick(state.lastTurnTime);
const clockTick = state.clockTick ?? lastTurnTick; const clockTick = state.clockTick ?? lastTurnTick;
@@ -541,6 +556,8 @@ export class InMemoryTurnWorld {
mode, mode,
wallAnchor, wallAnchor,
turnSeconds: state.tickSeconds, turnSeconds: state.tickSeconds,
phase,
revision,
}); });
const lastTurnTime = gameClock.tickToDate(lastTurnTick); const lastTurnTime = gameClock.tickToDate(lastTurnTick);
this.state = { this.state = {
@@ -550,6 +567,9 @@ export class InMemoryTurnWorld {
clockMode: mode, clockMode: mode,
clockWallAnchor: wallAnchor, clockWallAnchor: wallAnchor,
lastTurnTick, lastTurnTick,
clockPhase: phase,
clockRevision: revision,
deadlineGeneration,
lastTurnTime, lastTurnTime,
meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() }, meta: { ...state.meta, lastTurnTime: lastTurnTime.toISOString() },
}; };
@@ -621,6 +641,8 @@ export class InMemoryTurnWorld {
mode: this.state.clockMode ?? 'manual', mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
turnSeconds: this.state.tickSeconds, 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', mode: this.state.clockMode ?? 'manual',
wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()), wallAnchor: new Date((this.state.clockWallAnchor ?? this.state.lastTurnTime).getTime()),
lastTurnTick: this.state.lastTurnTick ?? 0, 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); 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 { getRunnableGameNow(wallNow: Date): Date {
const clock = this.getGameClock(); const clock = this.getGameClock();
// PREOPEN still needs negative game ticks for cooldowns, but executable // PREOPEN still needs negative game ticks for cooldowns, but executable
// turn schedules must not precede the wall-clock opening boundary. // 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(clock.wallAnchor);
} }
return clock.now(wallNow); return clock.now(wallNow);
@@ -676,6 +716,7 @@ export class InMemoryTurnWorld {
advanceGameClockTo(target: Date, wallNow: Date): void { advanceGameClockTo(target: Date, wallNow: Date): void {
const clock = this.getGameClock(); const clock = this.getGameClock();
assertGameplayCommitAllowed(clock.phase);
const targetTick = clock.dateToTick(target); const targetTick = clock.dateToTick(target);
// Realtime의 권위 시각은 wall anchor 이후 경과입니다. 밀린 턴을 과거 // Realtime의 권위 시각은 wall anchor 이후 경과입니다. 밀린 턴을 과거
// target으로 처리한 완료 시각에 anchor를 다시 고정하면, 처리에 걸린 // target으로 처리한 완료 시각에 anchor를 다시 고정하면, 처리에 걸린
@@ -696,7 +737,7 @@ export class InMemoryTurnWorld {
skippedTurns: number; skippedTurns: number;
} | null { } | null {
const clock = this.getGameClock(); const clock = this.getGameClock();
if (clock.mode !== 'realtime') { if (clock.mode !== 'realtime' || clock.phase !== 'RUNNING') {
return null; return null;
} }
const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60)); const turnMinutes = Math.max(1, Math.round(this.state.tickSeconds / 60));
@@ -969,6 +1010,8 @@ export class InMemoryTurnWorld {
mode: this.state.clockMode ?? 'manual', mode: this.state.clockMode ?? 'manual',
wallAnchor: anchorWall, wallAnchor: anchorWall,
turnSeconds: nextTickSeconds, 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 lastTurnTick = this.state.lastTurnTick ?? previousClock.dateToTick(this.state.lastTurnTime);
const nextLastTurnTime = nextClock.tickToDate(lastTurnTick); const nextLastTurnTime = nextClock.tickToDate(lastTurnTick);
@@ -1470,6 +1513,8 @@ export class InMemoryTurnWorld {
mode: this.state.clockMode ?? 'manual', mode: this.state.clockMode ?? 'manual',
wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime, wallAnchor: this.state.clockWallAnchor ?? this.state.lastTurnTime,
turnSeconds: this.state.tickSeconds, 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 nextLastTurnTime = shiftedClock.tickToDate(this.state.lastTurnTick ?? 0);
const nextMeta = { const nextMeta = {
@@ -1605,6 +1650,7 @@ export class InMemoryTurnWorld {
} }
executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution { executeGeneralTurn(general: TurnGeneral): GeneralTurnExecution {
assertGameplayCommitAllowed(this.getGameClock().phase);
const currentGeneral = this.generals.get(general.id) ?? general; const currentGeneral = this.generals.get(general.id) ?? general;
const executionYear = this.state.currentYear; const executionYear = this.state.currentYear;
const executionMonth = this.state.currentMonth; const executionMonth = this.state.currentMonth;
@@ -1787,6 +1833,7 @@ export class InMemoryTurnWorld {
} }
async advanceMonth(turnTime: Date): Promise<void> { async advanceMonth(turnTime: Date): Promise<void> {
assertGameplayCommitAllowed(this.getGameClock().phase);
const previousYear = this.state.currentYear; const previousYear = this.state.currentYear;
const previousMonth = this.state.currentMonth; const previousMonth = this.state.currentMonth;
let nextYear = previousYear; let nextYear = previousYear;
+2
View File
@@ -219,6 +219,8 @@ const resolveRuntimeState = (
mode: state.clockMode ?? 'manual', mode: state.clockMode ?? 'manual',
wallAnchor: state.clockWallAnchor ?? state.lastTurnTime, wallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
turnSeconds: state.tickSeconds, turnSeconds: state.tickSeconds,
phase: state.clockPhase,
revision: state.clockRevision,
}).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0), }).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
state.clockTick ?? state.lastTurnTick ?? 0, state.clockTick ?? state.lastTurnTick ?? 0,
nextTickSeconds nextTickSeconds
+4 -1
View File
@@ -11,7 +11,7 @@ import type {
WorldSnapshot, WorldSnapshot,
GeneralLastTurn, GeneralLastTurn,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import type { GameClockMode } from '@sammo-ts/common'; import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
export interface TurnWorldState { export interface TurnWorldState {
id: number; id: number;
@@ -24,6 +24,9 @@ export interface TurnWorldState {
clockMode?: GameClockMode; clockMode?: GameClockMode;
clockWallAnchor?: Date; clockWallAnchor?: Date;
lastTurnTick?: number; lastTurnTick?: number;
clockPhase?: GameClockPhase;
clockRevision?: number;
deadlineGeneration?: number;
meta: Record<string, unknown>; meta: Record<string, unknown>;
} }
+23 -1
View File
@@ -26,7 +26,14 @@ import { normalizeScenarioEffect } from '@sammo-ts/logic';
import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic'; import { parseScenarioGeneralPoolCandidate } from '@sammo-ts/logic';
import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js';
import { z } from 'zod'; 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 type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { loadMapDefinitionByName } from '../scenario/mapLoader.js'; import { loadMapDefinitionByName } from '../scenario/mapLoader.js';
@@ -433,6 +440,14 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
worldState.clockWallAnchor !== null && worldState.clockWallAnchor !== null &&
worldState.lastTurnTick !== null; worldState.lastTurnTick !== null;
const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual'; const clockMode = hasPersistedClock ? parseClockMode(worldState.clockMode) : 'manual';
const clockPhase = hasPersistedClock
? 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 clockBaseTime = worldState.clockBaseTime ?? legacyLastTurnTime;
const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime; const clockWallAnchor = worldState.clockWallAnchor ?? legacyLastTurnTime;
const bootstrapClock = new GameClock({ const bootstrapClock = new GameClock({
@@ -441,6 +456,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
mode: clockMode, mode: clockMode,
wallAnchor: clockWallAnchor, wallAnchor: clockWallAnchor,
turnSeconds: worldState.tickSeconds, turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
}); });
const legacyLastTurnTick = bootstrapClock.dateToTick(legacyLastTurnTime); const legacyLastTurnTick = bootstrapClock.dateToTick(legacyLastTurnTime);
const gameClock = new GameClock({ const gameClock = new GameClock({
@@ -452,6 +469,8 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
mode: clockMode, mode: clockMode,
wallAnchor: clockWallAnchor, wallAnchor: clockWallAnchor,
turnSeconds: worldState.tickSeconds, turnSeconds: worldState.tickSeconds,
phase: clockPhase,
revision: clockRevision,
}); });
const ranksByGeneral = new Map<number, TurnEngineRankDataRow[]>(); const ranksByGeneral = new Map<number, TurnEngineRankDataRow[]>();
@@ -519,6 +538,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
clockMode, clockMode,
clockWallAnchor: gameClock.wallAnchor, clockWallAnchor: gameClock.wallAnchor,
lastTurnTick, lastTurnTick,
clockPhase,
clockRevision,
deadlineGeneration,
meta, meta,
}, },
snapshot: { snapshot: {
@@ -183,17 +183,43 @@ describe('runtime clock shift', () => {
clockMode: 'realtime', clockMode: 'realtime',
clockWallAnchor: openAt, clockWallAnchor: openAt,
lastTurnTick: 0, lastTurnTick: 0,
clockPhase: 'PREOPEN',
}); });
const preopenAt = new Date('2026-09-02T23:03:00.000Z'); const preopenAt = new Date('2026-09-02T23:03:00.000Z');
expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime()); expect(world.getGameNow(preopenAt).getTime()).toBeLessThan(gameBase.getTime());
expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase); expect(world.getRunnableGameNow(preopenAt)).toEqual(gameBase);
expect(world.getRunnableGameNow(openAt)).toEqual(gameBase); expect(world.getRunnableGameNow(openAt)).toEqual(gameBase);
expect(world.promotePreopenAtOpening(openAt)).toBe(true);
expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual( expect(world.getRunnableGameNow(new Date(openAt.getTime() + 60_000))).toEqual(
new Date(gameBase.getTime() + 60_000) 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([ it.each([
[5, 6], [5, 6],
[10, 3], [10, 3],
@@ -1,6 +1,6 @@
import { GameClock } from '@sammo-ts/common'; import { GameClock } from '@sammo-ts/common';
import { describe, expect, test } from 'vitest'; 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', () => { describe('scenario seeder general turn tick', () => {
test('preserves Ref-compatible sub-millisecond RNG precision', () => { 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(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); 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');
});
}); });
@@ -1,6 +1,14 @@
import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
import type { GamePrisma } from '@sammo-ts/infra'; 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 { export interface AdminSeedUser {
id: string; id: string;
@@ -105,14 +113,22 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse
const name = await resolveAdminName(prisma, adminUser); const name = await resolveAdminName(prisma, adminUser);
const meta = asRecord(worldState.meta); const meta = asRecord(worldState.meta);
const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null; 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({ const gameClock = new GameClock({
baseTime: worldState.clockBaseTime ?? turnTime, baseTime: worldState.clockBaseTime ?? fallbackTurnTime,
tick: Number(worldState.clockTick ?? 0n), tick: Number(worldState.clockTick ?? 0n),
mode: worldState.clockMode === 'manual' ? 'manual' : 'realtime', mode,
wallAnchor: worldState.clockWallAnchor ?? turnTime, wallAnchor: worldState.clockWallAnchor ?? fallbackTurnTime,
turnSeconds: worldState.tickSeconds, 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({ await prisma.general.create({
data: { data: {
@@ -130,7 +146,7 @@ const ensureAdminGeneral = async (prisma: GamePrisma.TransactionClient, adminUse
specialCode: 'None', specialCode: 'None',
special2Code: 'None', special2Code: 'None',
turnTime, turnTime,
turnTick: BigInt(gameClock.dateToTick(turnTime)), turnTick: BigInt(turnTick),
meta: { meta: {
createdBy: 'admin-seed', createdBy: 'admin-seed',
killturn: 24, killturn: 24,
+1 -1
View File
@@ -39,7 +39,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260825000000_add_bulk_release_batches', gatewaySchemaHead: '20260825000000_add_bulk_release_batches',
gameSchemaHead: '20260824080000_vote_utc_wall_timestamps', gameSchemaHead: '20260903090000_add_game_clock_reconciliation',
}); });
}); });
@@ -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"
]
}
@@ -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.
@@ -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.
+40
View File
@@ -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.
+1 -1
View File
@@ -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: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: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: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", "check:typescript-toolchain": "node tools/check-typescript-toolchain.mjs",
"test:architecture": "node --test tools/check-package-boundaries.test.mjs", "test:architecture": "node --test tools/check-package-boundaries.test.mjs",
"test:image-sync": "node --test tools/sync-image-repository.test.mjs", "test:image-sync": "node --test tools/sync-image-repository.test.mjs",
+140 -2
View File
@@ -2,6 +2,31 @@ export const GAME_TICKS_PER_TURN = 36_000_000;
export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER; export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER;
export type GameClockMode = 'realtime' | 'manual'; export type GameClockMode = 'realtime' | 'manual';
export type GameClockPhase = 'PREOPEN' | 'RUNNING' | 'SUSPENDED' | 'RECONCILING' | 'MANUAL' | 'COMPLETED';
export type 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 { export interface GameClockState {
baseTime: Date; baseTime: Date;
@@ -9,6 +34,8 @@ export interface GameClockState {
mode: GameClockMode; mode: GameClockMode;
wallAnchor: Date; wallAnchor: Date;
turnSeconds: number; turnSeconds: number;
phase?: GameClockPhase;
revision?: number;
} }
const requireSafeTick = (tick: number): number => { const requireSafeTick = (tick: number): number => {
@@ -18,6 +45,108 @@ const requireSafeTick = (tick: number): number => {
return tick; return tick;
}; };
export const asGameTick = (tick: number): GameTick => requireSafeTick(tick) as GameTick;
export const asObservedGameInstant = (tick: number): ObservedGameInstant =>
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 tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => {
const wholeSeconds = Math.floor(tick / ticksPerSecond); const wholeSeconds = Math.floor(tick / ticksPerSecond);
const remainingTicks = tick - wholeSeconds * ticksPerSecond; const remainingTicks = tick - wholeSeconds * ticksPerSecond;
@@ -35,6 +164,8 @@ export class GameClock {
readonly wallAnchor: Date; readonly wallAnchor: Date;
readonly turnSeconds: number; readonly turnSeconds: number;
readonly ticksPerSecond: number; readonly ticksPerSecond: number;
readonly phase: GameClockPhase;
readonly revision: ClockRevision;
constructor(state: GameClockState) { constructor(state: GameClockState) {
if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) { if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) {
@@ -55,6 +186,8 @@ export class GameClock {
this.wallAnchor = new Date(state.wallAnchor.getTime()); this.wallAnchor = new Date(state.wallAnchor.getTime());
this.turnSeconds = state.turnSeconds; this.turnSeconds = state.turnSeconds;
this.ticksPerSecond = GAME_TICKS_PER_TURN / 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 { static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date {
@@ -76,14 +209,19 @@ export class GameClock {
} }
nowTick(wallNow: Date): number { nowTick(wallNow: Date): number {
if (this.mode === 'manual') { if (this.mode === 'manual' || this.phase === 'MANUAL' || this.phase === 'COMPLETED') {
return this.tick; return this.tick;
} }
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow); const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
// A future realtime anchor represents the formal opening at anchor tick. // A future realtime anchor represents the formal opening at anchor tick.
// Before that instant Ref exposes the elapsed offset as a negative tick, // Before that instant Ref exposes the elapsed offset as a negative tick,
// which lets PREOPEN-only actions keep their logical cooldowns moving. // 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 { now(wallNow: Date): Date {
+66 -1
View File
@@ -1,6 +1,15 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('GameClock', () => {
const baseTime = new Date('2042-01-01T00:00:00.000Z'); const baseTime = new Date('2042-01-01T00:00:00.000Z');
@@ -42,12 +51,68 @@ describe('GameClock', () => {
mode: 'realtime', mode: 'realtime',
wallAnchor: new Date('2026-01-01T01:00:00.000Z'), wallAnchor: new Date('2026-01-01T01:00:00.000Z'),
turnSeconds: 3_600, 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-01T00:30:00.000Z'))).toBe(-GAME_TICKS_PER_TURN / 2);
expect(clock.nowTick(new Date('2026-01-01T01:00:00.000Z'))).toBe(0); 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', () => { it('projects near the safe tick boundary without unsafe intermediate multiplication', () => {
const clock = new GameClock({ const clock = new GameClock({
baseTime: new Date(0), baseTime: new Date(0),
+107 -29
View File
@@ -63,21 +63,23 @@ enum InputEventTarget {
} }
model InputEvent { model InputEvent {
sequence BigInt @id @default(autoincrement()) sequence BigInt @id @default(autoincrement())
requestId String @unique @map("request_id") requestId String @unique @map("request_id")
target InputEventTarget target InputEventTarget
eventType String @map("event_type") eventType String @map("event_type")
payload Json @default(dbgenerated("'{}'::jsonb")) payload Json @default(dbgenerated("'{}'::jsonb"))
actorUserId String? @map("actor_user_id") actorUserId String? @map("actor_user_id")
status InputEventStatus @default(PENDING) acceptedGameTick BigInt? @map("accepted_game_tick")
result Json? acceptedClockRevision BigInt? @map("accepted_clock_revision")
error String? status InputEventStatus @default(PENDING)
attempts Int @default(0) result Json?
lockedBy String? @map("locked_by") error String?
leaseUntil DateTime? @map("lease_until") attempts Int @default(0)
createdAt DateTime @default(now()) @map("created_at") lockedBy String? @map("locked_by")
processingAt DateTime? @map("processing_at") leaseUntil DateTime? @map("lease_until")
completedAt DateTime? @map("completed_at") createdAt DateTime @default(now()) @map("created_at")
processingAt DateTime? @map("processing_at")
completedAt DateTime? @map("completed_at")
@@index([target, status, sequence]) @@index([target, status, sequence])
@@map("input_event") @@map("input_event")
@@ -145,25 +147,101 @@ model TurnDaemonLease {
} }
model WorldState { model WorldState {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
scenarioCode String @map("scenario_code") scenarioCode String @map("scenario_code")
currentYear Int @map("current_year") currentYear Int @map("current_year")
currentMonth Int @map("current_month") currentMonth Int @map("current_month")
tickSeconds Int @map("tick_seconds") tickSeconds Int @map("tick_seconds")
clockBaseTime DateTime? @map("clock_base_time") clockBaseTime DateTime? @map("clock_base_time")
clockTick BigInt? @map("clock_tick") clockTick BigInt? @map("clock_tick")
clockMode String @default("realtime") @map("clock_mode") clockMode String @default("realtime") @map("clock_mode")
clockWallAnchor DateTime? @map("clock_wall_anchor") clockWallAnchor DateTime? @map("clock_wall_anchor")
lastTurnTick BigInt? @map("last_turn_tick") lastTurnTick BigInt? @map("last_turn_tick")
config Json @default(dbgenerated("'{}'::jsonb")) clockPhase String @default("RUNNING") @map("clock_phase")
meta Json @default(dbgenerated("'{}'::jsonb")) clockRevision BigInt @default(1) @map("clock_revision")
updatedAt DateTime @updatedAt @map("updated_at") 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") @@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 { model Nation {
id Int @id id Int @id
name String name String
@@ -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);
@@ -8,6 +8,8 @@ interface TryLockRow {
/** Serializes score/traffic writers whose table lock order otherwise differs by entry point. */ /** Serializes score/traffic writers whose table lock order otherwise differs by entry point. */
export const GENERAL_ACCESS_PERSISTENCE_LOCK = 'general-access:persistence'; 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 => const lockKeySql = (logicalKey: string): GamePrisma.Sql =>
GamePrisma.sql`hashtextextended(current_schema() || chr(31) || ${logicalKey}, 0)`; GamePrisma.sql`hashtextextended(current_schema() || chr(31) || ${logicalKey}, 0)`;
+9
View File
@@ -16,6 +16,9 @@ export interface TurnEngineWorldStateRow {
clockMode: string; clockMode: string;
clockWallAnchor: Date | null; clockWallAnchor: Date | null;
lastTurnTick: bigint | null; lastTurnTick: bigint | null;
clockPhase: string;
clockRevision: bigint;
deadlineGeneration: bigint;
config: JsonValue; config: JsonValue;
meta: JsonValue; meta: JsonValue;
updatedAt?: Date | null; updatedAt?: Date | null;
@@ -181,6 +184,9 @@ export interface TurnEngineWorldStateUpdateInput {
clockMode: string; clockMode: string;
clockWallAnchor: Date; clockWallAnchor: Date;
lastTurnTick: bigint; lastTurnTick: bigint;
clockPhase: string;
clockRevision: bigint;
deadlineGeneration: bigint;
config: InputJsonValue; config: InputJsonValue;
meta: InputJsonValue; meta: InputJsonValue;
} }
@@ -195,6 +201,9 @@ export interface TurnEngineWorldStateCreateInput {
clockMode: string; clockMode: string;
clockWallAnchor: Date; clockWallAnchor: Date;
lastTurnTick: bigint; lastTurnTick: bigint;
clockPhase: string;
clockRevision: bigint;
deadlineGeneration: bigint;
config: InputJsonValue; config: InputJsonValue;
meta: InputJsonValue; meta: InputJsonValue;
} }
+1 -1
View File
@@ -2,6 +2,6 @@
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20260825000000_add_bulk_release_batches", "gatewaySchemaHead": "20260825000000_add_bulk_release_batches",
"gameSchemaHead": "20260824080000_vote_utc_wall_timestamps", "gameSchemaHead": "20260903090000_add_game_clock_reconciliation",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }
+75
View File
@@ -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.`
);
}