Files
core2026/app/game-frontend/src/stores/mainDashboard.ts
T
Hide_D 19629fe3cc fix: 갱신 비용에 따라 접속 점수를 기록
revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
2026-08-17 11:10:38 +00:00

1301 lines
52 KiB
TypeScript

import { computed, ref, toRaw, watch } from 'vue';
import { defineStore } from 'pinia';
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
import {
applyReadModelDelta,
cloneReadModelJson,
type PublicRealtimeEvent,
type RealtimeReadModelInvalidation,
} 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,
resolveDashboardContextBundleInclude,
type DashboardContextBundleInclude,
} from '../utils/dashboardReadModel';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
export const useMainDashboardStore = defineStore('mainDashboard', () => {
type GeneralContext = Awaited<ReturnType<typeof trpc.general.me.query>>;
type PresentGeneralContext = NonNullable<GeneralContext>;
type LobbyInfo = Awaited<ReturnType<typeof trpc.lobby.info.query>>;
type WorldMapResult = Awaited<ReturnType<typeof trpc.world.getMap.query>>;
type MapLayout = Awaited<ReturnType<typeof trpc.world.getMapLayout.query>>;
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
type MessageContacts = Awaited<ReturnType<typeof trpc.messages.getContacts.query>>;
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
type TournamentState = Awaited<ReturnType<typeof trpc.tournament.getState.query>>;
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
type DashboardReadModelPatch = {
contextSnapshot?: GeneralContext;
contextRevision?: string | null;
commandTableRevision?: string | null;
boardAccessRevision?: string | null;
contextSourceRevision?: string | null;
commandTableSourceRevision?: string | null;
boardAccessSourceRevision?: string | null;
general?: PresentGeneralContext['general'] | null;
city?: PresentGeneralContext['city'] | null;
nation?: PresentGeneralContext['nation'] | null;
lobbyInfo?: LobbyInfo | null;
worldMap?: WorldMapResult | null;
mapLayout?: MapLayout | null;
commandTable?: CommandTable | null;
messages?: MessageBundle | null;
messageContacts?: MessageContacts | null;
boardAccess?: BoardAccess | null;
reservedGeneralTurns?: ReservedTurnView[] | null;
reservedGeneralRevision?: number;
reservedGeneralAutorunLimit?: number | null;
globalRecords?: RecentRecord[];
generalRecords?: RecentRecord[];
worldHistory?: RecentRecord[];
frontStatus?: FrontStatus | null;
tournamentStage?: number;
};
type DashboardTabMessage =
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
const loading = ref(false);
const refreshing = ref(false);
const error = ref<string | null>(null);
const recordsError = ref<string | null>(null);
const frontStatusError = ref<string | null>(null);
const realtimeEnabled = ref(true);
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
const realtimeActive = ref(false);
const accessLimited = ref(false);
const handleDashboardError = (value: unknown) => {
const message = resolveErrorMessage(value);
error.value = message;
if (message.startsWith('접속 제한중입니다.')) {
accessLimited.value = true;
realtimeStatus.value = 'paused';
}
};
const general = ref<PresentGeneralContext['general'] | null>(null);
const city = ref<PresentGeneralContext['city'] | null>(null);
const nation = ref<PresentGeneralContext['nation'] | null>(null);
const lobbyInfo = ref<LobbyInfo | null>(null);
const worldMap = ref<WorldMapResult | null>(null);
const mapLayout = ref<MapLayout | null>(null);
const commandTable = ref<CommandTable | null>(null);
const messages = ref<MessageBundle | null>(null);
const messageContacts = ref<MessageContacts | null>(null);
const boardAccess = ref<BoardAccess | null>(null);
const reservedGeneralTurns = ref<ReservedTurnView[] | null>(null);
const reservedGeneralRevision = ref(0);
const reservedGeneralAutorunLimit = ref<number | null>(null);
const globalRecords = ref<RecentRecord[]>([]);
const generalRecords = ref<RecentRecord[]>([]);
const worldHistory = ref<RecentRecord[]>([]);
const frontStatus = ref<FrontStatus | null>(null);
const tournamentStage = ref(0);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0;
let recordGeneralId: number | null = null;
let initialized = false;
let contextSnapshot: GeneralContext | undefined;
let commandTableSnapshot: CommandTable | undefined;
let boardAccessSnapshot: BoardAccess | undefined;
let contextRevision: string | null = null;
let commandTableRevision: string | null = null;
let boardAccessRevision: string | null = null;
let contextSourceRevision: string | null = null;
let commandTableSourceRevision: string | null = null;
let boardAccessSourceRevision: string | null = null;
const messageDraftText = ref('');
const targetMailbox = ref<number>(MESSAGE_MAILBOX_PUBLIC);
let initializedMailboxGeneralId: number | null = null;
const generalId = computed(() => general.value?.id ?? null);
const nationId = computed(() => nation.value?.id ?? null);
const mapViewer = useMapViewerStore();
const session = useSessionStore();
const selectedCity = computed(() => {
const layout = mapLayout.value;
const map = worldMap.value;
const selectedId = mapViewer.selectedCityId;
if (!layout || !map || !selectedId) {
return null;
}
const layoutCity = layout.cityList.find((city) => city.id === selectedId);
const mapEntry = map.cityList.find((entry) => entry[0] === selectedId);
if (!layoutCity || !mapEntry) {
return null;
}
const [, level, state, nationIdValue, region, supplyFlag] = mapEntry;
const nationEntry = map.nationList.find((nationEntry) => nationEntry[0] === nationIdValue);
const regionName = layout.regionMap[region] ?? '-';
const levelName = layout.levelMap[level] ?? '-';
return {
id: layoutCity.id,
name: layoutCity.name,
level,
levelName,
region,
regionName,
nationId: nationIdValue,
nationName: nationEntry?.[1] ?? '무주',
nationColor: nationEntry?.[2] ?? '#444444',
state,
supply: supplyFlag > 0,
isCapital: nationEntry?.[3] === layoutCity.id,
isMyCity: map.myCity === layoutCity.id,
} as const;
});
const mailboxGroups = computed(() => {
type MailboxOption = {
label: string;
value: number;
disabled?: boolean;
color?: string;
};
type MailboxGroup = {
label: string;
color?: string;
options: MailboxOption[];
};
const ownNationId = nationId.value ?? 0;
const ownMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + ownNationId;
const permission = messages.value?.permission ?? -1;
const contacts = messageContacts.value?.nation ?? [];
const ownNation = contacts.find((nation) => nation.mailbox === ownMailbox);
const groups: MailboxGroup[] = [
{
label: '즐겨찾기',
color: '#000000',
options: [
{
label: '【 아국 메세지 】',
value: ownMailbox,
color: ownNation?.color ?? '#000000',
},
{
label: '【 전체 메세지 】',
value: MESSAGE_MAILBOX_PUBLIC,
color: '#000000',
},
],
},
];
if (permission >= 4) {
groups.push({
label: '외교메시지',
color: '#000000',
options: contacts
.filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0)
.map((nation) => ({
label: nation.name,
value: nation.mailbox,
color: nation.color,
})),
});
}
const sortedContacts = [...contacts].sort((left, right) => {
if (left.mailbox === ownMailbox) return -1;
if (right.mailbox === ownMailbox) return 1;
return left.mailbox - right.mailbox;
});
for (const nation of sortedContacts) {
const options = [...nation.general]
.filter(([id]) => id !== generalId.value)
.sort((left, right) => left[1].localeCompare(right[1], 'ko'))
.map(([id, name, flags]) => {
const ruler = Boolean(flags & 1);
const ambassador = Boolean(flags & 4);
return {
label: ruler ? `*${name}*` : ambassador ? `#${name}#` : name,
value: id,
disabled: permission === 4 && ambassador && nation.mailbox !== ownMailbox,
color: nation.color,
};
});
if (options.length > 0) {
groups.push({
label: nation.name,
color: nation.color,
options,
});
}
}
return groups;
});
const statusLine = computed(() => {
if (!lobbyInfo.value) {
return '상태 정보를 불러오는 중';
}
return `${lobbyInfo.value.year}${lobbyInfo.value.month}월 · 턴 ${lobbyInfo.value.turnTerm}분`;
});
const realtimeLabel = computed(() => {
if (!realtimeEnabled.value) {
return '끔';
}
return realtimeStatus.value === 'connected' ? '연결됨' : '대기중';
});
const setRealtimeEnabled = (enabled: boolean) => {
const wasEnabled = realtimeEnabled.value;
realtimeEnabled.value = enabled;
if (!enabled) {
realtimeStatus.value = 'paused';
reconcileRealtimeCoordinator();
return;
}
if (!wasEnabled) {
void refreshQueue.request().finally(() => reconcileRealtimeCoordinator());
}
};
const updateFrontStatus = (nextStatus: FrontStatus) => {
frontStatus.value = structurallyShare(frontStatus.value, nextStatus);
const latestVote = nextStatus.latestVote;
if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') {
surveyNotice.value = null;
return;
}
const serverId = session.profile?.split(':', 1)[0] ?? 'game';
const storageKey = `state.${serverId}.lastVote`;
const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10);
if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) {
surveyNotice.value = null;
return;
}
window.localStorage.setItem(storageKey, latestVote.id.toString());
surveyNotice.value = latestVote;
};
const dismissSurveyNotice = () => {
surveyNotice.value = null;
};
const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => {
const merged = new Map(current.map((entry) => [entry.id, entry]));
for (const entry of incoming) {
merged.set(entry.id, entry);
}
return [...merged.values()].sort((left, right) => right.id - left.id).slice(0, 15);
};
const resetRecentRecords = (id: number | null) => {
globalRecords.value = [];
generalRecords.value = [];
worldHistory.value = [];
lastGeneralRecordId = 0;
lastWorldHistoryId = 0;
recordGeneralId = id;
frontStatus.value = null;
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 applyDashboardPatch = (patch: DashboardReadModelPatch) => {
if (patch.contextSnapshot === null) {
contextSnapshot = null;
commandTableSnapshot = undefined;
boardAccessSnapshot = undefined;
general.value = null;
city.value = null;
nation.value = null;
commandTable.value = null;
boardAccess.value = null;
reservedGeneralTurns.value = null;
reservedGeneralRevision.value = 0;
reservedGeneralAutorunLimit.value = null;
resetRecentRecords(null);
commandTableRevision = null;
boardAccessRevision = null;
contextSourceRevision = null;
commandTableSourceRevision = null;
boardAccessSourceRevision = null;
} else if (patch.contextSnapshot !== undefined) {
contextSnapshot = patch.contextSnapshot;
general.value = structurallyShare(general.value, patch.contextSnapshot.general);
city.value = structurallyShare(city.value, patch.contextSnapshot.city);
nation.value = structurallyShare(nation.value, patch.contextSnapshot.nation);
}
if (patch.general === null) {
contextSnapshot = null;
commandTableSnapshot = undefined;
boardAccessSnapshot = undefined;
general.value = null;
city.value = null;
nation.value = null;
commandTable.value = null;
boardAccess.value = null;
reservedGeneralTurns.value = null;
reservedGeneralRevision.value = 0;
reservedGeneralAutorunLimit.value = null;
resetRecentRecords(null);
contextRevision = null;
commandTableRevision = null;
boardAccessRevision = null;
contextSourceRevision = null;
commandTableSourceRevision = null;
boardAccessSourceRevision = null;
} else if (patch.general !== undefined) {
general.value = structurallyShare(general.value, patch.general);
}
if (patch.city !== undefined) city.value = structurallyShare(city.value, patch.city);
if (patch.nation !== undefined) nation.value = structurallyShare(nation.value, patch.nation);
if (patch.lobbyInfo !== undefined) lobbyInfo.value = structurallyShare(lobbyInfo.value, patch.lobbyInfo);
if (patch.worldMap !== undefined) worldMap.value = structurallyShare(worldMap.value, patch.worldMap);
if (patch.mapLayout !== undefined) mapLayout.value = structurallyShare(mapLayout.value, patch.mapLayout);
if (patch.commandTable !== undefined) {
commandTableSnapshot = patch.commandTable ?? undefined;
commandTable.value = structurallyShare(commandTable.value, patch.commandTable);
}
if (patch.messages !== undefined) messages.value = structurallyShare(messages.value, patch.messages);
if (patch.messageContacts !== undefined) {
messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts);
}
if (patch.boardAccess !== undefined) {
boardAccessSnapshot = patch.boardAccess ?? undefined;
boardAccess.value = structurallyShare(boardAccess.value, patch.boardAccess);
}
if (patch.reservedGeneralTurns !== undefined) {
reservedGeneralTurns.value = structurallyShare<unknown>(
reservedGeneralTurns.value,
patch.reservedGeneralTurns
) as ReservedTurnView[] | null;
}
if (patch.reservedGeneralRevision !== undefined) {
reservedGeneralRevision.value = patch.reservedGeneralRevision;
}
if (patch.reservedGeneralAutorunLimit !== undefined) {
reservedGeneralAutorunLimit.value = patch.reservedGeneralAutorunLimit;
}
if (patch.globalRecords !== undefined) {
globalRecords.value = structurallyShare(globalRecords.value, patch.globalRecords);
lastGeneralRecordId = Math.max(lastGeneralRecordId, patch.globalRecords[0]?.id ?? 0);
}
if (patch.generalRecords !== undefined) {
generalRecords.value = structurallyShare(generalRecords.value, patch.generalRecords);
lastGeneralRecordId = Math.max(lastGeneralRecordId, patch.generalRecords[0]?.id ?? 0);
}
if (patch.worldHistory !== undefined) {
worldHistory.value = structurallyShare(worldHistory.value, patch.worldHistory);
lastWorldHistoryId = Math.max(lastWorldHistoryId, patch.worldHistory[0]?.id ?? 0);
}
if (patch.frontStatus === null) {
frontStatus.value = null;
surveyNotice.value = null;
} else if (patch.frontStatus !== undefined) {
updateFrontStatus(patch.frontStatus);
}
if (patch.tournamentStage !== undefined) {
tournamentStage.value = patch.tournamentStage;
}
if (patch.contextRevision !== undefined) {
contextRevision = patch.contextRevision;
contextSourceRevision = patch.contextSourceRevision ?? null;
} else if (patch.contextSourceRevision !== undefined) {
contextSourceRevision = patch.contextSourceRevision;
}
if (patch.commandTableRevision !== undefined) {
commandTableRevision = patch.commandTableRevision;
commandTableSourceRevision = patch.commandTableSourceRevision ?? null;
} else if (patch.commandTableSourceRevision !== undefined) {
commandTableSourceRevision = patch.commandTableSourceRevision;
}
if (patch.boardAccessRevision !== undefined) {
boardAccessRevision = patch.boardAccessRevision;
boardAccessSourceRevision = patch.boardAccessSourceRevision ?? null;
} else if (patch.boardAccessSourceRevision !== undefined) {
boardAccessSourceRevision = patch.boardAccessSourceRevision;
}
};
const currentDashboardPatch = (): DashboardReadModelPatch => {
const patch: DashboardReadModelPatch = {};
patch.contextSnapshot = toRaw(contextSnapshot);
patch.contextRevision = contextRevision;
patch.commandTableRevision = commandTableRevision;
patch.boardAccessRevision = boardAccessRevision;
patch.contextSourceRevision = contextSourceRevision;
patch.commandTableSourceRevision = commandTableSourceRevision;
patch.boardAccessSourceRevision = boardAccessSourceRevision;
patch.general = toRaw(general.value);
patch.city = toRaw(city.value);
patch.nation = toRaw(nation.value);
patch.lobbyInfo = toRaw(lobbyInfo.value);
patch.worldMap = toRaw(worldMap.value);
patch.mapLayout = toRaw(mapLayout.value);
patch.commandTable = commandTableSnapshot ?? null;
patch.messages = toRaw(messages.value);
patch.messageContacts = toRaw(messageContacts.value);
patch.boardAccess = boardAccessSnapshot ?? null;
patch.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null;
patch.reservedGeneralRevision = reservedGeneralRevision.value;
patch.reservedGeneralAutorunLimit = reservedGeneralAutorunLimit.value;
patch.globalRecords = toRaw(globalRecords.value);
patch.generalRecords = toRaw(generalRecords.value);
patch.worldHistory = toRaw(worldHistory.value);
patch.frontStatus = toRaw(frontStatus.value);
patch.tournamentStage = tournamentStage.value;
return patch;
};
const resolveContextBundlePatch = (bundle: ContextBundleDelta): DashboardReadModelPatch => {
const patch: DashboardReadModelPatch = {};
if (bundle.context) {
const applied = applyReadModelDelta(contextSnapshot, contextRevision, bundle.context);
patch.contextRevision = applied.revision;
patch.contextSourceRevision = applied.sourceRevision ?? null;
if (bundle.context.kind !== 'unchanged') {
patch.contextSnapshot = applied.data;
}
}
if (bundle.commandTable) {
const applied = applyReadModelDelta(commandTableSnapshot, commandTableRevision, bundle.commandTable);
patch.commandTableRevision = applied.revision;
patch.commandTableSourceRevision = applied.sourceRevision ?? null;
if (bundle.commandTable.kind !== 'unchanged') {
patch.commandTable = applied.data;
}
}
if (bundle.boardAccess) {
const applied = applyReadModelDelta(boardAccessSnapshot, boardAccessRevision, bundle.boardAccess);
patch.boardAccessRevision = applied.revision;
patch.boardAccessSourceRevision = applied.sourceRevision ?? null;
if (bundle.boardAccess.kind !== 'unchanged') {
patch.boardAccess = applied.data;
}
}
return patch;
};
const fetchContextBundlePatch = async (
include: DashboardContextBundleInclude,
forceSnapshot = false,
refreshGrant?: string
): Promise<DashboardReadModelPatch> => {
const queryOptions = createRealtimeRequestOptions(refreshGrant);
const request = (force: boolean) =>
trpc.dashboard.getContextBundleDelta.query(
{
include,
known: force
? undefined
: {
...(contextRevision ? { context: contextRevision } : {}),
...(commandTableRevision ? { commandTable: commandTableRevision } : {}),
...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}),
},
knownSource: force
? undefined
: {
...(contextSourceRevision ? { context: contextSourceRevision } : {}),
...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}),
...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}),
},
forceSnapshot: force || undefined,
},
queryOptions
);
return resolveWithReadModelSnapshotFallback({
request,
resolve: resolveContextBundlePatch,
forceSnapshot,
});
};
const refreshMainData = async () => {
const isInitialLoad = !initialized;
if (isInitialLoad) {
loading.value = true;
} else {
refreshing.value = true;
}
error.value = null;
recordsError.value = null;
frontStatusError.value = null;
try {
const contextPatch = await fetchContextBundlePatch(
{ context: true, commandTable: true, boardAccess: true },
true
);
accessLimited.value = false;
applyDashboardPatch(contextPatch);
const context = contextSnapshot;
if (!context) {
initialized = true;
return;
}
const id = context.general.id;
if (recordGeneralId !== id) {
resetRecentRecords(id);
}
const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query();
const generalTurnsPromise = trpc.turns.reserved.getGeneral.query({ generalId: id });
const recordsPromise = trpc.general.getRecentRecords
.query({
lastGeneralRecordId,
lastWorldHistoryId,
})
.catch((err: unknown) => {
recordsError.value = resolveErrorMessage(err);
return null;
});
const frontStatusPromise = trpc.general.getFrontStatus.query().catch((err: unknown) => {
frontStatusError.value = resolveErrorMessage(err);
return null;
});
const tournamentPromise = trpc.tournament.getState.query().catch(() => undefined);
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus, tournamentState] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
trpc.messages.getRecent.query({ generalId: id }),
trpc.messages.getContacts.query({ generalId: id }),
generalTurnsPromise,
recordsPromise,
frontStatusPromise,
tournamentPromise,
]);
general.value = structurallyShare(general.value, context.general);
city.value = structurallyShare(city.value, context.city);
nation.value = structurallyShare(nation.value, context.nation);
mapLayout.value = structurallyShare(mapLayout.value, layout);
lobbyInfo.value = structurallyShare(lobbyInfo.value, lobby);
worldMap.value = structurallyShare(worldMap.value, map);
messages.value = structurallyShare(messages.value, messageData);
messageContacts.value = structurallyShare(messageContacts.value, contacts);
reservedGeneralTurns.value = structurallyShare<unknown>(
reservedGeneralTurns.value,
generalTurns.turns
) as ReservedTurnView[];
reservedGeneralRevision.value = generalTurns.revision;
reservedGeneralAutorunLimit.value = generalTurns.autorunLimit ?? null;
if (records) {
applyRecentRecords(records);
}
if (nextFrontStatus) {
updateFrontStatus(nextFrontStatus);
}
if (tournamentState !== undefined) {
tournamentStage.value = tournamentState?.stage ?? 0;
}
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
}
initialized = true;
} catch (err) {
handleDashboardError(err);
} finally {
if (isInitialLoad) {
loading.value = false;
} else {
refreshing.value = false;
}
}
};
const refreshQueue = createLatestRefreshQueue(refreshMainData);
const loadMainData = () => refreshQueue.request();
let realtimeCoordinator: BroadcastTabCoordinator<DashboardTabMessage> | null = null;
let realtimeCoordinatorScope: string | null = null;
const publishDashboardPatch = (patch: DashboardReadModelPatch) => {
if (!realtimeCoordinator) {
return;
}
// BroadcastChannel uses the structured-clone algorithm. Normalize the
// full payload so nested Vue proxies never reach that browser boundary.
try {
realtimeCoordinator.postFromLeader({ kind: 'patch', patch: cloneReadModelJson(patch) });
} catch {
// Same-account fan-out is best effort; the leader's local refresh
// and the next visibility/full-snapshot recovery remain valid.
}
};
const realtimeRefreshQueue = createRateLimitedRefreshQueue(
async () => {
await refreshQueue.request();
publishDashboardPatch(currentDashboardPatch());
},
{
minIntervalMs: REALTIME_FULL_REFRESH_MIN_INTERVAL_MS,
}
);
const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation, refreshGrant: string) => {
const id = generalId.value;
if (!id) {
return;
}
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 queryOptions = createRealtimeRequestOptions(refreshGrant);
// Every automatic refresh crosses this access-limit gate before
// any selected follow-up query starts. An all-false bundle is an
// access-only check and does not project general context.
const contextPatch = await fetchContextBundlePatch(
resolveDashboardContextBundleInclude(plan),
false,
refreshGrant
);
accessLimited.value = false;
const lobbyPromise = plan.lobby
? trpc.lobby.info.query(undefined, queryOptions)
: Promise.resolve(undefined);
const mapPromise = plan.map
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }, queryOptions)
: Promise.resolve(undefined);
const contactsPromise = plan.contacts
? trpc.messages.getContacts.query({ generalId: id }, queryOptions)
: Promise.resolve(undefined);
const reservedPromise = plan.reservedTurns
? trpc.turns.reserved.getGeneral.query({ generalId: id }, queryOptions)
: Promise.resolve(undefined);
const recordsPromise = plan.records
? trpc.general.getRecentRecords
.query({ lastGeneralRecordId, lastWorldHistoryId }, queryOptions)
.catch((err: unknown) => {
recordsError.value = resolveErrorMessage(err);
return null;
})
: Promise.resolve(undefined);
const frontPromise = plan.frontStatus
? trpc.general.getFrontStatus.query(undefined, queryOptions).catch((err: unknown) => {
frontStatusError.value = resolveErrorMessage(err);
return null;
})
: Promise.resolve(undefined);
const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament
? trpc.tournament.getState.query(undefined, queryOptions).catch(() => undefined)
: Promise.resolve(undefined);
const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([
lobbyPromise,
mapPromise,
contactsPromise,
reservedPromise,
recordsPromise,
frontPromise,
tournamentPromise,
]);
const patch: DashboardReadModelPatch = { ...contextPatch };
if (lobby !== undefined) patch.lobbyInfo = lobby;
if (map !== undefined) patch.worldMap = map;
if (contacts !== undefined) patch.messageContacts = contacts;
if (generalTurns !== undefined) {
patch.reservedGeneralTurns = generalTurns.turns;
patch.reservedGeneralRevision = generalTurns.revision;
patch.reservedGeneralAutorunLimit = generalTurns.autorunLimit ?? null;
}
if (records) {
const nextGlobalRecords = mergeRecentRecords(globalRecords.value, records.global);
const nextGeneralRecords = mergeRecentRecords(generalRecords.value, records.general);
const nextWorldHistory = mergeRecentRecords(worldHistory.value, records.history);
patch.globalRecords = nextGlobalRecords;
patch.generalRecords = nextGeneralRecords;
patch.worldHistory = nextWorldHistory;
}
if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0;
applyDashboardPatch(patch);
publishDashboardPatch(patch);
} catch (err) {
handleDashboardError(err);
} finally {
refreshing.value = false;
}
};
const readModelRefreshQueue = createMergedReadModelRefreshQueue(refreshChangedReadModels);
const refreshMessages = async (refreshGrant?: string) => {
const id = generalId.value;
if (!id) {
return;
}
try {
const nextMessages = await trpc.messages.getRecent.query(
{ generalId: id },
createRealtimeRequestOptions(refreshGrant)
);
const patch = { messages: nextMessages } satisfies DashboardReadModelPatch;
applyDashboardPatch(patch);
publishDashboardPatch(patch);
} catch (err) {
handleDashboardError(err);
}
};
const sendMessage = async () => {
const id = generalId.value;
if (!id) {
return;
}
const mailbox = targetMailbox.value;
const text = messageDraftText.value.trim();
if (!text) {
return;
}
if (mailbox <= 0) {
return;
}
try {
messageDraftText.value = '';
await trpc.messages.send.mutate({
generalId: id,
mailbox,
text,
});
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const loadOlderMessages = async (type: MessageType) => {
const id = generalId.value;
if (!id || !messages.value) {
return;
}
const bucket = messages.value[type] ?? [];
const oldest = bucket[bucket.length - 1];
if (!oldest) {
return;
}
try {
const older = await trpc.messages.getOld.query({
generalId: id,
type,
to: oldest.id,
});
const merged = {
...messages.value,
[type]: [...bucket, ...older[type]],
} as MessageBundle;
messages.value = merged;
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const respondToMessage = async (messageId: number, response: boolean) => {
const id = generalId.value;
if (!id) {
return;
}
try {
const result = await trpc.messages.respond.mutate({
generalId: id,
messageId,
response,
});
if (!result.result) {
error.value = result.reason;
}
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const readLatestMessage = async (type: 'private' | 'diplomacy', messageId: number) => {
const id = generalId.value;
if (!id || messageId <= 0) {
return;
}
try {
await trpc.messages.readLatest.mutate({
generalId: id,
type,
messageId,
});
if (messages.value) {
messages.value = {
...messages.value,
latestRead: {
...messages.value.latestRead,
[type]: Math.max(messages.value.latestRead[type], messageId),
},
};
}
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const deleteMessage = async (messageId: number) => {
const id = generalId.value;
if (!id) {
return;
}
try {
await trpc.messages.delete.mutate({ generalId: id, messageId });
await refreshMessages();
} catch (err) {
error.value = resolveErrorMessage(err);
}
};
const setGeneralTurn = async (turnIndex: number, action: string, args: Record<string, unknown> = {}) => {
const id = generalId.value;
if (!id) {
return;
}
try {
const result = await trpc.turns.reserved.setGeneral.mutate({
generalId: id,
turnIndex,
action,
args,
expectedRevision: reservedGeneralRevision.value,
});
reservedGeneralTurns.value = result.turns;
reservedGeneralRevision.value = result.revision;
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
} catch (err) {
error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
if (snapshot) {
reservedGeneralTurns.value = snapshot.turns;
reservedGeneralRevision.value = snapshot.revision;
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
}
}
};
const shiftGeneralTurns = async (amount: number) => {
const id = generalId.value;
if (!id) {
return;
}
try {
const result = await trpc.turns.reserved.shiftGeneral.mutate({
generalId: id,
amount,
expectedRevision: reservedGeneralRevision.value,
});
reservedGeneralTurns.value = result.turns;
reservedGeneralRevision.value = result.revision;
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
} catch (err) {
error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
if (snapshot) {
reservedGeneralTurns.value = snapshot.turns;
reservedGeneralRevision.value = snapshot.revision;
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
}
}
};
const setGeneralTurns = async (
entries: Array<{ turnList: number[]; action: string; args: Record<string, unknown> }>
) => {
const id = generalId.value;
if (!id || !entries.length) return;
try {
const result = await trpc.turns.reserved.setGeneralBulk.mutate({
generalId: id,
entries,
expectedRevision: reservedGeneralRevision.value,
});
reservedGeneralTurns.value = result.turns;
reservedGeneralRevision.value = result.revision;
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
} catch (err) {
error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
if (snapshot) {
reservedGeneralTurns.value = snapshot.turns;
reservedGeneralRevision.value = snapshot.revision;
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
}
}
};
const repeatGeneralTurns = async (amount: number) => {
const id = generalId.value;
if (!id) return;
try {
const result = await trpc.turns.reserved.repeatGeneral.mutate({
generalId: id,
amount,
expectedRevision: reservedGeneralRevision.value,
});
reservedGeneralTurns.value = result.turns;
reservedGeneralRevision.value = result.revision;
reservedGeneralAutorunLimit.value = result.autorunLimit ?? null;
} catch (err) {
error.value = resolveErrorMessage(err);
const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null);
if (snapshot) {
reservedGeneralTurns.value = snapshot.turns;
reservedGeneralRevision.value = snapshot.revision;
reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null;
}
}
};
let realtimeSource: EventSource | null = null;
let realtimeToken: string | null = null;
let visibilityListenerInstalled = false;
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
const buildRealtimeUrl = (token: string): string => {
const base = import.meta.env.VITE_GAME_SSE_URL ?? '/events';
const url = new URL(base, window.location.origin);
url.searchParams.set('token', token);
return url.toString();
};
const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => {
if (!raw.data || typeof raw.data !== 'string') {
return null;
}
try {
const parsed = JSON.parse(raw.data) as PublicRealtimeEvent;
if (!parsed || typeof parsed !== 'object') {
return null;
}
if (typeof parsed.type !== 'string') {
return null;
}
return parsed;
} catch {
return null;
}
};
const closeRealtimeSource = () => {
if (!realtimeSource) {
return;
}
realtimeSource.close();
realtimeSource = null;
realtimeToken = null;
};
const isRealtimeParticipant = (): boolean =>
realtimeActive.value &&
document.visibilityState !== 'hidden' &&
realtimeEnabled.value &&
!accessLimited.value &&
session.isReady &&
session.hasGeneral &&
generalId.value !== null;
const closeRealtimeCoordinator = () => {
const coordinator = realtimeCoordinator;
realtimeCoordinator = null;
realtimeCoordinatorScope = null;
coordinator?.stop();
closeRealtimeSource();
};
const reconcileRealtimeCoordinator = () => {
if (typeof window === 'undefined') return;
if (!isRealtimeParticipant()) {
closeRealtimeCoordinator();
return;
}
if (typeof BroadcastChannel === 'undefined') {
void connectRealtime();
return;
}
const profile = session.profile ?? 'game';
const account = session.user?.id ?? `general-${generalId.value}`;
const scope = `${encodeURIComponent(profile)}:${encodeURIComponent(account)}`;
if (realtimeCoordinator && realtimeCoordinatorScope === scope) return;
closeRealtimeCoordinator();
realtimeCoordinatorScope = scope;
realtimeCoordinator = createBroadcastTabCoordinator<DashboardTabMessage>(`sammo-main-dashboard:${scope}`, {
onLeadershipChange: (leader) => {
if (leader) {
void connectRealtime();
} else {
closeRealtimeSource();
if (realtimeEnabled.value) realtimeStatus.value = 'idle';
}
},
onPayload: (message) => {
if (!isRealtimeParticipant()) return;
if (message.kind === 'patch') {
applyDashboardPatch(message.patch);
return;
}
realtimeStatus.value = message.status;
},
});
realtimeCoordinator.start();
};
const ensureAccessToken = async (): Promise<string | null> => {
if (!session.gameToken) {
return null;
}
if (isAccessToken(session.gameToken)) {
return session.gameToken;
}
const exchanged = await session.exchangeGatewayToken();
if (!exchanged) {
return null;
}
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
};
const connectRealtime = async () => {
if (typeof window === 'undefined') {
return;
}
if (
!realtimeActive.value ||
document.visibilityState === 'hidden' ||
!realtimeEnabled.value ||
!session.isReady ||
!session.hasGeneral ||
(realtimeCoordinator !== null && !realtimeCoordinator.isLeader())
) {
return;
}
const token = await ensureAccessToken();
if (!token) {
realtimeStatus.value = 'idle';
return;
}
if (!isRealtimeParticipant() || (realtimeCoordinator !== null && !realtimeCoordinator.isLeader())) {
return;
}
if (realtimeSource && realtimeToken === token) {
return;
}
closeRealtimeSource();
realtimeToken = token;
realtimeStatus.value = 'idle';
const source = new EventSource(buildRealtimeUrl(token));
realtimeSource = source;
source.addEventListener('open', () => {
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
});
source.addEventListener('error', () => {
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
});
source.addEventListener('readModelInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'readModelInvalidated') {
return;
}
readModelRefreshQueue.request(payload.invalidation, payload.refreshGrant);
});
source.addEventListener('messagesInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'messagesInvalidated') {
return;
}
void refreshMessages(payload.refreshGrant);
});
// 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();
});
}
source.addEventListener('messageCreated', () => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
void refreshMessages();
});
source.addEventListener('ping', () => {
if (realtimeEnabled.value) {
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
}
});
};
const handleVisibilityChange = () => {
if (!realtimeActive.value) return;
if (document.visibilityState === 'hidden') {
realtimeRefreshQueue.cancelPending();
readModelRefreshQueue.cancelPending();
closeRealtimeCoordinator();
realtimeStatus.value = 'idle';
return;
}
realtimeRefreshQueue.beginCooldown();
void refreshQueue.request().finally(() => reconcileRealtimeCoordinator());
};
const startRealtime = () => {
if (typeof window === 'undefined' || realtimeActive.value) return;
realtimeActive.value = true;
realtimeRefreshQueue.beginCooldown();
if (!visibilityListenerInstalled) {
document.addEventListener('visibilitychange', handleVisibilityChange);
visibilityListenerInstalled = true;
}
reconcileRealtimeCoordinator();
};
const stopRealtime = () => {
realtimeActive.value = false;
realtimeRefreshQueue.cancelPending();
readModelRefreshQueue.cancelPending();
closeRealtimeCoordinator();
if (visibilityListenerInstalled) {
document.removeEventListener('visibilitychange', handleVisibilityChange);
visibilityListenerInstalled = false;
}
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
};
watch(
() => [
realtimeActive.value,
realtimeEnabled.value,
session.isReady,
session.hasGeneral,
session.gameToken,
session.profile,
session.user?.id,
generalId.value,
accessLimited.value,
],
([active, enabled, ready, hasGeneral, , , , , limited]) => {
realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value;
if (!active || !ready || !hasGeneral) {
realtimeStatus.value = enabled && !limited ? 'idle' : 'paused';
}
reconcileRealtimeCoordinator();
}
);
return {
loading,
refreshing,
error,
recordsError,
frontStatusError,
realtimeEnabled,
realtimeStatus,
general,
city,
nation,
lobbyInfo,
worldMap,
mapLayout,
selectedCity,
commandTable,
messages,
messageContacts,
boardAccess,
reservedGeneralTurns,
reservedGeneralAutorunLimit,
globalRecords,
generalRecords,
worldHistory,
frontStatus,
tournamentStage,
surveyNotice,
messageDraftText,
targetMailbox,
mailboxGroups,
statusLine,
realtimeLabel,
setRealtimeEnabled,
startRealtime,
stopRealtime,
dismissSurveyNotice,
loadMainData,
refreshMessages,
sendMessage,
loadOlderMessages,
respondToMessage,
readLatestMessage,
deleteMessage,
setGeneralTurn,
setGeneralTurns,
shiftGeneralTurns,
repeatGeneralTurns,
};
});