feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state
Collapse the game UI to one route (`/`): a screen dispatcher renders login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of URL routes. Move screen components to lib/screens & lib/game; the game shell reads the game id from `appScreen.gameId` and re-inits per-game stores via an $effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now state-based. WIP: browser-history (Back→lobby), restore-validation, the return-to-lobby button, push deep-links, and the test migration are follow-ups on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
<!--
|
||||
In-game shell. Composes the header, a conditionally-visible sidebar
|
||||
(Calculator / Inspector / Order tabs), the active-view area selected
|
||||
by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
|
||||
app-shell there are no per-view routes: the active game id comes from
|
||||
`appScreen.gameId` and the visible view from `activeView`, both held
|
||||
in `$lib/app-nav.svelte`. The shell owns:
|
||||
|
||||
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
|
||||
sidebar pinned via CSS; mobile hides it entirely.
|
||||
- `mobileTool` — mobile-only tool overlay state. The tool only
|
||||
visually overrides the active-view area when the active view is the
|
||||
map, so switching to any other view through the More drawer or the
|
||||
header view-menu naturally drops the overlay even if `mobileTool`
|
||||
was set on a previous tap.
|
||||
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
|
||||
`order`). Held here, bound into the sidebar so a planet click on
|
||||
the map can flip it to `inspector` from the outside.
|
||||
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
|
||||
`SelectionStore`. All three are exposed to descendants via Svelte
|
||||
context; their lifetimes match the shell instance.
|
||||
|
||||
The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
|
||||
the matching lobby record to discover `current_turn`, then loads the
|
||||
report. The store is shared with descendants via
|
||||
`setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
|
||||
the map view, and the inspector tab all read from the same snapshot.
|
||||
|
||||
The planet inspector: the shell watches the selection store and, on
|
||||
the null → planet transition, flips `activeTab` to `inspector` and
|
||||
`sidebarOpen` to `true` so the inspector becomes visible regardless
|
||||
of breakpoint (desktop already has the sidebar pinned; tablet needs
|
||||
the drawer to surface). On mobile the `<PlanetSheet />` overlay reads
|
||||
the same selection and displays a read-only sheet over the map;
|
||||
closing the sheet clears the selection.
|
||||
|
||||
The per-game bootstrap (client construction, store init, push-event
|
||||
subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
|
||||
the cleanup tears the previous game's subscriptions down and the body
|
||||
re-initialises the shared stores for the new id, so a direct
|
||||
game → game switch (without leaving the shell) rebinds cleanly. The
|
||||
shell unmounts when the dispatcher leaves the `game` screen, so a
|
||||
return to the lobby still disposes the stores via `onDestroy`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy, setContext, untrack } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||
import Header from "$lib/header/header.svelte";
|
||||
import HistoryBanner from "$lib/header/history-banner.svelte";
|
||||
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
||||
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
|
||||
import Calculator from "$lib/sidebar/calculator-tab.svelte";
|
||||
import Order from "$lib/sidebar/order-tab.svelte";
|
||||
import MapView from "$lib/active-view/map.svelte";
|
||||
import TableView from "$lib/active-view/table.svelte";
|
||||
import ReportView from "$lib/active-view/report.svelte";
|
||||
import BattleView from "$lib/active-view/battle.svelte";
|
||||
import MailView from "$lib/active-view/mail.svelte";
|
||||
import DesignerScience from "$lib/active-view/designer-science.svelte";
|
||||
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
|
||||
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
|
||||
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
|
||||
import type { MobileTool, SidebarTab } from "$lib/sidebar/types";
|
||||
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
|
||||
import {
|
||||
SelectionStore,
|
||||
SELECTION_CONTEXT_KEY,
|
||||
} from "$lib/selection.svelte";
|
||||
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
|
||||
import {
|
||||
createRenderedReportSource,
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
} from "$lib/rendered-report.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../../sync/order-draft.svelte";
|
||||
import {
|
||||
MAP_PICK_CONTEXT_KEY,
|
||||
MapPickService,
|
||||
} from "$lib/map-pick.svelte";
|
||||
import {
|
||||
GALAXY_CLIENT_CONTEXT_KEY,
|
||||
GalaxyClientHolder,
|
||||
} from "$lib/galaxy-client-context.svelte";
|
||||
import {
|
||||
CORE_CONTEXT_KEY,
|
||||
CoreHolder,
|
||||
} from "$lib/core-context.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { loadStore } from "../../platform/store/index";
|
||||
import { loadCore } from "../../platform/core/index";
|
||||
import { createGatewayClient } from "../../api/connect";
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||
import {
|
||||
getSyntheticReport,
|
||||
isSyntheticGameId,
|
||||
} from "../../api/synthetic-report";
|
||||
import {
|
||||
eventStream,
|
||||
type VerifiedEvent,
|
||||
} from "../../api/events.svelte";
|
||||
import { toast } from "$lib/toast.svelte";
|
||||
import { mailStore } from "$lib/mail-store.svelte";
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
let mobileTool: MobileTool = $state("map");
|
||||
let activeTab: SidebarTab = $state("inspector");
|
||||
|
||||
// The tool overlay (Calculator / Order) only replaces the active
|
||||
// view while the map is showing; switching to any other view drops
|
||||
// it, matching the previous URL-driven behaviour.
|
||||
const isOnMap = $derived(activeView.view === "map");
|
||||
const effectiveTool: MobileTool = $derived.by(() =>
|
||||
isOnMap ? mobileTool : "map",
|
||||
);
|
||||
|
||||
const gameState = new GameStateStore();
|
||||
setContext(GAME_STATE_CONTEXT_KEY, gameState);
|
||||
const orderDraft = new OrderDraftStore();
|
||||
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
|
||||
// Phase 26: the order tab vanishes from the sidebar and bottom-tabs
|
||||
// when the player is viewing a past turn. The flag is owned by
|
||||
// `GameStateStore` (single source of truth for "what turn are we
|
||||
// looking at") and surfaced here so the Phase 12 sidebar wiring,
|
||||
// the new `HistoryBanner`, and `orderDraft.bindClient` all read
|
||||
// from the same derivation.
|
||||
const historyMode = $derived(gameState.historyMode);
|
||||
const selection = new SelectionStore();
|
||||
setContext(SELECTION_CONTEXT_KEY, selection);
|
||||
const renderedReport = createRenderedReportSource(gameState, orderDraft);
|
||||
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
|
||||
const galaxyClient = new GalaxyClientHolder();
|
||||
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
|
||||
const coreHolder = new CoreHolder();
|
||||
setContext(CORE_CONTEXT_KEY, coreHolder);
|
||||
// `MapPickService` lives at the layout so both the active map
|
||||
// view (which binds the renderer-side resolver) and the
|
||||
// inspector subsections (which call `pick(...)`) see the same
|
||||
// instance via context — they sit on sibling branches of the
|
||||
// component tree.
|
||||
const mapPick = new MapPickService();
|
||||
setContext(MAP_PICK_CONTEXT_KEY, mapPick);
|
||||
|
||||
// selectedPlanet resolves the current selection against the live
|
||||
// report so both the desktop sidebar and the mobile sheet display
|
||||
// the same snapshot. A selection that points at a planet missing
|
||||
// from the current report (e.g. visibility lost between turns)
|
||||
// reads as `null` here, which collapses the inspector and the
|
||||
// sheet without surfacing a stale row. The rendered report layers
|
||||
// the local order draft on top so the player sees their pending
|
||||
// renames immediately.
|
||||
const selectedPlanet = $derived.by(() => {
|
||||
const sel = selection.selected;
|
||||
if (sel === null || sel.kind !== "planet") return null;
|
||||
const report = renderedReport.report;
|
||||
if (report === null) return null;
|
||||
return report.planets.find((p) => p.number === sel.id) ?? null;
|
||||
});
|
||||
const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => {
|
||||
const sel = selection.selected;
|
||||
if (sel === null || sel.kind !== "shipGroup") return null;
|
||||
const report = renderedReport.report;
|
||||
if (report === null) return null;
|
||||
const ref = sel.ref;
|
||||
switch (ref.variant) {
|
||||
case "local": {
|
||||
const group = report.localShipGroups.find((g) => g.id === ref.id);
|
||||
if (group === undefined) return null;
|
||||
return { variant: "local", group };
|
||||
}
|
||||
case "other": {
|
||||
const group = report.otherShipGroups[ref.index];
|
||||
if (group === undefined) return null;
|
||||
return { variant: "other", group };
|
||||
}
|
||||
case "incoming": {
|
||||
const group = report.incomingShipGroups[ref.index];
|
||||
if (group === undefined) return null;
|
||||
return { variant: "incoming", group };
|
||||
}
|
||||
case "unidentified": {
|
||||
const group = report.unidentifiedShipGroups[ref.index];
|
||||
if (group === undefined) return null;
|
||||
return { variant: "unidentified", group };
|
||||
}
|
||||
}
|
||||
});
|
||||
const localShipClass = $derived(
|
||||
renderedReport.report?.localShipClass ?? [],
|
||||
);
|
||||
const localScience = $derived(renderedReport.report?.localScience ?? []);
|
||||
const inspectorPlanets = $derived(renderedReport.report?.planets ?? []);
|
||||
const inspectorRoutes = $derived(renderedReport.report?.routes ?? []);
|
||||
const inspectorMapWidth = $derived(renderedReport.report?.mapWidth ?? 1);
|
||||
const inspectorMapHeight = $derived(renderedReport.report?.mapHeight ?? 1);
|
||||
const inspectorLocalDrive = $derived(
|
||||
renderedReport.report?.localPlayerDrive ?? 0,
|
||||
);
|
||||
const inspectorLocalWeapons = $derived(
|
||||
renderedReport.report?.localPlayerWeapons ?? 0,
|
||||
);
|
||||
const inspectorLocalShields = $derived(
|
||||
renderedReport.report?.localPlayerShields ?? 0,
|
||||
);
|
||||
const inspectorLocalCargo = $derived(
|
||||
renderedReport.report?.localPlayerCargo ?? 0,
|
||||
);
|
||||
const inspectorLocalShipGroups = $derived(
|
||||
renderedReport.report?.localShipGroups ?? [],
|
||||
);
|
||||
const inspectorOtherShipGroups = $derived(
|
||||
renderedReport.report?.otherShipGroups ?? [],
|
||||
);
|
||||
const inspectorLocalFleets = $derived(
|
||||
renderedReport.report?.localFleets ?? [],
|
||||
);
|
||||
const inspectorOtherRaces = $derived(
|
||||
renderedReport.report?.otherRaces ?? [],
|
||||
);
|
||||
const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");
|
||||
|
||||
// Reveal the inspector whenever a new planet selection lands.
|
||||
// Reading `selection.selected` once outside the effect keeps the
|
||||
// effect dependent on the rune transition and not on the derived
|
||||
// `selectedPlanet`, which can flicker as the report refreshes.
|
||||
$effect(() => {
|
||||
const sel = selection.selected;
|
||||
if (sel === null) return;
|
||||
// Stay in the calculator when a planet is picked: the calculator
|
||||
// consumes the selection in its planet area + reach circles, and
|
||||
// it is a long-lived workspace the user should not be ejected
|
||||
// from. `activeTab` is read untracked so a manual tab switch does
|
||||
// not re-fire this effect. Any other case (including a ship-group
|
||||
// selection, which the calculator does not use) reveals the
|
||||
// inspector as before.
|
||||
const tab = untrack(() => activeTab);
|
||||
if (!(tab === "calculator" && sel.kind === "planet")) {
|
||||
activeTab = "inspector";
|
||||
}
|
||||
sidebarOpen = true;
|
||||
});
|
||||
|
||||
// Reveal the calculator whenever the ship-classes table or the
|
||||
// bottom-tabs entry asks to load a class (or start a fresh design).
|
||||
let lastCalcLoadToken = 0;
|
||||
$effect(() => {
|
||||
const token = calculatorLoadRequest.token;
|
||||
if (token === lastCalcLoadToken) return;
|
||||
lastCalcLoadToken = token;
|
||||
activeTab = "calculator";
|
||||
sidebarOpen = true;
|
||||
});
|
||||
|
||||
function toggleSidebar(): void {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||
return new Uint8Array(digest);
|
||||
}
|
||||
|
||||
// `unsubTurnReady` / `unsubGamePaused` carry the
|
||||
// `eventStream.on(...)` disposers for the game-scoped push
|
||||
// handlers. The layout registers them once the local
|
||||
// `GameStateStore` is initialised so an event arriving before
|
||||
// `currentTurn` is known cannot misfire.
|
||||
let unsubTurnReady: (() => void) | null = null;
|
||||
let unsubGamePaused: (() => void) | null = null;
|
||||
let unsubMailReceived: (() => void) | null = null;
|
||||
const turnReadyDecoder = new TextDecoder("utf-8");
|
||||
|
||||
function parseTurnReadyPayload(
|
||||
event: VerifiedEvent,
|
||||
): { gameId: string; turn: number } | null {
|
||||
try {
|
||||
const text = turnReadyDecoder.decode(event.payloadBytes);
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return null;
|
||||
}
|
||||
const record = json as Record<string, unknown>;
|
||||
const eventGameId = record.game_id;
|
||||
const eventTurn = record.turn;
|
||||
if (
|
||||
typeof eventGameId !== "string" ||
|
||||
typeof eventTurn !== "number" ||
|
||||
!Number.isFinite(eventTurn)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { gameId: eventGameId, turn: eventTurn };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMailReceivedPayload(
|
||||
event: VerifiedEvent,
|
||||
): { gameId: string; from: string } | null {
|
||||
try {
|
||||
const text = turnReadyDecoder.decode(event.payloadBytes);
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return null;
|
||||
}
|
||||
const record = json as Record<string, unknown>;
|
||||
const eventGameId = record.game_id;
|
||||
if (typeof eventGameId !== "string") {
|
||||
return null;
|
||||
}
|
||||
const subject =
|
||||
typeof record.subject === "string" && record.subject !== ""
|
||||
? record.subject
|
||||
: typeof record.preview === "string"
|
||||
? record.preview
|
||||
: "";
|
||||
return { gameId: eventGameId, from: subject };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGamePausedPayload(
|
||||
event: VerifiedEvent,
|
||||
): { gameId: string; reason: string } | null {
|
||||
try {
|
||||
const text = turnReadyDecoder.decode(event.payloadBytes);
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (typeof json !== "object" || json === null) {
|
||||
return null;
|
||||
}
|
||||
const record = json as Record<string, unknown>;
|
||||
const eventGameId = record.game_id;
|
||||
if (typeof eventGameId !== "string") {
|
||||
return null;
|
||||
}
|
||||
const reason = typeof record.reason === "string" ? record.reason : "";
|
||||
return { gameId: eventGameId, reason };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let activeTurnReadyToastId: string | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const pending = gameState.pendingTurn;
|
||||
if (pending === null) {
|
||||
if (activeTurnReadyToastId !== null) {
|
||||
toast.dismiss(activeTurnReadyToastId);
|
||||
activeTurnReadyToastId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
activeTurnReadyToastId = toast.show({
|
||||
messageKey: "game.events.turn_ready.message",
|
||||
messageParams: { turn: String(pending) },
|
||||
actionLabelKey: "game.events.turn_ready.action",
|
||||
onAction: () => {
|
||||
void gameState.advanceToPending();
|
||||
},
|
||||
durationMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
function teardownSubscriptions(): void {
|
||||
if (unsubTurnReady !== null) {
|
||||
unsubTurnReady();
|
||||
unsubTurnReady = null;
|
||||
}
|
||||
if (unsubGamePaused !== null) {
|
||||
unsubGamePaused();
|
||||
unsubGamePaused = null;
|
||||
}
|
||||
if (unsubMailReceived !== null) {
|
||||
unsubMailReceived();
|
||||
unsubMailReceived = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-game bootstrap. The effect re-runs whenever `appScreen.gameId`
|
||||
// changes: its cleanup tears the previous game's push-event
|
||||
// subscriptions down, then the body rebinds the shared stores to the
|
||||
// new id. The shared store instances persist across the switch
|
||||
// (descendants captured them through context at construction), so a
|
||||
// game → game switch re-initialises them in place rather than
|
||||
// recreating them; `onDestroy` performs the terminal `dispose()`
|
||||
// when the dispatcher leaves the `game` screen and unmounts the
|
||||
// shell. A null id (no active game) is a no-op.
|
||||
$effect(() => {
|
||||
const activeGameId = appScreen.gameId;
|
||||
if (activeGameId === null || activeGameId === "") return;
|
||||
|
||||
(async (): Promise<void> => {
|
||||
// DEV-only synthetic-report path. The lobby's "Load
|
||||
// synthetic report" affordance enters the game with a
|
||||
// `synthetic-<uuid>` id and the matching report
|
||||
// pre-registered in an in-memory map. A page reload
|
||||
// loses the map entry; that case returns to the lobby
|
||||
// so the user reloads the JSON.
|
||||
if (isSyntheticGameId(activeGameId)) {
|
||||
const report = getSyntheticReport(activeGameId);
|
||||
if (report === undefined) {
|
||||
appScreen.go("lobby");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Synthetic mode still needs the wasm `Core` so
|
||||
// components that bridge to `pkg/calc/ship.go`
|
||||
// (designer preview, BattleViewer mass radii) can
|
||||
// resolve their math against the same engine helpers
|
||||
// the live path uses. The live branch below also
|
||||
// calls `loadCore()`; without it here the Battle
|
||||
// Viewer rendered every ship-class circle at
|
||||
// MAX_RADIUS in synthetic mode.
|
||||
const [{ cache }, core] = await Promise.all([
|
||||
loadStore(),
|
||||
loadCore(),
|
||||
]);
|
||||
coreHolder.set(core);
|
||||
await Promise.all([
|
||||
gameState.initSynthetic({ cache, gameId: activeGameId, report }),
|
||||
orderDraft.init({ cache, gameId: activeGameId }),
|
||||
]);
|
||||
// Deliberately no `galaxyClient.set` and no
|
||||
// `orderDraft.bindClient`: synthetic mode never
|
||||
// sends to the gateway. The auto-sync pipeline
|
||||
// already short-circuits via the UUID guard in
|
||||
// `scheduleSync`, but skipping the bind keeps
|
||||
// the path simple to reason about.
|
||||
} catch (err) {
|
||||
gameState.failBootstrap(describeBootstrapError(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
session.keypair === null ||
|
||||
session.deviceSessionId === null ||
|
||||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const keypair = session.keypair;
|
||||
const deviceSessionId = session.deviceSessionId;
|
||||
try {
|
||||
const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
|
||||
coreHolder.set(core);
|
||||
const client = new GalaxyClient({
|
||||
core,
|
||||
edge: createGatewayClient(gatewayRpcBaseUrl()),
|
||||
signer: (canonical) => keypair.sign(canonical),
|
||||
sha256,
|
||||
deviceSessionId,
|
||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
||||
});
|
||||
// Register the `game.turn.ready` dispatch before the
|
||||
// network roundtrips below so an event delivered
|
||||
// while `gameState.init` is still in flight is not
|
||||
// dropped by the singleton stream. `markPendingTurn`
|
||||
// already protects against turns that do not advance
|
||||
// past the current snapshot. Phase 25: a turn-ready
|
||||
// frame arriving while the draft is in `conflict` or
|
||||
// `paused` state also resets the draft and rehydrates
|
||||
// from the server for the new turn — the old commands
|
||||
// became history at the cutoff.
|
||||
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
||||
const parsed = parseTurnReadyPayload(event);
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
gameState.markPendingTurn(parsed.turn);
|
||||
if (
|
||||
orderDraft.syncStatus === "conflict" ||
|
||||
orderDraft.syncStatus === "paused"
|
||||
) {
|
||||
void orderDraft.resetForNewTurn({
|
||||
client,
|
||||
turn: parsed.turn,
|
||||
});
|
||||
}
|
||||
});
|
||||
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
||||
const parsed = parseGamePausedPayload(event);
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
orderDraft.markPaused({ reason: parsed.reason });
|
||||
});
|
||||
unsubMailReceived = eventStream.on(
|
||||
"diplomail.message.received",
|
||||
(event) => {
|
||||
const parsed = parseMailReceivedPayload(event);
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
void mailStore.applyPushEvent(parsed.gameId);
|
||||
toast.show({
|
||||
messageKey: "game.events.mail_new.message",
|
||||
messageParams: { from: parsed.from },
|
||||
actionLabelKey: "game.events.mail_new.action",
|
||||
onAction: () => {
|
||||
activeView.select("mail");
|
||||
},
|
||||
durationMs: 8000,
|
||||
});
|
||||
},
|
||||
);
|
||||
await Promise.all([
|
||||
gameState.init({ client, cache, gameId: activeGameId }),
|
||||
orderDraft.init({ cache, gameId: activeGameId }),
|
||||
mailStore.init({ client, cache, gameId: activeGameId }),
|
||||
]);
|
||||
galaxyClient.set(client);
|
||||
orderDraft.bindClient(client, {
|
||||
getCurrentTurn: () => gameState.currentTurn,
|
||||
getHistoryMode: () => gameState.historyMode,
|
||||
});
|
||||
// The server is always polled at game boot — its
|
||||
// stored order may be fresher than the local cache
|
||||
// (e.g. user is on a new device), and an offline
|
||||
// edit must catch up at re-sync time. The hydration
|
||||
// is non-fatal: a network error keeps the local
|
||||
// cache and surfaces through `draft.syncStatus`.
|
||||
await orderDraft.hydrateFromServer({
|
||||
client,
|
||||
turn: gameState.currentTurn,
|
||||
});
|
||||
} catch (err) {
|
||||
gameState.failBootstrap(describeBootstrapError(err));
|
||||
}
|
||||
})();
|
||||
|
||||
return teardownSubscriptions;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
teardownSubscriptions();
|
||||
gameState.dispose();
|
||||
orderDraft.dispose();
|
||||
selection.dispose();
|
||||
});
|
||||
|
||||
function describeBootstrapError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return "request failed";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="game-shell" data-testid="game-shell">
|
||||
<a class="skip-link" href="#active-view-host">
|
||||
{i18n.t("common.skip_to_content")}
|
||||
</a>
|
||||
<Header
|
||||
{sidebarOpen}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
/>
|
||||
<HistoryBanner />
|
||||
<div class="body">
|
||||
<main
|
||||
class="active-view-host"
|
||||
id="active-view-host"
|
||||
tabindex="-1"
|
||||
data-testid="active-view-host"
|
||||
>
|
||||
{#if effectiveTool === "calc"}
|
||||
<Calculator />
|
||||
{:else if effectiveTool === "order"}
|
||||
<Order />
|
||||
{:else if activeView.view === "map"}
|
||||
<MapView />
|
||||
{:else if activeView.view === "table"}
|
||||
<TableView entity={activeView.state.tableEntity ?? ""} />
|
||||
{:else if activeView.view === "report"}
|
||||
<ReportView />
|
||||
{:else if activeView.view === "battle"}
|
||||
<BattleView
|
||||
gameId={appScreen.gameId ?? ""}
|
||||
turn={activeView.state.turn ?? 0}
|
||||
battleId={activeView.state.battleId ?? ""}
|
||||
/>
|
||||
{:else if activeView.view === "mail"}
|
||||
<MailView />
|
||||
{:else if activeView.view === "designer-science"}
|
||||
<DesignerScience scienceId={activeView.state.scienceId} />
|
||||
{/if}
|
||||
</main>
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
onClose={() => (sidebarOpen = false)}
|
||||
{historyMode}
|
||||
bind:activeTab
|
||||
/>
|
||||
</div>
|
||||
<BottomTabs
|
||||
activeTool={effectiveTool}
|
||||
onSelectTool={(tool) => (mobileTool = tool)}
|
||||
hideOrder={historyMode}
|
||||
/>
|
||||
<PlanetSheet
|
||||
planet={selectedPlanet}
|
||||
{localShipClass}
|
||||
{localScience}
|
||||
routes={inspectorRoutes}
|
||||
planets={inspectorPlanets}
|
||||
mapWidth={inspectorMapWidth}
|
||||
mapHeight={inspectorMapHeight}
|
||||
localPlayerDrive={inspectorLocalDrive}
|
||||
localShipGroups={inspectorLocalShipGroups}
|
||||
otherShipGroups={inspectorOtherShipGroups}
|
||||
localRace={inspectorLocalRace}
|
||||
onMap={effectiveTool === "map"}
|
||||
onClose={() => selection.clear()}
|
||||
/>
|
||||
<ShipGroupSheet
|
||||
selection={selectedShipGroup}
|
||||
planets={inspectorPlanets}
|
||||
{localShipClass}
|
||||
localFleets={inspectorLocalFleets}
|
||||
otherRaces={inspectorOtherRaces}
|
||||
mapWidth={inspectorMapWidth}
|
||||
mapHeight={inspectorMapHeight}
|
||||
localPlayerDrive={inspectorLocalDrive}
|
||||
localPlayerWeapons={inspectorLocalWeapons}
|
||||
localPlayerShields={inspectorLocalShields}
|
||||
localPlayerCargo={inspectorLocalCargo}
|
||||
onMap={effectiveTool === "map"}
|
||||
onClose={() => selection.clear()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.game-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.active-view-host {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.body {
|
||||
padding-bottom: 3.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user