diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index d382c54b..602c853d 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -89,12 +89,8 @@ describe('summarizeRealtimeReadModelChanges', () => { penalty: {}, }, ], - listCities: () => [ - { id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 }, - ], - listNations: () => [ - { id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 }, - ], + listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 }], + listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 }], } as unknown as InMemoryTurnWorld); const changes = { generals: [ @@ -153,6 +149,107 @@ describe('summarizeRealtimeReadModelChanges', () => { }); }); + it('classifies defence, nation policy, and current-city state changes by canonical projection', () => { + const baseline = createRealtimeReadModelBaseline({ + listGenerals: () => [], + listCities: () => [ + { + id: 3, + name: '업', + level: 8, + nationId: 2, + state: 0, + supplyState: 1, + defence: 1_000, + defenceMax: 2_000, + }, + ], + listNations: () => [ + { + id: 2, + name: '위', + color: '#008000', + capitalCityId: 3, + meta: { rate: 20, bill: 100 }, + }, + ], + } as unknown as InMemoryTurnWorld); + const emptyChanges = { + generals: [], + createdGenerals: [], + deletedGenerals: [], + createdNations: [], + deletedNations: [], + deletedNationSnapshots: [], + lifecycleEvents: [], + logs: [], + }; + + const defenceChanges = { + ...emptyChanges, + cities: [ + { + id: 3, + name: '업', + level: 8, + nationId: 2, + state: 0, + supplyState: 1, + defence: 900, + defenceMax: 2_000, + }, + ], + nations: [], + } as unknown as TurnWorldChanges; + expect(summarizeRealtimeReadModelChanges(defenceChanges, undefined, baseline)).toMatchObject({ + cityIds: [3], + mapCityIds: [], + nationIds: [], + }); + + const policyChanges = { + ...emptyChanges, + cities: [], + nations: [ + { + id: 2, + name: '위', + color: '#008000', + capitalCityId: 3, + meta: { rate: 25, bill: 120 }, + }, + ], + } as unknown as TurnWorldChanges; + expect(summarizeRealtimeReadModelChanges(policyChanges, undefined, baseline)).toMatchObject({ + cityIds: [], + nationIds: [2], + mapNationIds: [], + frontStatusNationIds: [], + }); + + const stateChanges = { + ...emptyChanges, + cities: [ + { + id: 3, + name: '업', + level: 8, + nationId: 2, + state: 5, + supplyState: 1, + defence: 1_000, + defenceMax: 2_000, + }, + ], + nations: [], + } as unknown as TurnWorldChanges; + expect(summarizeRealtimeReadModelChanges(stateChanges, undefined, baseline)).toMatchObject({ + cityIds: [3], + mapCityIds: [3], + nationIds: [], + }); + }); + it('detects map, contact, and front-status fields independently', () => { const baseline = createRealtimeReadModelBaseline({ listGenerals: () => [ @@ -168,9 +265,7 @@ describe('summarizeRealtimeReadModelChanges', () => { }, ], listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1 }], - listNations: () => [ - { id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }, - ], + listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }], } as unknown as InMemoryTurnWorld); const changes = { generals: [ @@ -188,9 +283,7 @@ describe('summarizeRealtimeReadModelChanges', () => { createdGenerals: [], deletedGenerals: [], cities: [{ id: 1, level: 2, nationId: 1, state: 0, supplyState: 1 }], - nations: [ - { id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } }, - ], + nations: [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } }], createdNations: [], deletedNations: [], deletedNationSnapshots: [], @@ -229,9 +322,7 @@ describe('summarizeRealtimeReadModelChanges', () => { }, ], listCities: () => [], - listNations: () => [ - { id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }, - ], + listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }], } as unknown as InMemoryTurnWorld); const changes = { generals: [ @@ -249,9 +340,7 @@ describe('summarizeRealtimeReadModelChanges', () => { createdGenerals: [], deletedGenerals: [], cities: [], - nations: [ - { id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } }, - ], + nations: [{ id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } }], createdNations: [], deletedNations: [], deletedNationSnapshots: [], diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index fe89e09d..ea4db25b 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -20,6 +20,15 @@ type NavigationFixture = { generalMeCalls: number; operations: string[]; generalName?: string; + cityDefence?: number; + cityState?: number; + nationRate?: number; + contextRevision?: string; + contextOperations?: JsonPatchOperation[]; + commandTableRevision?: string; + commandTableOperations?: JsonPatchOperation[]; + commandBlockedCount?: number; + forceSnapshotCalls?: number; refreshDelayMs?: number; largeCommandTable?: boolean; dashboardResponses?: Array<{ @@ -30,6 +39,12 @@ type NavigationFixture = { }>; }; +type JsonPatchOperation = { + op: 'add' | 'remove' | 'replace'; + path: string; + value?: unknown; +}; + type DashboardBundleInput = { include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean }; known?: { context?: string; commandTable?: string; boardAccess?: string }; @@ -44,7 +59,62 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => { return entry.json ?? (entry as DashboardBundleInput); }; -const commandTableFixture = (large: boolean) => ({ +const readModelChanges = ( + 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; + }> +) => ({ + generalIds: [], + cityIds: [], + nationIds: [], + mapGeneralIds: [], + mapCityIds: [], + mapNationIds: [], + frontStatusGeneralIds: [], + frontStatusNationIds: [], + frontStatusActorIds: [], + frontStatusChanged: false, + lobbyGeneralIds: [], + lobbyChanged: false, + reservedGeneralIds: [], + recordGeneralIds: [], + worldChanged: false, + globalRecordsChanged: false, + worldHistoryChanged: false, + contactsChanged: false, + ...overrides, +}); + +const emitReadModelChanges = (page: Page, changes: ReturnType) => + page.evaluate((payload) => { + (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( + 'readModelChanged', + { + at: new Date().toISOString(), + revision: Date.now(), + changes: payload, + } + ); + }, changes); + +const commandTableFixture = (large: boolean, blockedCount = 0) => ({ general: large ? [ { @@ -53,8 +123,8 @@ const commandTableFixture = (large: boolean) => ({ key: `command-${index}`, name: `명령 ${index}`, reqArg: index % 2 === 0, - possible: true, - status: 'available', + possible: index >= blockedCount, + status: index >= blockedCount ? 'available' : 'blocked', inputFields: [ { key: 'amount', @@ -87,6 +157,7 @@ const COMMAND_TABLE_REVISION = 'CCCCCCCCCCCCCCCCCCCCCC'; const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD'; const contextRevision = (state: NavigationFixture) => { + if (state.contextRevision) return state.contextRevision; const name = state.generalName ?? '메뉴검증장수'; if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION; if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE'; @@ -95,14 +166,20 @@ const contextRevision = (state: NavigationFixture) => { return 'HHHHHHHHHHHHHHHHHHHHHH'; }; -const deltaSlice = (value: T, revision: string, known: string | undefined, forceSnapshot: boolean) => { +const deltaSlice = ( + value: T, + revision: string, + known: string | undefined, + forceSnapshot: boolean, + operations?: JsonPatchOperation[] +) => { if (forceSnapshot || !known) return { kind: 'snapshot' as const, revision, data: value }; if (known === revision) return { kind: 'unchanged' as const, revision }; return { kind: 'patch' as const, baseRevision: known, revision, - operations: [ + operations: operations ?? [ { op: 'replace' as const, path: '/general/name', @@ -169,12 +246,13 @@ const generalContext = (state: NavigationFixture) => ({ securityMax: 2_000, trust: 80, trade: 100, - defence: 1_000, + defence: state.cityDefence ?? 1_000, defenceMax: 2_000, wall: 1_000, wallMax: 2_000, supplyState: 1, frontState: 0, + state: state.cityState ?? 0, }, nation: { id: 1, @@ -184,7 +262,7 @@ const generalContext = (state: NavigationFixture) => ({ gold: 10_000, rice: 20_000, tech: 100, - rate: 20, + rate: state.nationRate ?? 20, bill: 100, capitalCityId: 1, typeCode: 'che_유가', @@ -241,18 +319,33 @@ const installFixture = async (page: Page, state: NavigationFixture) => { const input = operationInput(route, index); const include = input.include ?? {}; const forceSnapshot = input.forceSnapshot === true; + if (forceSnapshot) state.forceSnapshotCalls = (state.forceSnapshotCalls ?? 0) + 1; const revision = contextRevision(state); const context = include.context - ? deltaSlice(generalContext(state), revision, input.known?.context, forceSnapshot) + ? deltaSlice( + generalContext(state), + revision, + input.known?.context, + forceSnapshot, + state.contextOperations + ) : undefined; + const currentCommandTableRevision = state.commandTableRevision ?? COMMAND_TABLE_REVISION; const commandTable = include.commandTable ? forceSnapshot || !input.known?.commandTable ? { kind: 'snapshot' as const, - revision: COMMAND_TABLE_REVISION, - data: commandTableFixture(state.largeCommandTable === true), + revision: currentCommandTableRevision, + data: commandTableFixture(state.largeCommandTable === true, state.commandBlockedCount), } - : { kind: 'unchanged' as const, revision: COMMAND_TABLE_REVISION } + : input.known.commandTable === currentCommandTableRevision + ? { kind: 'unchanged' as const, revision: currentCommandTableRevision } + : { + kind: 'patch' as const, + baseRevision: input.known.commandTable, + revision: currentCommandTableRevision, + operations: state.commandTableOperations ?? [], + } : undefined; const boardAccess = include.boardAccess ? forceSnapshot || !input.known?.boardAccess @@ -297,7 +390,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { startYear: 180, year: 185, month: 1, - cityList: [[1, 8, 0, 1, 1, 1]], + cityList: [[1, 8, state.cityState ?? 0, 1, 1, 1]], nationList: [[1, '위', '#008000', 1]], spyList: {}, shownByGeneralList: [], @@ -1002,6 +1095,78 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl ]); } + const callsBeforeDefence = state.generalMeCalls; + state.cityDefence = 900; + state.contextRevision = 'I'.repeat(22); + state.contextOperations = [{ op: 'replace', path: '/city/defence', value: 900 }]; + state.commandTableRevision = 'J'.repeat(22); + state.commandBlockedCount = 1; + state.commandTableOperations = [ + { 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 expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1); + await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000'); + + const callsBeforeTax = state.generalMeCalls; + state.nationRate = 25; + state.contextRevision = 'K'.repeat(22); + state.contextOperations = [{ op: 'replace', path: '/nation/rate', value: 25 }]; + state.commandTableRevision = 'L'.repeat(22); + state.commandBlockedCount = 2; + state.commandTableOperations = [ + { 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 expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1); + + const callsBeforeCityState = state.generalMeCalls; + const operationsBeforeCityState = state.operations.length; + state.cityState = 5; + state.contextRevision = 'M'.repeat(22); + state.contextOperations = [{ op: 'replace', path: '/city/state', value: 5 }]; + state.commandTableRevision = 'N'.repeat(22); + state.commandBlockedCount = 3; + state.commandTableOperations = [ + { 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 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( + ['dashboard.getContextBundleDelta', 'world.getMap'].sort() + ); + + const callsBeforeFallback = state.generalMeCalls; + const forcedBeforeFallback = state.forceSnapshotCalls ?? 0; + state.generalName = 'snapshot복구장수'; + state.contextRevision = 'O'.repeat(22); + state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }]; + state.commandTableOperations = []; + await emitReadModelChanges(page, readModelChanges({ generalIds: [7] })); + 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복구장수'); + await expect(page.locator('.game-feedback')).toHaveCount(0); + await expect(page.locator('[data-main-target="general"] .skeleton-line')).toHaveCount(0); + await expect(page.locator('[data-main-target="city"] .skeleton-line')).toHaveCount(0); + expect( + await page.evaluate(() => { + const probe = ( + window as unknown as { + __mainRefreshProbe: { general: Element; city: Element }; + } + ).__mainRefreshProbe; + return { + generalMounted: probe.general === document.querySelector('[data-main-target="general"]'), + cityMounted: probe.city === document.querySelector('[data-main-target="city"]'), + }; + }) + ).toEqual({ generalMounted: true, cityMounted: true }); + await page.locator(`a[href="${basePath}/board"]`).first().click(); await page.waitForURL(`**${basePath}/board`); expect( diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index cefa7e1e..c178bd5c 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -3,7 +3,7 @@ import { defineStore } from 'pinia'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic'; import { applyReadModelDelta, - ReadModelDeltaMismatchError, + cloneReadModelJson, type RealtimeEvent, type RealtimeReadModelChanges, } from '@sammo-ts/common'; @@ -15,6 +15,7 @@ import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue' import { structurallyShare } from '../utils/structuralShare'; import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; +import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000; @@ -103,6 +104,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { let recordGeneralId: number | null = null; let initialized = false; let contextSnapshot: GeneralContext | undefined; + let commandTableSnapshot: CommandTable | undefined; + let boardAccessSnapshot: BoardAccess | undefined; let contextRevision: string | null = null; let commandTableRevision: string | null = null; let boardAccessRevision: string | null = null; @@ -319,6 +322,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const applyDashboardPatch = (patch: DashboardReadModelPatch) => { if (patch.contextSnapshot === null) { contextSnapshot = null; + commandTableSnapshot = undefined; + boardAccessSnapshot = undefined; general.value = null; city.value = null; nation.value = null; @@ -337,6 +342,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } if (patch.general === null) { contextSnapshot = null; + commandTableSnapshot = undefined; + boardAccessSnapshot = undefined; general.value = null; city.value = null; nation.value = null; @@ -357,14 +364,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { 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) { + commandTableSnapshot = 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) + if (patch.boardAccess !== undefined) { + boardAccessSnapshot = patch.boardAccess ?? undefined; boardAccess.value = structurallyShare(boardAccess.value, patch.boardAccess); + } if (patch.reservedGeneralTurns !== undefined) { reservedGeneralTurns.value = structurallyShare( reservedGeneralTurns.value, @@ -409,10 +419,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { patch.lobbyInfo = toRaw(lobbyInfo.value); patch.worldMap = toRaw(worldMap.value); patch.mapLayout = toRaw(mapLayout.value); - patch.commandTable = toRaw(commandTable.value); + patch.commandTable = commandTableSnapshot ?? null; patch.messages = toRaw(messages.value); patch.messageContacts = toRaw(messageContacts.value); - patch.boardAccess = toRaw(boardAccess.value); + patch.boardAccess = boardAccessSnapshot ?? null; patch.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null; patch.reservedGeneralRevision = reservedGeneralRevision.value; patch.globalRecords = toRaw(globalRecords.value); @@ -433,16 +443,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } } if (bundle.commandTable) { - const current = commandTable.value === null ? undefined : toRaw(commandTable.value); - const applied = applyReadModelDelta(current, commandTableRevision, bundle.commandTable); + const applied = applyReadModelDelta(commandTableSnapshot, commandTableRevision, bundle.commandTable); patch.commandTableRevision = applied.revision; if (bundle.commandTable.kind !== 'unchanged') { patch.commandTable = applied.data; } } if (bundle.boardAccess) { - const current = boardAccess.value === null ? undefined : toRaw(boardAccess.value); - const applied = applyReadModelDelta(current, boardAccessRevision, bundle.boardAccess); + const applied = applyReadModelDelta(boardAccessSnapshot, boardAccessRevision, bundle.boardAccess); patch.boardAccessRevision = applied.revision; if (bundle.boardAccess.kind !== 'unchanged') { patch.boardAccess = applied.data; @@ -469,15 +477,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { forceSnapshot: force || undefined, }); - const bundle = await request(forceSnapshot); - try { - return resolveContextBundlePatch(bundle); - } catch (error) { - if (forceSnapshot || !(error instanceof ReadModelDeltaMismatchError)) { - throw error; - } - return resolveContextBundlePatch(await request(true)); - } + return resolveWithReadModelSnapshotFallback({ + request, + resolve: resolveContextBundlePatch, + forceSnapshot, + }); }; const refreshMainData = async () => { @@ -576,7 +580,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { let realtimeCoordinatorScope: string | null = null; const publishDashboardPatch = (patch: DashboardReadModelPatch) => { - realtimeCoordinator?.postFromLeader({ kind: 'patch', patch }); + if (!realtimeCoordinator) { + return; + } + // BroadcastChannel uses the structured-clone algorithm. Normalize the + // full payload so nested Vue proxies never reach that browser boundary. + try { + realtimeCoordinator.postFromLeader({ kind: 'patch', patch: cloneReadModelJson(patch) }); + } catch { + // Same-account fan-out is best effort; the leader's local refresh + // and the next visibility/full-snapshot recovery remain valid. + } }; const realtimeRefreshQueue = createRateLimitedRefreshQueue( diff --git a/app/game-frontend/src/utils/readModelDeltaRecovery.ts b/app/game-frontend/src/utils/readModelDeltaRecovery.ts new file mode 100644 index 00000000..4b391d63 --- /dev/null +++ b/app/game-frontend/src/utils/readModelDeltaRecovery.ts @@ -0,0 +1,21 @@ +/** + * Resolves one delta response and retries once with a full snapshot when the + * client baseline cannot be reconstructed. The retry intentionally covers all + * local application failures, including browser DataCloneError variants. + */ +export const resolveWithReadModelSnapshotFallback = async (options: { + request: (forceSnapshot: boolean) => Promise; + resolve: (response: Response) => Result; + forceSnapshot?: boolean; +}): Promise => { + const forceSnapshot = options.forceSnapshot === true; + const response = await options.request(forceSnapshot); + try { + return options.resolve(response); + } catch (error) { + if (forceSnapshot) { + throw error; + } + return options.resolve(await options.request(true)); + } +}; diff --git a/app/game-frontend/test/dashboardReadModel.test.ts b/app/game-frontend/test/dashboardReadModel.test.ts index c085bc06..6f4a41f6 100644 --- a/app/game-frontend/test/dashboardReadModel.test.ts +++ b/app/game-frontend/test/dashboardReadModel.test.ts @@ -2,10 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common'; -import { - createMergedReadModelRefreshQueue, - resolveDashboardRefreshPlan, -} from '../src/utils/dashboardReadModel.ts'; +import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts'; void test('last-turn-time-only events do not schedule any dashboard query', () => { const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), { @@ -55,10 +52,63 @@ void test('refreshes the map only for map-projection changes', () => { mapGeneralIds: [7], }; - assert.equal( - resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, - true + assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true); +}); + +void test('routes defence, tax-rate, and current-city-state events to their exact dashboard slices', () => { + const identity = { generalId: 7, cityId: 3, nationId: 2 }; + const defence = resolveDashboardRefreshPlan( + { + ...createEmptyRealtimeReadModelChanges(), + cityIds: [3], + mapCityIds: [], + }, + identity ); + assert.deepEqual(defence, { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: false, + reservedTurns: false, + records: false, + frontStatus: false, + }); + + const taxRate = resolveDashboardRefreshPlan( + { + ...createEmptyRealtimeReadModelChanges(), + nationIds: [2], + mapNationIds: [], + frontStatusNationIds: [], + }, + identity + ); + assert.deepEqual(taxRate, { + context: true, + lobby: false, + map: false, + commands: true, + contacts: false, + boardAccess: true, + reservedTurns: false, + records: false, + frontStatus: false, + }); + + const cityState = resolveDashboardRefreshPlan( + { + ...createEmptyRealtimeReadModelChanges(), + cityIds: [3], + mapCityIds: [3], + }, + identity + ); + assert.equal(cityState.context, true); + assert.equal(cityState.commands, true); + assert.equal(cityState.map, true); }); void test('keeps conservative map behavior for rolling-deploy payloads without projections', () => { @@ -74,10 +124,7 @@ void test('keeps conservative map behavior for rolling-deploy payloads without p contactsChanged: false, }; - assert.equal( - resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, - true - ); + assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true); }); void test('does not refresh front status for contact-only permission changes', () => { @@ -119,14 +166,8 @@ void test('targets a submitted survey projection to its own general', () => { frontStatusActorIds: [7], }; - assert.equal( - resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus, - true - ); - assert.equal( - resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, - false - ); + assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus, true); + 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 () => { diff --git a/app/game-frontend/test/readModelDeltaRecovery.test.ts b/app/game-frontend/test/readModelDeltaRecovery.test.ts new file mode 100644 index 00000000..a4bfbd54 --- /dev/null +++ b/app/game-frontend/test/readModelDeltaRecovery.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveWithReadModelSnapshotFallback } from '../src/utils/readModelDeltaRecovery.ts'; + +void test('retries any local delta application failure once with a forced snapshot', async () => { + const requests: boolean[] = []; + const result = await resolveWithReadModelSnapshotFallback({ + request: async (forceSnapshot) => { + requests.push(forceSnapshot); + return forceSnapshot ? { kind: 'snapshot', value: 25 } : { kind: 'patch', value: 20 }; + }, + resolve: (response) => { + if (response.kind === 'patch') { + throw new DOMException('[object Object] could not be cloned.', 'DataCloneError'); + } + return response.value; + }, + }); + + assert.equal(result, 25); + assert.deepEqual(requests, [false, true]); +}); + +void test('does not retry a forced snapshot application failure', async () => { + const requests: boolean[] = []; + + await assert.rejects( + resolveWithReadModelSnapshotFallback({ + forceSnapshot: true, + request: async (forceSnapshot) => { + requests.push(forceSnapshot); + return { kind: 'snapshot' }; + }, + resolve: () => { + throw new Error('snapshot failure'); + }, + }), + /snapshot failure/u + ); + assert.deepEqual(requests, [true]); +}); diff --git a/packages/common/src/realtime/delta.ts b/packages/common/src/realtime/delta.ts index 69b0cfa9..a1c986ea 100644 --- a/packages/common/src/realtime/delta.ts +++ b/packages/common/src/realtime/delta.ts @@ -31,12 +31,37 @@ export class ReadModelDeltaMismatchError extends Error { } } +export class ReadModelDeltaApplyError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'ReadModelDeltaApplyError'; + } +} + export interface AppliedReadModelDelta { data: T; revision: string; } -const cloneJsonValue = (value: T): T => structuredClone(value); +/** + * Read-model deltas operate on JSON documents received over tRPC. Serializing + * through JSON also unwraps Vue's nested reactive proxies, which a root-level + * `toRaw()` does not remove and `structuredClone()` cannot clone. + */ +export const cloneReadModelJson = (value: T): T => { + try { + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new TypeError('The read-model value is not a JSON document.'); + } + return JSON.parse(serialized) as T; + } catch (error) { + if (error instanceof ReadModelDeltaApplyError) { + throw error; + } + throw new ReadModelDeltaApplyError('Failed to clone the read-model JSON document.', { cause: error }); + } +}; export const createJsonPatch = (current: unknown, next: unknown): JsonPatchOperation[] => createPatch(current, next); @@ -74,11 +99,18 @@ export const applyReadModelDelta = ( ); } - const next = cloneJsonValue(current); - const errors = applyPatch(next, delta.operations as Operation[]); - const failure = errors.find((error) => error !== null); - if (failure) { - throw new ReadModelDeltaMismatchError(`JSON Patch application failed: ${failure.message}`); + const next = cloneReadModelJson(current); + try { + const errors = applyPatch(next, delta.operations as Operation[]); + const failure = errors.find((error) => error !== null); + if (failure) { + throw new ReadModelDeltaApplyError(`JSON Patch application failed: ${failure.message}`); + } + } catch (error) { + if (error instanceof ReadModelDeltaApplyError) { + throw error; + } + throw new ReadModelDeltaApplyError('JSON Patch application failed.', { cause: error }); } return { diff --git a/packages/common/test/realtimeDelta.test.ts b/packages/common/test/realtimeDelta.test.ts index a5177c02..d53bdc2c 100644 --- a/packages/common/test/realtimeDelta.test.ts +++ b/packages/common/test/realtimeDelta.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { applyReadModelDelta, ReadModelDeltaMismatchError } from '../src/realtime/delta.js'; +import { applyReadModelDelta, ReadModelDeltaApplyError, ReadModelDeltaMismatchError } from '../src/realtime/delta.js'; describe('applyReadModelDelta', () => { it('applies a JSON Patch without mutating the previous snapshot', () => { @@ -46,4 +46,44 @@ describe('applyReadModelDelta', () => { }) ).toThrow(ReadModelDeltaMismatchError); }); + + it('unwraps nested reactive-style proxies before applying a later patch', () => { + const proxiedStableBranch = new Proxy( + { + values: [{ key: '이동', possible: true, status: 'available' }], + }, + {} + ); + const current = { + general: [ + { category: '일반', values: [{ key: '휴식', possible: false, status: 'blocked' }] }, + proxiedStableBranch, + ], + }; + + expect(() => structuredClone(current)).toThrow(); + const applied = applyReadModelDelta(current, 'revision-1', { + kind: 'patch', + baseRevision: 'revision-1', + revision: 'revision-2', + operations: [{ op: 'replace', path: '/general/1/values/0/possible', value: false }], + }); + + expect(applied.data.general[1]?.values[0]?.possible).toBe(false); + expect(applied.data.general[1]).not.toBe(proxiedStableBranch); + }); + + it('reports a non-JSON baseline as a recoverable delta application error', () => { + const current: { value: number; self?: unknown } = { value: 1 }; + current.self = current; + + expect(() => + applyReadModelDelta(current, 'revision-1', { + kind: 'patch', + baseRevision: 'revision-1', + revision: 'revision-2', + operations: [{ op: 'replace', path: '/value', value: 2 }], + }) + ).toThrow(ReadModelDeltaApplyError); + }); });