fix(realtime): redact browser event details

This commit is contained in:
2026-08-13 00:19:24 +00:00
parent de804a6c2a
commit 08ea4d96bb
8 changed files with 570 additions and 327 deletions
+85
View File
@@ -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<string>();
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 };
};
+38 -9
View File
@@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static';
import path from 'path'; import path from 'path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; 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 type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
@@ -23,6 +23,7 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.js'; import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { formatSseFrame } from './realtime/sse.js'; import { formatSseFrame } from './realtime/sse.js';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
@@ -229,6 +230,17 @@ export const createGameApiServer = async () => {
return; return;
} }
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
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(); reply.hijack();
const requestOrigin = request.headers.origin; const requestOrigin = request.headers.origin;
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) { if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
@@ -256,30 +268,47 @@ export const createGameApiServer = async () => {
sendFrame( sendFrame(
formatSseFrame({ formatSseFrame({
event: 'ready', event: 'ready',
data: JSON.stringify({ at: new Date().toISOString() }), data: '{}',
}) })
); );
let closed = false;
let eventQueue = Promise.resolve();
const unsubscribe = realtimeHub.subscribe((event) => { const unsubscribe = realtimeHub.subscribe((event) => {
sendFrame( eventQueue = eventQueue
formatSseFrame({ .then(async () => {
event: event.type, if (closed) return;
data: JSON.stringify(event), const identities = [viewerIdentity];
id: event.at, 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(() => { const heartbeat = setInterval(() => {
sendFrame( sendFrame(
formatSseFrame({ formatSseFrame({
event: 'ping', event: 'ping',
data: JSON.stringify({ at: new Date().toISOString() }), data: '{}',
}) })
); );
}, 15000); }, 15000);
const close = () => { const close = () => {
closed = true;
clearInterval(heartbeat); clearInterval(heartbeat);
unsubscribe(); unsubscribe();
}; };
@@ -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,
},
});
});
});
+94 -185
View File
@@ -68,60 +68,40 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => {
return entry.json ?? (entry as DashboardBundleInput); return entry.json ?? (entry as DashboardBundleInput);
}; };
const readModelChanges = ( const readModelInvalidation = (
overrides: Partial<{ overrides: Partial<{
generalIds: number[]; context: boolean;
cityIds: number[]; lobby: boolean;
nationIds: number[]; map: boolean;
mapGeneralIds: number[]; commands: boolean;
mapCityIds: number[]; contacts: boolean;
mapNationIds: number[]; boardAccess: boolean;
frontStatusGeneralIds: number[]; reservedTurns: boolean;
frontStatusNationIds: number[]; records: boolean;
frontStatusActorIds: number[]; frontStatus: boolean;
frontStatusChanged: boolean;
lobbyGeneralIds: number[];
lobbyChanged: boolean;
reservedGeneralIds: number[];
recordGeneralIds: number[];
worldChanged: boolean;
globalRecordsChanged: boolean;
worldHistoryChanged: boolean;
contactsChanged: boolean;
}> }>
) => ({ ) => ({
generalIds: [], context: false,
cityIds: [], lobby: false,
nationIds: [], map: false,
mapGeneralIds: [], commands: false,
mapCityIds: [], contacts: false,
mapNationIds: [], boardAccess: false,
frontStatusGeneralIds: [], reservedTurns: false,
frontStatusNationIds: [], records: false,
frontStatusActorIds: [], frontStatus: false,
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
...overrides, ...overrides,
}); });
const emitReadModelChanges = (page: Page, changes: ReturnType<typeof readModelChanges>) => const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof readModelInvalidation>) =>
page.evaluate((payload) => { page.evaluate((payload) => {
(window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime(
'readModelChanged', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: payload,
revision: Date.now(),
changes: payload,
} }
); );
}, changes); }, invalidation);
const commandTableFixture = (large: boolean, blockedCount = 0) => ({ const commandTableFixture = (large: boolean, blockedCount = 0) => ({
general: large 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 callsBeforeRefresh = state.generalMeCalls;
const operationsBeforeClockOnly = state.operations.length; 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)); await new Promise((resolve) => setTimeout(resolve, 300));
expect(state.operations.slice(operationsBeforeClockOnly)).toEqual([]); 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 }) const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
.__emitMainRealtime; .__emitMainRealtime;
for (let index = 0; index < 100; index += 1) { for (let index = 0; index < 100; index += 1) {
emit('turnCompleted', { emit('readModelInvalidated', {
at: new Date().toISOString(), invalidation: {
lastTurnTime: '0185-02-01T00:00:00.000Z', context: true,
changes: { lobby: false,
generalIds: [7], map: false,
cityIds: [], commands: true,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: true,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: false,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
}); });
} }
@@ -1374,29 +1323,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
const operationsBeforeSurvey = state.operations.length; const operationsBeforeSurvey = state.operations.length;
await page.evaluate(() => { await page.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'readModelChanged', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: {
revision: 42, context: false,
changes: { lobby: false,
generalIds: [], map: false,
cityIds: [], commands: false,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: false,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: true,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: true,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
} }
); );
@@ -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/possible', value: false },
{ op: 'replace', path: '/general/0/values/0/status', value: 'blocked' }, { 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.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1);
await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000'); 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/possible', value: false },
{ op: 'replace', path: '/general/0/values/1/status', value: 'blocked' }, { 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); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1);
const callsBeforeCityState = state.generalMeCalls; 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/possible', value: false },
{ op: 'replace', path: '/general/0/values/2/status', value: 'blocked' }, { 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.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeCityState + 1);
await expect(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u); await expect(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u);
expect(state.operations.slice(operationsBeforeCityState).sort()).toEqual( 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.contextRevision = 'O'.repeat(22);
state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }]; state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }];
state.commandTableOperations = []; 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); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2);
expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1); expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1);
await expect(page.locator('.general-title')).toContainText('snapshot복구장수'); 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; const callsAfterLeavingMain = state.generalMeCalls;
await page.evaluate(() => { await page.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'turnCompleted', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: {
lastTurnTime: '0185-02-01T00:00:00.000Z', context: true,
changes: { lobby: false,
generalIds: [7], map: false,
cityIds: [], commands: true,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: true,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: false,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
} }
); );
@@ -1597,7 +1530,7 @@ test('global activity, world history, and a month boundary refresh their visible
{ id: 3, text: '장수 동향 기록' }, { id: 3, text: '장수 동향 기록' },
]; ];
const operationsBeforeGlobal = state.operations.length; 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('자동 갱신된 장수 동향'); await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']); 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: '중원 정세 기록' }, { id: 1, text: '중원 정세 기록' },
]; ];
const operationsBeforeHistory = state.operations.length; 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('자동 갱신된 중원 정세'); await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']); expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
state.currentMonth = 2; state.currentMonth = 2;
const operationsBeforeMonth = state.operations.length; const operationsBeforeMonth = state.operations.length;
await page.evaluate( await page.evaluate(
(changes) => { (invalidation) => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'turnCompleted', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation,
lastTurnTime: '0185-02-01T00:00:00.000Z',
changes,
} }
); );
}, },
readModelChanges({ worldChanged: true }) readModelInvalidation({ lobby: true, map: true, commands: true })
); );
await expect(page.getByText('현재: 185년 2월')).toBeVisible(); await expect(page.getByText('현재: 185년 2월')).toBeVisible();
await expect(page.locator('.map-viewer')).toContainText('185年 2月'); 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 = '탭공유갱신장수'; state.generalName = '탭공유갱신장수';
await leaderPage.evaluate(() => { await leaderPage.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'readModelChanged', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: {
revision: 100, context: true,
changes: { lobby: false,
generalIds: [7], map: false,
cityIds: [], commands: true,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: true,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: false,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
} }
); );
@@ -1727,29 +1647,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
state.generalName = '리더만갱신장수'; state.generalName = '리더만갱신장수';
await leaderPage.evaluate(() => { await leaderPage.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'readModelChanged', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: {
revision: 101, context: true,
changes: { lobby: false,
generalIds: [7], map: false,
cityIds: [], commands: true,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: true,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: false,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
} }
); );
+28 -50
View File
@@ -4,8 +4,8 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType
import { import {
applyReadModelDelta, applyReadModelDelta,
cloneReadModelJson, cloneReadModelJson,
type RealtimeEvent, type PublicRealtimeEvent,
type RealtimeReadModelChanges, type RealtimeReadModelInvalidation,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { useMapViewerStore } from './mapViewer'; import { useMapViewerStore } from './mapViewer';
@@ -13,7 +13,7 @@ import { useSessionStore } from './session';
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'; import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
import { structurallyShare } from '../utils/structuralShare'; import { structurallyShare } from '../utils/structuralShare';
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel'; import { createMergedReadModelRefreshQueue } from '../utils/dashboardReadModel';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; 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; const id = generalId.value;
if (!id) { if (!id) {
return; return;
} }
const plan = resolveDashboardRefreshPlan(changes, {
generalId: id,
cityId: city.value?.id ?? null,
nationId: nation.value?.id ?? null,
});
if (!Object.values(plan).some(Boolean)) { if (!Object.values(plan).some(Boolean)) {
return; return;
} }
@@ -944,12 +939,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
return url.toString(); return url.toString();
}; };
const parseRealtimePayload = (raw: MessageEvent): RealtimeEvent | null => { const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => {
if (!raw.data || typeof raw.data !== 'string') { if (!raw.data || typeof raw.data !== 'string') {
return null; return null;
} }
try { try {
const parsed = JSON.parse(raw.data) as RealtimeEvent; const parsed = JSON.parse(raw.data) as PublicRealtimeEvent;
if (!parsed || typeof parsed !== 'object') { if (!parsed || typeof parsed !== 'object') {
return null; 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 = () => { const closeRealtimeSource = () => {
if (!realtimeSource) { if (!realtimeSource) {
return; return;
@@ -1095,36 +1075,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
}); });
source.addEventListener('turnCompleted', (event) => { source.addEventListener('readModelInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event); const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'turnCompleted') { if (!payload || payload.type !== 'readModelInvalidated') {
return; return;
} }
if (!payload.changes) { readModelRefreshQueue.request(payload.invalidation);
// Rolling deployment fallback for an older daemon. });
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(); realtimeRefreshQueue.request();
return; });
} }
readModelRefreshQueue.request(payload.changes); source.addEventListener('messageCreated', () => {
});
source.addEventListener('readModelChanged', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event); void refreshMessages();
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();
}
}); });
source.addEventListener('ping', () => { source.addEventListener('ping', () => {
if (realtimeEnabled.value) { if (realtimeEnabled.value) {
@@ -1,85 +1,29 @@
import { import {
createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation,
mergeRealtimeReadModelChanges, mergeRealtimeReadModelInvalidations,
resolveRealtimeReadModelInvalidation,
type RealtimeReadModelChanges, type RealtimeReadModelChanges,
type RealtimeReadModelInvalidation,
type RealtimeViewerIdentity,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
export interface DashboardReadModelIdentity { export type DashboardReadModelIdentity = RealtimeViewerIdentity;
generalId: number | null; export type DashboardRefreshPlan = RealtimeReadModelInvalidation;
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 const resolveDashboardRefreshPlan = ( export const resolveDashboardRefreshPlan = (
changes: RealtimeReadModelChanges, changes: RealtimeReadModelChanges,
identity: DashboardReadModelIdentity identity: DashboardReadModelIdentity
): DashboardRefreshPlan => { ): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
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,
};
};
type TimerHandle = ReturnType<typeof setTimeout>; type TimerHandle = ReturnType<typeof setTimeout>;
export interface MergedReadModelRefreshQueue { export interface MergedReadModelRefreshQueue {
request(changes: RealtimeReadModelChanges): void; request(invalidation: RealtimeReadModelInvalidation): void;
cancelPending(): void; cancelPending(): void;
} }
export const createMergedReadModelRefreshQueue = ( export const createMergedReadModelRefreshQueue = (
refresh: (changes: RealtimeReadModelChanges) => Promise<void>, refresh: (invalidation: RealtimeReadModelInvalidation) => Promise<void>,
options: { options: {
minIntervalMs?: number; minIntervalMs?: number;
now?: () => number; now?: () => number;
@@ -91,7 +35,7 @@ export const createMergedReadModelRefreshQueue = (
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)); const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
let pending = createEmptyRealtimeReadModelChanges(); let pending = createEmptyRealtimeReadModelInvalidation();
let hasPending = false; let hasPending = false;
let running = false; let running = false;
let timer: TimerHandle | null = null; let timer: TimerHandle | null = null;
@@ -108,7 +52,7 @@ export const createMergedReadModelRefreshQueue = (
return; return;
} }
const next = pending; const next = pending;
pending = createEmptyRealtimeReadModelChanges(); pending = createEmptyRealtimeReadModelInvalidation();
hasPending = false; hasPending = false;
running = true; running = true;
lastStartedAt = now(); lastStartedAt = now();
@@ -120,14 +64,14 @@ export const createMergedReadModelRefreshQueue = (
}; };
return { return {
request: (changes) => { request: (invalidation) => {
pending = hasPending ? mergeRealtimeReadModelChanges(pending, changes) : changes; pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation;
hasPending = true; hasPending = true;
schedule(); schedule();
}, },
cancelPending: () => { cancelPending: () => {
hasPending = false; hasPending = false;
pending = createEmptyRealtimeReadModelChanges(); pending = createEmptyRealtimeReadModelInvalidation();
if (timer !== null) { if (timer !== null) {
clearTimer(timer); clearTimer(timer);
timer = null; timer = null;
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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'; import { 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', () => {
@@ -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); 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 nowMs = 0;
let nextTimerId = 1; let nextTimerId = 1;
const timers = new Map<number, { callback: () => void; at: number }>(); const timers = new Map<number, { callback: () => void; at: number }>();
const observed: number[][] = []; const observed: Array<{ context: boolean; records: boolean }> = [];
const queue = createMergedReadModelRefreshQueue( const queue = createMergedReadModelRefreshQueue(
async (changes) => { async (invalidation) => {
observed.push(changes.generalIds); observed.push({ context: invalidation.context, records: invalidation.records });
}, },
{ {
minIntervalMs: 1_000, 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(); runDueTimers();
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(observed, [[7]]); assert.deepEqual(observed, [{ context: true, records: false }]);
queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [9] }); queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [8, 9] }); queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true });
nowMs = 999; nowMs = 999;
runDueTimers(); runDueTimers();
assert.equal(observed.length, 1); assert.equal(observed.length, 1);
nowMs = 1_000; nowMs = 1_000;
runDueTimers(); runDueTimers();
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(observed, [[7], [8, 9]]); assert.deepEqual(observed, [
{ context: true, records: false },
{ context: true, records: true },
]);
}); });
+128 -2
View File
@@ -2,8 +2,9 @@ export type MessageTypeKey = 'public' | 'private' | 'national' | 'diplomacy';
/** /**
* Durable mutations summarized after the database transaction commits. * Durable mutations summarized after the database transaction commits.
* Entity IDs let each authenticated client decide whether its own read model * This internal Redis contract carries entity IDs so the authenticated game
* is affected without exposing entity payloads over the shared Redis channel. * API can derive each subscriber's browser-safe invalidation. It must never be
* serialized directly to a public SSE response.
*/ */
export interface RealtimeReadModelChanges { export interface RealtimeReadModelChanges {
generalIds: number[]; generalIds: number[];
@@ -32,6 +33,119 @@ export interface RealtimeReadModelChanges {
lobbyChanged?: boolean; 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 => ({ export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges => ({
generalIds: [], generalIds: [],
cityIds: [], cityIds: [],
@@ -125,6 +239,18 @@ export interface MessageCreatedEvent {
senderId: number; 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 = export type RealtimeEvent =
| TurnCompletedEvent | TurnCompletedEvent
| ReadModelChangedEvent | ReadModelChangedEvent