fix(realtime): redact browser event details
This commit is contained in:
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user