feat(ui): app-nav state stores (app-shell foundation)
Add `appScreen` + `activeView` rune singletons with a shared sessionStorage snapshot — the in-memory source of truth that replaces URL-based screen/view routing for the single-URL app-shell. Not wired in yet (additive). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<NavSnapshot> | 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<GameViewState>;
|
||||
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<AppScreen>(initial?.screen ?? "lobby");
|
||||
#gameId = $state<string | null>(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<GameViewState>(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<GameViewState, "view"> = {}): 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();
|
||||
Reference in New Issue
Block a user