diff --git a/ui/frontend/src/lib/app-nav.svelte.ts b/ui/frontend/src/lib/app-nav.svelte.ts new file mode 100644 index 0000000..7065357 --- /dev/null +++ b/ui/frontend/src/lib/app-nav.svelte.ts @@ -0,0 +1,193 @@ +// 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. + +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(); + } +} + +/** + * 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();