fix(game): recover realtime dashboard delta cloning

This commit is contained in:
2026-08-11 15:09:32 +00:00
parent d1c35cc380
commit 6e95dbd487
8 changed files with 518 additions and 74 deletions
@@ -89,12 +89,8 @@ describe('summarizeRealtimeReadModelChanges', () => {
penalty: {}, penalty: {},
}, },
], ],
listCities: () => [ listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 }],
{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1, population: 100 }, listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 }],
],
listNations: () => [
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, gold: 100 },
],
} as unknown as InMemoryTurnWorld); } as unknown as InMemoryTurnWorld);
const changes = { const changes = {
generals: [ 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', () => { it('detects map, contact, and front-status fields independently', () => {
const baseline = createRealtimeReadModelBaseline({ const baseline = createRealtimeReadModelBaseline({
listGenerals: () => [ listGenerals: () => [
@@ -168,9 +265,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
}, },
], ],
listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1 }], listCities: () => [{ id: 1, level: 1, nationId: 1, state: 0, supplyState: 1 }],
listNations: () => [ listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }],
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } },
],
} as unknown as InMemoryTurnWorld); } as unknown as InMemoryTurnWorld);
const changes = { const changes = {
generals: [ generals: [
@@ -188,9 +283,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
createdGenerals: [], createdGenerals: [],
deletedGenerals: [], deletedGenerals: [],
cities: [{ id: 1, level: 2, nationId: 1, state: 0, supplyState: 1 }], cities: [{ id: 1, level: 2, nationId: 1, state: 0, supplyState: 1 }],
nations: [ nations: [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } }],
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '새 방침' } },
],
createdNations: [], createdNations: [],
deletedNations: [], deletedNations: [],
deletedNationSnapshots: [], deletedNationSnapshots: [],
@@ -229,9 +322,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
}, },
], ],
listCities: () => [], listCities: () => [],
listNations: () => [ listNations: () => [{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } }],
{ id: 1, name: '위', color: '#008000', capitalCityId: 1, meta: { notice: '이전' } },
],
} as unknown as InMemoryTurnWorld); } as unknown as InMemoryTurnWorld);
const changes = { const changes = {
generals: [ generals: [
@@ -249,9 +340,7 @@ describe('summarizeRealtimeReadModelChanges', () => {
createdGenerals: [], createdGenerals: [],
deletedGenerals: [], deletedGenerals: [],
cities: [], cities: [],
nations: [ nations: [{ id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } }],
{ id: 1, name: '촉', color: '#008000', capitalCityId: 1, meta: { notice: '새 공지' } },
],
createdNations: [], createdNations: [],
deletedNations: [], deletedNations: [],
deletedNationSnapshots: [], deletedNationSnapshots: [],
+177 -12
View File
@@ -20,6 +20,15 @@ type NavigationFixture = {
generalMeCalls: number; generalMeCalls: number;
operations: string[]; operations: string[];
generalName?: string; generalName?: string;
cityDefence?: number;
cityState?: number;
nationRate?: number;
contextRevision?: string;
contextOperations?: JsonPatchOperation[];
commandTableRevision?: string;
commandTableOperations?: JsonPatchOperation[];
commandBlockedCount?: number;
forceSnapshotCalls?: number;
refreshDelayMs?: number; refreshDelayMs?: number;
largeCommandTable?: boolean; largeCommandTable?: boolean;
dashboardResponses?: Array<{ dashboardResponses?: Array<{
@@ -30,6 +39,12 @@ type NavigationFixture = {
}>; }>;
}; };
type JsonPatchOperation = {
op: 'add' | 'remove' | 'replace';
path: string;
value?: unknown;
};
type DashboardBundleInput = { type DashboardBundleInput = {
include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean }; include?: { context?: boolean; commandTable?: boolean; boardAccess?: boolean };
known?: { context?: string; commandTable?: string; boardAccess?: string }; known?: { context?: string; commandTable?: string; boardAccess?: string };
@@ -44,7 +59,62 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => {
return entry.json ?? (entry as 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<typeof readModelChanges>) =>
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 general: large
? [ ? [
{ {
@@ -53,8 +123,8 @@ const commandTableFixture = (large: boolean) => ({
key: `command-${index}`, key: `command-${index}`,
name: `명령 ${index}`, name: `명령 ${index}`,
reqArg: index % 2 === 0, reqArg: index % 2 === 0,
possible: true, possible: index >= blockedCount,
status: 'available', status: index >= blockedCount ? 'available' : 'blocked',
inputFields: [ inputFields: [
{ {
key: 'amount', key: 'amount',
@@ -87,6 +157,7 @@ const COMMAND_TABLE_REVISION = 'CCCCCCCCCCCCCCCCCCCCCC';
const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD'; const BOARD_ACCESS_REVISION = 'DDDDDDDDDDDDDDDDDDDDDD';
const contextRevision = (state: NavigationFixture) => { const contextRevision = (state: NavigationFixture) => {
if (state.contextRevision) return state.contextRevision;
const name = state.generalName ?? '메뉴검증장수'; const name = state.generalName ?? '메뉴검증장수';
if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION; if (name === '메뉴검증장수') return CONTEXT_INITIAL_REVISION;
if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE'; if (name === '부드럽게갱신된장수') return 'EEEEEEEEEEEEEEEEEEEEEE';
@@ -95,14 +166,20 @@ const contextRevision = (state: NavigationFixture) => {
return 'HHHHHHHHHHHHHHHHHHHHHH'; return 'HHHHHHHHHHHHHHHHHHHHHH';
}; };
const deltaSlice = <T>(value: T, revision: string, known: string | undefined, forceSnapshot: boolean) => { const deltaSlice = <T>(
value: T,
revision: string,
known: string | undefined,
forceSnapshot: boolean,
operations?: JsonPatchOperation[]
) => {
if (forceSnapshot || !known) return { kind: 'snapshot' as const, revision, data: value }; if (forceSnapshot || !known) return { kind: 'snapshot' as const, revision, data: value };
if (known === revision) return { kind: 'unchanged' as const, revision }; if (known === revision) return { kind: 'unchanged' as const, revision };
return { return {
kind: 'patch' as const, kind: 'patch' as const,
baseRevision: known, baseRevision: known,
revision, revision,
operations: [ operations: operations ?? [
{ {
op: 'replace' as const, op: 'replace' as const,
path: '/general/name', path: '/general/name',
@@ -169,12 +246,13 @@ const generalContext = (state: NavigationFixture) => ({
securityMax: 2_000, securityMax: 2_000,
trust: 80, trust: 80,
trade: 100, trade: 100,
defence: 1_000, defence: state.cityDefence ?? 1_000,
defenceMax: 2_000, defenceMax: 2_000,
wall: 1_000, wall: 1_000,
wallMax: 2_000, wallMax: 2_000,
supplyState: 1, supplyState: 1,
frontState: 0, frontState: 0,
state: state.cityState ?? 0,
}, },
nation: { nation: {
id: 1, id: 1,
@@ -184,7 +262,7 @@ const generalContext = (state: NavigationFixture) => ({
gold: 10_000, gold: 10_000,
rice: 20_000, rice: 20_000,
tech: 100, tech: 100,
rate: 20, rate: state.nationRate ?? 20,
bill: 100, bill: 100,
capitalCityId: 1, capitalCityId: 1,
typeCode: 'che_유가', typeCode: 'che_유가',
@@ -241,18 +319,33 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
const input = operationInput(route, index); const input = operationInput(route, index);
const include = input.include ?? {}; const include = input.include ?? {};
const forceSnapshot = input.forceSnapshot === true; const forceSnapshot = input.forceSnapshot === true;
if (forceSnapshot) state.forceSnapshotCalls = (state.forceSnapshotCalls ?? 0) + 1;
const revision = contextRevision(state); const revision = contextRevision(state);
const context = include.context const context = include.context
? deltaSlice(generalContext(state), revision, input.known?.context, forceSnapshot) ? deltaSlice(
generalContext(state),
revision,
input.known?.context,
forceSnapshot,
state.contextOperations
)
: undefined; : undefined;
const currentCommandTableRevision = state.commandTableRevision ?? COMMAND_TABLE_REVISION;
const commandTable = include.commandTable const commandTable = include.commandTable
? forceSnapshot || !input.known?.commandTable ? forceSnapshot || !input.known?.commandTable
? { ? {
kind: 'snapshot' as const, kind: 'snapshot' as const,
revision: COMMAND_TABLE_REVISION, revision: currentCommandTableRevision,
data: commandTableFixture(state.largeCommandTable === true), data: commandTableFixture(state.largeCommandTable === true, state.commandBlockedCount),
}
: input.known.commandTable === currentCommandTableRevision
? { kind: 'unchanged' as const, revision: currentCommandTableRevision }
: {
kind: 'patch' as const,
baseRevision: input.known.commandTable,
revision: currentCommandTableRevision,
operations: state.commandTableOperations ?? [],
} }
: { kind: 'unchanged' as const, revision: COMMAND_TABLE_REVISION }
: undefined; : undefined;
const boardAccess = include.boardAccess const boardAccess = include.boardAccess
? forceSnapshot || !input.known?.boardAccess ? forceSnapshot || !input.known?.boardAccess
@@ -297,7 +390,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
startYear: 180, startYear: 180,
year: 185, year: 185,
month: 1, month: 1,
cityList: [[1, 8, 0, 1, 1, 1]], cityList: [[1, 8, state.cityState ?? 0, 1, 1, 1]],
nationList: [[1, '위', '#008000', 1]], nationList: [[1, '위', '#008000', 1]],
spyList: {}, spyList: {},
shownByGeneralList: [], 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.locator(`a[href="${basePath}/board"]`).first().click();
await page.waitForURL(`**${basePath}/board`); await page.waitForURL(`**${basePath}/board`);
expect( expect(
+32 -18
View File
@@ -3,7 +3,7 @@ import { defineStore } from 'pinia';
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic'; import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
import { import {
applyReadModelDelta, applyReadModelDelta,
ReadModelDeltaMismatchError, cloneReadModelJson,
type RealtimeEvent, type RealtimeEvent,
type RealtimeReadModelChanges, type RealtimeReadModelChanges,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
@@ -15,6 +15,7 @@ import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'
import { structurallyShare } from '../utils/structuralShare'; import { structurallyShare } from '../utils/structuralShare';
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel'; import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000; const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
@@ -103,6 +104,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
let recordGeneralId: number | null = null; let recordGeneralId: number | null = null;
let initialized = false; let initialized = false;
let contextSnapshot: GeneralContext | undefined; let contextSnapshot: GeneralContext | undefined;
let commandTableSnapshot: CommandTable | undefined;
let boardAccessSnapshot: BoardAccess | undefined;
let contextRevision: string | null = null; let contextRevision: string | null = null;
let commandTableRevision: string | null = null; let commandTableRevision: string | null = null;
let boardAccessRevision: string | null = null; let boardAccessRevision: string | null = null;
@@ -319,6 +322,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const applyDashboardPatch = (patch: DashboardReadModelPatch) => { const applyDashboardPatch = (patch: DashboardReadModelPatch) => {
if (patch.contextSnapshot === null) { if (patch.contextSnapshot === null) {
contextSnapshot = null; contextSnapshot = null;
commandTableSnapshot = undefined;
boardAccessSnapshot = undefined;
general.value = null; general.value = null;
city.value = null; city.value = null;
nation.value = null; nation.value = null;
@@ -337,6 +342,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} }
if (patch.general === null) { if (patch.general === null) {
contextSnapshot = null; contextSnapshot = null;
commandTableSnapshot = undefined;
boardAccessSnapshot = undefined;
general.value = null; general.value = null;
city.value = null; city.value = null;
nation.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.worldMap !== undefined) worldMap.value = structurallyShare(worldMap.value, patch.worldMap);
if (patch.mapLayout !== undefined) mapLayout.value = structurallyShare(mapLayout.value, patch.mapLayout); if (patch.mapLayout !== undefined) mapLayout.value = structurallyShare(mapLayout.value, patch.mapLayout);
if (patch.commandTable !== undefined) { if (patch.commandTable !== undefined) {
commandTableSnapshot = patch.commandTable ?? undefined;
commandTable.value = structurallyShare(commandTable.value, patch.commandTable); commandTable.value = structurallyShare(commandTable.value, patch.commandTable);
} }
if (patch.messages !== undefined) messages.value = structurallyShare(messages.value, patch.messages); if (patch.messages !== undefined) messages.value = structurallyShare(messages.value, patch.messages);
if (patch.messageContacts !== undefined) { if (patch.messageContacts !== undefined) {
messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts); 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); boardAccess.value = structurallyShare(boardAccess.value, patch.boardAccess);
}
if (patch.reservedGeneralTurns !== undefined) { if (patch.reservedGeneralTurns !== undefined) {
reservedGeneralTurns.value = structurallyShare<unknown>( reservedGeneralTurns.value = structurallyShare<unknown>(
reservedGeneralTurns.value, reservedGeneralTurns.value,
@@ -409,10 +419,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.lobbyInfo = toRaw(lobbyInfo.value); patch.lobbyInfo = toRaw(lobbyInfo.value);
patch.worldMap = toRaw(worldMap.value); patch.worldMap = toRaw(worldMap.value);
patch.mapLayout = toRaw(mapLayout.value); patch.mapLayout = toRaw(mapLayout.value);
patch.commandTable = toRaw(commandTable.value); patch.commandTable = commandTableSnapshot ?? null;
patch.messages = toRaw(messages.value); patch.messages = toRaw(messages.value);
patch.messageContacts = toRaw(messageContacts.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.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null;
patch.reservedGeneralRevision = reservedGeneralRevision.value; patch.reservedGeneralRevision = reservedGeneralRevision.value;
patch.globalRecords = toRaw(globalRecords.value); patch.globalRecords = toRaw(globalRecords.value);
@@ -433,16 +443,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} }
} }
if (bundle.commandTable) { if (bundle.commandTable) {
const current = commandTable.value === null ? undefined : toRaw(commandTable.value); const applied = applyReadModelDelta(commandTableSnapshot, commandTableRevision, bundle.commandTable);
const applied = applyReadModelDelta(current, commandTableRevision, bundle.commandTable);
patch.commandTableRevision = applied.revision; patch.commandTableRevision = applied.revision;
if (bundle.commandTable.kind !== 'unchanged') { if (bundle.commandTable.kind !== 'unchanged') {
patch.commandTable = applied.data; patch.commandTable = applied.data;
} }
} }
if (bundle.boardAccess) { if (bundle.boardAccess) {
const current = boardAccess.value === null ? undefined : toRaw(boardAccess.value); const applied = applyReadModelDelta(boardAccessSnapshot, boardAccessRevision, bundle.boardAccess);
const applied = applyReadModelDelta(current, boardAccessRevision, bundle.boardAccess);
patch.boardAccessRevision = applied.revision; patch.boardAccessRevision = applied.revision;
if (bundle.boardAccess.kind !== 'unchanged') { if (bundle.boardAccess.kind !== 'unchanged') {
patch.boardAccess = applied.data; patch.boardAccess = applied.data;
@@ -469,15 +477,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
forceSnapshot: force || undefined, forceSnapshot: force || undefined,
}); });
const bundle = await request(forceSnapshot); return resolveWithReadModelSnapshotFallback({
try { request,
return resolveContextBundlePatch(bundle); resolve: resolveContextBundlePatch,
} catch (error) { forceSnapshot,
if (forceSnapshot || !(error instanceof ReadModelDeltaMismatchError)) { });
throw error;
}
return resolveContextBundlePatch(await request(true));
}
}; };
const refreshMainData = async () => { const refreshMainData = async () => {
@@ -576,7 +580,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
let realtimeCoordinatorScope: string | null = null; let realtimeCoordinatorScope: string | null = null;
const publishDashboardPatch = (patch: DashboardReadModelPatch) => { 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( const realtimeRefreshQueue = createRateLimitedRefreshQueue(
@@ -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 <Response, Result>(options: {
request: (forceSnapshot: boolean) => Promise<Response>;
resolve: (response: Response) => Result;
forceSnapshot?: boolean;
}): Promise<Result> => {
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));
}
};
@@ -2,10 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common'; import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common';
import { import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts';
createMergedReadModelRefreshQueue,
resolveDashboardRefreshPlan,
} from '../src/utils/dashboardReadModel.ts';
void test('last-turn-time-only events do not schedule any dashboard query', () => { void test('last-turn-time-only events do not schedule any dashboard query', () => {
const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), { const plan = resolveDashboardRefreshPlan(createEmptyRealtimeReadModelChanges(), {
@@ -55,10 +52,63 @@ void test('refreshes the map only for map-projection changes', () => {
mapGeneralIds: [7], mapGeneralIds: [7],
}; };
assert.equal( assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true);
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', () => { 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, contactsChanged: false,
}; };
assert.equal( assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map, true);
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).map,
true
);
}); });
void test('does not refresh front status for contact-only permission changes', () => { 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], frontStatusActorIds: [7],
}; };
assert.equal( assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus, true);
resolveDashboardRefreshPlan(changes, { generalId: 7, cityId: 3, nationId: 2 }).frontStatus, assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
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 () => { void test('merges burst payloads without losing entity ids and starts at most once per interval', async () => {
@@ -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]);
});
+35 -3
View File
@@ -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<T> { export interface AppliedReadModelDelta<T> {
data: T; data: T;
revision: string; revision: string;
} }
const cloneJsonValue = <T>(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 = <T>(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); export const createJsonPatch = (current: unknown, next: unknown): JsonPatchOperation[] => createPatch(current, next);
@@ -74,11 +99,18 @@ export const applyReadModelDelta = <T>(
); );
} }
const next = cloneJsonValue(current); const next = cloneReadModelJson(current);
try {
const errors = applyPatch(next, delta.operations as Operation[]); const errors = applyPatch(next, delta.operations as Operation[]);
const failure = errors.find((error) => error !== null); const failure = errors.find((error) => error !== null);
if (failure) { if (failure) {
throw new ReadModelDeltaMismatchError(`JSON Patch application failed: ${failure.message}`); 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 { return {
+41 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('applyReadModelDelta', () => {
it('applies a JSON Patch without mutating the previous snapshot', () => { it('applies a JSON Patch without mutating the previous snapshot', () => {
@@ -46,4 +46,44 @@ describe('applyReadModelDelta', () => {
}) })
).toThrow(ReadModelDeltaMismatchError); ).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);
});
}); });