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 } from '../utils/dashboardReadModel'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; 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>; type PresentGeneralContext = NonNullable; type LobbyInfo = Awaited>; type WorldMapResult = Awaited>; type MapLayout = Awaited>; type CommandTable = Awaited>; type MessageBundle = Awaited>; type MessageContacts = Awaited>; type BoardAccess = Awaited>; type ReservedTurnView = Awaited>['turns'][number]; type RecentRecord = Awaited>['global'][number]; type FrontStatus = Awaited>; type ContextBundleDelta = Awaited>; type ContextBundleInclude = { context: boolean; commandTable: boolean; boardAccess: boolean; }; type DashboardReadModelPatch = { contextSnapshot?: GeneralContext; contextRevision?: string | null; commandTableRevision?: string | null; boardAccessRevision?: 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; }; type DashboardTabMessage = { kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' }; const loading = ref(false); const refreshing = ref(false); const error = ref(null); const recordsError = ref(null); const frontStatusError = ref(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(null); const city = ref(null); const nation = ref(null); const lobbyInfo = ref(null); const worldMap = ref(null); const mapLayout = ref(null); const commandTable = ref(null); const messages = ref(null); const messageContacts = ref(null); const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedGeneralRevision = ref(0); const reservedGeneralAutorunLimit = ref(null); const globalRecords = ref([]); const generalRecords = ref([]); const worldHistory = ref([]); const frontStatus = ref(null); const surveyNotice = ref | 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; const messageDraftText = ref(''); const targetMailbox = ref(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>) => { 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; } 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; } 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( 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.contextRevision !== undefined) contextRevision = patch.contextRevision; if (patch.commandTableRevision !== undefined) commandTableRevision = patch.commandTableRevision; if (patch.boardAccessRevision !== undefined) boardAccessRevision = patch.boardAccessRevision; }; const currentDashboardPatch = (): DashboardReadModelPatch => { const patch: DashboardReadModelPatch = {}; patch.contextSnapshot = toRaw(contextSnapshot); patch.contextRevision = contextRevision; patch.commandTableRevision = commandTableRevision; patch.boardAccessRevision = boardAccessRevision; 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); return patch; }; const resolveContextBundlePatch = (bundle: ContextBundleDelta): DashboardReadModelPatch => { const patch: DashboardReadModelPatch = {}; if (bundle.context) { const applied = applyReadModelDelta(contextSnapshot, contextRevision, bundle.context); patch.contextRevision = applied.revision; if (bundle.context.kind !== 'unchanged') { patch.contextSnapshot = applied.data; } } if (bundle.commandTable) { const applied = applyReadModelDelta(commandTableSnapshot, commandTableRevision, bundle.commandTable); patch.commandTableRevision = applied.revision; if (bundle.commandTable.kind !== 'unchanged') { patch.commandTable = applied.data; } } if (bundle.boardAccess) { const applied = applyReadModelDelta(boardAccessSnapshot, boardAccessRevision, bundle.boardAccess); patch.boardAccessRevision = applied.revision; if (bundle.boardAccess.kind !== 'unchanged') { patch.boardAccess = applied.data; } } return patch; }; const fetchContextBundlePatch = async ( include: ContextBundleInclude, forceSnapshot = false ): Promise => { const request = (force: boolean) => trpc.dashboard.getContextBundleDelta.query({ include, known: force ? undefined : { ...(contextRevision ? { context: contextRevision } : {}), ...(commandTableRevision ? { commandTable: commandTableRevision } : {}), ...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}), }, forceSnapshot: force || undefined, }); 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 [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus] = 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, ]); 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( reservedGeneralTurns.value, generalTurns.turns ) as ReservedTurnView[]; reservedGeneralRevision.value = generalTurns.revision; reservedGeneralAutorunLimit.value = generalTurns.autorunLimit ?? null; if (records) { applyRecentRecords(records); } if (nextFrontStatus) { updateFrontStatus(nextFrontStatus); } 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 | 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) => { 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 contextPatch = await fetchContextBundlePatch({ // Every automatic refresh crosses this access-limit gate. The // context delta is usually unchanged and therefore stays small. context: true, commandTable: plan.commands, boardAccess: plan.boardAccess, }); accessLimited.value = false; 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 contactsPromise = plan.contacts ? trpc.messages.getContacts.query({ generalId: id }) : 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 [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([ lobbyPromise, mapPromise, contactsPromise, reservedPromise, recordsPromise, frontPromise, ]); 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; applyDashboardPatch(patch); publishDashboardPatch(patch); } catch (err) { handleDashboardError(err); } finally { refreshing.value = false; } }; const readModelRefreshQueue = createMergedReadModelRefreshQueue(refreshChangedReadModels); const refreshMessages = async () => { const id = generalId.value; if (!id) { return; } try { const nextMessages = await trpc.messages.getRecent.query({ generalId: id }); 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 = {}) => { 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 }> ) => { 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(`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 => { 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); }); 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(); }); } 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, surveyNotice, messageDraftText, targetMailbox, mailboxGroups, statusLine, realtimeLabel, setRealtimeEnabled, startRealtime, stopRealtime, dismissSurveyNotice, loadMainData, refreshMessages, sendMessage, loadOlderMessages, respondToMessage, readLatestMessage, deleteMessage, setGeneralTurn, setGeneralTurns, shiftGeneralTurns, repeatGeneralTurns, }; });