diff --git a/app/game-api/src/realtime/publicEvent.ts b/app/game-api/src/realtime/publicEvent.ts new file mode 100644 index 00000000..cf70bd69 --- /dev/null +++ b/app/game-api/src/realtime/publicEvent.ts @@ -0,0 +1,85 @@ +import { + createFullRealtimeReadModelInvalidation, + hasRealtimeReadModelInvalidation, + mergeRealtimeReadModelInvalidations, + resolveRealtimeReadModelInvalidation, + type PublicRealtimeEvent, + type RealtimeEvent, + type RealtimeReadModelChanges, + type RealtimeViewerIdentity, +} from '@sammo-ts/common'; +import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts/logic'; + +const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => { + const seen = new Set(); + return identities.filter((identity) => { + const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +const isMailboxRelevant = (mailbox: number, identity: RealtimeViewerIdentity): boolean => + mailbox === MESSAGE_MAILBOX_PUBLIC || + (identity.generalId !== null && mailbox === identity.generalId) || + (identity.nationId !== null && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + identity.nationId); + +const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null => { + if (event.type === 'readModelChanged') return event.changes; + if (event.type === 'turnCompleted') return event.changes ?? null; + return null; +}; + +export const shouldReloadRealtimeViewerIdentity = ( + event: RealtimeEvent, + identity: RealtimeViewerIdentity +): boolean => { + if (identity.generalId === null) return false; + const changes = eventChanges(event); + if (!changes) return false; + const generalId = identity.generalId; + return [ + changes.generalIds, + changes.mapGeneralIds ?? changes.generalIds, + changes.frontStatusGeneralIds ?? [], + changes.frontStatusActorIds ?? [], + changes.lobbyGeneralIds ?? changes.generalIds, + changes.reservedGeneralIds, + changes.recordGeneralIds, + ].some((ids) => ids.includes(generalId)); +}; + +/** + * Converts an internal Redis event to the minimal browser contract. Empty + * clock-only turn events are suppressed; the remaining payload never includes + * entity IDs, wall-clock timestamps, logical turn times, or revisions. + */ +export const toPublicRealtimeEvent = ( + event: RealtimeEvent, + identities: readonly RealtimeViewerIdentity[] +): PublicRealtimeEvent | null => { + const viewers = uniqueIdentities( + identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }] + ); + if (event.type === 'messageCreated') { + return viewers.some((identity) => isMailboxRelevant(event.mailbox, identity)) + ? { type: 'messagesInvalidated' } + : null; + } + + if (event.type === 'turnCompleted' && !event.changes) { + return { + type: 'readModelInvalidated', + invalidation: createFullRealtimeReadModelInvalidation(), + }; + } + + const changes = eventChanges(event); + if (!changes) return null; + const invalidation = viewers + .map((identity) => resolveRealtimeReadModelInvalidation(changes, identity)) + .reduce(mergeRealtimeReadModelInvalidations); + if (!hasRealtimeReadModelInvalidation(invalidation)) return null; + return { type: 'readModelInvalidated', invalidation }; +}; diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index acd274b2..02d257d6 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static'; import path from 'path'; import fs from 'node:fs/promises'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; -import { buildGameEventChannel } from '@sammo-ts/common'; +import { buildGameEventChannel, type RealtimeViewerIdentity } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, @@ -23,6 +23,7 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisRealtimeEventHub } from './realtime/eventHub.js'; import { formatSseFrame } from './realtime/sse.js'; +import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; @@ -229,6 +230,17 @@ export const createGameApiServer = async () => { return; } + const loadViewerIdentity = async (): Promise => { + const general = await postgres.prisma.general.findFirst({ + where: { userId: auth.user.id, npcState: 0 }, + select: { id: true, cityId: true, nationId: true }, + }); + return general + ? { generalId: general.id, cityId: general.cityId, nationId: general.nationId } + : { generalId: null, cityId: null, nationId: null }; + }; + let viewerIdentity = await loadViewerIdentity(); + reply.hijack(); const requestOrigin = request.headers.origin; if (typeof requestOrigin === 'string' && requestOrigin.length > 0) { @@ -256,30 +268,47 @@ export const createGameApiServer = async () => { sendFrame( formatSseFrame({ event: 'ready', - data: JSON.stringify({ at: new Date().toISOString() }), + data: '{}', }) ); + let closed = false; + let eventQueue = Promise.resolve(); const unsubscribe = realtimeHub.subscribe((event) => { - sendFrame( - formatSseFrame({ - event: event.type, - data: JSON.stringify(event), - id: event.at, + eventQueue = eventQueue + .then(async () => { + if (closed) return; + const identities = [viewerIdentity]; + if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) { + const nextIdentity = await loadViewerIdentity(); + identities.push(nextIdentity); + viewerIdentity = nextIdentity; + } + const publicEvent = toPublicRealtimeEvent(event, identities); + if (!publicEvent || closed) return; + sendFrame( + formatSseFrame({ + event: publicEvent.type, + data: JSON.stringify(publicEvent), + }) + ); }) - ); + .catch(() => { + // A best-effort notification must not affect committed game state. + }); }); const heartbeat = setInterval(() => { sendFrame( formatSseFrame({ event: 'ping', - data: JSON.stringify({ at: new Date().toISOString() }), + data: '{}', }) ); }, 15000); const close = () => { + closed = true; clearInterval(heartbeat); unsubscribe(); }; diff --git a/app/game-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts new file mode 100644 index 00000000..19474989 --- /dev/null +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; + +import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common'; +import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic'; + +import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from '../src/realtime/publicEvent.js'; + +const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const; + +const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({ + type: 'turnCompleted', + at: '2026-08-12T12:34:56.789Z', + lastTurnTime: '0185-02-01T00:00:00.000Z', + changes, + revision: 42, +}); + +describe('public realtime event privacy boundary', () => { + it('suppresses clock-only and unrelated private general turns', () => { + expect(toPublicRealtimeEvent(turnEvent(), [viewer])).toBeNull(); + expect( + toPublicRealtimeEvent( + turnEvent({ + ...createEmptyRealtimeReadModelChanges(), + generalIds: [99], + }), + [viewer] + ) + ).toBeNull(); + }); + + it('publishes only viewer-specific boolean invalidations', () => { + const publicEvent = toPublicRealtimeEvent( + turnEvent({ + ...createEmptyRealtimeReadModelChanges(), + generalIds: [7, 99], + reservedGeneralIds: [7], + recordGeneralIds: [7], + }), + [viewer] + ); + + expect(publicEvent).toEqual({ + type: 'readModelInvalidated', + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: true, + records: true, + frontStatus: false, + }, + }); + const serialized = JSON.stringify(publicEvent); + expect(publicEvent).not.toHaveProperty('at'); + expect(publicEvent).not.toHaveProperty('lastTurnTime'); + expect(publicEvent).not.toHaveProperty('revision'); + for (const forbidden of ['generalIds', 'cityIds', 'nationIds', '99']) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('keeps global refresh meaning without exposing its source identity or time', () => { + const publicEvent = toPublicRealtimeEvent( + { + type: 'readModelChanged', + at: '2026-08-12T12:34:56.789Z', + revision: 43, + changes: { + ...createEmptyRealtimeReadModelChanges(), + worldChanged: true, + globalRecordsChanged: true, + worldHistoryChanged: true, + }, + }, + [viewer] + ); + + expect(publicEvent).toMatchObject({ + type: 'readModelInvalidated', + invalidation: { lobby: true, map: true, commands: true, records: true }, + }); + expect(JSON.stringify(publicEvent)).not.toMatch(/2026|0185|revision|Ids/u); + }); + + it('uses a conservative identifier-free fallback for an older daemon', () => { + expect( + toPublicRealtimeEvent( + { + type: 'turnCompleted', + at: '2026-08-12T12:34:56.789Z', + lastTurnTime: '0185-02-01T00:00:00.000Z', + }, + [viewer] + ) + ).toEqual({ + type: 'readModelInvalidated', + invalidation: { + context: true, + lobby: true, + map: true, + commands: true, + contacts: true, + boardAccess: true, + reservedTurns: true, + records: true, + frontStatus: true, + }, + }); + }); + + it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => { + const event: RealtimeEvent = { + type: 'messageCreated', + at: '2026-08-12T12:34:56.789Z', + mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId, + msgType: 'national', + messageId: 123, + senderId: 99, + }; + + expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' }); + expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull(); + }); + + it('requests an identity refresh only when the viewer general may have changed', () => { + expect( + shouldReloadRealtimeViewerIdentity( + turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] }), + viewer + ) + ).toBe(true); + expect( + shouldReloadRealtimeViewerIdentity( + turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [99] }), + viewer + ) + ).toBe(false); + }); + + it('merges previous and committed identities across an ownership transition', () => { + const event: RealtimeEvent = { + type: 'readModelChanged', + at: '2026-08-12T12:34:56.789Z', + revision: 44, + changes: { + ...createEmptyRealtimeReadModelChanges(), + generalIds: [7], + nationIds: [3], + frontStatusNationIds: [3], + }, + }; + + expect( + toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }]) + ).toMatchObject({ + type: 'readModelInvalidated', + invalidation: { + context: true, + commands: true, + boardAccess: true, + frontStatus: true, + }, + }); + }); +}); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 7654e9ec..2651a2ce 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -68,60 +68,40 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => { return entry.json ?? (entry as DashboardBundleInput); }; -const readModelChanges = ( +const readModelInvalidation = ( overrides: Partial<{ - generalIds: number[]; - cityIds: number[]; - nationIds: number[]; - mapGeneralIds: number[]; - mapCityIds: number[]; - mapNationIds: number[]; - frontStatusGeneralIds: number[]; - frontStatusNationIds: number[]; - frontStatusActorIds: number[]; - frontStatusChanged: boolean; - lobbyGeneralIds: number[]; - lobbyChanged: boolean; - reservedGeneralIds: number[]; - recordGeneralIds: number[]; - worldChanged: boolean; - globalRecordsChanged: boolean; - worldHistoryChanged: boolean; - contactsChanged: boolean; + context: boolean; + lobby: boolean; + map: boolean; + commands: boolean; + contacts: boolean; + boardAccess: boolean; + reservedTurns: boolean; + records: boolean; + frontStatus: boolean; }> ) => ({ - generalIds: [], - cityIds: [], - nationIds: [], - mapGeneralIds: [], - mapCityIds: [], - mapNationIds: [], - frontStatusGeneralIds: [], - frontStatusNationIds: [], - frontStatusActorIds: [], - frontStatusChanged: false, - lobbyGeneralIds: [], - lobbyChanged: false, - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, + context: false, + lobby: false, + map: false, + commands: false, + contacts: false, + boardAccess: false, + reservedTurns: false, + records: false, + frontStatus: false, ...overrides, }); -const emitReadModelChanges = (page: Page, changes: ReturnType) => +const emitReadModelInvalidation = (page: Page, invalidation: ReturnType) => page.evaluate((payload) => { (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( - 'readModelChanged', + 'readModelInvalidated', { - at: new Date().toISOString(), - revision: Date.now(), - changes: payload, + invalidation: payload, } ); - }, changes); + }, invalidation); const commandTableFixture = (large: boolean, blockedCount = 0) => ({ general: large @@ -1279,26 +1259,6 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const callsBeforeRefresh = state.generalMeCalls; const operationsBeforeClockOnly = state.operations.length; - await page.evaluate(() => { - (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'turnCompleted', - { - at: new Date().toISOString(), - lastTurnTime: '0185-02-01T00:00:00.000Z', - changes: { - generalIds: [], - cityIds: [], - nationIds: [], - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, - }, - } - ); - }); await new Promise((resolve) => setTimeout(resolve, 300)); expect(state.operations.slice(operationsBeforeClockOnly)).toEqual([]); @@ -1308,28 +1268,17 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) .__emitMainRealtime; for (let index = 0; index < 100; index += 1) { - emit('turnCompleted', { - at: new Date().toISOString(), - lastTurnTime: '0185-02-01T00:00:00.000Z', - 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, + emit('readModelInvalidated', { + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, }, }); } @@ -1374,29 +1323,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const operationsBeforeSurvey = state.operations.length; await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'readModelChanged', + 'readModelInvalidated', { - at: new Date().toISOString(), - revision: 42, - changes: { - generalIds: [], - cityIds: [], - nationIds: [], - mapGeneralIds: [], - mapCityIds: [], - mapNationIds: [], - frontStatusGeneralIds: [], - frontStatusNationIds: [], - frontStatusActorIds: [], - frontStatusChanged: true, - lobbyGeneralIds: [], - lobbyChanged: false, - reservedGeneralIds: [], - recordGeneralIds: [], - worldChanged: false, - globalRecordsChanged: false, - worldHistoryChanged: false, - contactsChanged: false, + invalidation: { + context: false, + lobby: false, + map: false, + commands: false, + contacts: false, + boardAccess: false, + reservedTurns: false, + records: false, + frontStatus: true, }, } ); @@ -1471,7 +1409,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl { op: 'replace', path: '/general/0/values/0/possible', value: false }, { op: 'replace', path: '/general/0/values/0/status', value: 'blocked' }, ]; - await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [] })); + await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1); await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000'); @@ -1485,7 +1423,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl { op: 'replace', path: '/general/0/values/1/possible', value: false }, { op: 'replace', path: '/general/0/values/1/status', value: 'blocked' }, ]; - await emitReadModelChanges(page, readModelChanges({ nationIds: [1], mapNationIds: [], frontStatusNationIds: [] })); + await emitReadModelInvalidation( + page, + readModelInvalidation({ context: true, commands: true, boardAccess: true }) + ); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1); const callsBeforeCityState = state.generalMeCalls; @@ -1499,7 +1440,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl { op: 'replace', path: '/general/0/values/2/possible', value: false }, { op: 'replace', path: '/general/0/values/2/status', value: 'blocked' }, ]; - await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [1] })); + await emitReadModelInvalidation(page, readModelInvalidation({ context: true, map: true, commands: true })); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeCityState + 1); await expect(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u); expect(state.operations.slice(operationsBeforeCityState).sort()).toEqual( @@ -1512,7 +1453,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl state.contextRevision = 'O'.repeat(22); state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }]; state.commandTableOperations = []; - await emitReadModelChanges(page, readModelChanges({ generalIds: [7] })); + await emitReadModelInvalidation( + page, + readModelInvalidation({ context: true, commands: true, boardAccess: true }) + ); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2); expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1); await expect(page.locator('.general-title')).toContainText('snapshot복구장수'); @@ -1541,29 +1485,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl const callsAfterLeavingMain = state.generalMeCalls; await page.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'turnCompleted', + 'readModelInvalidated', { - at: new Date().toISOString(), - lastTurnTime: '0185-02-01T00:00:00.000Z', - 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, + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, }, } ); @@ -1597,7 +1530,7 @@ test('global activity, world history, and a month boundary refresh their visible { id: 3, text: '장수 동향 기록' }, ]; const operationsBeforeGlobal = state.operations.length; - await emitReadModelChanges(page, readModelChanges({ globalRecordsChanged: true })); + await emitReadModelInvalidation(page, readModelInvalidation({ records: true })); await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향'); expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']); @@ -1606,24 +1539,22 @@ test('global activity, world history, and a month boundary refresh their visible { id: 1, text: '중원 정세 기록' }, ]; const operationsBeforeHistory = state.operations.length; - await emitReadModelChanges(page, readModelChanges({ worldHistoryChanged: true })); + await emitReadModelInvalidation(page, readModelInvalidation({ records: true })); await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세'); expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']); state.currentMonth = 2; const operationsBeforeMonth = state.operations.length; await page.evaluate( - (changes) => { + (invalidation) => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'turnCompleted', + 'readModelInvalidated', { - at: new Date().toISOString(), - lastTurnTime: '0185-02-01T00:00:00.000Z', - changes, + invalidation, } ); }, - readModelChanges({ worldChanged: true }) + readModelInvalidation({ lobby: true, map: true, commands: true }) ); await expect(page.getByText('현재: 185년 2월')).toBeVisible(); await expect(page.locator('.map-viewer')).toContainText('185年 2月'); @@ -1689,29 +1620,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn state.generalName = '탭공유갱신장수'; await leaderPage.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'readModelChanged', + 'readModelInvalidated', { - 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, + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, }, } ); @@ -1727,29 +1647,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn state.generalName = '리더만갱신장수'; await leaderPage.evaluate(() => { (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( - 'readModelChanged', + 'readModelInvalidated', { - 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, + invalidation: { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, }, } ); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 4fd05e72..b392d3d5 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -4,8 +4,8 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType import { applyReadModelDelta, cloneReadModelJson, - type RealtimeEvent, - type RealtimeReadModelChanges, + type PublicRealtimeEvent, + type RealtimeReadModelInvalidation, } from '@sammo-ts/common'; import { trpc } from '../utils/trpc'; import { useMapViewerStore } from './mapViewer'; @@ -13,7 +13,7 @@ import { useSessionStore } from './session'; import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'; import { structurallyShare } from '../utils/structuralShare'; -import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel'; +import { createMergedReadModelRefreshQueue } from '../utils/dashboardReadModel'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; @@ -612,16 +612,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } ); - const refreshChangedReadModels = async (changes: RealtimeReadModelChanges) => { + const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation) => { const id = generalId.value; if (!id) { return; } - const plan = resolveDashboardRefreshPlan(changes, { - generalId: id, - cityId: city.value?.id ?? null, - nationId: nation.value?.id ?? null, - }); if (!Object.values(plan).some(Boolean)) { return; } @@ -944,12 +939,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return url.toString(); }; - const parseRealtimePayload = (raw: MessageEvent): RealtimeEvent | null => { + const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => { if (!raw.data || typeof raw.data !== 'string') { return null; } try { - const parsed = JSON.parse(raw.data) as RealtimeEvent; + const parsed = JSON.parse(raw.data) as PublicRealtimeEvent; if (!parsed || typeof parsed !== 'object') { return null; } @@ -962,21 +957,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; - const isMailboxRelevant = (mailbox: number): boolean => { - if (mailbox === MESSAGE_MAILBOX_PUBLIC) { - return true; - } - const currentGeneralId = generalId.value; - if (currentGeneralId && mailbox === currentGeneralId) { - return true; - } - const currentNationId = nationId.value; - if (currentNationId && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + currentNationId) { - return true; - } - return false; - }; - const closeRealtimeSource = () => { if (!realtimeSource) { return; @@ -1095,36 +1075,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); }); - source.addEventListener('turnCompleted', (event) => { + source.addEventListener('readModelInvalidated', (event) => { if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; const payload = parseRealtimePayload(event); - if (!payload || payload.type !== 'turnCompleted') { + if (!payload || payload.type !== 'readModelInvalidated') { return; } - if (!payload.changes) { - // Rolling deployment fallback for an older daemon. + readModelRefreshQueue.request(payload.invalidation); + }); + source.addEventListener('messagesInvalidated', (event) => { + if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; + const payload = parseRealtimePayload(event); + if (!payload || payload.type !== 'messagesInvalidated') { + return; + } + void refreshMessages(); + }); + + // Rolling deployment fallback: an older API may still expose internal + // events. Do not inspect their payload; use the bounded full refresh. + for (const legacyEventType of ['turnCompleted', 'readModelChanged'] as const) { + source.addEventListener(legacyEventType, () => { + if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; realtimeRefreshQueue.request(); - return; - } - readModelRefreshQueue.request(payload.changes); - }); - source.addEventListener('readModelChanged', (event) => { + }); + } + source.addEventListener('messageCreated', () => { if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; - const payload = parseRealtimePayload(event); - if (!payload || payload.type !== 'readModelChanged') { - return; - } - readModelRefreshQueue.request(payload.changes); - }); - source.addEventListener('messageCreated', (event) => { - if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; - const payload = parseRealtimePayload(event); - if (!payload || payload.type !== 'messageCreated') { - return; - } - if (isMailboxRelevant(payload.mailbox)) { - void refreshMessages(); - } + void refreshMessages(); }); source.addEventListener('ping', () => { if (realtimeEnabled.value) { diff --git a/app/game-frontend/src/utils/dashboardReadModel.ts b/app/game-frontend/src/utils/dashboardReadModel.ts index af7f09f2..b7d93e8e 100644 --- a/app/game-frontend/src/utils/dashboardReadModel.ts +++ b/app/game-frontend/src/utils/dashboardReadModel.ts @@ -1,85 +1,29 @@ import { - createEmptyRealtimeReadModelChanges, - mergeRealtimeReadModelChanges, + createEmptyRealtimeReadModelInvalidation, + mergeRealtimeReadModelInvalidations, + resolveRealtimeReadModelInvalidation, type RealtimeReadModelChanges, + type RealtimeReadModelInvalidation, + type RealtimeViewerIdentity, } from '@sammo-ts/common'; -export interface DashboardReadModelIdentity { - generalId: number | null; - cityId: number | null; - nationId: number | null; -} - -export interface DashboardRefreshPlan { - context: boolean; - lobby: boolean; - map: boolean; - commands: boolean; - contacts: boolean; - boardAccess: boolean; - reservedTurns: boolean; - records: boolean; - frontStatus: boolean; -} - -const contains = (ids: readonly number[], id: number | null): boolean => id !== null && ids.includes(id); +export type DashboardReadModelIdentity = RealtimeViewerIdentity; +export type DashboardRefreshPlan = RealtimeReadModelInvalidation; export const resolveDashboardRefreshPlan = ( changes: RealtimeReadModelChanges, identity: DashboardReadModelIdentity -): DashboardRefreshPlan => { - const ownGeneralChanged = contains(changes.generalIds, identity.generalId); - const ownCityChanged = contains(changes.cityIds, identity.cityId); - const ownNationChanged = contains(changes.nationIds, identity.nationId); - const ownFrontStatusNationChanged = contains( - changes.frontStatusNationIds ?? changes.nationIds, - identity.nationId - ); - const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId); - const frontStatusGeneralChanged = - changes.frontStatusGeneralIds !== undefined - ? changes.frontStatusGeneralIds.length > 0 - : changes.contactsChanged; - const ownFrontStatusActorChanged = contains(changes.frontStatusActorIds ?? [], identity.generalId); - const ownLobbyGeneralChanged = contains(changes.lobbyGeneralIds ?? changes.generalIds, identity.generalId); - const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged; - const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged; - const mapEntitiesChanged = - (changes.mapCityIds ?? changes.cityIds).length > 0 || - (changes.mapNationIds ?? changes.nationIds).length > 0; - const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0; - - return { - context: entityContextChanged, - lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged, - map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged, - commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged, - contacts: changes.contactsChanged, - boardAccess: ownGeneralChanged || ownNationChanged, - reservedTurns: contains(changes.reservedGeneralIds, identity.generalId), - records: - changes.globalRecordsChanged || - changes.worldHistoryChanged || - contains(changes.recordGeneralIds, identity.generalId), - // lastTurnTime is intentionally excluded. This slice contains the - // nation notice/vote/presence model and only follows related changes. - frontStatus: - Boolean(changes.frontStatusChanged) || - frontStatusGeneralChanged || - ownFrontStatusNationChanged || - ownFrontStatusActorChanged, - }; -}; +): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity); type TimerHandle = ReturnType; export interface MergedReadModelRefreshQueue { - request(changes: RealtimeReadModelChanges): void; + request(invalidation: RealtimeReadModelInvalidation): void; cancelPending(): void; } export const createMergedReadModelRefreshQueue = ( - refresh: (changes: RealtimeReadModelChanges) => Promise, + refresh: (invalidation: RealtimeReadModelInvalidation) => Promise, options: { minIntervalMs?: number; now?: () => number; @@ -91,7 +35,7 @@ export const createMergedReadModelRefreshQueue = ( const now = options.now ?? Date.now; const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)); - let pending = createEmptyRealtimeReadModelChanges(); + let pending = createEmptyRealtimeReadModelInvalidation(); let hasPending = false; let running = false; let timer: TimerHandle | null = null; @@ -108,7 +52,7 @@ export const createMergedReadModelRefreshQueue = ( return; } const next = pending; - pending = createEmptyRealtimeReadModelChanges(); + pending = createEmptyRealtimeReadModelInvalidation(); hasPending = false; running = true; lastStartedAt = now(); @@ -120,14 +64,14 @@ export const createMergedReadModelRefreshQueue = ( }; return { - request: (changes) => { - pending = hasPending ? mergeRealtimeReadModelChanges(pending, changes) : changes; + request: (invalidation) => { + pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation; hasPending = true; schedule(); }, cancelPending: () => { hasPending = false; - pending = createEmptyRealtimeReadModelChanges(); + pending = createEmptyRealtimeReadModelInvalidation(); if (timer !== null) { clearTimer(timer); timer = null; diff --git a/app/game-frontend/test/dashboardReadModel.test.ts b/app/game-frontend/test/dashboardReadModel.test.ts index 6f4a41f6..2baae215 100644 --- a/app/game-frontend/test/dashboardReadModel.test.ts +++ b/app/game-frontend/test/dashboardReadModel.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common'; +import { createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation } from '@sammo-ts/common'; import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts'; void test('last-turn-time-only events do not schedule any dashboard query', () => { @@ -170,14 +170,14 @@ void test('targets a submitted survey projection to its own general', () => { assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false); }); -void test('merges burst payloads without losing entity ids and starts at most once per interval', async () => { +void test('merges browser-safe boolean invalidations and starts at most once per interval', async () => { let nowMs = 0; let nextTimerId = 1; const timers = new Map void; at: number }>(); - const observed: number[][] = []; + const observed: Array<{ context: boolean; records: boolean }> = []; const queue = createMergedReadModelRefreshQueue( - async (changes) => { - observed.push(changes.generalIds); + async (invalidation) => { + observed.push({ context: invalidation.context, records: invalidation.records }); }, { minIntervalMs: 1_000, @@ -199,18 +199,21 @@ void test('merges burst payloads without losing entity ids and starts at most on } }; - queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] }); + queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }); runDueTimers(); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(observed, [[7]]); + assert.deepEqual(observed, [{ context: true, records: false }]); - queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [9] }); - queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [8, 9] }); + queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }); + queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true }); nowMs = 999; runDueTimers(); assert.equal(observed.length, 1); nowMs = 1_000; runDueTimers(); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(observed, [[7], [8, 9]]); + assert.deepEqual(observed, [ + { context: true, records: false }, + { context: true, records: true }, + ]); }); diff --git a/packages/common/src/realtime/types.ts b/packages/common/src/realtime/types.ts index d83dac0a..19d72fc9 100644 --- a/packages/common/src/realtime/types.ts +++ b/packages/common/src/realtime/types.ts @@ -2,8 +2,9 @@ export type MessageTypeKey = 'public' | 'private' | 'national' | 'diplomacy'; /** * Durable mutations summarized after the database transaction commits. - * Entity IDs let each authenticated client decide whether its own read model - * is affected without exposing entity payloads over the shared Redis channel. + * This internal Redis contract carries entity IDs so the authenticated game + * API can derive each subscriber's browser-safe invalidation. It must never be + * serialized directly to a public SSE response. */ export interface RealtimeReadModelChanges { generalIds: number[]; @@ -32,6 +33,119 @@ export interface RealtimeReadModelChanges { lobbyChanged?: boolean; } +/** + * Browser-visible invalidation contract. It deliberately contains no entity + * IDs, timestamps, turn times, or global revisions. The API derives these + * viewer-specific booleans from the internal committed-change summary before + * crossing the SSE boundary. + */ +export interface RealtimeReadModelInvalidation { + context: boolean; + lobby: boolean; + map: boolean; + commands: boolean; + contacts: boolean; + boardAccess: boolean; + reservedTurns: boolean; + records: boolean; + frontStatus: boolean; +} + +export interface RealtimeViewerIdentity { + generalId: number | null; + cityId: number | null; + nationId: number | null; +} + +export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({ + context: false, + lobby: false, + map: false, + commands: false, + contacts: false, + boardAccess: false, + reservedTurns: false, + records: false, + frontStatus: false, +}); + +export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({ + context: true, + lobby: true, + map: true, + commands: true, + contacts: true, + boardAccess: true, + reservedTurns: true, + records: true, + frontStatus: true, +}); + +export const mergeRealtimeReadModelInvalidations = ( + left: RealtimeReadModelInvalidation, + right: RealtimeReadModelInvalidation +): RealtimeReadModelInvalidation => ({ + context: left.context || right.context, + lobby: left.lobby || right.lobby, + map: left.map || right.map, + commands: left.commands || right.commands, + contacts: left.contacts || right.contacts, + boardAccess: left.boardAccess || right.boardAccess, + reservedTurns: left.reservedTurns || right.reservedTurns, + records: left.records || right.records, + frontStatus: left.frontStatus || right.frontStatus, +}); + +export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean => + Object.values(invalidation).some(Boolean); + +const contains = (ids: readonly number[], id: number | null): boolean => id !== null && ids.includes(id); + +export const resolveRealtimeReadModelInvalidation = ( + changes: RealtimeReadModelChanges, + identity: RealtimeViewerIdentity +): RealtimeReadModelInvalidation => { + const ownGeneralChanged = contains(changes.generalIds, identity.generalId); + const ownCityChanged = contains(changes.cityIds, identity.cityId); + const ownNationChanged = contains(changes.nationIds, identity.nationId); + const ownFrontStatusNationChanged = contains( + changes.frontStatusNationIds ?? changes.nationIds, + identity.nationId + ); + const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId); + const frontStatusGeneralChanged = + changes.frontStatusGeneralIds !== undefined + ? changes.frontStatusGeneralIds.length > 0 + : changes.contactsChanged; + const ownFrontStatusActorChanged = contains(changes.frontStatusActorIds ?? [], identity.generalId); + const ownLobbyGeneralChanged = contains(changes.lobbyGeneralIds ?? changes.generalIds, identity.generalId); + const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged; + const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged; + const mapEntitiesChanged = + (changes.mapCityIds ?? changes.cityIds).length > 0 || + (changes.mapNationIds ?? changes.nationIds).length > 0; + const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0; + + return { + context: entityContextChanged, + lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged, + map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged, + commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged, + contacts: changes.contactsChanged, + boardAccess: ownGeneralChanged || ownNationChanged, + reservedTurns: contains(changes.reservedGeneralIds, identity.generalId), + records: + changes.globalRecordsChanged || + changes.worldHistoryChanged || + contains(changes.recordGeneralIds, identity.generalId), + frontStatus: + Boolean(changes.frontStatusChanged) || + frontStatusGeneralChanged || + ownFrontStatusNationChanged || + ownFrontStatusActorChanged, + }; +}; + export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges => ({ generalIds: [], cityIds: [], @@ -125,6 +239,18 @@ export interface MessageCreatedEvent { senderId: number; } +export interface ReadModelInvalidatedEvent { + type: 'readModelInvalidated'; + invalidation: RealtimeReadModelInvalidation; +} + +export interface MessagesInvalidatedEvent { + type: 'messagesInvalidated'; +} + +/** Events safe to expose to an authenticated browser over SSE. */ +export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent; + export type RealtimeEvent = | TurnCompletedEvent | ReadModelChangedEvent