feat: 게임 시계 reconciliation 권위 기반 추가
This commit is contained in:
@@ -2,6 +2,31 @@ export const GAME_TICKS_PER_TURN = 36_000_000;
|
||||
export const MAX_SAFE_GAME_TICK = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export type GameClockMode = 'realtime' | 'manual';
|
||||
export type GameClockPhase = 'PREOPEN' | 'RUNNING' | 'SUSPENDED' | 'RECONCILING' | 'MANUAL' | 'COMPLETED';
|
||||
export type ClockAlignmentPolicy = 'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP';
|
||||
|
||||
declare const gameTickBrand: unique symbol;
|
||||
declare const observedGameInstantBrand: unique symbol;
|
||||
declare const scheduleInstantBrand: unique symbol;
|
||||
declare const clockRevisionBrand: unique symbol;
|
||||
declare const wallInstantBrand: unique symbol;
|
||||
|
||||
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
|
||||
export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' };
|
||||
export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' };
|
||||
export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' };
|
||||
export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' };
|
||||
|
||||
export interface ExactClockAlignmentPlan {
|
||||
policy: 'EXACT';
|
||||
sourceRevision: ClockRevision;
|
||||
targetRevision: ClockRevision;
|
||||
cutTick: GameTick;
|
||||
gapTicks: GameTick;
|
||||
catchUpTicks: GameTick;
|
||||
shiftTicks: GameTick;
|
||||
alignedTick: GameTick;
|
||||
}
|
||||
|
||||
export interface GameClockState {
|
||||
baseTime: Date;
|
||||
@@ -9,6 +34,8 @@ export interface GameClockState {
|
||||
mode: GameClockMode;
|
||||
wallAnchor: Date;
|
||||
turnSeconds: number;
|
||||
phase?: GameClockPhase;
|
||||
revision?: number;
|
||||
}
|
||||
|
||||
const requireSafeTick = (tick: number): number => {
|
||||
@@ -18,6 +45,108 @@ const requireSafeTick = (tick: number): number => {
|
||||
return tick;
|
||||
};
|
||||
|
||||
export const asGameTick = (tick: number): GameTick => requireSafeTick(tick) as GameTick;
|
||||
|
||||
export const asObservedGameInstant = (tick: number): ObservedGameInstant =>
|
||||
requireSafeTick(tick) as ObservedGameInstant;
|
||||
|
||||
export const asScheduleInstant = (tick: number): ScheduleInstant => requireSafeTick(tick) as ScheduleInstant;
|
||||
|
||||
export const asClockRevision = (revision: number): ClockRevision => {
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new Error(`Clock revision must be a positive safe integer: ${revision}`);
|
||||
}
|
||||
return revision as ClockRevision;
|
||||
};
|
||||
|
||||
export const asWallInstant = (instant: Date): WallInstant => {
|
||||
if (Number.isNaN(instant.getTime())) {
|
||||
throw new Error('Wall instant must be a valid date.');
|
||||
}
|
||||
return new Date(instant.getTime()) as WallInstant;
|
||||
};
|
||||
|
||||
export const inferClockPhase = (mode: GameClockMode): GameClockPhase => (mode === 'manual' ? 'MANUAL' : 'RUNNING');
|
||||
|
||||
const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [
|
||||
'PREOPEN',
|
||||
'RUNNING',
|
||||
'SUSPENDED',
|
||||
'RECONCILING',
|
||||
'MANUAL',
|
||||
'COMPLETED',
|
||||
];
|
||||
|
||||
export const parseGameClockPhase = (value: string): GameClockPhase => {
|
||||
if ((GAME_CLOCK_PHASES as readonly string[]).includes(value)) {
|
||||
return value as GameClockPhase;
|
||||
}
|
||||
throw new Error(`Unknown game clock phase: ${value}`);
|
||||
};
|
||||
|
||||
export const scheduleNotBefore = (instant: ObservedGameInstant, phase: GameClockPhase): ScheduleInstant => {
|
||||
if (phase === 'PREOPEN') {
|
||||
return asScheduleInstant(Math.max(0, instant));
|
||||
}
|
||||
return asScheduleInstant(instant);
|
||||
};
|
||||
|
||||
export const createDeadline = (
|
||||
instant: ObservedGameInstant,
|
||||
durationTicks: GameTick,
|
||||
phase: GameClockPhase
|
||||
): ScheduleInstant => scheduleNotBefore(asObservedGameInstant(requireSafeTick(instant + durationTicks)), phase);
|
||||
|
||||
export const assertGameplayCommitAllowed = (phase: GameClockPhase): void => {
|
||||
if (phase !== 'RUNNING' && phase !== 'MANUAL') {
|
||||
throw new Error(`Gameplay commit is forbidden while the game clock phase is ${phase}.`);
|
||||
}
|
||||
};
|
||||
|
||||
export const buildExactClockAlignmentPlan = (input: {
|
||||
sourceRevision: number;
|
||||
cutTick: number;
|
||||
cutWall: Date;
|
||||
resumeWall: Date;
|
||||
ticksPerSecond: number;
|
||||
catchUpTicks?: number;
|
||||
}): ExactClockAlignmentPlan => {
|
||||
const sourceRevision = asClockRevision(input.sourceRevision);
|
||||
const cutTick = asGameTick(input.cutTick);
|
||||
const cutWall = asWallInstant(input.cutWall);
|
||||
const resumeWall = asWallInstant(input.resumeWall);
|
||||
if (!Number.isSafeInteger(input.ticksPerSecond) || input.ticksPerSecond <= 0) {
|
||||
throw new Error(`ticksPerSecond must be a positive safe integer: ${input.ticksPerSecond}`);
|
||||
}
|
||||
const elapsedMilliseconds = Math.max(0, resumeWall.getTime() - cutWall.getTime());
|
||||
if (!Number.isSafeInteger(elapsedMilliseconds)) {
|
||||
throw new Error('Clock suspension wall gap is outside the safe integer range.');
|
||||
}
|
||||
const wholeSeconds = Math.trunc(elapsedMilliseconds / 1_000);
|
||||
const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000;
|
||||
const gapTicks = asGameTick(
|
||||
requireSafeTick(
|
||||
wholeSeconds * input.ticksPerSecond +
|
||||
Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
|
||||
)
|
||||
);
|
||||
const catchUpTicks = asGameTick(input.catchUpTicks ?? 0);
|
||||
if (catchUpTicks < 0 || catchUpTicks > gapTicks) {
|
||||
throw new Error(`catchUpTicks must be between 0 and the wall gap (${gapTicks}): ${catchUpTicks}`);
|
||||
}
|
||||
const shiftTicks = asGameTick(gapTicks - catchUpTicks);
|
||||
return {
|
||||
policy: 'EXACT',
|
||||
sourceRevision,
|
||||
targetRevision: asClockRevision(sourceRevision + 1),
|
||||
cutTick,
|
||||
gapTicks,
|
||||
catchUpTicks,
|
||||
shiftTicks,
|
||||
alignedTick: asGameTick(cutTick + gapTicks),
|
||||
};
|
||||
};
|
||||
|
||||
const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => {
|
||||
const wholeSeconds = Math.floor(tick / ticksPerSecond);
|
||||
const remainingTicks = tick - wholeSeconds * ticksPerSecond;
|
||||
@@ -35,6 +164,8 @@ export class GameClock {
|
||||
readonly wallAnchor: Date;
|
||||
readonly turnSeconds: number;
|
||||
readonly ticksPerSecond: number;
|
||||
readonly phase: GameClockPhase;
|
||||
readonly revision: ClockRevision;
|
||||
|
||||
constructor(state: GameClockState) {
|
||||
if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) {
|
||||
@@ -55,6 +186,8 @@ export class GameClock {
|
||||
this.wallAnchor = new Date(state.wallAnchor.getTime());
|
||||
this.turnSeconds = state.turnSeconds;
|
||||
this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds;
|
||||
this.phase = state.phase ?? inferClockPhase(state.mode);
|
||||
this.revision = asClockRevision(state.revision ?? 1);
|
||||
}
|
||||
|
||||
static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date {
|
||||
@@ -76,14 +209,19 @@ export class GameClock {
|
||||
}
|
||||
|
||||
nowTick(wallNow: Date): number {
|
||||
if (this.mode === 'manual') {
|
||||
if (this.mode === 'manual' || this.phase === 'MANUAL' || this.phase === 'COMPLETED') {
|
||||
return this.tick;
|
||||
}
|
||||
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
|
||||
// A future realtime anchor represents the formal opening at anchor tick.
|
||||
// Before that instant Ref exposes the elapsed offset as a negative tick,
|
||||
// which lets PREOPEN-only actions keep their logical cooldowns moving.
|
||||
return this.addTicks(this.tick, elapsedTicks);
|
||||
const projectedTick = this.addTicks(this.tick, elapsedTicks);
|
||||
// PREOPEN is the only phase where the opening anchor may project a
|
||||
// signed negative coordinate. Once a realtime game is running, an NTP
|
||||
// rewind must never make the observed coordinate decrease below the
|
||||
// last durable clock snapshot.
|
||||
return this.phase === 'PREOPEN' ? projectedTick : Math.max(this.tick, projectedTick);
|
||||
}
|
||||
|
||||
now(wallNow: Date): Date {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GAME_TICKS_PER_TURN, GameClock, MAX_SAFE_GAME_TICK } from '../src/time/GameClock.js';
|
||||
import {
|
||||
GAME_TICKS_PER_TURN,
|
||||
GameClock,
|
||||
MAX_SAFE_GAME_TICK,
|
||||
asGameTick,
|
||||
asObservedGameInstant,
|
||||
buildExactClockAlignmentPlan,
|
||||
createDeadline,
|
||||
scheduleNotBefore,
|
||||
} from '../src/time/GameClock.js';
|
||||
|
||||
describe('GameClock', () => {
|
||||
const baseTime = new Date('2042-01-01T00:00:00.000Z');
|
||||
@@ -42,12 +51,68 @@ describe('GameClock', () => {
|
||||
mode: 'realtime',
|
||||
wallAnchor: new Date('2026-01-01T01:00:00.000Z'),
|
||||
turnSeconds: 3_600,
|
||||
phase: 'PREOPEN',
|
||||
});
|
||||
|
||||
expect(clock.nowTick(new Date('2026-01-01T00:30:00.000Z'))).toBe(-GAME_TICKS_PER_TURN / 2);
|
||||
expect(clock.nowTick(new Date('2026-01-01T01:00:00.000Z'))).toBe(0);
|
||||
});
|
||||
|
||||
it('does not rewind a RUNNING realtime tick when wall time moves backward', () => {
|
||||
const clock = new GameClock({
|
||||
baseTime,
|
||||
tick: GAME_TICKS_PER_TURN,
|
||||
mode: 'realtime',
|
||||
wallAnchor: new Date('2026-01-01T01:00:00.000Z'),
|
||||
turnSeconds: 3_600,
|
||||
phase: 'RUNNING',
|
||||
revision: 7,
|
||||
});
|
||||
|
||||
expect(clock.nowTick(new Date('2026-01-01T00:59:55.000Z'))).toBe(GAME_TICKS_PER_TURN);
|
||||
expect(clock.revision).toBe(7);
|
||||
});
|
||||
|
||||
it('floors PREOPEN executable schedules at opening tick zero', () => {
|
||||
const observed = asObservedGameInstant(-GAME_TICKS_PER_TURN / 2);
|
||||
|
||||
expect(scheduleNotBefore(observed, 'PREOPEN')).toBe(0);
|
||||
expect(createDeadline(observed, asGameTick(GAME_TICKS_PER_TURN / 4), 'PREOPEN')).toBe(0);
|
||||
expect(scheduleNotBefore(observed, 'RUNNING')).toBe(observed);
|
||||
});
|
||||
|
||||
it('preserves a 65 minute 17.250 second sub-turn suspension remainder exactly', () => {
|
||||
const plan = buildExactClockAlignmentPlan({
|
||||
sourceRevision: 11,
|
||||
cutTick: 123_456,
|
||||
cutWall: new Date('2026-01-01T00:00:00.000Z'),
|
||||
resumeWall: new Date('2026-01-01T01:05:17.250Z'),
|
||||
ticksPerSecond: 10_000,
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
sourceRevision: 11,
|
||||
targetRevision: 12,
|
||||
gapTicks: 39_172_500,
|
||||
shiftTicks: 39_172_500,
|
||||
alignedTick: 39_295_956,
|
||||
});
|
||||
expect(plan.shiftTicks % GAME_TICKS_PER_TURN).toBe(3_172_500);
|
||||
});
|
||||
|
||||
it('aligns a 24 hour exact maintenance without catch-up', () => {
|
||||
const plan = buildExactClockAlignmentPlan({
|
||||
sourceRevision: 1,
|
||||
cutTick: 2 * GAME_TICKS_PER_TURN,
|
||||
cutWall: new Date('2026-01-01T00:00:00.000Z'),
|
||||
resumeWall: new Date('2026-01-02T00:00:00.000Z'),
|
||||
ticksPerSecond: 10_000,
|
||||
});
|
||||
|
||||
expect(plan.gapTicks).toBe(24 * GAME_TICKS_PER_TURN);
|
||||
expect(plan.alignedTick).toBe(26 * GAME_TICKS_PER_TURN);
|
||||
});
|
||||
|
||||
it('projects near the safe tick boundary without unsafe intermediate multiplication', () => {
|
||||
const clock = new GameClock({
|
||||
baseTime: new Date(0),
|
||||
|
||||
@@ -63,21 +63,23 @@ enum InputEventTarget {
|
||||
}
|
||||
|
||||
model InputEvent {
|
||||
sequence BigInt @id @default(autoincrement())
|
||||
requestId String @unique @map("request_id")
|
||||
target InputEventTarget
|
||||
eventType String @map("event_type")
|
||||
payload Json @default(dbgenerated("'{}'::jsonb"))
|
||||
actorUserId String? @map("actor_user_id")
|
||||
status InputEventStatus @default(PENDING)
|
||||
result Json?
|
||||
error String?
|
||||
attempts Int @default(0)
|
||||
lockedBy String? @map("locked_by")
|
||||
leaseUntil DateTime? @map("lease_until")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
processingAt DateTime? @map("processing_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
sequence BigInt @id @default(autoincrement())
|
||||
requestId String @unique @map("request_id")
|
||||
target InputEventTarget
|
||||
eventType String @map("event_type")
|
||||
payload Json @default(dbgenerated("'{}'::jsonb"))
|
||||
actorUserId String? @map("actor_user_id")
|
||||
acceptedGameTick BigInt? @map("accepted_game_tick")
|
||||
acceptedClockRevision BigInt? @map("accepted_clock_revision")
|
||||
status InputEventStatus @default(PENDING)
|
||||
result Json?
|
||||
error String?
|
||||
attempts Int @default(0)
|
||||
lockedBy String? @map("locked_by")
|
||||
leaseUntil DateTime? @map("lease_until")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
processingAt DateTime? @map("processing_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
|
||||
@@index([target, status, sequence])
|
||||
@@map("input_event")
|
||||
@@ -145,25 +147,101 @@ model TurnDaemonLease {
|
||||
}
|
||||
|
||||
model WorldState {
|
||||
id Int @id @default(autoincrement())
|
||||
scenarioCode String @map("scenario_code")
|
||||
currentYear Int @map("current_year")
|
||||
currentMonth Int @map("current_month")
|
||||
tickSeconds Int @map("tick_seconds")
|
||||
clockBaseTime DateTime? @map("clock_base_time")
|
||||
clockTick BigInt? @map("clock_tick")
|
||||
clockMode String @default("realtime") @map("clock_mode")
|
||||
clockWallAnchor DateTime? @map("clock_wall_anchor")
|
||||
lastTurnTick BigInt? @map("last_turn_tick")
|
||||
config Json @default(dbgenerated("'{}'::jsonb"))
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id Int @id @default(autoincrement())
|
||||
scenarioCode String @map("scenario_code")
|
||||
currentYear Int @map("current_year")
|
||||
currentMonth Int @map("current_month")
|
||||
tickSeconds Int @map("tick_seconds")
|
||||
clockBaseTime DateTime? @map("clock_base_time")
|
||||
clockTick BigInt? @map("clock_tick")
|
||||
clockMode String @default("realtime") @map("clock_mode")
|
||||
clockWallAnchor DateTime? @map("clock_wall_anchor")
|
||||
lastTurnTick BigInt? @map("last_turn_tick")
|
||||
clockPhase String @default("RUNNING") @map("clock_phase")
|
||||
clockRevision BigInt @default(1) @map("clock_revision")
|
||||
deadlineGeneration BigInt @default(1) @map("deadline_generation")
|
||||
config Json @default(dbgenerated("'{}'::jsonb"))
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
trafficPeriods TrafficPeriod[]
|
||||
trafficPeriods TrafficPeriod[]
|
||||
clockSuspensions ClockSuspension[]
|
||||
clockProjectionOutbox ClockProjectionOutbox[]
|
||||
|
||||
@@map("world_state")
|
||||
}
|
||||
|
||||
model ClockSuspension {
|
||||
id String @id @db.VarChar(64)
|
||||
worldStateId Int @map("world_state_id")
|
||||
source String
|
||||
policy String
|
||||
status String @default("SUSPENDED")
|
||||
sourceRevision BigInt @map("source_revision")
|
||||
targetRevision BigInt @map("target_revision")
|
||||
cutTick BigInt @map("cut_tick")
|
||||
cutWallAt DateTime @map("cut_wall_at") @db.Timestamp(3)
|
||||
resumeWallAt DateTime? @map("resume_wall_at") @db.Timestamp(3)
|
||||
rateTicksPerSecond Int @map("rate_ticks_per_second")
|
||||
catchUpTicks BigInt @default(0) @map("catch_up_ticks")
|
||||
gapTicks BigInt? @map("gap_ticks")
|
||||
shiftTicks BigInt? @map("shift_ticks")
|
||||
alignedTick BigInt? @map("aligned_tick")
|
||||
participantChecksumBefore String? @map("participant_checksum_before")
|
||||
participantChecksumAfter String? @map("participant_checksum_after")
|
||||
detail Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
|
||||
worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade)
|
||||
participants ClockReconciliationParticipant[]
|
||||
projectionOutbox ClockProjectionOutbox[]
|
||||
|
||||
@@unique([worldStateId, targetRevision])
|
||||
@@index([status, createdAt])
|
||||
@@map("clock_suspension")
|
||||
}
|
||||
|
||||
model ClockReconciliationParticipant {
|
||||
suspensionId String @map("suspension_id") @db.VarChar(64)
|
||||
participantKey String @map("participant_key") @db.VarChar(96)
|
||||
policy String
|
||||
beforeChecksum String @map("before_checksum") @db.VarChar(128)
|
||||
afterChecksum String @map("after_checksum") @db.VarChar(128)
|
||||
affectedCount Int @default(0) @map("affected_count")
|
||||
detail Json @default(dbgenerated("'{}'::jsonb"))
|
||||
|
||||
suspension ClockSuspension @relation(fields: [suspensionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([suspensionId, participantKey])
|
||||
@@map("clock_reconciliation_participant")
|
||||
}
|
||||
|
||||
model ClockProjectionOutbox {
|
||||
id BigInt @id @default(autoincrement())
|
||||
worldStateId Int @map("world_state_id")
|
||||
suspensionId String? @map("suspension_id") @db.VarChar(64)
|
||||
targetRevision BigInt @map("target_revision")
|
||||
status String @default("PENDING")
|
||||
payload Json @default(dbgenerated("'{}'::jsonb"))
|
||||
checksum String @db.VarChar(128)
|
||||
attempts Int @default(0)
|
||||
availableAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("available_at") @db.Timestamp(3)
|
||||
lockedAt DateTime? @map("locked_at") @db.Timestamp(3)
|
||||
lockedBy String? @map("locked_by") @db.VarChar(128)
|
||||
appliedAt DateTime? @map("applied_at") @db.Timestamp(3)
|
||||
lastError String? @map("last_error")
|
||||
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
|
||||
worldState WorldState @relation(fields: [worldStateId], references: [id], onDelete: Cascade)
|
||||
suspension ClockSuspension? @relation(fields: [suspensionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([worldStateId, targetRevision])
|
||||
@@index([status, availableAt, id])
|
||||
@@map("clock_projection_outbox")
|
||||
}
|
||||
|
||||
model Nation {
|
||||
id Int @id
|
||||
name String
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
ALTER TABLE world_state
|
||||
ADD COLUMN clock_phase TEXT NOT NULL DEFAULT 'RUNNING',
|
||||
ADD COLUMN clock_revision BIGINT NOT NULL DEFAULT 1,
|
||||
ADD COLUMN deadline_generation BIGINT NOT NULL DEFAULT 1;
|
||||
|
||||
UPDATE world_state
|
||||
SET clock_phase = CASE
|
||||
WHEN clock_mode = 'manual' THEN 'MANUAL'
|
||||
WHEN clock_wall_anchor IS NOT NULL AND clock_wall_anchor > CURRENT_TIMESTAMP THEN 'PREOPEN'
|
||||
ELSE 'RUNNING'
|
||||
END;
|
||||
|
||||
ALTER TABLE input_event
|
||||
ADD COLUMN accepted_game_tick BIGINT,
|
||||
ADD COLUMN accepted_clock_revision BIGINT;
|
||||
|
||||
CREATE TABLE clock_suspension (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
world_state_id INTEGER NOT NULL REFERENCES world_state(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL,
|
||||
policy TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'SUSPENDED',
|
||||
source_revision BIGINT NOT NULL,
|
||||
target_revision BIGINT NOT NULL,
|
||||
cut_tick BIGINT NOT NULL,
|
||||
cut_wall_at TIMESTAMP(3) NOT NULL,
|
||||
resume_wall_at TIMESTAMP(3),
|
||||
rate_ticks_per_second INTEGER NOT NULL,
|
||||
catch_up_ticks BIGINT NOT NULL DEFAULT 0,
|
||||
gap_ticks BIGINT,
|
||||
shift_ticks BIGINT,
|
||||
aligned_tick BIGINT,
|
||||
participant_checksum_before VARCHAR(128),
|
||||
participant_checksum_after VARCHAR(128),
|
||||
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT clock_suspension_world_revision_key UNIQUE (world_state_id, target_revision),
|
||||
CONSTRAINT clock_suspension_revision_step CHECK (target_revision = source_revision + 1),
|
||||
CONSTRAINT clock_suspension_nonnegative_catchup CHECK (catch_up_ticks >= 0),
|
||||
CONSTRAINT clock_suspension_positive_rate CHECK (rate_ticks_per_second > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX clock_suspension_status_created_at_idx ON clock_suspension(status, created_at);
|
||||
|
||||
CREATE TABLE clock_reconciliation_participant (
|
||||
suspension_id VARCHAR(64) NOT NULL REFERENCES clock_suspension(id) ON DELETE CASCADE,
|
||||
participant_key VARCHAR(96) NOT NULL,
|
||||
policy TEXT NOT NULL,
|
||||
before_checksum VARCHAR(128) NOT NULL,
|
||||
after_checksum VARCHAR(128) NOT NULL,
|
||||
affected_count INTEGER NOT NULL DEFAULT 0,
|
||||
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (suspension_id, participant_key),
|
||||
CONSTRAINT clock_reconciliation_participant_policy_check CHECK (policy IN ('SHIFT', 'KEEP', 'REBUILD', 'FORBID')),
|
||||
CONSTRAINT clock_reconciliation_participant_count_check CHECK (affected_count >= 0)
|
||||
);
|
||||
|
||||
CREATE TABLE clock_projection_outbox (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
world_state_id INTEGER NOT NULL REFERENCES world_state(id) ON DELETE CASCADE,
|
||||
suspension_id VARCHAR(64) REFERENCES clock_suspension(id) ON DELETE CASCADE,
|
||||
target_revision BIGINT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
checksum VARCHAR(128) NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
available_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
locked_at TIMESTAMP(3),
|
||||
locked_by VARCHAR(128),
|
||||
applied_at TIMESTAMP(3),
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT clock_projection_outbox_world_revision_key UNIQUE (world_state_id, target_revision),
|
||||
CONSTRAINT clock_projection_outbox_status_check CHECK (status IN ('PENDING', 'APPLYING', 'APPLIED', 'FAILED')),
|
||||
CONSTRAINT clock_projection_outbox_attempts_check CHECK (attempts >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX clock_projection_outbox_status_available_at_id_idx
|
||||
ON clock_projection_outbox(status, available_at, id);
|
||||
@@ -8,6 +8,8 @@ interface TryLockRow {
|
||||
|
||||
/** Serializes score/traffic writers whose table lock order otherwise differs by entry point. */
|
||||
export const GENERAL_ACCESS_PERSISTENCE_LOCK = 'general-access:persistence';
|
||||
/** Serializes phase/revision changes with every gameplay flush in one game schema. */
|
||||
export const CLOCK_OPERATION_PERSISTENCE_LOCK = 'game-clock:operation';
|
||||
|
||||
const lockKeySql = (logicalKey: string): GamePrisma.Sql =>
|
||||
GamePrisma.sql`hashtextextended(current_schema() || chr(31) || ${logicalKey}, 0)`;
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface TurnEngineWorldStateRow {
|
||||
clockMode: string;
|
||||
clockWallAnchor: Date | null;
|
||||
lastTurnTick: bigint | null;
|
||||
clockPhase: string;
|
||||
clockRevision: bigint;
|
||||
deadlineGeneration: bigint;
|
||||
config: JsonValue;
|
||||
meta: JsonValue;
|
||||
updatedAt?: Date | null;
|
||||
@@ -181,6 +184,9 @@ export interface TurnEngineWorldStateUpdateInput {
|
||||
clockMode: string;
|
||||
clockWallAnchor: Date;
|
||||
lastTurnTick: bigint;
|
||||
clockPhase: string;
|
||||
clockRevision: bigint;
|
||||
deadlineGeneration: bigint;
|
||||
config: InputJsonValue;
|
||||
meta: InputJsonValue;
|
||||
}
|
||||
@@ -195,6 +201,9 @@ export interface TurnEngineWorldStateCreateInput {
|
||||
clockMode: string;
|
||||
clockWallAnchor: Date;
|
||||
lastTurnTick: bigint;
|
||||
clockPhase: string;
|
||||
clockRevision: bigint;
|
||||
deadlineGeneration: bigint;
|
||||
config: InputJsonValue;
|
||||
meta: InputJsonValue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user