refactor: 턴 경계와 12턴 묶음을 보존하는 2배속 복구 구현
This commit is contained in:
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user