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 interface GameClockState { baseTime: Date; tick: number; mode: GameClockMode; wallAnchor: Date; turnSeconds: number; } const requireSafeTick = (tick: number): number => { if (!Number.isSafeInteger(tick)) { throw new Error(`Game tick must be a safe integer: ${tick}`); } return tick; }; const tickOffsetMilliseconds = (tick: number, ticksPerSecond: number): number => { const wholeSeconds = Math.floor(tick / ticksPerSecond); const remainingTicks = tick - wholeSeconds * ticksPerSecond; const milliseconds = wholeSeconds * 1_000 + Math.floor((remainingTicks * 1_000) / ticksPerSecond); if (!Number.isSafeInteger(milliseconds)) { throw new Error(`Game tick offset is outside the safe millisecond range: ${milliseconds}`); } return milliseconds; }; export class GameClock { readonly baseTime: Date; readonly tick: number; readonly mode: GameClockMode; readonly wallAnchor: Date; readonly turnSeconds: number; readonly ticksPerSecond: number; constructor(state: GameClockState) { if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) { throw new Error('turnSeconds must be a positive integer.'); } if (GAME_TICKS_PER_TURN % state.turnSeconds !== 0) { throw new Error(`turnSeconds ${state.turnSeconds} cannot be represented as integer game ticks.`); } if (state.mode !== 'realtime' && state.mode !== 'manual') { throw new Error(`Unknown game clock mode: ${String(state.mode)}`); } if (Number.isNaN(state.baseTime.getTime()) || Number.isNaN(state.wallAnchor.getTime())) { throw new Error('Game clock anchors must be valid dates.'); } this.baseTime = new Date(state.baseTime.getTime()); this.tick = requireSafeTick(state.tick); this.mode = state.mode; this.wallAnchor = new Date(state.wallAnchor.getTime()); this.turnSeconds = state.turnSeconds; this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds; } static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date { requireSafeTick(tick); if (!Number.isInteger(turnSeconds) || turnSeconds <= 0 || GAME_TICKS_PER_TURN % turnSeconds !== 0) { throw new Error(`turnSeconds ${turnSeconds} cannot be represented as integer game ticks.`); } const ticksPerSecond = GAME_TICKS_PER_TURN / turnSeconds; const offsetMs = tickOffsetMilliseconds(tick, ticksPerSecond); const baseMs = projectedTime.getTime() - offsetMs; if (!Number.isSafeInteger(baseMs)) { throw new Error(`Projected game clock base is outside the safe Date range: ${baseMs}`); } const baseTime = new Date(baseMs); if (Number.isNaN(baseTime.getTime())) { throw new Error(`Projected game clock base is invalid: ${baseMs}`); } return baseTime; } nowTick(wallNow: Date): number { if (this.mode === 'manual') { 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); } now(wallNow: Date): Date { return this.tickToDate(this.nowTick(wallNow)); } dateToTick(date: Date): number { return requireSafeTick(this.ticksBetween(this.baseTime, date)); } tickToDate(tick: number): Date { requireSafeTick(tick); const milliseconds = tickOffsetMilliseconds(tick, this.ticksPerSecond); const projected = this.baseTime.getTime() + milliseconds; if (!Number.isSafeInteger(projected)) { throw new Error(`Projected game time is outside the safe Date range: ${projected}`); } const result = new Date(projected); if (Number.isNaN(result.getTime())) { throw new Error(`Projected game time is invalid: ${projected}`); } return result; } addTicks(tick: number, delta: number): number { requireSafeTick(delta); return requireSafeTick(tick + delta); } private ticksBetween(from: Date, to: Date): number { const milliseconds = to.getTime() - from.getTime(); if (!Number.isSafeInteger(milliseconds)) { throw new Error('Game clock date difference is outside the safe integer range.'); } const wholeSeconds = Math.trunc(milliseconds / 1_000); const remainingMilliseconds = milliseconds - wholeSeconds * 1_000; return requireSafeTick( wholeSeconds * this.ticksPerSecond + Math.trunc((remainingMilliseconds * this.ticksPerSecond) / 1_000) ); } }