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 { trpc } from '../utils/trpc'; import { useMapViewerStore } from './mapViewer'; import { useSessionStore } from './session'; 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 LobbyInfo = Awaited>; type WorldMapResult = Awaited>; type MapLayout = Awaited>; type CommandTable = Awaited>; type MessageBundle = Awaited>; type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; const loading = ref(false); const error = ref(null); const realtimeEnabled = ref(true); const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle'); const generalContext = ref(null); const lobbyInfo = ref(null); const worldMap = ref(null); const mapLayout = ref(null); const commandTable = ref(null); const messages = ref(null); const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedNationTurns = ref(null); const messageDraftText = ref(''); const targetMailbox = ref(MESSAGE_MAILBOX_PUBLIC); const general = computed(() => generalContext.value?.general ?? null); const city = computed(() => generalContext.value?.city ?? null); const nation = computed(() => generalContext.value?.nation ?? 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 [, , state, nationIdValue, region, supplyFlag] = mapEntry; const nationEntry = map.nationList.find((nationEntry) => nationEntry[0] === nationIdValue); const regionName = layout.regionMap[region] ?? '-'; const levelName = layout.levelMap[layoutCity.level] ?? '-'; return { id: layoutCity.id, name: layoutCity.name, level: layoutCity.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 mailboxOptions = computed(() => { const options: Array<{ label: string; value: number; disabled?: boolean }> = [ { label: '공공', value: MESSAGE_MAILBOX_PUBLIC }, ]; if (nationId.value) { options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value }); } else { options.push({ label: '국가', value: -1, disabled: true }); } options.push({ label: '외교', value: -2, disabled: true }); options.push({ label: '개인', value: -3, disabled: true }); return options; }); 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) => { realtimeEnabled.value = enabled; if (!enabled) { realtimeStatus.value = 'paused'; } }; const loadMainData = async () => { if (loading.value) { return; } loading.value = true; error.value = null; try { const context = await trpc.general.me.query(); generalContext.value = context; if (!context) { reservedGeneralTurns.value = null; reservedNationTurns.value = null; boardAccess.value = null; loading.value = false; return; } const id = context.general.id; const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query(); const generalTurnsPromise = trpc.turns.reserved.getGeneral.query({ generalId: id }); const nationTurnsPromise = context.general.nationId > 0 && context.general.officerLevel >= 5 ? trpc.turns.reserved.getNation.query({ generalId: id }) : Promise.resolve(null); const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([ layoutPromise, trpc.lobby.info.query(), trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), trpc.turns.getCommandTable.query({ generalId: id }), trpc.messages.getRecent.query({ generalId: id }), trpc.board.getAccess.query(), generalTurnsPromise, nationTurnsPromise, ]); mapLayout.value = layout; lobbyInfo.value = lobby; worldMap.value = map; commandTable.value = commands; messages.value = messageData; boardAccess.value = access; reservedGeneralTurns.value = generalTurns; reservedNationTurns.value = nationTurns; } catch (err) { error.value = resolveErrorMessage(err); } finally { loading.value = false; } }; const refreshMessages = async () => { const id = generalId.value; if (!id) { return; } try { messages.value = await trpc.messages.getRecent.query({ generalId: id }); } catch (err) { error.value = resolveErrorMessage(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 { await trpc.messages.send.mutate({ generalId: id, mailbox, text, }); messageDraftText.value = ''; 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 setGeneralTurn = async (turnIndex: number, action: string) => { const id = generalId.value; if (!id) { return; } try { const result = await trpc.turns.reserved.setGeneral.mutate({ generalId: id, turnIndex, action, args: {}, }); reservedGeneralTurns.value = result.turns; } catch (err) { error.value = resolveErrorMessage(err); } }; const shiftGeneralTurns = async (amount: number) => { const id = generalId.value; if (!id) { return; } try { const result = await trpc.turns.reserved.shiftGeneral.mutate({ generalId: id, amount, }); reservedGeneralTurns.value = result.turns; } catch (err) { error.value = resolveErrorMessage(err); } }; const setNationTurn = async (turnIndex: number, action: string) => { const id = generalId.value; const currentGeneral = general.value; if (!id || !currentGeneral) { return; } if (currentGeneral.nationId <= 0 || currentGeneral.officerLevel < 5) { return; } try { const result = await trpc.turns.reserved.setNation.mutate({ generalId: id, turnIndex, action, args: {}, }); reservedNationTurns.value = result.turns; } catch (err) { error.value = resolveErrorMessage(err); } }; const shiftNationTurns = async (amount: number) => { const id = generalId.value; const currentGeneral = general.value; if (!id || !currentGeneral) { return; } if (currentGeneral.nationId <= 0 || currentGeneral.officerLevel < 5) { return; } try { const result = await trpc.turns.reserved.shiftNation.mutate({ generalId: id, amount, }); reservedNationTurns.value = result.turns; } catch (err) { error.value = resolveErrorMessage(err); } }; let realtimeSource: EventSource | null = null; let realtimeToken: string | null = null; 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): RealtimeEvent | null => { if (!raw.data || typeof raw.data !== 'string') { return null; } try { const parsed = JSON.parse(raw.data) as RealtimeEvent; if (!parsed || typeof parsed !== 'object') { return null; } if (typeof parsed.type !== 'string') { return null; } return parsed; } catch { return null; } }; const isMailboxRelevant = (mailbox: number): boolean => { if (mailbox === MESSAGE_MAILBOX_PUBLIC) { return true; } const currentGeneralId = generalId.value; if (currentGeneralId && mailbox === currentGeneralId) { return true; } const currentNationId = nationId.value; if (currentNationId && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + currentNationId) { return true; } return false; }; const closeRealtimeSource = () => { if (!realtimeSource) { return; } realtimeSource.close(); realtimeSource = null; realtimeToken = null; }; 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 (!realtimeEnabled.value || !session.isReady || !session.hasGeneral) { return; } const token = await ensureAccessToken(); if (!token) { realtimeStatus.value = 'idle'; 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'; }); source.addEventListener('error', () => { realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; }); source.addEventListener('turnCompleted', () => { void loadMainData(); }); source.addEventListener('messageCreated', (event) => { const payload = parseRealtimePayload(event); if (!payload || payload.type !== 'messageCreated') { return; } if (isMailboxRelevant(payload.mailbox)) { void refreshMessages(); } }); source.addEventListener('ping', () => { if (realtimeEnabled.value) { realtimeStatus.value = 'connected'; } }); }; watch( () => [realtimeEnabled.value, session.isReady, session.hasGeneral, session.gameToken], ([enabled, ready, hasGeneral]) => { if (!enabled) { closeRealtimeSource(); realtimeStatus.value = 'paused'; return; } if (!ready || !hasGeneral) { closeRealtimeSource(); realtimeStatus.value = 'idle'; return; } void connectRealtime(); }, { immediate: true } ); return { loading, error, realtimeEnabled, realtimeStatus, generalContext, general, city, nation, lobbyInfo, worldMap, mapLayout, selectedCity, commandTable, messages, boardAccess, reservedGeneralTurns, reservedNationTurns, messageDraftText, targetMailbox, mailboxOptions, statusLine, realtimeLabel, setRealtimeEnabled, loadMainData, refreshMessages, sendMessage, loadOlderMessages, respondToMessage, setGeneralTurn, shiftGeneralTurns, setNationTurn, shiftNationTurns, }; });