feat: 게임 시계 reconciliation 권위 기반 추가
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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: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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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. */
|
/** 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)`;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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