refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현

This commit is contained in:
2026-09-06 10:04:33 +00:00
parent 63da0921cb
commit d3443ec7f5
53 changed files with 1467 additions and 160 deletions
@@ -46,6 +46,7 @@ type NavigationFixture = {
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
turnEngineRunning?: boolean | null;
cityDefence?: number;
cityState?: number;
@@ -636,6 +637,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
clockMode: state.clockMode ?? 'realtime',
clockRunning: state.clockRunning ?? true,
clockStartsAt: state.clockStartsAt ?? null,
clockRecovery: state.clockRecovery ?? null,
turnEngineRunning: state.turnEngineRunning === undefined ? true : state.turnEngineRunning,
scenarioTitle: state.scenarioTitle ?? '',
autorunUser: state.autorunUser ?? null,
@@ -6078,3 +6080,63 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
})
.toBe(1);
});
for (const viewport of [
{ width: 1200, height: 900 },
{ width: 390, height: 844 },
]) {
test(`turn recovery returns to normal speed at the boundary (${viewport.width}px)`, async ({ page }) => {
const start = new Date('2026-09-06T07:59:50Z');
await page.clock.install({ time: start });
await page.setViewportSize(viewport);
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 1,
npcMode: 1,
generalMeCalls: 0,
operations: [],
serverTime: '2026-09-06T07:59:40Z',
serverWallTime: start.toISOString(),
clockMode: 'realtime',
clockRunning: true,
turnEngineRunning: true,
clockRecovery: { startsAt: '2026-09-06T04:00:00Z', endsAt: '2026-09-06T08:00:00Z' },
};
await installFixture(page, state);
await page.goto('./');
await expect(page.locator('.game-shell__title')).toBeVisible({ timeout: 15_000 });
await page.evaluate(() => document.fonts.ready);
const status = page.locator('.execution-status:visible');
await expect(status).toContainText('복구 2배속');
const root = process.env.TURN_RECOVERY_ARTIFACT_DIR;
const measure = () =>
status.evaluate((element) => ({
rect: element.getBoundingClientRect().toJSON(),
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
font: getComputedStyle(element).font,
lineHeight: getComputedStyle(element).lineHeight,
text: element.textContent,
}));
const before = await measure();
expect(before.scrollWidth).toBeLessThanOrEqual(before.clientWidth + 1);
if (root) {
await mkdir(root, { recursive: true });
await page.screenshot({ path: resolve(root, `recovering-${viewport.width}.png`), fullPage: true });
}
await page.clock.runFor(20_000);
await expect(status).not.toContainText('복구 2배속');
await expect(status).toContainText('17:00');
const after = await measure();
expect(after.scrollWidth).toBeLessThanOrEqual(after.clientWidth + 1);
if (root) {
await page.screenshot({ path: resolve(root, `normal-${viewport.width}.png`), fullPage: true });
await writeFile(
resolve(root, `geometry-${viewport.width}.json`),
JSON.stringify({ viewport, before, after }, null, 2)
);
}
});
}
@@ -29,6 +29,7 @@ const props = defineProps<{
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
autorunLimit?: number | null;
storageKey?: string;
mapData?: CommandMapData | null;
@@ -60,11 +61,7 @@ const labelMap = computed(() => {
});
const firstReservedMonth = computed(
() =>
(props.currentYear ?? 0) * 12 +
(props.currentMonth ?? 1) -
1 +
(props.general?.nextTurnMonthOffset ?? 0)
() => (props.currentYear ?? 0) * 12 + (props.currentMonth ?? 1) - 1 + (props.general?.nextTurnMonthOffset ?? 0)
);
const rows = computed<ReservedCommandRow[]>(() => {
@@ -119,7 +116,7 @@ const updateServerClock = () => {
currentServerTime.value = '--:--:--';
return;
}
const { clientElapsedMs, time: projectedTime } = projectServerClock(serverClockSample);
const { clientElapsedMs, time: projectedTime, rate } = projectServerClock(serverClockSample);
currentServerTime.value = formatLocalTimeSeconds(projectedTime);
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs;
@@ -127,15 +124,30 @@ const updateServerClock = () => {
updateServerClock,
untilStartMs > 0
? Math.min(untilStartMs, MAX_SERVER_CLOCK_TIMER_DELAY_MS)
: 1_000 - projectedTime.getMilliseconds()
: (1_000 - projectedTime.getMilliseconds()) / rate
);
}
};
watch(
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => {
serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt });
() =>
[
props.serverTime,
props.serverWallTime,
props.clockMode,
props.clockRunning,
props.clockStartsAt,
props.clockRecovery,
] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock();
},
{ immediate: true }
@@ -16,6 +16,7 @@ const props = defineProps<{
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
turnEngineRunning?: boolean | null;
status: {
onlineUserCount: number;
@@ -34,6 +35,7 @@ const props = defineProps<{
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
const currentServerTime = ref('기록 없음');
const hasServerClock = ref(false);
const recovering = ref(false);
const turnEngineStopped = computed(() => props.turnEngineRunning === false);
const turnEngineStatusUnknown = computed(() => typeof props.turnEngineRunning !== 'boolean');
const serverClockTitle = computed(() => {
@@ -57,6 +59,7 @@ const updateServerClock = () => {
const now = Date.now();
const projection = projectServerClock(serverClockSample, now);
recovering.value = projection.rate === 2;
currentServerTime.value = formatServerDateTime(projection.time, {
format: 'monthDayTime',
fallback: '기록 없음',
@@ -67,16 +70,37 @@ const updateServerClock = () => {
const nextDelays: number[] = [];
if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) {
const untilStartMs = serverClockSample.startDelayMs - projection.clientElapsedMs;
nextDelays.push(untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time));
nextDelays.push(
untilStartMs > 0 ? untilStartMs : millisecondsUntilNextMinute(projection.time) / projection.rate
);
}
for (const boundary of [serverClockSample.recoveryStartDelayMs, serverClockSample.recoveryEndDelayMs]) {
if (boundary !== undefined && boundary > projection.clientElapsedMs)
nextDelays.push(boundary - projection.clientElapsedMs);
}
if (nextDelays.length === 0) return;
serverClockTimer = setTimeout(updateServerClock, Math.max(1, Math.min(...nextDelays)));
};
watch(
() => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => {
serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt });
() =>
[
props.serverTime,
props.serverWallTime,
props.clockMode,
props.clockRunning,
props.clockStartsAt,
props.clockRecovery,
] as const,
([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt, clockRecovery]) => {
serverClockSample = sampleServerClock({
serverTime,
serverWallTime,
clockMode,
clockRunning,
clockStartsAt,
clockRecovery,
});
updateServerClock();
},
{ immediate: true }
@@ -100,7 +124,7 @@ onUnmounted(() => {
}"
:title="serverClockTitle"
>
현재 시각: {{ currentServerTime }}
현재 시각: {{ currentServerTime }}<span v-if="recovering"> · 복구 2배속</span>
</div>
<div class="status-row tournament-status">
<RouterLink to="/tournament">
@@ -4,6 +4,7 @@ export type ServerClockProjectionInput = {
clockMode?: 'realtime' | 'manual';
clockRunning?: boolean;
clockStartsAt?: string | null;
clockRecovery?: { startsAt: string; endsAt: string } | null;
};
export type SampledServerClock = {
@@ -11,6 +12,8 @@ export type SampledServerClock = {
sampledClientTimeMs: number;
clockMode: 'realtime' | 'manual';
startDelayMs: number | null;
recoveryStartDelayMs?: number;
recoveryEndDelayMs?: number;
};
const parseInstant = (value?: string | null): number | null => {
@@ -40,11 +43,20 @@ export const sampleServerClock = (
: null;
}
const wallSample = parseInstant(input.serverWallTime);
const recoveryStart = parseInstant(input.clockRecovery?.startsAt);
const recoveryEnd = parseInstant(input.clockRecovery?.endsAt);
return {
serverTimeMs,
sampledClientTimeMs,
clockMode: input.clockMode ?? 'realtime',
startDelayMs,
...(wallSample !== null && recoveryStart !== null && recoveryEnd !== null && recoveryEnd > recoveryStart
? {
recoveryStartDelayMs: recoveryStart - wallSample,
recoveryEndDelayMs: recoveryEnd - wallSample,
}
: {}),
};
};
@@ -55,9 +67,25 @@ export const projectServerClock = (sample: SampledServerClock, clientTimeMs = Da
? 0
: Math.max(0, clientElapsedMs - sample.startDelayMs);
const accelerationMs =
sample.startDelayMs === null || sample.clockMode === 'manual'
? 0
: Math.max(
0,
Math.min(clientElapsedMs, sample.recoveryEndDelayMs ?? 0) -
Math.max(0, sample.recoveryStartDelayMs ?? 0)
);
const rate =
sample.startDelayMs !== null &&
sample.clockMode !== 'manual' &&
clientElapsedMs >= (sample.recoveryStartDelayMs ?? Infinity) &&
clientElapsedMs < (sample.recoveryEndDelayMs ?? -Infinity)
? 2
: 1;
return {
clientElapsedMs,
time: new Date(sample.serverTimeMs + elapsedGameMs),
rate,
time: new Date(sample.serverTimeMs + elapsedGameMs + accelerationMs),
};
};
+3
View File
@@ -311,6 +311,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:turn-engine-running="lobbyInfo?.turnEngineRunning"
/>
</div>
@@ -365,6 +366,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@@ -547,6 +549,7 @@ watch(
:clock-mode="lobbyInfo?.clockMode"
:clock-running="lobbyInfo?.clockRunning"
:clock-starts-at="lobbyInfo?.clockStartsAt"
:clock-recovery="lobbyInfo?.clockRecovery"
:autorun-limit="reservedGeneralAutorunLimit"
:map-data="worldMap"
:map-layout="mapLayout"
@@ -49,3 +49,22 @@ void test('holds a preopen clock until its wall-clock start delay passes', () =>
void test('rejects an invalid server clock sample', () => {
assert.equal(sampleServerClock({ serverTime: 'not-a-time' }, 10_000), null);
});
void test('a single browser sample accelerates only inside the recovery window and rejoins normal time', () => {
const sample = sampleServerClock(
{
serverTime: '2026-09-06T00:20:00Z',
serverWallTime: '2026-09-06T04:20:00Z',
clockRunning: true,
clockRecovery: { startsAt: '2026-09-06T05:00:00Z', endsAt: '2026-09-06T09:00:00Z' },
},
0
);
assert.ok(sample);
const minute = 60_000;
assert.equal(projectServerClock(sample, 40 * minute).time.toISOString(), '2026-09-06T01:00:00.000Z');
assert.equal(projectServerClock(sample, 100 * minute).time.toISOString(), '2026-09-06T03:00:00.000Z');
assert.equal(projectServerClock(sample, 280 * minute).time.toISOString(), '2026-09-06T09:00:00.000Z');
assert.equal(projectServerClock(sample, 340 * minute).time.toISOString(), '2026-09-06T10:00:00.000Z');
assert.equal(projectServerClock(sample, 280 * minute).rate, 1);
});