Files
core2026/app/game-api/src/realtime/publicEvent.ts
T

138 lines
5.5 KiB
TypeScript

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 ?? ''}:${identity.canReadDiplomacy}`;
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 type RealtimeSubscriptionScope = 'dashboard' | 'tournament';
/** Keeps page-family projection traffic off the dashboard subscription and vice versa. */
export const shouldForwardRealtimeEvent = (event: RealtimeEvent, scope: RealtimeSubscriptionScope): boolean =>
scope === 'tournament'
? event.type === 'tournamentProjectionChanged'
: event.type !== 'tournamentProjectionChanged';
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;
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
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[],
createRefreshGrant: () => string
): PublicRealtimeEvent | null => {
const viewers = uniqueIdentities(
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
);
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
const mailboxes =
event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
const diplomacyMailboxes =
event.type === 'messageCreated'
? event.msgType === 'diplomacy'
? [event.mailbox]
: []
: (event.diplomacyMailboxes ?? []);
return viewers.some(
(identity) =>
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
(identity.canReadDiplomacy &&
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
)
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
: null;
}
if (event.type === 'tournamentChanged') {
return {
type: 'readModelInvalidated',
refreshGrant: createRefreshGrant(),
invalidation: {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: true,
},
};
}
if (event.type === 'tournamentProjectionChanged') {
return {
type: 'tournamentViewInvalidated',
refreshGrant: createRefreshGrant(),
invalidation: {
snapshot: event.invalidation.snapshot === true,
betting: event.invalidation.betting === true,
rankings: event.invalidation.rankings === true,
},
};
}
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
refreshGrant: createRefreshGrant(),
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, refreshGrant: createRefreshGrant() };
};