diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index b7979b7..0f961f0 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -4,6 +4,7 @@ import { expect, test, type Page, type Route } from '@playwright/test'; const response = (data: unknown) => ({ result: { data } }); const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR; +const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR; const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; const operationNames = (route: Route) => @@ -17,6 +18,8 @@ type NavigationFixture = { npcMode: number; generalMeCalls: number; operations: string[]; + generalName?: string; + refreshDelayMs?: number; }; const emptyMessages = (permission: number) => ({ @@ -34,7 +37,7 @@ const emptyMessages = (permission: number) => ({ const generalContext = (state: NavigationFixture) => ({ general: { id: 7, - name: '메뉴검증장수', + name: state.generalName ?? '메뉴검증장수', nationId: 1, cityId: 1, troopId: 0, @@ -124,6 +127,9 @@ const installFixture = async (page: Page, state: NavigationFixture) => { await page.route(`**${basePath}/api/trpc/**`, async (route) => { const operations = operationNames(route); state.operations.push(...operations); + if (operations.includes('general.me') && state.generalMeCalls > 0 && state.refreshDelayMs) { + await new Promise((resolve) => setTimeout(resolve, state.refreshDelayMs)); + } const results = operations.map((operation) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') { @@ -206,6 +212,36 @@ const installFixture = async (page: Page, state: NavigationFixture) => { }); }; +const installRealtimeHarness = async (page: Page) => { + await page.addInitScript(() => { + class TestEventSource extends EventTarget { + static latest: TestEventSource | null = null; + readonly url: string; + + constructor(url: string | URL) { + super(); + this.url = url.toString(); + TestEventSource.latest = this; + queueMicrotask(() => this.dispatchEvent(new Event('open'))); + } + + close() { + if (TestEventSource.latest === this) TestEventSource.latest = null; + } + } + + Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource }); + Object.defineProperty(window, '__emitMainRealtime', { + configurable: true, + value: (type: string, payload: unknown) => { + TestEventSource.latest?.dispatchEvent( + new MessageEvent(type, { data: JSON.stringify({ type, ...((payload as object) ?? {}) }) }) + ); + }, + }); + }); +}; + const waitForMain = async (page: Page) => { await page.goto('./'); await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); @@ -476,3 +512,118 @@ test('mobile single document refreshes once and preserves tokens on lobby return }); expect(state.operations).not.toContain('auth.logout'); }); + +test('turn realtime refresh keeps rendered panels mounted and patches only changed state', async ({ page }) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + refreshDelayMs: 300, + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + await expect(page.locator('.general-title')).toContainText('메뉴검증장수'); + + await page.evaluate(() => { + const general = document.querySelector('[data-main-target="general"]'); + const city = document.querySelector('[data-main-target="city"]'); + if (!general || !city) throw new Error('refresh probe targets missing'); + const probe = { + general, + city, + generalMutations: 0, + cityMutations: 0, + vueMeasures: [] as string[], + }; + new MutationObserver((records) => (probe.generalMutations += records.length)).observe(general, { + childList: true, + subtree: true, + characterData: true, + }); + new MutationObserver((records) => (probe.cityMutations += records.length)).observe(city, { + childList: true, + subtree: true, + characterData: true, + }); + Object.defineProperty(window, '__mainRefreshProbe', { configurable: true, value: probe }); + performance.clearMarks(); + performance.clearMeasures(); + new PerformanceObserver((entries) => { + probe.vueMeasures.push(...entries.getEntries().map((entry) => entry.name)); + }).observe({ entryTypes: ['measure'] }); + }); + + const callsBeforeRefresh = state.generalMeCalls; + state.generalName = '부드럽게갱신된장수'; + await page.evaluate(() => { + const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) + .__emitMainRealtime; + emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() }); + emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() }); + emit('turnCompleted', { year: 185, month: 2, processedAt: new Date().toISOString() }); + }); + + await expect.poll(() => state.generalMeCalls).toBe(callsBeforeRefresh + 1); + await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0); + await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0); + await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'true'); + if (autoRefreshArtifactRoot) { + await mkdir(resolve(autoRefreshArtifactRoot), { recursive: true }); + await page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-in-flight.png'), fullPage: true }); + } + + await expect.poll(() => state.generalMeCalls, { timeout: 5_000 }).toBe(callsBeforeRefresh + 2); + await expect(page.locator('.general-title')).toContainText('부드럽게갱신된장수'); + await expect(page.getByRole('button', { name: '갱 신' })).toHaveAttribute('aria-busy', 'false'); + + const profile = await page.evaluate(() => { + const probe = ( + window as unknown as { + __mainRefreshProbe: { + general: Element; + city: Element; + generalMutations: number; + cityMutations: number; + vueMeasures: string[]; + }; + } + ).__mainRefreshProbe; + return { + generalMounted: probe.general === document.querySelector('[data-main-target="general"]'), + cityMounted: probe.city === document.querySelector('[data-main-target="city"]'), + generalMutations: probe.generalMutations, + cityMutations: probe.cityMutations, + vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)), + }; + }); + expect(profile.generalMounted).toBe(true); + expect(profile.cityMounted).toBe(true); + expect(profile.generalMutations).toBeGreaterThan(0); + expect(profile.cityMutations).toBe(0); + expect(profile.vueMeasures.some((name) => name.includes('GeneralBasicCard'))).toBe(true); + expect(profile.vueMeasures.some((name) => name.includes('CityBasicCard'))).toBe(false); + if (autoRefreshArtifactRoot) { + await Promise.all([ + page.screenshot({ path: resolve(autoRefreshArtifactRoot, 'auto-refresh-complete.png'), fullPage: true }), + writeFile( + resolve(autoRefreshArtifactRoot, 'profile.json'), + `${JSON.stringify( + { + emittedTurnEvents: 3, + refreshRequests: state.generalMeCalls - callsBeforeRefresh, + inFlightSkeletons: { general: 0, city: 0 }, + ...profile, + }, + null, + 2 + )}\n` + ), + ]); + } +}); diff --git a/app/game-frontend/src/main.ts b/app/game-frontend/src/main.ts index 05866a0..f23a5fc 100644 --- a/app/game-frontend/src/main.ts +++ b/app/game-frontend/src/main.ts @@ -6,6 +6,11 @@ import './assets/main.css'; const app = createApp(App); +// Vue emits component init/render/patch measures in development builds. This +// keeps realtime refresh profiling available in Chromium DevTools without +// adding production runtime work. +app.config.performance = import.meta.env.DEV; + const pinia = createPinia(); app.use(pinia); app.use(router); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 7fdc787..0e6cb5b 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -5,6 +5,8 @@ import type { RealtimeEvent } from '@sammo-ts/common'; import { trpc } from '../utils/trpc'; import { useMapViewerStore } from './mapViewer'; import { useSessionStore } from './session'; +import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; +import { structurallyShare } from '../utils/structuralShare'; const resolveErrorMessage = (value: unknown): string => { if (value instanceof Error) { @@ -18,6 +20,7 @@ const resolveErrorMessage = (value: unknown): string => { export const useMainDashboardStore = defineStore('mainDashboard', () => { type GeneralContext = Awaited>; + type PresentGeneralContext = NonNullable; type LobbyInfo = Awaited>; type WorldMapResult = Awaited>; type MapLayout = Awaited>; @@ -30,13 +33,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type FrontStatus = Awaited>; const loading = ref(false); + const refreshing = ref(false); const error = ref(null); const recordsError = ref(null); const frontStatusError = ref(null); const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); - const generalContext = ref(null); + const general = ref(null); + const city = ref(null); + const nation = ref(null); const lobbyInfo = ref(null); const worldMap = ref(null); const mapLayout = ref(null); @@ -54,14 +60,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { let lastGeneralRecordId = 0; let lastWorldHistoryId = 0; let recordGeneralId: number | null = null; + let initialized = false; const messageDraftText = ref(''); const targetMailbox = ref(MESSAGE_MAILBOX_PUBLIC); let initializedMailboxGeneralId: number | null = null; - const general = computed(() => generalContext.value?.general ?? null); - const city = computed(() => generalContext.value?.city ?? null); - const nation = computed(() => generalContext.value?.nation ?? null); const generalId = computed(() => general.value?.id ?? null); const nationId = computed(() => nation.value?.id ?? null); const mapViewer = useMapViewerStore(); @@ -114,7 +118,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { options: MailboxOption[]; }; - const ownNationId = general.value?.nationId ?? 0; + const ownNationId = nationId.value ?? 0; const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId; const permission = messages.value?.permission ?? -1; const contacts = messageContacts.value?.nation ?? []; @@ -204,7 +208,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }; const updateFrontStatus = (nextStatus: FrontStatus) => { - frontStatus.value = nextStatus; + frontStatus.value = structurallyShare(frontStatus.value, nextStatus); const latestVote = nextStatus.latestVote; if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') { surveyNotice.value = null; @@ -244,25 +248,29 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { surveyNotice.value = null; }; - const loadMainData = async () => { - if (loading.value) { - return; + const refreshMainData = async () => { + const isInitialLoad = !initialized; + if (isInitialLoad) { + loading.value = true; + } else { + refreshing.value = true; } - loading.value = true; error.value = null; recordsError.value = null; frontStatusError.value = null; try { const context = await trpc.general.me.query(); - generalContext.value = context; if (!context) { + general.value = null; + city.value = null; + nation.value = null; reservedGeneralTurns.value = null; reservedGeneralRevision.value = 0; boardAccess.value = null; resetRecentRecords(null); - loading.value = false; + initialized = true; return; } @@ -309,19 +317,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { frontStatusPromise, ]); - mapLayout.value = layout; - lobbyInfo.value = lobby; - worldMap.value = map; - commandTable.value = commands; - messages.value = messageData; - messageContacts.value = contacts; - boardAccess.value = access; - reservedGeneralTurns.value = generalTurns.turns; + general.value = structurallyShare(general.value, context.general); + city.value = structurallyShare(city.value, context.city); + nation.value = structurallyShare(nation.value, context.nation); + mapLayout.value = structurallyShare(mapLayout.value, layout); + lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby); + worldMap.value = structurallyShare(worldMap.value, map); + commandTable.value = structurallyShare(commandTable.value, commands); + messages.value = structurallyShare(messages.value, messageData); + messageContacts.value = structurallyShare(messageContacts.value, contacts); + boardAccess.value = structurallyShare(boardAccess.value, access); + reservedGeneralTurns.value = structurallyShare( + reservedGeneralTurns.value, + generalTurns.turns + ) as ReservedTurnView[]; reservedGeneralRevision.value = generalTurns.revision; if (records) { - globalRecords.value = mergeRecentRecords(globalRecords.value, records.global); - generalRecords.value = mergeRecentRecords(generalRecords.value, records.general); - worldHistory.value = mergeRecentRecords(worldHistory.value, records.history); + globalRecords.value = structurallyShare( + globalRecords.value, + mergeRecentRecords(globalRecords.value, records.global) + ); + generalRecords.value = structurallyShare( + generalRecords.value, + mergeRecentRecords(generalRecords.value, records.general) + ); + worldHistory.value = structurallyShare( + worldHistory.value, + mergeRecentRecords(worldHistory.value, records.history) + ); lastGeneralRecordId = Math.max( lastGeneralRecordId, records.global[0]?.id ?? 0, @@ -336,20 +359,28 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId; initializedMailboxGeneralId = id; } + initialized = true; } catch (err) { error.value = resolveErrorMessage(err); } finally { - loading.value = false; + if (isInitialLoad) { + loading.value = false; + } else { + refreshing.value = false; + } } }; + const refreshQueue = createLatestRefreshQueue(refreshMainData); + const loadMainData = () => refreshQueue.request(); + const refreshMessages = async () => { const id = generalId.value; if (!id) { return; } try { - messages.value = await trpc.messages.getRecent.query({ generalId: id }); + messages.value = structurallyShare(messages.value, await trpc.messages.getRecent.query({ generalId: id })); } catch (err) { error.value = resolveErrorMessage(err); } @@ -695,12 +726,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return { loading, + refreshing, error, recordsError, frontStatusError, realtimeEnabled, realtimeStatus, - generalContext, general, city, nation, diff --git a/app/game-frontend/src/utils/latestRefreshQueue.ts b/app/game-frontend/src/utils/latestRefreshQueue.ts new file mode 100644 index 0000000..c60d34e --- /dev/null +++ b/app/game-frontend/src/utils/latestRefreshQueue.ts @@ -0,0 +1,38 @@ +export type LatestRefreshQueue = { + request: () => Promise; + isRunning: () => boolean; +}; + +/** + * Coalesces bursts while guaranteeing one final refresh after an in-flight run. + * This matches realtime state semantics: intermediate turn notifications can be + * skipped, but the newest committed server state must never be lost. + */ +export const createLatestRefreshQueue = (refresh: () => Promise): LatestRefreshQueue => { + let active: Promise | null = null; + let refreshAgain = false; + + const request = (): Promise => { + if (active) { + refreshAgain = true; + return active; + } + + const run = async () => { + do { + refreshAgain = false; + await refresh(); + } while (refreshAgain); + }; + + active = run().finally(() => { + active = null; + }); + return active; + }; + + return { + request, + isRunning: () => active !== null, + }; +}; diff --git a/app/game-frontend/src/utils/structuralShare.ts b/app/game-frontend/src/utils/structuralShare.ts new file mode 100644 index 0000000..a92602c --- /dev/null +++ b/app/game-frontend/src/utils/structuralShare.ts @@ -0,0 +1,52 @@ +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype; + +/** + * Reuses every unchanged branch from the current snapshot. + * + * tRPC returns a fresh object graph for every request. Assigning that graph + * directly makes Vue notify consumers even when their slice did not change. + * Structural sharing keeps that notification boundary aligned with actual + * value changes without maintaining a field-by-field event routing table. + */ +export const structurallyShare = (current: T, incoming: T): T => { + if (Object.is(current, incoming)) { + return current; + } + + if (current instanceof Date && incoming instanceof Date) { + return (current.getTime() === incoming.getTime() ? current : incoming) as T; + } + + if (Array.isArray(current) && Array.isArray(incoming)) { + if (current.length !== incoming.length) { + return incoming; + } + let unchanged = true; + const shared = incoming.map((value, index) => { + const next = structurallyShare(current[index], value); + unchanged &&= Object.is(next, current[index]); + return next; + }); + return (unchanged ? current : shared) as T; + } + + if (isRecord(current) && isRecord(incoming)) { + const currentKeys = Object.keys(current); + const incomingKeys = Object.keys(incoming); + if (currentKeys.length !== incomingKeys.length || currentKeys.some((key) => !(key in incoming))) { + return incoming; + } + + let unchanged = true; + const shared: Record = {}; + for (const key of incomingKeys) { + const next = structurallyShare(current[key], incoming[key]); + shared[key] = next; + unchanged &&= Object.is(next, current[key]); + } + return (unchanged ? current : shared) as T; + } + + return incoming; +}; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index d057a78..4934c74 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -31,6 +31,7 @@ const npcMode = ref(0); const { loading, + refreshing, error, recordsError, frontStatusError, @@ -158,7 +159,13 @@ watch( > 실시간 동기화: {{ realtimeLabel }} - diff --git a/app/game-frontend/test/latestRefreshQueue.test.ts b/app/game-frontend/test/latestRefreshQueue.test.ts new file mode 100644 index 0000000..6bb7fbf --- /dev/null +++ b/app/game-frontend/test/latestRefreshQueue.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createLatestRefreshQueue } from '../src/utils/latestRefreshQueue.ts'; + +void test('coalesces an event burst into one final refresh without losing it', async () => { + const releases: Array<() => void> = []; + let runs = 0; + const queue = createLatestRefreshQueue(async () => { + runs += 1; + await new Promise((resolve) => releases.push(resolve)); + }); + + const first = queue.request(); + assert.equal(queue.isRunning(), true); + const second = queue.request(); + const third = queue.request(); + assert.equal(second, first); + assert.equal(third, first); + assert.equal(runs, 1); + + releases.shift()?.(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(runs, 2); + + releases.shift()?.(); + await first; + assert.equal(queue.isRunning(), false); + assert.equal(runs, 2); +}); diff --git a/app/game-frontend/test/structuralShare.test.ts b/app/game-frontend/test/structuralShare.test.ts new file mode 100644 index 0000000..826c369 --- /dev/null +++ b/app/game-frontend/test/structuralShare.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { structurallyShare } from '../src/utils/structuralShare.ts'; + +void test('reuses a completely unchanged tRPC snapshot', () => { + const current = { + general: { id: 7, name: '장수' }, + records: [{ id: 3, text: '기록' }], + createdAt: new Date('2026-08-07T00:00:00.000Z'), + }; + const incoming = { + general: { id: 7, name: '장수' }, + records: [{ id: 3, text: '기록' }], + createdAt: new Date('2026-08-07T00:00:00.000Z'), + }; + + assert.equal(structurallyShare(current, incoming), current); +}); + +void test('replaces only changed branches and preserves sibling identities', () => { + const current = { + general: { id: 7, name: '이전 이름' }, + city: { id: 1, name: '업' }, + records: [{ id: 3, text: '기록' }], + }; + const incoming = { + general: { id: 7, name: '새 이름' }, + city: { id: 1, name: '업' }, + records: [{ id: 3, text: '기록' }], + }; + + const shared = structurallyShare(current, incoming); + assert.notEqual(shared, current); + assert.notEqual(shared.general, current.general); + assert.deepEqual(shared.general, incoming.general); + assert.equal(shared.city, current.city); + assert.equal(shared.records, current.records); +});