중단 후 대기와 2배속 복구 경계 및 전체 공지 적용

This commit is contained in:
2026-09-07 14:37:55 +00:00
parent d57fd3e553
commit c39527bdd7
12 changed files with 541 additions and 69 deletions
+3 -2
View File
@@ -241,6 +241,7 @@ export const buildClockAlignmentPlan = (input: {
catchUpTicks: asGameTick(Math.max(0, (input.normalTick ?? exact.alignedTick) - input.cutTick - shiftTicks)),
alignedTick: recovery.initialTick,
recovery: recovery.recovery,
...(recovery.recovery ? { resumeAnchor: recovery.recovery.startWallAt } : {}),
...(recovery.initialTick > (input.normalTick ?? exact.alignedTick)
? {
resumeAnchor: new Date(
@@ -380,8 +381,8 @@ export class GameClock {
normalNowTick(wallNow: Date): number {
if (this.recovery) {
return this.addTicks(
(this.recovery.startTick + this.recovery.endTick) / 2,
this.ticksBetween(this.recovery.startWallAt, wallNow)
this.recovery.endTick,
this.ticksBetween(this.tickToWallDate(this.recovery.endTick), wallNow)
);
}
return this.addTicks(this.tick, this.ticksBetween(this.wallAnchor, wallNow));
+49 -27
View File
@@ -1,6 +1,6 @@
import { asGameTick, GAME_TICKS_PER_TURN, type GameTick } from './gameTimeUnits.js';
/** 정상 시간표는 바꾸지 않고, 정수 턴의 지연만 두 배 속도로 소진한다. */
/** 정상 시간표를 유지하며 대기 후 두 배 속도로 월 경계에 합류한다. */
export interface TurnRecoveryWindow {
startTick: GameTick;
endTick: GameTick;
@@ -68,9 +68,12 @@ export const turnShiftTicks = (turns: number): GameTick => {
return asGameTick(turns * GAME_TICKS_PER_TURN);
};
/** 즉시 처리 여부는 12턴 묶음 생략 전의 전체 지연으로 판정한다. */
export const immediateRecoveryLimitSeconds = (turnSeconds: number): number => Math.min(600, turnSeconds / 10);
/**
* observedTick은 중단 전에 저장한 관측 지점, normalTick은 기존 시간표의 현재 지점이다.
* 잔여 한 턴 미만은 정상 실행하고, 다음 경계부터 정수 턴 지연을 두 배속으로 처리한다.
* 짧은 전체 지연만 즉시 처리한다. 그 외에는 나머지도 생략하지 않고 대기 후 두 배속으로 처리한다.
* 반환한 skip은 호출자가 미래 일정과 실행 cursor에 원자적으로 적용해야 한다.
*/
export const planTurnRecovery = (input: {
@@ -86,26 +89,32 @@ export const planTurnRecovery = (input: {
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)
);
const gap = Math.max(0, normalTick - observedTick);
const ticksPerSecond = GAME_TICKS_PER_TURN / turnSeconds;
const immediateLimit = immediateRecoveryLimitSeconds(turnSeconds) * ticksPerSecond;
if (gap < immediateLimit) {
return {
skippedTurns: 0,
recoveryTurns: 0,
initialTick: asGameTick(Math.max(observedTick, normalTick)),
recovery: null,
};
}
const skippedTurns = Math.floor(gap / (12 * GAME_TICKS_PER_TURN)) * 12;
const initialTick = asGameTick(observedTick + turnShiftTicks(skippedTurns));
const remaining = normalTick - initialTick;
if (remaining === 0) return { skippedTurns, recoveryTurns: 0, initialTick, recovery: null };
// 즉시 2배속으로 따라잡을 수 있는 가장 이른 지점 이후의 월 경계를 고른다.
// 대기도 추가 지연이므로 경계까지 여유의 절반만 기다린다. 밀리초 반올림은 종료 시각을 보존한다.
const endTick = nextTurnBoundary(normalTick + remaining);
const endWallMs = wallNow.getTime() + Math.ceil(((endTick - normalTick) * 1_000) / ticksPerSecond);
const durationMs = Math.ceil(((endTick - initialTick) * 1_000) / (2 * ticksPerSecond));
return {
skippedTurns,
recoveryTurns,
recoveryTurns: remaining / GAME_TICKS_PER_TURN,
initialTick,
recovery: {
startTick: asGameTick(boundary - turnShiftTicks(recoveryTurns)),
endTick: asGameTick(boundary + turnShiftTicks(recoveryTurns)),
startWallAt,
},
recovery: { startTick: initialTick, endTick, startWallAt: new Date(endWallMs - durationMs) },
};
};
@@ -115,23 +124,32 @@ export const validateTurnRecovery = (window: TurnRecoveryWindow): void => {
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
span >= 25 * GAME_TICKS_PER_TURN
)
throw new Error('Recovery must join turn boundaries after one to eleven turns at double speed.');
throw new Error('Recovery must end at a turn boundary with a positive span below twenty-five turns.');
};
/** 경계 전에는 정상 속도, 복구 구간은 두 배, 합류 경계 이후는 정상 속도이다. */
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)
Math.trunc(
((wallNow.getTime() - window.startWallAt.getTime()) *
ticksPerSecond *
(wallNow < window.startWallAt ? 1 : 2)) /
1_000
)
);
const halfSpan = (window.endTick - window.startTick) / 2;
return asGameTick(window.startTick + elapsed + Math.max(0, Math.min(elapsed, halfSpan)));
const endWallAt = projectRecoveryDeadline(window, window.endTick, ticksPerSecond);
if (wallNow >= endWallAt) {
return asGameTick(
window.endTick + Math.trunc(((wallNow.getTime() - endWallAt.getTime()) * ticksPerSecond) / 1_000)
);
}
// 이전 복구 창은 시작 전 1배속이었다. 새 창은 GameClock의 저장 tick 하한으로 대기한다.
return asGameTick(Math.min(window.endTick, window.startTick + elapsed));
};
/** 게임 좌표의 예정 시각을 사용자에게 표시할 실제 실행 시각으로 투영한다. */
@@ -140,6 +158,10 @@ export const projectRecoveryDeadline = (window: TurnRecoveryWindow, tick: number
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;
if (offset > span) {
const endWallMs = window.startWallAt.getTime() + Math.ceil((span * 1_000) / (2 * ticksPerSecond));
return new Date(endWallMs + Math.ceil(((offset - span) * 1_000) / ticksPerSecond));
}
const elapsed = offset < 0 ? offset : offset / 2;
return new Date(window.startWallAt.getTime() + Math.ceil((elapsed * 1_000) / ticksPerSecond));
};
+110 -9
View File
@@ -35,6 +35,21 @@ describe('turn-aligned double-speed recovery', () => {
expect(reloaded.executionRate(wall(8))).toBe(1);
});
it('preserves pre-start normal speed for an existing serialized window', () => {
const old = new GameClock({
baseTime: wall(0),
tick: T / 3,
wallAnchor: wall(4 + 1 / 3),
mode: 'realtime',
turnSeconds: 3600,
recovery: { startTick: nextTurnBoundary(T), endTick: nextTurnBoundary(9 * T), startWallAt: wall(5) },
});
expect(old.nowTick(wall(4.5))).toBe(T / 2);
expect(old.nowTick(wall(5))).toBe(T);
expect(old.nowTick(wall(9))).toBe(9 * T);
expect(old.normalNowTick(wall(4.5))).toBe(4.5 * T);
});
it('resumes a planned wait at a whole-turn boundary without changing purchased phase', () => {
const plan = buildClockAlignmentPlan({
policy: 'TURN_BOUNDARY',
@@ -102,18 +117,104 @@ describe('turn-aligned double-speed recovery', () => {
expect(observeTurnRecovery(recovery!, wall(9), 10_000)).toBe(9 * T);
});
it('retains the fractional phase and begins acceleration at the next boundary', () => {
it.each([
[14, 11, 60],
[49, 6, 120],
[59, 26, 180],
[100, 15, 240],
[240, 25, 540],
[400, 15, 840],
[700, 15, 1440],
])('waits then runs 2x after stopping at 00:10 for %i minutes', (delay, wait, endMinute) => {
const observed = T / 6;
const resumed = wall((10 + delay) / 60);
const plan = planTurnRecovery({
observedTick: 0,
normalTick: 4 * T + T / 3,
wallNow: wall(4 + 1 / 3),
observedTick: observed,
normalTick: ((10 + delay) * T) / 60,
wallNow: resumed,
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);
const recovery = plan.recovery!;
const start = recovery.startWallAt;
const end = wall(endMinute / 60);
const clock = new GameClock({
baseTime: wall(0),
tick: plan.initialTick,
wallAnchor: start,
mode: 'realtime',
turnSeconds: 3600,
recovery,
});
expect(plan.initialTick).toBe(observed);
expect(start.getTime() - resumed.getTime()).toBeCloseTo(wait * 60000, 0);
expect(clock.nowTick(resumed)).toBe(observed);
expect(clock.nowTick(new Date(start.getTime() - 1))).toBe(observed);
expect(clock.nowTick(start)).toBe(observed);
expect(clock.nowTick(new Date(start.getTime() + 1))).toBe(observed + 20);
expect(clock.tickToWallDate(recovery.endTick)).toEqual(end);
expect(recovery.endTick % T).toBe(0);
expect(clock.nowTick(new Date(end.getTime() - 1))).toBe(recovery.endTick - 20);
expect(clock.nowTick(end)).toBe(clock.normalNowTick(end));
expect(clock.nowTick(new Date(end.getTime() + 1))).toBe(recovery.endTick + 10);
expect(clock.executionRate(new Date(end.getTime() - 1))).toBe(2);
expect(clock.executionRate(end)).toBe(1);
});
it.each([300, 3600, 6000, 7200])('uses the strict whole-delay threshold for a %i second turn', (turnSeconds) => {
const rate = T / turnSeconds;
const limitMs = Math.min(600, turnSeconds / 10) * 1000;
for (const delta of [-1, 0, 1]) {
const delayMs = limitMs + delta;
const normal = Math.trunc((delayMs * rate) / 1000);
const plan = planTurnRecovery({
observedTick: 0,
normalTick: normal,
wallNow: new Date(base + delayMs),
turnSeconds,
});
expect(plan.recovery === null).toBe(delta < 0);
expect(plan.initialTick).toBe(delta < 0 ? normal : 0);
}
// 장시간 중단의 작은 나머지에는 즉시 처리 예외를 다시 적용하지 않는다.
const long = planTurnRecovery({ observedTick: 0, normalTick: 12 * T + rate, wallNow: wall(20), turnSeconds });
expect(long.skippedTurns).toBe(12);
expect(long.initialTick).toBe(12 * T);
expect(long.recovery).not.toBeNull();
});
it('keeps integer ticks and deadline ordering for sub-millisecond phases', () => {
for (const turnSeconds of [300, 3600, 7200, 36000]) {
const rate = T / turnSeconds;
for (const observed of [1, T / 6 + 1, T - 1]) {
const now = wall(4);
const normal = observed + 4 * T + 1;
const plan = planTurnRecovery({
observedTick: observed,
normalTick: normal,
wallNow: now,
turnSeconds,
});
const recovery = plan.recovery!;
const clock = new GameClock({
baseTime: wall(0),
tick: observed,
wallAnchor: recovery.startWallAt,
mode: 'realtime',
turnSeconds,
recovery,
});
expect(recovery.startWallAt.getTime()).toBeGreaterThanOrEqual(now.getTime());
const end = clock.tickToWallDate(recovery.endTick);
expect(clock.nowTick(end)).toBe(recovery.endTick);
expect(clock.nowTick(new Date(end.getTime() - 1))).toBeLessThan(recovery.endTick);
expect(Math.abs(clock.normalNowTick(now) - normal)).toBeLessThanOrEqual(Math.ceil(rate / 1000));
for (const tick of [observed + 1, observed + T, recovery.endTick - 1, recovery.endTick + 1]) {
const deadline = clock.tickToWallDate(tick);
expect(clock.nowTick(deadline)).toBeGreaterThanOrEqual(tick);
expect(clock.nowTick(new Date(deadline.getTime() - 1))).toBeLessThan(tick);
}
}
}
});
it('preserves purchased phase coordinates while projecting compressed wall deadlines', () => {