diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index eae4b2cf..44880b54 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -216,17 +216,23 @@ const installRealtimeHarness = async (page: Page) => { await page.addInitScript(() => { class TestEventSource extends EventTarget { static latest: TestEventSource | null = null; + static created = 0; + static closed = 0; readonly url: string; constructor(url: string | URL) { super(); this.url = url.toString(); + TestEventSource.created += 1; TestEventSource.latest = this; queueMicrotask(() => this.dispatchEvent(new Event('open'))); } close() { - if (TestEventSource.latest === this) TestEventSource.latest = null; + if (TestEventSource.latest === this) { + TestEventSource.latest = null; + TestEventSource.closed += 1; + } } } @@ -243,6 +249,14 @@ const installRealtimeHarness = async (page: Page) => { configurable: true, value: () => TestEventSource.latest !== null, }); + Object.defineProperty(window, '__mainRealtimeStats', { + configurable: true, + value: () => ({ + active: TestEventSource.latest !== null, + created: TestEventSource.created, + closed: TestEventSource.closed, + }), + }); }); }; @@ -517,7 +531,9 @@ test('mobile single document refreshes once and preserves tokens on lobby return expect(state.operations).not.toContain('auth.logout'); }); -test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({ page }) => { +test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({ + page, +}) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -673,9 +689,9 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl } ); }); - await expect.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 }).toEqual([ - 'general.getFrontStatus', - ]); + await expect + .poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 }) + .toEqual(['general.getFrontStatus']); const profile = await page.evaluate(() => { const probe = ( @@ -725,10 +741,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl await page.locator(`a[href="${basePath}/board"]`).first().click(); await page.waitForURL(`**${basePath}/board`); expect( - await page.evaluate( - () => - (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() - ) + await page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) ).toBe(false); const callsAfterLeavingMain = state.generalMeCalls; await page.evaluate(() => { @@ -763,3 +776,145 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl await new Promise((resolve) => setTimeout(resolve, 300)); expect(state.generalMeCalls).toBe(callsAfterLeavingMain); }); + +test('same-account main tabs share one realtime diff and exclude a tab while sync is off', async ({ + context, + page, +}) => { + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + const secondPage = await context.newPage(); + const pages = [page, secondPage]; + for (const currentPage of pages) { + await currentPage.addInitScript(() => { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' }); + }); + await installRealtimeHarness(currentPage); + await installFixture(currentPage, state); + await currentPage.setViewportSize({ width: 1200, height: 900 }); + } + + await Promise.all(pages.map((currentPage) => waitForMain(currentPage))); + await expect + .poll(async () => { + const stats = await Promise.all( + pages.map((currentPage) => + currentPage.evaluate(() => + ( + window as unknown as { + __mainRealtimeStats: () => { active: boolean; created: number; closed: number }; + } + ).__mainRealtimeStats() + ) + ) + ); + return stats.filter((entry) => entry.active).length; + }) + .toBe(1); + + const activeFlags = await Promise.all( + pages.map((currentPage) => + currentPage.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) + ) + ); + const leaderIndex = activeFlags.findIndex(Boolean); + const followerIndex = leaderIndex === 0 ? 1 : 0; + const leaderPage = pages[leaderIndex]; + const followerPage = pages[followerIndex]; + if (!leaderPage || !followerPage) throw new Error('realtime leader election failed'); + + const callsBeforeSharedRefresh = state.generalMeCalls; + state.generalName = '탭공유갱신장수'; + await leaderPage.evaluate(() => { + (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( + 'readModelChanged', + { + at: new Date().toISOString(), + revision: 100, + changes: { + generalIds: [7], + cityIds: [], + nationIds: [], + mapGeneralIds: [], + mapCityIds: [], + mapNationIds: [], + frontStatusGeneralIds: [], + frontStatusNationIds: [], + frontStatusActorIds: [], + frontStatusChanged: false, + lobbyGeneralIds: [], + lobbyChanged: false, + reservedGeneralIds: [], + recordGeneralIds: [], + worldChanged: false, + globalRecordsChanged: false, + worldHistoryChanged: false, + contactsChanged: false, + }, + } + ); + }); + await expect.poll(() => state.generalMeCalls).toBe(callsBeforeSharedRefresh + 1); + await Promise.all( + pages.map((currentPage) => expect(currentPage.locator('.general-title')).toContainText('탭공유갱신장수')) + ); + + await followerPage.getByRole('button', { name: /실시간 동기화/u }).click(); + await expect(followerPage.getByRole('button', { name: /실시간 동기화: 끔/u })).toBeVisible(); + const callsBeforeExcludedRefresh = state.generalMeCalls; + state.generalName = '리더만갱신장수'; + await leaderPage.evaluate(() => { + (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( + 'readModelChanged', + { + at: new Date().toISOString(), + revision: 101, + changes: { + generalIds: [7], + cityIds: [], + nationIds: [], + mapGeneralIds: [], + mapCityIds: [], + mapNationIds: [], + frontStatusGeneralIds: [], + frontStatusNationIds: [], + frontStatusActorIds: [], + frontStatusChanged: false, + lobbyGeneralIds: [], + lobbyChanged: false, + reservedGeneralIds: [], + recordGeneralIds: [], + worldChanged: false, + globalRecordsChanged: false, + worldHistoryChanged: false, + contactsChanged: false, + }, + } + ); + }); + await expect.poll(() => state.generalMeCalls).toBe(callsBeforeExcludedRefresh + 1); + await expect(leaderPage.locator('.general-title')).toContainText('리더만갱신장수'); + await expect(followerPage.locator('.general-title')).toContainText('탭공유갱신장수'); + + await followerPage.getByRole('button', { name: /실시간 동기화: 끔/u }).click(); + await expect(followerPage.locator('.general-title')).toContainText('리더만갱신장수'); + await expect + .poll(async () => { + const flags = await Promise.all( + pages.map((currentPage) => + currentPage.evaluate(() => + (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime() + ) + ) + ); + return flags.filter(Boolean).length; + }) + .toBe(1); +}); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index aeccfbcd..00285b62 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -1,4 +1,4 @@ -import { computed, ref, watch } from 'vue'; +import { computed, ref, toRaw, watch } from 'vue'; import { defineStore } from 'pinia'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic'; import type { RealtimeEvent, RealtimeReadModelChanges } from '@sammo-ts/common'; @@ -9,6 +9,7 @@ import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'; import { structurallyShare } from '../utils/structuralShare'; import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel'; +import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000; @@ -35,6 +36,26 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type ReservedTurnView = Awaited>['turns'][number]; type RecentRecord = Awaited>['global'][number]; type FrontStatus = Awaited>; + type DashboardReadModelPatch = { + general?: PresentGeneralContext['general'] | null; + city?: PresentGeneralContext['city'] | null; + nation?: PresentGeneralContext['nation'] | null; + lobbyInfo?: LobbyInfo | null; + worldMap?: WorldMapResult | null; + mapLayout?: MapLayout | null; + commandTable?: CommandTable | null; + messages?: MessageBundle | null; + messageContacts?: MessageContacts | null; + boardAccess?: BoardAccess | null; + reservedGeneralTurns?: ReservedTurnView[] | null; + reservedGeneralRevision?: number; + globalRecords?: RecentRecord[]; + generalRecords?: RecentRecord[]; + worldHistory?: RecentRecord[]; + frontStatus?: FrontStatus | null; + }; + type DashboardTabMessage = + { kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' }; const loading = ref(false); const refreshing = ref(false); @@ -206,9 +227,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); const setRealtimeEnabled = (enabled: boolean) => { + const wasEnabled = realtimeEnabled.value; realtimeEnabled.value = enabled; if (!enabled) { realtimeStatus.value = 'paused'; + reconcileRealtimeCoordinator(); + return; + } + if (!wasEnabled) { + void refreshQueue.request().finally(() => reconcileRealtimeCoordinator()); } }; @@ -253,9 +280,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { surveyNotice.value = null; }; - const applyRecentRecords = ( - records: Awaited> - ) => { + const applyRecentRecords = (records: Awaited>) => { globalRecords.value = structurallyShare( globalRecords.value, mergeRecentRecords(globalRecords.value, records.global) @@ -268,14 +293,86 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { worldHistory.value, mergeRecentRecords(worldHistory.value, records.history) ); - lastGeneralRecordId = Math.max( - lastGeneralRecordId, - records.global[0]?.id ?? 0, - records.general[0]?.id ?? 0 - ); + lastGeneralRecordId = Math.max(lastGeneralRecordId, records.global[0]?.id ?? 0, records.general[0]?.id ?? 0); lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0); }; + const applyDashboardPatch = (patch: DashboardReadModelPatch) => { + if (patch.general === null) { + general.value = null; + city.value = null; + nation.value = null; + reservedGeneralTurns.value = null; + reservedGeneralRevision.value = 0; + boardAccess.value = null; + resetRecentRecords(null); + } else if (patch.general !== undefined) { + general.value = structurallyShare(general.value, patch.general); + } + if (patch.city !== undefined) city.value = structurallyShare(city.value, patch.city); + if (patch.nation !== undefined) nation.value = structurallyShare(nation.value, patch.nation); + if (patch.lobbyInfo !== undefined) lobbyInfo.value = structurallyShare(lobbyInfo.value, patch.lobbyInfo); + if (patch.worldMap !== undefined) worldMap.value = structurallyShare(worldMap.value, patch.worldMap); + if (patch.mapLayout !== undefined) mapLayout.value = structurallyShare(mapLayout.value, patch.mapLayout); + if (patch.commandTable !== undefined) { + commandTable.value = structurallyShare(commandTable.value, patch.commandTable); + } + if (patch.messages !== undefined) messages.value = structurallyShare(messages.value, patch.messages); + if (patch.messageContacts !== undefined) { + messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts); + } + if (patch.boardAccess !== undefined) + boardAccess.value = structurallyShare(boardAccess.value, patch.boardAccess); + if (patch.reservedGeneralTurns !== undefined) { + reservedGeneralTurns.value = structurallyShare( + reservedGeneralTurns.value, + patch.reservedGeneralTurns + ) as ReservedTurnView[] | null; + } + if (patch.reservedGeneralRevision !== undefined) { + reservedGeneralRevision.value = patch.reservedGeneralRevision; + } + if (patch.globalRecords !== undefined) { + globalRecords.value = structurallyShare(globalRecords.value, patch.globalRecords); + lastGeneralRecordId = Math.max(lastGeneralRecordId, patch.globalRecords[0]?.id ?? 0); + } + if (patch.generalRecords !== undefined) { + generalRecords.value = structurallyShare(generalRecords.value, patch.generalRecords); + lastGeneralRecordId = Math.max(lastGeneralRecordId, patch.generalRecords[0]?.id ?? 0); + } + if (patch.worldHistory !== undefined) { + worldHistory.value = structurallyShare(worldHistory.value, patch.worldHistory); + lastWorldHistoryId = Math.max(lastWorldHistoryId, patch.worldHistory[0]?.id ?? 0); + } + if (patch.frontStatus === null) { + frontStatus.value = null; + surveyNotice.value = null; + } else if (patch.frontStatus !== undefined) { + updateFrontStatus(patch.frontStatus); + } + }; + + const currentDashboardPatch = (): DashboardReadModelPatch => { + const patch: DashboardReadModelPatch = {}; + patch.general = toRaw(general.value); + patch.city = toRaw(city.value); + patch.nation = toRaw(nation.value); + patch.lobbyInfo = toRaw(lobbyInfo.value); + patch.worldMap = toRaw(worldMap.value); + patch.mapLayout = toRaw(mapLayout.value); + patch.commandTable = toRaw(commandTable.value); + patch.messages = toRaw(messages.value); + patch.messageContacts = toRaw(messageContacts.value); + patch.boardAccess = toRaw(boardAccess.value); + patch.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null; + patch.reservedGeneralRevision = reservedGeneralRevision.value; + patch.globalRecords = toRaw(globalRecords.value); + patch.generalRecords = toRaw(generalRecords.value); + patch.worldHistory = toRaw(worldHistory.value); + patch.frontStatus = toRaw(frontStatus.value); + return patch; + }; + const refreshMainData = async () => { const isInitialLoad = !initialized; if (isInitialLoad) { @@ -384,9 +481,22 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const refreshQueue = createLatestRefreshQueue(refreshMainData); const loadMainData = () => refreshQueue.request(); - const realtimeRefreshQueue = createRateLimitedRefreshQueue(() => refreshQueue.request(), { - minIntervalMs: REALTIME_FULL_REFRESH_MIN_INTERVAL_MS, - }); + let realtimeCoordinator: BroadcastTabCoordinator | null = null; + let realtimeCoordinatorScope: string | null = null; + + const publishDashboardPatch = (patch: DashboardReadModelPatch) => { + realtimeCoordinator?.postFromLeader({ kind: 'patch', patch }); + }; + + const realtimeRefreshQueue = createRateLimitedRefreshQueue( + async () => { + await refreshQueue.request(); + publishDashboardPatch(currentDashboardPatch()); + }, + { + minIntervalMs: REALTIME_FULL_REFRESH_MIN_INTERVAL_MS, + } + ); const refreshChangedReadModels = async (changes: RealtimeReadModelChanges) => { const id = generalId.value; @@ -452,35 +562,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { frontPromise, ]); + const patch: DashboardReadModelPatch = {}; if (context === null) { - general.value = null; - city.value = null; - nation.value = null; - reservedGeneralTurns.value = null; - reservedGeneralRevision.value = 0; - boardAccess.value = null; - resetRecentRecords(null); - return; + patch.general = null; + } else if (context !== undefined) { + patch.general = context.general; + patch.city = context.city; + patch.nation = context.nation; } - if (context !== undefined) { - general.value = structurallyShare(general.value, context.general); - city.value = structurallyShare(city.value, context.city); - nation.value = structurallyShare(nation.value, context.nation); - } - if (lobby !== undefined) lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby); - if (map !== undefined) worldMap.value = structurallyShare(worldMap.value, map); - if (commands !== undefined) commandTable.value = structurallyShare(commandTable.value, commands); - if (contacts !== undefined) messageContacts.value = structurallyShare(messageContacts.value, contacts); - if (access !== undefined) boardAccess.value = structurallyShare(boardAccess.value, access); + if (lobby !== undefined) patch.lobbyInfo = lobby; + if (map !== undefined) patch.worldMap = map; + if (commands !== undefined) patch.commandTable = commands; + if (contacts !== undefined) patch.messageContacts = contacts; + if (access !== undefined) patch.boardAccess = access; if (generalTurns !== undefined) { - reservedGeneralTurns.value = structurallyShare( - reservedGeneralTurns.value, - generalTurns.turns - ) as ReservedTurnView[]; - reservedGeneralRevision.value = generalTurns.revision; + patch.reservedGeneralTurns = generalTurns.turns; + patch.reservedGeneralRevision = generalTurns.revision; } - if (records) applyRecentRecords(records); - if (nextFrontStatus) updateFrontStatus(nextFrontStatus); + if (records) { + const nextGlobalRecords = mergeRecentRecords(globalRecords.value, records.global); + const nextGeneralRecords = mergeRecentRecords(generalRecords.value, records.general); + const nextWorldHistory = mergeRecentRecords(worldHistory.value, records.history); + patch.globalRecords = nextGlobalRecords; + patch.generalRecords = nextGeneralRecords; + patch.worldHistory = nextWorldHistory; + } + if (nextFrontStatus) patch.frontStatus = nextFrontStatus; + applyDashboardPatch(patch); + publishDashboardPatch(patch); } catch (err) { error.value = resolveErrorMessage(err); } finally { @@ -496,7 +605,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return; } try { - messages.value = structurallyShare(messages.value, await trpc.messages.getRecent.query({ generalId: id })); + const nextMessages = await trpc.messages.getRecent.query({ generalId: id }); + const patch = { messages: nextMessages } satisfies DashboardReadModelPatch; + applyDashboardPatch(patch); + publishDashboardPatch(patch); } catch (err) { error.value = resolveErrorMessage(err); } @@ -762,6 +874,61 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { realtimeToken = null; }; + const isRealtimeParticipant = (): boolean => + realtimeActive.value && + document.visibilityState !== 'hidden' && + realtimeEnabled.value && + session.isReady && + session.hasGeneral && + generalId.value !== null; + + const closeRealtimeCoordinator = () => { + const coordinator = realtimeCoordinator; + realtimeCoordinator = null; + realtimeCoordinatorScope = null; + coordinator?.stop(); + closeRealtimeSource(); + }; + + const reconcileRealtimeCoordinator = () => { + if (typeof window === 'undefined') return; + if (!isRealtimeParticipant()) { + closeRealtimeCoordinator(); + return; + } + if (typeof BroadcastChannel === 'undefined') { + void connectRealtime(); + return; + } + + const profile = session.profile ?? 'game'; + const account = session.user?.id ?? `general-${generalId.value}`; + const scope = `${encodeURIComponent(profile)}:${encodeURIComponent(account)}`; + if (realtimeCoordinator && realtimeCoordinatorScope === scope) return; + + closeRealtimeCoordinator(); + realtimeCoordinatorScope = scope; + realtimeCoordinator = createBroadcastTabCoordinator(`sammo-main-dashboard:${scope}`, { + onLeadershipChange: (leader) => { + if (leader) { + void connectRealtime(); + } else { + closeRealtimeSource(); + if (realtimeEnabled.value) realtimeStatus.value = 'idle'; + } + }, + onPayload: (message) => { + if (!isRealtimeParticipant()) return; + if (message.kind === 'patch') { + applyDashboardPatch(message.patch); + return; + } + realtimeStatus.value = message.status; + }, + }); + realtimeCoordinator.start(); + }; + const ensureAccessToken = async (): Promise => { if (!session.gameToken) { return null; @@ -785,7 +952,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { document.visibilityState === 'hidden' || !realtimeEnabled.value || !session.isReady || - !session.hasGeneral + !session.hasGeneral || + (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) ) { return; } @@ -794,6 +962,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { realtimeStatus.value = 'idle'; return; } + if (!isRealtimeParticipant() || (realtimeCoordinator !== null && !realtimeCoordinator.isLeader())) { + return; + } if (realtimeSource && realtimeToken === token) { return; } @@ -806,11 +977,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { source.addEventListener('open', () => { realtimeStatus.value = 'connected'; + realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' }); }); source.addEventListener('error', () => { realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; + realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); }); source.addEventListener('turnCompleted', (event) => { + if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; const payload = parseRealtimePayload(event); if (!payload || payload.type !== 'turnCompleted') { return; @@ -823,6 +997,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { readModelRefreshQueue.request(payload.changes); }); source.addEventListener('readModelChanged', (event) => { + if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; const payload = parseRealtimePayload(event); if (!payload || payload.type !== 'readModelChanged') { return; @@ -830,6 +1005,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { readModelRefreshQueue.request(payload.changes); }); source.addEventListener('messageCreated', (event) => { + if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; const payload = parseRealtimePayload(event); if (!payload || payload.type !== 'messageCreated') { return; @@ -841,6 +1017,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { source.addEventListener('ping', () => { if (realtimeEnabled.value) { realtimeStatus.value = 'connected'; + realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' }); } }); }; @@ -850,13 +1027,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (document.visibilityState === 'hidden') { realtimeRefreshQueue.cancelPending(); readModelRefreshQueue.cancelPending(); - closeRealtimeSource(); + closeRealtimeCoordinator(); realtimeStatus.value = 'idle'; return; } realtimeRefreshQueue.beginCooldown(); - void connectRealtime(); - realtimeRefreshQueue.request(); + void refreshQueue.request().finally(() => reconcileRealtimeCoordinator()); }; const startRealtime = () => { @@ -867,13 +1043,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { document.addEventListener('visibilitychange', handleVisibilityChange); visibilityListenerInstalled = true; } + reconcileRealtimeCoordinator(); }; const stopRealtime = () => { realtimeActive.value = false; realtimeRefreshQueue.cancelPending(); readModelRefreshQueue.cancelPending(); - closeRealtimeSource(); + closeRealtimeCoordinator(); if (visibilityListenerInstalled) { document.removeEventListener('visibilitychange', handleVisibilityChange); visibilityListenerInstalled = false; @@ -882,24 +1059,22 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }; watch( - () => [realtimeActive.value, realtimeEnabled.value, session.isReady, session.hasGeneral, session.gameToken], + () => [ + realtimeActive.value, + realtimeEnabled.value, + session.isReady, + session.hasGeneral, + session.gameToken, + session.profile, + session.user?.id, + generalId.value, + ], ([active, enabled, ready, hasGeneral]) => { - if (!active) { - closeRealtimeSource(); + realtimeStatus.value = !enabled ? 'paused' : realtimeStatus.value; + if (!active || !ready || !hasGeneral) { realtimeStatus.value = enabled ? 'idle' : 'paused'; - return; } - if (!enabled) { - closeRealtimeSource(); - realtimeStatus.value = 'paused'; - return; - } - if (!ready || !hasGeneral) { - closeRealtimeSource(); - realtimeStatus.value = 'idle'; - return; - } - void connectRealtime(); + reconcileRealtimeCoordinator(); } ); diff --git a/app/game-frontend/src/utils/broadcastTabCoordinator.ts b/app/game-frontend/src/utils/broadcastTabCoordinator.ts new file mode 100644 index 00000000..4c6e92d6 --- /dev/null +++ b/app/game-frontend/src/utils/broadcastTabCoordinator.ts @@ -0,0 +1,166 @@ +export interface BroadcastChannelLike { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: T): void; + close(): void; +} + +type CoordinatorWireMessage = + | { kind: 'presence'; tabId: string; sentAt: number } + | { kind: 'leave'; tabId: string; sentAt: number } + | { kind: 'payload'; tabId: string; sentAt: number; payload: T }; + +export interface BroadcastTabCoordinator { + start(): void; + stop(): void; + isLeader(): boolean; + postFromLeader(payload: T): boolean; +} + +interface BroadcastTabCoordinatorOptions { + onLeadershipChange: (leader: boolean) => void; + onPayload: (payload: T) => void; + createChannel?: (name: string) => BroadcastChannelLike>; + createTabId?: () => string; + now?: () => number; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; + settleMs?: number; + heartbeatMs?: number; + peerExpiryMs?: number; +} + +const defaultCreateTabId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +}; + +/** + * Elect one active tab per channel without persisting credentials or state. + * Every participant advertises a short-lived presence lease; the lowest tab id + * owns realtime I/O and broadcasts the fetched result to the other tabs. + */ +export const createBroadcastTabCoordinator = ( + channelName: string, + options: BroadcastTabCoordinatorOptions +): BroadcastTabCoordinator => { + const createChannel = + options.createChannel ?? + ((name: string) => new BroadcastChannel(name) as BroadcastChannelLike>); + const createTabId = options.createTabId ?? defaultCreateTabId; + const now = options.now ?? Date.now; + const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)); + const settleMs = Math.max(0, options.settleMs ?? 100); + const heartbeatMs = Math.max(100, options.heartbeatMs ?? 2_000); + const peerExpiryMs = Math.max(heartbeatMs * 2, options.peerExpiryMs ?? 5_000); + const tabId = createTabId(); + + let channel: BroadcastChannelLike> | null = null; + let settleTimer: ReturnType | null = null; + let heartbeatTimer: ReturnType | null = null; + let started = false; + let settled = false; + let leader = false; + const peers = new Map(); + + const send = (message: CoordinatorWireMessage): boolean => { + if (!channel) return false; + try { + channel.postMessage(message); + return true; + } catch { + // Realtime fan-out is best effort, like the SSE/Redis boundary. + return false; + } + }; + + const announce = () => { + const sentAt = now(); + peers.set(tabId, sentAt); + send({ kind: 'presence', tabId, sentAt }); + }; + + const setLeader = (next: boolean) => { + if (leader === next) return; + leader = next; + options.onLeadershipChange(next); + }; + + const elect = () => { + if (!started || !settled) return; + const cutoff = now() - peerExpiryMs; + for (const [peerId, seenAt] of peers) { + if (peerId !== tabId && seenAt < cutoff) peers.delete(peerId); + } + peers.set(tabId, now()); + const elected = [...peers.keys()].sort()[0]; + setLeader(elected === tabId); + }; + + const scheduleHeartbeat = () => { + heartbeatTimer = setTimer(() => { + heartbeatTimer = null; + if (!started) return; + announce(); + elect(); + scheduleHeartbeat(); + }, heartbeatMs); + }; + + return { + start: () => { + if (started) return; + started = true; + channel = createChannel(channelName); + channel.onmessage = (event) => { + const message = event.data; + if (!message || message.tabId === tabId) return; + if (message.kind === 'presence') { + const isNewPeer = !peers.has(message.tabId); + peers.set(message.tabId, now()); + if (isNewPeer) announce(); + elect(); + return; + } + if (message.kind === 'leave') { + peers.delete(message.tabId); + elect(); + return; + } + if (message.kind === 'payload' && !leader) { + peers.set(message.tabId, now()); + options.onPayload(message.payload); + elect(); + } + }; + announce(); + settleTimer = setTimer(() => { + settleTimer = null; + settled = true; + elect(); + }, settleMs); + scheduleHeartbeat(); + }, + stop: () => { + if (!started) return; + send({ kind: 'leave', tabId, sentAt: now() }); + started = false; + settled = false; + peers.clear(); + if (settleTimer !== null) clearTimer(settleTimer); + if (heartbeatTimer !== null) clearTimer(heartbeatTimer); + settleTimer = null; + heartbeatTimer = null; + channel?.close(); + channel = null; + setLeader(false); + }, + isLeader: () => started && leader, + postFromLeader: (payload) => { + if (!started || !leader) return false; + return send({ kind: 'payload', tabId, sentAt: now(), payload }); + }, + }; +}; diff --git a/app/game-frontend/test/broadcastTabCoordinator.test.ts b/app/game-frontend/test/broadcastTabCoordinator.test.ts new file mode 100644 index 00000000..7851f730 --- /dev/null +++ b/app/game-frontend/test/broadcastTabCoordinator.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createBroadcastTabCoordinator } from '../src/utils/broadcastTabCoordinator.ts'; + +type FakeMessage = { kind: string; tabId: string; sentAt: number; payload?: string }; + +class FakeBroadcastBus { + readonly channels = new Set(); + + open(): FakeBroadcastChannel { + const channel = new FakeBroadcastChannel(this); + this.channels.add(channel); + return channel; + } +} + +class FakeBroadcastChannel { + onmessage: ((event: MessageEvent) => void) | null = null; + private readonly bus: FakeBroadcastBus; + + constructor(bus: FakeBroadcastBus) { + this.bus = bus; + } + + postMessage(message: FakeMessage): void { + for (const channel of this.bus.channels) { + if (channel === this) continue; + queueMicrotask(() => channel.onmessage?.({ data: message } as MessageEvent)); + } + } + + close(): void { + this.bus.channels.delete(this); + this.onmessage = null; + } +} + +const wait = (delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs)); + +void test('one active tab owns broadcasts, followers consume its payload, and leave hands leadership over', async () => { + const bus = new FakeBroadcastBus(); + const firstLeadership: boolean[] = []; + const secondLeadership: boolean[] = []; + const firstPayloads: string[] = []; + const secondPayloads: string[] = []; + const common = { + createChannel: () => bus.open() as never, + settleMs: 5, + heartbeatMs: 100, + peerExpiryMs: 500, + }; + const first = createBroadcastTabCoordinator('same-account', { + ...common, + createTabId: () => 'tab-b', + onLeadershipChange: (leader) => firstLeadership.push(leader), + onPayload: (payload) => firstPayloads.push(payload), + }); + const second = createBroadcastTabCoordinator('same-account', { + ...common, + createTabId: () => 'tab-a', + onLeadershipChange: (leader) => secondLeadership.push(leader), + onPayload: (payload) => secondPayloads.push(payload), + }); + + first.start(); + second.start(); + await wait(20); + + assert.equal(first.isLeader(), false); + assert.equal(second.isLeader(), true); + assert.equal(first.postFromLeader('ignored'), false); + assert.equal(second.postFromLeader('shared-patch'), true); + await wait(0); + assert.deepEqual(firstPayloads, ['shared-patch']); + assert.deepEqual(secondPayloads, []); + + second.stop(); + await wait(0); + assert.equal(first.isLeader(), true); + assert.equal(firstLeadership.at(-1), true); + assert.deepEqual(secondLeadership, [true, false]); + + first.stop(); +});