From 1e14320911d435bfb7783afeae4fafcf3e49c911 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 17:19:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=ED=98=84=EC=9E=AC?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=81=EC=9D=84=20=EC=B5=9C=EA=B7=BC=20=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완료 턴 시각 대신 lobby serverTime을 분 경계에 맞춰 표시한다. 게임 API 응답과 SSE 및 같은 계정 탭 전달을 최근 통신으로 기록하고, 45초 공백에는 시계를 멈춘다. 공통 시계 투영과 단위 및 Chromium 회귀 검증을 추가한다. --- app/game-frontend/e2e/mainNavigation.spec.ts | 85 ++++++++++++++++- .../src/components/main/CommandListPanel.vue | 34 ++----- .../src/components/main/MainFrontStatus.vue | 91 +++++++++++++++++-- app/game-frontend/src/stores/mainDashboard.ts | 9 ++ .../src/utils/gameServerActivity.ts | 34 +++++++ .../src/utils/serverClockProjection.ts | 67 ++++++++++++++ app/game-frontend/src/utils/trpc.ts | 6 ++ app/game-frontend/src/views/MainView.vue | 10 +- .../test/gameServerActivity.test.ts | 22 +++++ .../test/serverClockProjection.test.ts | 51 +++++++++++ 10 files changed, 374 insertions(+), 35 deletions(-) create mode 100644 app/game-frontend/src/utils/gameServerActivity.ts create mode 100644 app/game-frontend/src/utils/serverClockProjection.ts create mode 100644 app/game-frontend/test/gameServerActivity.test.ts create mode 100644 app/game-frontend/test/serverClockProjection.test.ts diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 2114ca10..d5aa1667 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1188,7 +1188,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); await expect(page.locator('.legacy-game-info')).not.toContainText('최근 턴:'); - await expect(page.locator('.execution-status')).toHaveText('동작 시각: 08-13 09:05'); + await expect(page.locator('.execution-status')).toHaveText('현재 시각: 08-13 09:00'); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중'); await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문'); const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => { @@ -1793,6 +1793,89 @@ test('main general card uses local turn time and command clock tracks corrected expect(state.operations).toHaveLength(operationsBeforePreopenBoundary); }); +test('main header clock follows minute boundaries only while game-server contact is recent', async ({ + page, +}, testInfo) => { + const state: NavigationFixture = { + officerLevel: 0, + permission: 0, + nationLevel: 0, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + serverTime: '2026-08-13T00:00:35.000Z', + serverWallTime: '2026-08-13T00:00:00.000Z', + clockMode: 'realtime', + clockRunning: true, + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await page.clock.install({ time: new Date('2026-08-13T00:00:00.000Z') }); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + await waitForMainRealtime(page); + + const clock = page.locator('.execution-status'); + const initialRequestCount = state.trpcRequests?.length ?? 0; + await expect(clock).toHaveText('현재 시각: 08-13 09:00'); + await expect(clock).not.toHaveClass(/execution-status--stale/u); + + await page.clock.runFor(25_000); + await expect(clock).toHaveText('현재 시각: 08-13 09:01'); + + await page.clock.runFor(21_000); + await expect(clock).toHaveClass(/execution-status--stale/u); + await expect(clock).toHaveAttribute('title', '최근 45초 동안 서버 통신이 없어 시각 갱신을 멈췄습니다.'); + await expect.poll(() => clock.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(255, 0, 255)'); + const staleDesktopGeometry = await clock.evaluate((element) => ({ + rect: element.getBoundingClientRect().toJSON(), + overflow: element.scrollWidth - element.clientWidth, + color: getComputedStyle(element).color, + fontSize: getComputedStyle(element).fontSize, + lineHeight: getComputedStyle(element).lineHeight, + })); + expect(staleDesktopGeometry.rect.width).toBeCloseTo(333.33, 0); + expect(staleDesktopGeometry.rect.height).toBeGreaterThanOrEqual(36); + expect(staleDesktopGeometry.overflow).toBeLessThanOrEqual(0); + await clock.screenshot({ path: testInfo.outputPath('main-header-clock-stale-desktop-1200.png') }); + await page.clock.runFor(60_000); + await expect(clock).toHaveText('현재 시각: 08-13 09:01'); + + await page.evaluate(() => { + (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( + 'ping', + {} + ); + }); + await expect(clock).toHaveText('현재 시각: 08-13 09:02'); + await expect(clock).not.toHaveClass(/execution-status--stale/u); + await page.clock.runFor(39_000); + await expect(clock).toHaveText('현재 시각: 08-13 09:03'); + await expect.poll(() => clock.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(0, 255, 255)'); + + await page.setViewportSize({ width: 500, height: 900 }); + const freshMobileGeometry = await clock.evaluate((element) => ({ + rect: element.getBoundingClientRect().toJSON(), + overflow: element.scrollWidth - element.clientWidth, + color: getComputedStyle(element).color, + fontSize: getComputedStyle(element).fontSize, + lineHeight: getComputedStyle(element).lineHeight, + documentScrollWidth: document.documentElement.scrollWidth, + })); + expect(freshMobileGeometry.rect.width).toBeCloseTo(166.67, 0); + expect(freshMobileGeometry.overflow).toBeLessThanOrEqual(0); + expect(freshMobileGeometry.documentScrollWidth).toBe(500); + await Promise.all([ + clock.screenshot({ path: testInfo.outputPath('main-header-clock-fresh-mobile-500.png') }), + writeFile( + testInfo.outputPath('main-header-clock-geometry.json'), + `${JSON.stringify({ staleDesktopGeometry, freshMobileGeometry }, null, 2)}\n` + ), + ]); + expect(state.trpcRequests?.length ?? 0).toBe(initialRequestCount); +}); + test('message targets keep reply behavior and use nation-color contrast in labels and select options', async ({ page, }) => { diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index 260037dd..99c274de 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -3,6 +3,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'; import { addMinutes } from 'date-fns'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime'; +import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection'; import type { CommandMapData, CommandMapLayout, @@ -90,27 +91,20 @@ const autonomousUntil = computed(() => { const currentServerTime = ref('--:--:--'); const MAX_SERVER_CLOCK_TIMER_DELAY_MS = 60_000; -let sampledServerTimeMs: number | null = null; -let sampledClientTimeMs = 0; -let sampledStartDelayMs: number | null = 0; +let serverClockSample: SampledServerClock | null = null; let serverClockTimer: ReturnType | undefined; const updateServerClock = () => { if (serverClockTimer !== undefined) clearTimeout(serverClockTimer); serverClockTimer = undefined; - if (sampledServerTimeMs === null) { + if (serverClockSample === null) { currentServerTime.value = '--:--:--'; return; } - const clientElapsedMs = Math.max(0, Date.now() - sampledClientTimeMs); - const elapsedGameMs = - props.clockMode === 'manual' || sampledStartDelayMs === null - ? 0 - : Math.max(0, clientElapsedMs - sampledStartDelayMs); - const projectedTime = new Date(sampledServerTimeMs + elapsedGameMs); + const { clientElapsedMs, time: projectedTime } = projectServerClock(serverClockSample); currentServerTime.value = formatLocalTimeSeconds(projectedTime); - if (props.clockMode !== 'manual' && sampledStartDelayMs !== null) { - const untilStartMs = sampledStartDelayMs - clientElapsedMs; + if (serverClockSample.clockMode !== 'manual' && serverClockSample.startDelayMs !== null) { + const untilStartMs = serverClockSample.startDelayMs - clientElapsedMs; serverClockTimer = setTimeout( updateServerClock, untilStartMs > 0 @@ -123,21 +117,7 @@ const updateServerClock = () => { watch( () => [props.serverTime, props.serverWallTime, props.clockMode, props.clockRunning, props.clockStartsAt] as const, ([serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt]) => { - const parsed = serverTime ? new Date(serverTime).getTime() : Number.NaN; - sampledServerTimeMs = Number.isFinite(parsed) ? parsed : null; - sampledClientTimeMs = Date.now(); - if (clockMode === 'manual') { - sampledStartDelayMs = null; - } else if (clockRunning !== false) { - sampledStartDelayMs = 0; - } else { - const wallTimeMs = serverWallTime ? new Date(serverWallTime).getTime() : Number.NaN; - const startsAtMs = clockStartsAt ? new Date(clockStartsAt).getTime() : Number.NaN; - sampledStartDelayMs = - Number.isFinite(wallTimeMs) && Number.isFinite(startsAtMs) - ? Math.max(0, startsAtMs - wallTimeMs) - : null; - } + serverClockSample = sampleServerClock({ serverTime, serverWallTime, clockMode, clockRunning, clockStartsAt }); updateServerClock(); }, { immediate: true } diff --git a/app/game-frontend/src/components/main/MainFrontStatus.vue b/app/game-frontend/src/components/main/MainFrontStatus.vue index d4e5000f..b8e5d1d2 100644 --- a/app/game-frontend/src/components/main/MainFrontStatus.vue +++ b/app/game-frontend/src/components/main/MainFrontStatus.vue @@ -1,10 +1,26 @@