refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export * from './rng.js';
|
||||
export * from './time/Clock.js';
|
||||
export * from './time/GameClock.js';
|
||||
export * from './time/TurnRecovery.js';
|
||||
export * from './time/ServerDateTime.js';
|
||||
export * from './util/BytesLike.js';
|
||||
export * from './util/convertBytesLikeToArrayBuffer.js';
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
export const GAME_TICKS_PER_TURN = 36_000_000;
|
||||
import {
|
||||
observeTurnRecovery,
|
||||
nextTurnBoundary,
|
||||
planTurnRecovery,
|
||||
projectRecoveryDeadline,
|
||||
validateTurnRecovery,
|
||||
type TurnRecoveryWindow,
|
||||
} from './TurnRecovery.js';
|
||||
import { GAME_TICKS_PER_TURN, asGameTick, type GameTick } from './gameTimeUnits.js';
|
||||
export { GAME_TICKS_PER_TURN, asGameTick, type GameTick } from './gameTimeUnits.js';
|
||||
|
||||
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' | 'PRESERVE_SCHEDULE';
|
||||
export type ClockAlignmentPolicy =
|
||||
'EXACT' | 'LEGACY_COMPLETE_TURNS' | 'CATCH_UP' | 'PRESERVE_SCHEDULE' | 'RECOVER_TURNS' | 'TURN_BOUNDARY';
|
||||
|
||||
declare const gameTickBrand: unique symbol;
|
||||
declare const observedGameInstantBrand: unique symbol;
|
||||
declare const scheduleInstantBrand: unique symbol;
|
||||
declare const clockRevisionBrand: unique symbol;
|
||||
@@ -13,7 +23,6 @@ declare const deadlineGenerationBrand: unique symbol;
|
||||
declare const wallInstantBrand: unique symbol;
|
||||
declare const monotonicDurationBrand: 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' };
|
||||
@@ -31,6 +40,8 @@ export interface ClockAlignmentPlan {
|
||||
catchUpTicks: GameTick;
|
||||
shiftTicks: GameTick;
|
||||
alignedTick: GameTick;
|
||||
recovery?: TurnRecoveryWindow | null;
|
||||
resumeAnchor?: Date;
|
||||
}
|
||||
|
||||
export interface GameClockState {
|
||||
@@ -41,6 +52,7 @@ export interface GameClockState {
|
||||
turnSeconds: number;
|
||||
phase?: GameClockPhase;
|
||||
revision?: number;
|
||||
recovery?: TurnRecoveryWindow | null;
|
||||
}
|
||||
|
||||
const requireSafeTick = (tick: number): number => {
|
||||
@@ -50,8 +62,6 @@ 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;
|
||||
|
||||
@@ -108,6 +118,8 @@ const CLOCK_ALIGNMENT_POLICIES: readonly ClockAlignmentPolicy[] = [
|
||||
'LEGACY_COMPLETE_TURNS',
|
||||
'CATCH_UP',
|
||||
'PRESERVE_SCHEDULE',
|
||||
'RECOVER_TURNS',
|
||||
'TURN_BOUNDARY',
|
||||
];
|
||||
|
||||
export const parseClockAlignmentPolicy = (value: string): ClockAlignmentPolicy => {
|
||||
@@ -192,7 +204,56 @@ export const buildClockAlignmentPlan = (input: {
|
||||
resumeWall: Date;
|
||||
ticksPerSecond: number;
|
||||
catchUpTicks?: number;
|
||||
normalTick?: number;
|
||||
}): ClockAlignmentPlan => {
|
||||
if (input.policy === 'TURN_BOUNDARY') {
|
||||
if (input.cutTick % GAME_TICKS_PER_TURN !== 0 || (input.catchUpTicks ?? 0) !== 0) {
|
||||
throw new Error('Planned resume requires a suspended turn boundary and no catch-up.');
|
||||
}
|
||||
const exact = buildAlignmentPlan({ ...input, catchUpTicks: 0 });
|
||||
const normalTick = input.normalTick ?? exact.alignedTick;
|
||||
const alignedTick = nextTurnBoundary(Math.max(input.cutTick, normalTick));
|
||||
return {
|
||||
...exact,
|
||||
alignedTick,
|
||||
shiftTicks: asGameTick(alignedTick - input.cutTick),
|
||||
catchUpTicks: asGameTick(0),
|
||||
resumeAnchor: new Date(
|
||||
input.resumeWall.getTime() + Math.ceil(((alignedTick - normalTick) * 1_000) / input.ticksPerSecond)
|
||||
),
|
||||
recovery: null,
|
||||
};
|
||||
}
|
||||
if (input.policy === 'RECOVER_TURNS') {
|
||||
if ((input.catchUpTicks ?? 0) !== 0)
|
||||
throw new Error('Turn recovery derives its backlog from the saved observation.');
|
||||
const exact = buildAlignmentPlan({ ...input, catchUpTicks: 0 });
|
||||
const recovery = planTurnRecovery({
|
||||
observedTick: input.cutTick,
|
||||
normalTick: input.normalTick ?? exact.alignedTick,
|
||||
wallNow: input.resumeWall,
|
||||
turnSeconds: GAME_TICKS_PER_TURN / input.ticksPerSecond,
|
||||
});
|
||||
const shiftTicks = asGameTick(recovery.skippedTurns * GAME_TICKS_PER_TURN);
|
||||
return {
|
||||
...exact,
|
||||
shiftTicks,
|
||||
catchUpTicks: asGameTick(Math.max(0, (input.normalTick ?? exact.alignedTick) - input.cutTick - shiftTicks)),
|
||||
alignedTick: recovery.initialTick,
|
||||
recovery: recovery.recovery,
|
||||
...(recovery.initialTick > (input.normalTick ?? exact.alignedTick)
|
||||
? {
|
||||
resumeAnchor: new Date(
|
||||
input.resumeWall.getTime() +
|
||||
Math.ceil(
|
||||
((recovery.initialTick - (input.normalTick ?? exact.alignedTick)) * 1_000) /
|
||||
input.ticksPerSecond
|
||||
)
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (input.policy === 'PRESERVE_SCHEDULE') {
|
||||
if ((input.catchUpTicks ?? 0) !== 0) {
|
||||
throw new Error('PRESERVE_SCHEDULE derives catch-up from the complete wall gap.');
|
||||
@@ -239,6 +300,7 @@ export class GameClock {
|
||||
readonly ticksPerSecond: number;
|
||||
readonly phase: GameClockPhase;
|
||||
readonly revision: ClockRevision;
|
||||
readonly recovery: TurnRecoveryWindow | null;
|
||||
|
||||
constructor(state: GameClockState) {
|
||||
if (!Number.isInteger(state.turnSeconds) || state.turnSeconds <= 0) {
|
||||
@@ -261,6 +323,10 @@ export class GameClock {
|
||||
this.ticksPerSecond = GAME_TICKS_PER_TURN / state.turnSeconds;
|
||||
this.phase = state.phase ?? inferClockPhase(state.mode);
|
||||
this.revision = asClockRevision(state.revision ?? 1);
|
||||
this.recovery = state.recovery
|
||||
? { ...state.recovery, startWallAt: new Date(state.recovery.startWallAt) }
|
||||
: null;
|
||||
if (this.recovery) validateTurnRecovery(this.recovery);
|
||||
}
|
||||
|
||||
static baseTimeForProjection(projectedTime: Date, tick: number, turnSeconds: number): Date {
|
||||
@@ -291,6 +357,9 @@ export class GameClock {
|
||||
) {
|
||||
return this.tick;
|
||||
}
|
||||
if (this.recovery && this.phase === 'RUNNING') {
|
||||
return Math.max(this.tick, observeTurnRecovery(this.recovery, wallNow, this.ticksPerSecond));
|
||||
}
|
||||
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,
|
||||
@@ -307,6 +376,30 @@ export class GameClock {
|
||||
return this.tickToDate(this.nowTick(wallNow));
|
||||
}
|
||||
|
||||
/** 가속·대기와 별개로 유저가 익숙한 기존 시간표의 현재 좌표를 구한다. */
|
||||
normalNowTick(wallNow: Date): number {
|
||||
if (this.recovery) {
|
||||
return this.addTicks(
|
||||
(this.recovery.startTick + this.recovery.endTick) / 2,
|
||||
this.ticksBetween(this.recovery.startWallAt, wallNow)
|
||||
);
|
||||
}
|
||||
return this.addTicks(this.tick, this.ticksBetween(this.wallAnchor, wallNow));
|
||||
}
|
||||
|
||||
/** tickToDate는 안정된 게임 좌표이며 이 메서드만 실제 실행 예정 시각을 반환한다. */
|
||||
tickToWallDate(tick: number): Date {
|
||||
return this.recovery
|
||||
? projectRecoveryDeadline(this.recovery, tick, this.ticksPerSecond)
|
||||
: new Date(this.wallAnchor.getTime() + tickOffsetMilliseconds(tick - this.tick, this.ticksPerSecond));
|
||||
}
|
||||
|
||||
executionRate(wallNow: Date): 1 | 2 {
|
||||
if (!this.recovery || this.phase !== 'RUNNING' || this.mode !== 'realtime') return 1;
|
||||
const end = projectRecoveryDeadline(this.recovery, this.recovery.endTick, this.ticksPerSecond);
|
||||
return wallNow >= this.recovery.startWallAt && wallNow < end ? 2 : 1;
|
||||
}
|
||||
|
||||
dateToTick(date: Date): number {
|
||||
return requireSafeTick(this.ticksBetween(this.baseTime, date));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { asGameTick, GAME_TICKS_PER_TURN, type GameTick } from './gameTimeUnits.js';
|
||||
|
||||
/** 정상 시간표는 바꾸지 않고, 정수 턴의 지연만 두 배 속도로 소진한다. */
|
||||
export interface TurnRecoveryWindow {
|
||||
startTick: GameTick;
|
||||
endTick: GameTick;
|
||||
startWallAt: Date;
|
||||
}
|
||||
|
||||
export const readTurnRecovery = (row: {
|
||||
clockRecoveryStartTick?: bigint | number | null;
|
||||
clockRecoveryEndTick?: bigint | number | null;
|
||||
clockRecoveryStartWallAt?: Date | null;
|
||||
}): TurnRecoveryWindow | null => {
|
||||
const values = [row.clockRecoveryStartTick, row.clockRecoveryEndTick, row.clockRecoveryStartWallAt];
|
||||
if (values.every((value) => value == null)) return null;
|
||||
if (values.some((value) => value == null)) throw new Error('Incomplete durable turn recovery window.');
|
||||
const window = {
|
||||
startTick: asGameTick(Number(row.clockRecoveryStartTick)),
|
||||
endTick: asGameTick(Number(row.clockRecoveryEndTick)),
|
||||
startWallAt: new Date(row.clockRecoveryStartWallAt!),
|
||||
};
|
||||
validateTurnRecovery(window);
|
||||
return window;
|
||||
};
|
||||
|
||||
export const serializeTurnRecovery = (window: TurnRecoveryWindow | null) => ({
|
||||
clockRecoveryStartTick: window?.startTick ?? null,
|
||||
clockRecoveryEndTick: window?.endTick ?? null,
|
||||
clockRecoveryStartWallAt: window?.startWallAt.toISOString() ?? null,
|
||||
});
|
||||
|
||||
export const readSerializedTurnRecovery = (value: unknown): TurnRecoveryWindow | null => {
|
||||
if (value == null) return null;
|
||||
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid serialized recovery window.');
|
||||
const row = value as Record<string, unknown>;
|
||||
if (row.clockRecoveryStartTick == null && row.clockRecoveryEndTick == null && row.clockRecoveryStartWallAt == null)
|
||||
return null;
|
||||
if (
|
||||
typeof row.clockRecoveryStartTick !== 'number' ||
|
||||
typeof row.clockRecoveryEndTick !== 'number' ||
|
||||
typeof row.clockRecoveryStartWallAt !== 'string'
|
||||
) {
|
||||
throw new Error('Incomplete serialized recovery window.');
|
||||
}
|
||||
return readTurnRecovery({
|
||||
clockRecoveryStartTick: row.clockRecoveryStartTick,
|
||||
clockRecoveryEndTick: row.clockRecoveryEndTick,
|
||||
clockRecoveryStartWallAt: new Date(row.clockRecoveryStartWallAt),
|
||||
});
|
||||
};
|
||||
|
||||
export interface TurnRecoveryPlan {
|
||||
skippedTurns: number;
|
||||
recoveryTurns: number;
|
||||
initialTick: GameTick;
|
||||
recovery: TurnRecoveryWindow | null;
|
||||
}
|
||||
|
||||
export const nextTurnBoundary = (tick: number): GameTick => {
|
||||
asGameTick(tick);
|
||||
return asGameTick(Math.ceil(tick / GAME_TICKS_PER_TURN) * GAME_TICKS_PER_TURN);
|
||||
};
|
||||
|
||||
/** 운영자 이동은 정수 턴으로만 받는다. 과거 실행의 취소를 뜻하지 않는다. */
|
||||
export const turnShiftTicks = (turns: number): GameTick => {
|
||||
if (!Number.isSafeInteger(turns)) throw new Error('Schedule movement requires an integer number of turns.');
|
||||
return asGameTick(turns * GAME_TICKS_PER_TURN);
|
||||
};
|
||||
|
||||
/**
|
||||
* observedTick은 중단 전에 저장한 관측 지점, normalTick은 기존 시간표의 현재 지점이다.
|
||||
* 잔여 한 턴 미만은 정상 실행하고, 다음 경계부터 정수 턴 지연을 두 배속으로 처리한다.
|
||||
* 반환한 skip은 호출자가 미래 일정과 실행 cursor에 원자적으로 적용해야 한다.
|
||||
*/
|
||||
export const planTurnRecovery = (input: {
|
||||
observedTick: number;
|
||||
normalTick: number;
|
||||
wallNow: Date;
|
||||
turnSeconds: number;
|
||||
}): TurnRecoveryPlan => {
|
||||
const { observedTick, normalTick, wallNow, turnSeconds } = input;
|
||||
asGameTick(observedTick);
|
||||
asGameTick(normalTick);
|
||||
if (!Number.isInteger(turnSeconds) || turnSeconds <= 0 || GAME_TICKS_PER_TURN % turnSeconds !== 0) {
|
||||
throw new Error('Recovery requires a representable positive turn length.');
|
||||
}
|
||||
if (!Number.isFinite(wallNow.getTime())) throw new Error('Recovery wall instant is invalid.');
|
||||
const overdueTurns = Math.max(0, Math.floor((normalTick - observedTick) / GAME_TICKS_PER_TURN));
|
||||
const skippedTurns = Math.floor(overdueTurns / 12) * 12;
|
||||
const recoveryTurns = overdueTurns % 12;
|
||||
const initialTick = asGameTick(
|
||||
Math.max(observedTick + turnShiftTicks(skippedTurns), normalTick - turnShiftTicks(recoveryTurns))
|
||||
);
|
||||
if (recoveryTurns === 0) return { skippedTurns, recoveryTurns, initialTick, recovery: null };
|
||||
const boundary = nextTurnBoundary(normalTick);
|
||||
const startWallAt = new Date(
|
||||
wallNow.getTime() + Math.ceil(((boundary - normalTick) * turnSeconds * 1_000) / GAME_TICKS_PER_TURN)
|
||||
);
|
||||
return {
|
||||
skippedTurns,
|
||||
recoveryTurns,
|
||||
initialTick,
|
||||
recovery: {
|
||||
startTick: asGameTick(boundary - turnShiftTicks(recoveryTurns)),
|
||||
endTick: asGameTick(boundary + turnShiftTicks(recoveryTurns)),
|
||||
startWallAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const validateTurnRecovery = (window: TurnRecoveryWindow): void => {
|
||||
asGameTick(window.startTick);
|
||||
asGameTick(window.endTick);
|
||||
const span = window.endTick - window.startTick;
|
||||
if (
|
||||
!Number.isFinite(window.startWallAt.getTime()) ||
|
||||
window.startTick % GAME_TICKS_PER_TURN !== 0 ||
|
||||
window.endTick % GAME_TICKS_PER_TURN !== 0 ||
|
||||
span <= 0 ||
|
||||
span % (2 * GAME_TICKS_PER_TURN) !== 0 ||
|
||||
span >= 24 * GAME_TICKS_PER_TURN
|
||||
)
|
||||
throw new Error('Recovery must join turn boundaries after one to eleven turns at double speed.');
|
||||
};
|
||||
|
||||
/** 경계 전에는 정상 속도, 복구 구간은 두 배, 합류 경계 이후는 정상 속도이다. */
|
||||
export const observeTurnRecovery = (window: TurnRecoveryWindow, wallNow: Date, ticksPerSecond: number): GameTick => {
|
||||
validateTurnRecovery(window);
|
||||
const elapsed = asGameTick(
|
||||
Math.trunc(((wallNow.getTime() - window.startWallAt.getTime()) * ticksPerSecond) / 1_000)
|
||||
);
|
||||
const halfSpan = (window.endTick - window.startTick) / 2;
|
||||
return asGameTick(window.startTick + elapsed + Math.max(0, Math.min(elapsed, halfSpan)));
|
||||
};
|
||||
|
||||
/** 게임 좌표의 예정 시각을 사용자에게 표시할 실제 실행 시각으로 투영한다. */
|
||||
export const projectRecoveryDeadline = (window: TurnRecoveryWindow, tick: number, ticksPerSecond: number): Date => {
|
||||
validateTurnRecovery(window);
|
||||
asGameTick(tick);
|
||||
const offset = tick - window.startTick;
|
||||
const span = window.endTick - window.startTick;
|
||||
const elapsed = offset < 0 ? offset : offset <= span ? offset / 2 : offset - span / 2;
|
||||
return new Date(window.startWallAt.getTime() + Math.ceil((elapsed * 1_000) / ticksPerSecond));
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export const GAME_TICKS_PER_TURN = 36_000_000;
|
||||
|
||||
declare const gameTickBrand: unique symbol;
|
||||
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
|
||||
|
||||
export const asGameTick = (tick: number): GameTick => {
|
||||
if (!Number.isSafeInteger(tick)) throw new Error(`Game tick must be a safe integer: ${tick}`);
|
||||
return tick as GameTick;
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GAME_TICKS_PER_TURN as T, GameClock, buildClockAlignmentPlan } from '../src/time/GameClock.js';
|
||||
import {
|
||||
nextTurnBoundary,
|
||||
observeTurnRecovery,
|
||||
planTurnRecovery,
|
||||
projectRecoveryDeadline,
|
||||
turnShiftTicks,
|
||||
} from '../src/time/TurnRecovery.js';
|
||||
|
||||
const base = Date.parse('2026-09-06T00:00:00Z');
|
||||
const wall = (hours: number) => new Date(base + hours * 3_600_000);
|
||||
|
||||
describe('turn-aligned double-speed recovery', () => {
|
||||
it('reloads midway through acceleration without restarting the recovery duration', () => {
|
||||
const { recovery } = planTurnRecovery({
|
||||
observedTick: 0,
|
||||
normalTick: 4 * T,
|
||||
wallNow: wall(4),
|
||||
turnSeconds: 3600,
|
||||
});
|
||||
const reloaded = new GameClock({
|
||||
baseTime: wall(0),
|
||||
tick: 2 * T,
|
||||
wallAnchor: wall(5),
|
||||
mode: 'realtime',
|
||||
turnSeconds: 3600,
|
||||
recovery,
|
||||
});
|
||||
expect(reloaded.nowTick(wall(6))).toBe(4 * T);
|
||||
expect(reloaded.nowTick(wall(8))).toBe(8 * T);
|
||||
expect(reloaded.nowTick(wall(9))).toBe(9 * T);
|
||||
expect(reloaded.normalNowTick(wall(6))).toBe(6 * T);
|
||||
expect(reloaded.executionRate(wall(7))).toBe(2);
|
||||
expect(reloaded.executionRate(wall(8))).toBe(1);
|
||||
});
|
||||
|
||||
it('resumes a planned wait at a whole-turn boundary without changing purchased phase', () => {
|
||||
const plan = buildClockAlignmentPlan({
|
||||
policy: 'TURN_BOUNDARY',
|
||||
sourceRevision: 1,
|
||||
cutTick: 0,
|
||||
cutWall: wall(0),
|
||||
resumeWall: wall(4 + 1 / 3),
|
||||
ticksPerSecond: 10_000,
|
||||
});
|
||||
expect(plan.shiftTicks).toBe(5 * T);
|
||||
expect(plan.alignedTick).toBe(5 * T);
|
||||
expect(plan.resumeAnchor).toEqual(wall(5));
|
||||
expect((199_020 + plan.shiftTicks) % T).toBe(199_020);
|
||||
});
|
||||
|
||||
it('preserves a normal schedule whose game epoch differs from the real opening date', () => {
|
||||
const plan = buildClockAlignmentPlan({
|
||||
policy: 'RECOVER_TURNS',
|
||||
sourceRevision: 1,
|
||||
cutTick: 0,
|
||||
cutWall: wall(100),
|
||||
resumeWall: wall(104),
|
||||
ticksPerSecond: 10_000,
|
||||
normalTick: 4 * T,
|
||||
});
|
||||
expect(plan.shiftTicks).toBe(0);
|
||||
const clock = new GameClock({
|
||||
baseTime: wall(0),
|
||||
tick: plan.alignedTick,
|
||||
wallAnchor: wall(104),
|
||||
turnSeconds: 3600,
|
||||
mode: 'realtime',
|
||||
recovery: plan.recovery,
|
||||
});
|
||||
expect(clock.nowTick(wall(108))).toBe(8 * T);
|
||||
expect(clock.tickToWallDate(8 * T + 199_020)).toEqual(new Date(wall(108).getTime() + 19_902));
|
||||
});
|
||||
it.each([0, 1, 4, 11, 12, 13, 23, 24, 28])('preserves twelve-turn blocks for %i overdue turns', (turns) => {
|
||||
const plan = planTurnRecovery({
|
||||
observedTick: 0,
|
||||
normalTick: turns * T,
|
||||
wallNow: wall(turns),
|
||||
turnSeconds: 3600,
|
||||
});
|
||||
expect(plan.skippedTurns).toBe(Math.floor(turns / 12) * 12);
|
||||
expect(plan.recoveryTurns).toBe(turns % 12);
|
||||
expect(plan.initialTick).toBe(plan.skippedTurns * T);
|
||||
if (!plan.recovery) return;
|
||||
const end = turns + plan.recoveryTurns;
|
||||
expect(observeTurnRecovery(plan.recovery, wall(end), 10_000)).toBe(end * T);
|
||||
expect(observeTurnRecovery(plan.recovery, wall(end + 1), 10_000)).toBe((end + 1) * T);
|
||||
});
|
||||
|
||||
it('executes four delayed turns over four hours and meets the original eight-hour boundary', () => {
|
||||
const { recovery } = planTurnRecovery({
|
||||
observedTick: 0,
|
||||
normalTick: 4 * T,
|
||||
wallNow: wall(4),
|
||||
turnSeconds: 3600,
|
||||
});
|
||||
expect(recovery).not.toBeNull();
|
||||
expect(observeTurnRecovery(recovery!, wall(4), 10_000)).toBe(0);
|
||||
expect(observeTurnRecovery(recovery!, wall(5), 10_000)).toBe(2 * T);
|
||||
expect(observeTurnRecovery(recovery!, wall(8), 10_000)).toBe(8 * T);
|
||||
expect(observeTurnRecovery(recovery!, wall(9), 10_000)).toBe(9 * T);
|
||||
});
|
||||
|
||||
it('retains the fractional phase and begins acceleration at the next boundary', () => {
|
||||
const plan = planTurnRecovery({
|
||||
observedTick: 0,
|
||||
normalTick: 4 * T + T / 3,
|
||||
wallNow: wall(4 + 1 / 3),
|
||||
turnSeconds: 3600,
|
||||
});
|
||||
expect(plan.initialTick).toBe(T / 3);
|
||||
expect(plan.recovery!.startWallAt).toEqual(wall(5));
|
||||
expect(observeTurnRecovery(plan.recovery!, wall(4.5), 10_000)).toBe(T / 2);
|
||||
expect(observeTurnRecovery(plan.recovery!, wall(5), 10_000)).toBe(T);
|
||||
expect(observeTurnRecovery(plan.recovery!, wall(9), 10_000)).toBe(9 * T);
|
||||
});
|
||||
|
||||
it('preserves purchased phase coordinates while projecting compressed wall deadlines', () => {
|
||||
const { recovery } = planTurnRecovery({
|
||||
observedTick: 0,
|
||||
normalTick: 4 * T,
|
||||
wallNow: wall(4),
|
||||
turnSeconds: 3600,
|
||||
});
|
||||
const phase = 199_020; // 00:19.902 at normal speed
|
||||
expect(projectRecoveryDeadline(recovery!, phase, 10_000).getTime()).toBe(wall(4).getTime() + 9951);
|
||||
expect(projectRecoveryDeadline(recovery!, 8 * T + phase, 10_000).getTime()).toBe(wall(8).getTime() + 19902);
|
||||
});
|
||||
|
||||
it('does not rewind a persisted observation when wall time moves backwards', () => {
|
||||
const plan = planTurnRecovery({ observedTick: 5 * T, normalTick: 4 * T, wallNow: wall(4), turnSeconds: 3600 });
|
||||
expect(plan.initialTick).toBe(5 * T);
|
||||
expect(plan.recovery).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts signed whole-turn shifts and rejects fractional movement', () => {
|
||||
expect(turnShiftTicks(-4)).toBe(-4 * T);
|
||||
expect(turnShiftTicks(12)).toBe(12 * T);
|
||||
expect(() => turnShiftTicks(0.5)).toThrow();
|
||||
expect(nextTurnBoundary(4 * T + 1)).toBe(5 * T);
|
||||
expect(nextTurnBoundary(4 * T)).toBe(4 * T);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user