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),
|
||||
|
||||
Reference in New Issue
Block a user