시간 작동 방식 변경 #1
@@ -65,7 +65,15 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
|
||||
|
||||
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<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';
|
||||
|
||||
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TurnCheckpoint | undefined>;
|
||||
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
|
||||
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>;
|
||||
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
|
||||
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<InMemoryTurnWorld['getGameClockState']>['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<boolean> {
|
||||
return this.world.promotePreopenAtOpening(wallNow);
|
||||
}
|
||||
|
||||
async rebaseRealtimeBacklog(wallNow: Date) {
|
||||
return this.world.rebaseRealtimeBacklog(wallNow);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
assertGameplayCommitAllowed(this.getGameClock().phase);
|
||||
const previousYear = this.state.currentYear;
|
||||
const previousMonth = this.state.currentMonth;
|
||||
let nextYear = previousYear;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number, TurnEngineRankDataRow[]>();
|
||||
@@ -519,6 +538,9 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions)
|
||||
clockMode,
|
||||
clockWallAnchor: gameClock.wallAnchor,
|
||||
lastTurnTick,
|
||||
clockPhase,
|
||||
clockRevision,
|
||||
deadlineGeneration,
|
||||
meta,
|
||||
},
|
||||
snapshot: {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
+81
@@ -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. */
|
||||
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)`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user