// App-shell navigation state. // // The game UI is a single-URL app-shell (served at `/game/`): there are no // per-screen or per-view routes, so the address bar never changes. Two rune // singletons hold what the URL used to encode: // // - `appScreen` — the top-level screen (login / lobby / lobby-create / // game) and the active game id. It replaces the `goto`-based redirects // and the `[id]` route param. // - `activeView` — the in-game view (map / table / report / battle / mail / // designer-science) and its sub-parameters. It replaces the URL params the // old route wrappers read. // // Both live in this one module so they can share a single `sessionStorage` // snapshot (persisted here) without a circular import. The snapshot is read // once at construction to seed the initial render and rewritten on every // mutation; `restoredGameId` lets the boot path validate a restored game // before loading it (a cancelled/removed game falls back to lobby — see the // dispatcher). Screen-level browser history (Back → lobby) is layered on top // in the shell via SvelteKit shallow routing; this module is the source of // truth, history only mirrors it. import { pushState, replaceState } from "$app/navigation"; export type AppScreen = "login" | "lobby" | "lobby-create" | "game"; export type GameView = | "map" | "table" | "report" | "battle" | "mail" | "designer-science"; /** In-game view plus the sub-parameters the old route segments carried. */ export interface GameViewState { view: GameView; /** Table entity slug when `view === "table"` (e.g. `planets`, `sciences`). */ tableEntity?: string; /** Selected battle when `view === "battle"`; empty string = list/none. */ battleId?: string; /** Viewed turn for the battle view; 0 = current. */ turn?: number; /** Science id when `view === "designer-science"`; absent = new-science form. */ scienceId?: string; } const STORAGE_KEY = "galaxy-app-nav"; const APP_SCREENS: readonly AppScreen[] = [ "login", "lobby", "lobby-create", "game", ]; const GAME_VIEWS: readonly GameView[] = [ "map", "table", "report", "battle", "mail", "designer-science", ]; const DEFAULT_VIEW: GameViewState = { view: "map" }; interface NavSnapshot { screen: AppScreen; gameId: string | null; game: GameViewState; } function readSnapshot(): NavSnapshot | null { if (typeof sessionStorage === "undefined") return null; let raw: string | null; try { raw = sessionStorage.getItem(STORAGE_KEY); } catch { return null; } if (raw === null) return null; try { const parsed = JSON.parse(raw) as Partial | null; if (parsed === null || typeof parsed !== "object") return null; const screen = APP_SCREENS.includes(parsed.screen as AppScreen) ? (parsed.screen as AppScreen) : "lobby"; const gameId = typeof parsed.gameId === "string" && parsed.gameId.length > 0 ? parsed.gameId : null; return { screen, gameId, game: sanitizeView(parsed.game) }; } catch { return null; } } function sanitizeView(value: unknown): GameViewState { if (value === null || typeof value !== "object") return { ...DEFAULT_VIEW }; const v = value as Partial; const view = GAME_VIEWS.includes(v.view as GameView) ? (v.view as GameView) : "map"; const out: GameViewState = { view }; if (typeof v.tableEntity === "string") out.tableEntity = v.tableEntity; if (typeof v.battleId === "string") out.battleId = v.battleId; if (typeof v.turn === "number" && Number.isFinite(v.turn) && v.turn >= 0) { out.turn = Math.trunc(v.turn); } if (typeof v.scienceId === "string") out.scienceId = v.scienceId; return out; } function persist(): void { if (typeof sessionStorage === "undefined") return; const snapshot: NavSnapshot = { screen: appScreen.screen, gameId: appScreen.gameId, game: activeView.state, }; try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch { // Storage full / disabled / private-mode quota — navigation still // works in memory; only refresh-restore is lost. } } const initial = readSnapshot(); /** * AppScreenStore owns the top-level screen and the active game id. Anonymous * vs authenticated gating is applied by the dispatcher on top of `screen`. */ class AppScreenStore { #screen = $state(initial?.screen ?? "lobby"); #gameId = $state(initial?.gameId ?? null); /** The game id captured from a restored snapshot, for boot-time validation. */ readonly restoredGameId: string | null = initial?.gameId ?? null; get screen(): AppScreen { return this.#screen; } get gameId(): string | null { return this.#gameId; } /** * go switches the top-level screen. Entering a game requires a `gameId`; * leaving a game clears it. Persists the snapshot. History wiring (Back → * lobby) is added by the shell, which observes `screen`. */ go(screen: AppScreen, options: { gameId?: string } = {}): void { this.#screen = screen; if (screen === "game") { if (options.gameId !== undefined) this.#gameId = options.gameId; } else { this.#gameId = null; } persist(); this.#syncHistory(); } /** * syncFromHistory applies a screen restored from browser history (a * Back/Forward popstate) WITHOUT pushing a new entry. An absent/unknown * screen (the load entry beneath an overlay) falls back to lobby. */ syncFromHistory(screen: AppScreen | undefined, gameId: string | null): void { const next = screen !== undefined && APP_SCREENS.includes(screen) ? screen : "lobby"; this.#screen = next; this.#gameId = next === "game" ? gameId : null; persist(); } // Mirror the screen into browser history via shallow routing (the URL is // unchanged — the address bar stays at /game/). Overlays (game, // lobby-create) push a new entry so browser Back returns to the lobby // beneath; lobby/login replace in place. #syncHistory(): void { if (typeof window === "undefined") return; const state: App.PageState = { screen: this.#screen, gameId: this.#gameId }; if (this.#screen === "game" || this.#screen === "lobby-create") { pushState("", state); } else { replaceState("", state); } } } /** * ActiveViewStore owns the in-game view and its sub-parameters. It is only * meaningful while `appScreen.screen === "game"`. */ class ActiveViewStore { #state = $state(initial?.game ?? { ...DEFAULT_VIEW }); get state(): GameViewState { return this.#state; } get view(): GameView { return this.#state.view; } /** Replace the active in-game view and its sub-parameters. Persists. */ select(view: GameView, params: Omit = {}): void { this.#state = { view, ...params }; persist(); } /** Reset to the default view (map). Used when entering a fresh game. */ reset(): void { this.#state = { ...DEFAULT_VIEW }; persist(); } } export const appScreen = new AppScreenStore(); export const activeView = new ActiveViewStore();