refactor(game): refresh committed read models selectively

This commit is contained in:
2026-08-09 15:46:01 +00:00
parent 00b9fcdb93
commit a2683df80f
14 changed files with 807 additions and 48 deletions
+148 -21
View File
@@ -1,13 +1,14 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
import type { RealtimeEvent } from '@sammo-ts/common';
import type { RealtimeEvent, RealtimeReadModelChanges } from '@sammo-ts/common';
import { trpc } from '../utils/trpc';
import { useMapViewerStore } from './mapViewer';
import { useSessionStore } from './session';
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
import { structurallyShare } from '../utils/structuralShare';
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
@@ -252,6 +253,29 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
surveyNotice.value = null;
};
const applyRecentRecords = (
records: Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>
) => {
globalRecords.value = structurallyShare(
globalRecords.value,
mergeRecentRecords(globalRecords.value, records.global)
);
generalRecords.value = structurallyShare(
generalRecords.value,
mergeRecentRecords(generalRecords.value, records.general)
);
worldHistory.value = structurallyShare(
worldHistory.value,
mergeRecentRecords(worldHistory.value, records.history)
);
lastGeneralRecordId = Math.max(
lastGeneralRecordId,
records.global[0]?.id ?? 0,
records.general[0]?.id ?? 0
);
lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0);
};
const refreshMainData = async () => {
const isInitialLoad = !initialized;
if (isInitialLoad) {
@@ -337,24 +361,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
) as ReservedTurnView[];
reservedGeneralRevision.value = generalTurns.revision;
if (records) {
globalRecords.value = structurallyShare(
globalRecords.value,
mergeRecentRecords(globalRecords.value, records.global)
);
generalRecords.value = structurallyShare(
generalRecords.value,
mergeRecentRecords(generalRecords.value, records.general)
);
worldHistory.value = structurallyShare(
worldHistory.value,
mergeRecentRecords(worldHistory.value, records.history)
);
lastGeneralRecordId = Math.max(
lastGeneralRecordId,
records.global[0]?.id ?? 0,
records.general[0]?.id ?? 0
);
lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0);
applyRecentRecords(records);
}
if (nextFrontStatus) {
updateFrontStatus(nextFrontStatus);
@@ -381,6 +388,108 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
minIntervalMs: REALTIME_FULL_REFRESH_MIN_INTERVAL_MS,
});
const refreshChangedReadModels = async (changes: RealtimeReadModelChanges) => {
const id = generalId.value;
if (!id) {
return;
}
const plan = resolveDashboardRefreshPlan(changes, {
generalId: id,
cityId: city.value?.id ?? null,
nationId: nation.value?.id ?? null,
});
if (!Object.values(plan).some(Boolean)) {
return;
}
refreshing.value = true;
error.value = null;
if (plan.records) recordsError.value = null;
if (plan.frontStatus) frontStatusError.value = null;
try {
const contextPromise = plan.context
? trpc.general.me.query()
: Promise.resolve(undefined as GeneralContext | undefined);
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
const mapPromise = plan.map
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
: Promise.resolve(undefined);
const commandsPromise = plan.commands
? trpc.turns.getCommandTable.query({ generalId: id })
: Promise.resolve(undefined);
const contactsPromise = plan.contacts
? trpc.messages.getContacts.query({ generalId: id })
: Promise.resolve(undefined);
const boardPromise = plan.boardAccess ? trpc.board.getAccess.query() : Promise.resolve(undefined);
const reservedPromise = plan.reservedTurns
? trpc.turns.reserved.getGeneral.query({ generalId: id })
: Promise.resolve(undefined);
const recordsPromise = plan.records
? trpc.general.getRecentRecords
.query({ lastGeneralRecordId, lastWorldHistoryId })
.catch((err: unknown) => {
recordsError.value = resolveErrorMessage(err);
return null;
})
: Promise.resolve(undefined);
const frontPromise = plan.frontStatus
? trpc.general.getFrontStatus.query().catch((err: unknown) => {
frontStatusError.value = resolveErrorMessage(err);
return null;
})
: Promise.resolve(undefined);
const [context, lobby, map, commands, contacts, access, generalTurns, records, nextFrontStatus] =
await Promise.all([
contextPromise,
lobbyPromise,
mapPromise,
commandsPromise,
contactsPromise,
boardPromise,
reservedPromise,
recordsPromise,
frontPromise,
]);
if (context === null) {
general.value = null;
city.value = null;
nation.value = null;
reservedGeneralTurns.value = null;
reservedGeneralRevision.value = 0;
boardAccess.value = null;
resetRecentRecords(null);
return;
}
if (context !== undefined) {
general.value = structurallyShare(general.value, context.general);
city.value = structurallyShare(city.value, context.city);
nation.value = structurallyShare(nation.value, context.nation);
}
if (lobby !== undefined) lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby);
if (map !== undefined) worldMap.value = structurallyShare(worldMap.value, map);
if (commands !== undefined) commandTable.value = structurallyShare(commandTable.value, commands);
if (contacts !== undefined) messageContacts.value = structurallyShare(messageContacts.value, contacts);
if (access !== undefined) boardAccess.value = structurallyShare(boardAccess.value, access);
if (generalTurns !== undefined) {
reservedGeneralTurns.value = structurallyShare<unknown>(
reservedGeneralTurns.value,
generalTurns.turns
) as ReservedTurnView[];
reservedGeneralRevision.value = generalTurns.revision;
}
if (records) applyRecentRecords(records);
if (nextFrontStatus) updateFrontStatus(nextFrontStatus);
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
refreshing.value = false;
}
};
const readModelRefreshQueue = createMergedReadModelRefreshQueue(refreshChangedReadModels);
const refreshMessages = async () => {
const id = generalId.value;
if (!id) {
@@ -701,8 +810,24 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
source.addEventListener('error', () => {
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
});
source.addEventListener('turnCompleted', () => {
realtimeRefreshQueue.request();
source.addEventListener('turnCompleted', (event) => {
const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'turnCompleted') {
return;
}
if (!payload.changes) {
// Rolling deployment fallback for an older daemon.
realtimeRefreshQueue.request();
return;
}
readModelRefreshQueue.request(payload.changes);
});
source.addEventListener('readModelChanged', (event) => {
const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'readModelChanged') {
return;
}
readModelRefreshQueue.request(payload.changes);
});
source.addEventListener('messageCreated', (event) => {
const payload = parseRealtimePayload(event);
@@ -724,6 +849,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!realtimeActive.value) return;
if (document.visibilityState === 'hidden') {
realtimeRefreshQueue.cancelPending();
readModelRefreshQueue.cancelPending();
closeRealtimeSource();
realtimeStatus.value = 'idle';
return;
@@ -746,6 +872,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const stopRealtime = () => {
realtimeActive.value = false;
realtimeRefreshQueue.cancelPending();
readModelRefreshQueue.cancelPending();
closeRealtimeSource();
if (visibilityListenerInstalled) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
@@ -0,0 +1,118 @@
import {
createEmptyRealtimeReadModelChanges,
mergeRealtimeReadModelChanges,
type RealtimeReadModelChanges,
} from '@sammo-ts/common';
export interface DashboardReadModelIdentity {
generalId: number | null;
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 = (
changes: RealtimeReadModelChanges,
identity: DashboardReadModelIdentity
): DashboardRefreshPlan => {
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
const ownCityChanged = contains(changes.cityIds, identity.cityId);
const ownNationChanged = contains(changes.nationIds, identity.nationId);
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
const worldEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
return {
context: entityContextChanged,
lobby: changes.worldChanged || changes.contactsChanged,
map: changes.worldChanged || worldEntitiesChanged || ownGeneralChanged,
commands: changes.worldChanged || worldEntitiesChanged || ownGeneralChanged,
contacts: changes.contactsChanged,
boardAccess: entityContextChanged,
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: changes.contactsChanged || ownNationChanged,
};
};
type TimerHandle = ReturnType<typeof setTimeout>;
export interface MergedReadModelRefreshQueue {
request(changes: RealtimeReadModelChanges): void;
cancelPending(): void;
}
export const createMergedReadModelRefreshQueue = (
refresh: (changes: RealtimeReadModelChanges) => Promise<void>,
options: {
minIntervalMs?: number;
now?: () => number;
setTimer?: (callback: () => void, delayMs: number) => TimerHandle;
clearTimer?: (handle: TimerHandle) => void;
} = {}
): MergedReadModelRefreshQueue => {
const minIntervalMs = Math.max(0, options.minIntervalMs ?? 1_000);
const now = options.now ?? Date.now;
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
let pending = createEmptyRealtimeReadModelChanges();
let hasPending = false;
let running = false;
let timer: TimerHandle | null = null;
let lastStartedAt = Number.NEGATIVE_INFINITY;
const schedule = () => {
if (!hasPending || running || timer !== null) {
return;
}
const delayMs = Math.max(0, lastStartedAt + minIntervalMs - now());
timer = setTimer(() => {
timer = null;
if (!hasPending || running) {
return;
}
const next = pending;
pending = createEmptyRealtimeReadModelChanges();
hasPending = false;
running = true;
lastStartedAt = now();
void refresh(next).finally(() => {
running = false;
schedule();
});
}, delayMs);
};
return {
request: (changes) => {
pending = hasPending ? mergeRealtimeReadModelChanges(pending, changes) : changes;
hasPending = true;
schedule();
},
cancelPending: () => {
hasPending = false;
pending = createEmptyRealtimeReadModelChanges();
if (timer !== null) {
clearTimer(timer);
timer = null;
}
},
};
};