feat: add logical game clock

This commit is contained in:
2026-08-04 02:51:27 +00:00
parent 87965a39d6
commit a26031dc3f
51 changed files with 1605 additions and 398 deletions
+1
View File
@@ -1,5 +1,6 @@
export * from './rng.js';
export * from './time/Clock.js';
export * from './time/GameClock.js';
export * from './util/BytesLike.js';
export * from './util/convertBytesLikeToArrayBuffer.js';
export * from './util/convertBytesLikeToUint8Array.js';
+125
View File
@@ -0,0 +1,125 @@
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.trunc(tick / ticksPerSecond);
const remainingTicks = tick - wholeSeconds * ticksPerSecond;
const milliseconds = wholeSeconds * 1_000 + Math.round((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;
}
// A wall-clock correction must never rewind already-observed gameplay.
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
return elapsedTicks <= 0 ? this.tick : 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.round((remainingMilliseconds * this.ticksPerSecond) / 1_000)
);
}
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { GAME_TICKS_PER_TURN, GameClock, MAX_SAFE_GAME_TICK } from '../src/time/GameClock.js';
describe('GameClock', () => {
const baseTime = new Date('2042-01-01T00:00:00.000Z');
it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => {
const clock = new GameClock({
baseTime,
tick: GAME_TICKS_PER_TURN,
mode: 'manual',
wallAnchor: new Date('2026-01-01T00:00:00.000Z'),
turnSeconds: 3_600,
});
expect(clock.ticksPerSecond).toBe(10_000);
expect(clock.nowTick(new Date('2126-01-01T00:00:00.000Z'))).toBe(GAME_TICKS_PER_TURN);
expect(clock.now(new Date('2126-01-01T00:00:00.000Z')).toISOString()).toBe('2042-01-01T01:00:00.000Z');
});
it('accepts a large forward wall jump without losing tick precision', () => {
const wallAnchor = new Date('2026-01-01T00:00:00.000Z');
const clock = new GameClock({
baseTime,
tick: 0,
mode: 'realtime',
wallAnchor,
turnSeconds: 7_200,
});
const jumped = new Date('2126-01-01T00:00:00.000Z');
const tick = clock.nowTick(jumped);
expect(Number.isSafeInteger(tick)).toBe(true);
expect(clock.tickToDate(tick).getTime() - baseTime.getTime()).toBe(jumped.getTime() - wallAnchor.getTime());
});
it('does not rewind game time after a backward wall-clock correction', () => {
const clock = new GameClock({
baseTime,
tick: GAME_TICKS_PER_TURN * 10,
mode: 'realtime',
wallAnchor: new Date('2026-01-02T00:00:00.000Z'),
turnSeconds: 3_600,
});
expect(clock.nowTick(new Date('2025-01-01T00:00:00.000Z'))).toBe(GAME_TICKS_PER_TURN * 10);
});
it('projects near the safe tick boundary without unsafe intermediate multiplication', () => {
const clock = new GameClock({
baseTime: new Date(0),
tick: 0,
mode: 'manual',
wallAnchor: new Date(0),
turnSeconds: 7_200,
});
const tick = MAX_SAFE_GAME_TICK - (MAX_SAFE_GAME_TICK % clock.ticksPerSecond);
const projected = clock.tickToDate(tick);
expect(Number.isNaN(projected.getTime())).toBe(false);
expect(clock.dateToTick(projected)).toBe(tick);
});
});
+45 -29
View File
@@ -94,14 +94,19 @@ 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")
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")
config Json @default(dbgenerated("'{}'::jsonb"))
meta Json @default(dbgenerated("'{}'::jsonb"))
updatedAt DateTime @updatedAt @map("updated_at")
trafficPeriods TrafficPeriod[]
@@ -183,7 +188,9 @@ model General {
horseCode String @default("None") @map("horse_code")
itemCode String @default("None") @map("item_code")
turnTime DateTime @map("turn_time")
turnTick BigInt? @map("turn_tick")
recentWarTime DateTime? @map("recent_war_time")
recentWarTick BigInt? @map("recent_war_tick")
age Int @default(20)
startAge Int @default(20) @map("start_age")
personalCode String @default("None") @map("personal_code")
@@ -199,12 +206,13 @@ model General {
}
model SelectPoolEntry {
id Int @id @default(autoincrement())
uniqueName String @unique @map("unique_name") @db.VarChar(20)
ownerUserId String? @map("owner_user_id")
generalId Int? @unique @map("general_id")
reservedUntil DateTime? @map("reserved_until")
info Json
id Int @id @default(autoincrement())
uniqueName String @unique @map("unique_name") @db.VarChar(20)
ownerUserId String? @map("owner_user_id")
generalId Int? @unique @map("general_id")
reservedUntil DateTime? @map("reserved_until")
reservedUntilTick BigInt? @map("reserved_until_tick")
info Json
@@index([ownerUserId])
@@index([reservedUntil, generalId])
@@ -212,13 +220,15 @@ model SelectPoolEntry {
}
model NpcSelectionToken {
ownerUserId String @id @map("owner_user_id")
validUntil DateTime @map("valid_until")
pickMoreFrom DateTime @map("pick_more_from")
pickResult Json @map("pick_result")
nonce Int
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
ownerUserId String @id @map("owner_user_id")
validUntil DateTime @map("valid_until")
validUntilTick BigInt? @map("valid_until_tick")
pickMoreFrom DateTime @map("pick_more_from")
pickMoreFromTick BigInt? @map("pick_more_from_tick")
pickResult Json @map("pick_result")
nonce Int
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([validUntil])
@@map("select_npc_token")
@@ -280,14 +290,16 @@ model MessageReadState {
}
model Message {
id Int @id @default(autoincrement())
mailbox Int
type String
src Int
dest Int
time DateTime
validUntil DateTime @map("valid_until")
message Json
id Int @id @default(autoincrement())
mailbox Int
type String
src Int
dest Int
time DateTime
timeTick BigInt? @map("time_tick")
validUntil DateTime @map("valid_until")
validUntilTick BigInt? @map("valid_until_tick")
message Json
@@map("message")
}
@@ -674,6 +686,8 @@ model Auction {
detail Json @default(dbgenerated("'{}'::jsonb"))
status AuctionStatus @default(OPEN)
closeAt DateTime @map("close_at")
openTick BigInt? @map("open_tick")
closeTick BigInt? @map("close_tick")
latestEventId String @default("") @map("latest_event_id")
latestEventAt DateTime @default(now()) @map("latest_event_at")
finalizingAt DateTime? @map("finalizing_at")
@@ -770,7 +784,9 @@ model VotePoll {
openerGeneralId Int @map("opener_general_id")
openerName String @map("opener_name")
startAt DateTime @default(now()) @map("start_at")
startTick BigInt? @map("start_tick")
endAt DateTime? @map("end_at")
endTick BigInt? @map("end_tick")
closedAt DateTime? @map("closed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -0,0 +1,60 @@
ALTER TABLE world_state
ADD COLUMN clock_base_time TIMESTAMP(3),
ADD COLUMN clock_tick BIGINT,
ADD COLUMN clock_mode TEXT NOT NULL DEFAULT 'realtime',
ADD COLUMN clock_wall_anchor TIMESTAMP(3),
ADD COLUMN last_turn_tick BIGINT;
UPDATE world_state
SET clock_base_time = COALESCE(NULLIF(meta->>'lastTurnTime', '')::timestamp, updated_at),
clock_wall_anchor = CURRENT_TIMESTAMP,
clock_tick = ROUND(
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - COALESCE(NULLIF(meta->>'lastTurnTime', '')::timestamp, updated_at)))
* (36000000::numeric / tick_seconds)
)::bigint,
last_turn_tick = 0;
ALTER TABLE general
ADD COLUMN turn_tick BIGINT,
ADD COLUMN recent_war_tick BIGINT;
UPDATE general
SET turn_tick = ROUND(
EXTRACT(EPOCH FROM (general.turn_time - world_state.clock_base_time))
* (36000000::numeric / world_state.tick_seconds)
)::bigint,
recent_war_tick = CASE WHEN general.recent_war_time IS NULL THEN NULL ELSE ROUND(
EXTRACT(EPOCH FROM (general.recent_war_time - world_state.clock_base_time))
* (36000000::numeric / world_state.tick_seconds)
)::bigint END
FROM world_state;
ALTER TABLE select_pool ADD COLUMN reserved_until_tick BIGINT;
ALTER TABLE select_npc_token ADD COLUMN valid_until_tick BIGINT, ADD COLUMN pick_more_from_tick BIGINT;
ALTER TABLE message ADD COLUMN time_tick BIGINT, ADD COLUMN valid_until_tick BIGINT;
ALTER TABLE auction ADD COLUMN open_tick BIGINT, ADD COLUMN close_tick BIGINT;
ALTER TABLE vote_poll ADD COLUMN start_tick BIGINT, ADD COLUMN end_tick BIGINT;
UPDATE select_pool SET reserved_until_tick = CASE WHEN reserved_until IS NULL THEN NULL ELSE ROUND(EXTRACT(EPOCH FROM (reserved_until - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint END FROM world_state;
UPDATE select_npc_token SET valid_until_tick = ROUND(EXTRACT(EPOCH FROM (valid_until - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, pick_more_from_tick = ROUND(EXTRACT(EPOCH FROM (pick_more_from - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint FROM world_state;
UPDATE message
SET time_tick = ROUND(
EXTRACT(EPOCH FROM (time - world_state.clock_base_time))
* (36000000::numeric / world_state.tick_seconds)
)::bigint,
valid_until_tick = CASE
-- Legacy permanent messages use the year-9999 sentinel. Keep their
-- DateTime fallback instead of persisting a tick JavaScript cannot
-- represent safely.
WHEN valid_until >= TIMESTAMP '9999-01-01 00:00:00' THEN NULL
ELSE ROUND(
EXTRACT(EPOCH FROM (valid_until - world_state.clock_base_time))
* (36000000::numeric / world_state.tick_seconds)
)::bigint
END
FROM world_state;
UPDATE auction SET open_tick = ROUND(EXTRACT(EPOCH FROM (created_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, close_tick = ROUND(EXTRACT(EPOCH FROM (close_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint FROM world_state;
UPDATE vote_poll SET start_tick = ROUND(EXTRACT(EPOCH FROM (start_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint, end_tick = CASE WHEN end_at IS NULL THEN NULL ELSE ROUND(EXTRACT(EPOCH FROM (end_at - world_state.clock_base_time)) * (36000000::numeric / world_state.tick_seconds))::bigint END FROM world_state;
CREATE INDEX general_turn_tick_id_idx ON general(turn_tick, id);
CREATE INDEX auction_status_close_tick_idx ON auction(status, close_tick);
+21
View File
@@ -9,6 +9,11 @@ export interface TurnEngineWorldStateRow {
currentYear: number;
currentMonth: number;
tickSeconds: number;
clockBaseTime: Date | null;
clockTick: bigint | null;
clockMode: string;
clockWallAnchor: Date | null;
lastTurnTick: bigint | null;
config: JsonValue;
meta: JsonValue;
updatedAt?: Date | null;
@@ -53,7 +58,9 @@ export interface TurnEngineGeneralRow {
meta: JsonValue;
penalty: JsonValue;
turnTime: Date;
turnTick: bigint | null;
recentWarTime: Date | null;
recentWarTick: bigint | null;
}
export interface TurnEngineRankDataRow {
@@ -157,6 +164,11 @@ export interface TurnEngineWorldStateUpdateInput {
currentYear: number;
currentMonth: number;
tickSeconds: number;
clockBaseTime: Date;
clockTick: bigint;
clockMode: string;
clockWallAnchor: Date;
lastTurnTick: bigint;
meta: InputJsonValue;
}
@@ -165,6 +177,11 @@ export interface TurnEngineWorldStateCreateInput {
currentYear: number;
currentMonth: number;
tickSeconds: number;
clockBaseTime: Date;
clockTick: bigint;
clockMode: string;
clockWallAnchor: Date;
lastTurnTick: bigint;
config: InputJsonValue;
meta: InputJsonValue;
}
@@ -207,7 +224,9 @@ export interface TurnEngineGeneralUpdateInput {
penalty: InputJsonValue;
meta: InputJsonValue;
turnTime: Date;
turnTick: bigint;
recentWarTime: Date | null;
recentWarTick: bigint | null;
}
export interface TurnEngineGeneralCreateManyInput {
@@ -241,7 +260,9 @@ export interface TurnEngineGeneralCreateManyInput {
special2Code: string;
meta: InputJsonValue;
turnTime: Date;
turnTick: bigint;
recentWarTime?: Date | null;
recentWarTick?: bigint | null;
affinity?: number | null;
bornYear?: number;
deadYear?: number;