feat: 게임 시계 reconciliation 권위 기반 추가

This commit is contained in:
2026-09-03 08:44:43 +00:00
parent b91dcbcaaa
commit ae7d55ef47
29 changed files with 1265 additions and 81 deletions
+140 -2
View File
@@ -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 {
+66 -1
View File
@@ -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),