Files
galaxy-game/ui/frontend/src/routes/games/[id]/+layout.svelte
T
Ilia Denisov 7bea22b0b5 ui/phase-21: sciences CRUD list, designer, and production-picker integration
Lights up the player-defined sciences feature: a table view with sort
and filter, a designer with four percent inputs and a strict
sum-equals-100 gate, and a Research-sub-row integration so the
planet production picker lists the user's sciences alongside the
four tech buttons. Phase 21 decisions are baked back into ui/PLAN.md
(no UpdateScience on the wire — write-once via createScience +
removeScience; percentages instead of fractions; sciences live under
the existing Research segment).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 21:32:37 +02:00

398 lines
13 KiB
Svelte

<!--
Phase 10 in-game shell. Composes the header, a conditionally-visible
sidebar (Calculator / Inspector / Order tabs), the active-view slot
filled by the child route, and a mobile-only bottom-tab bar. The
layout 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 slot when the URL is `/map`,
so navigating 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 (Phase 13).
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
Phase 13 `SelectionStore`. All three are exposed to descendants
via Svelte context; their lifetimes match the layout instance,
which itself stays mounted across active-view switches inside
`/games/:id/*`.
Phase 11 added the per-game `GameStateStore` instance owned by this
layout: it constructs the `GalaxyClient`, fetches the matching lobby
record to discover `current_turn`, then loads the report. The store
is shared with descendants via `setContext("gameState", ...)` so the
header turn counter, the map view, and the inspector tab all read
from the same snapshot.
Phase 13 adds the planet inspector. The layout 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.
State preservation across active-view switches works for free
because SvelteKit keeps this layout instance mounted while children
swap; navigating between games unmounts and remounts the layout, so
the next game's snapshot — and the next game's selection — start
fresh.
-->
<script lang="ts">
import { onDestroy, onMount, setContext } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Header from "$lib/header/header.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 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 {
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 { createEdgeGatewayClient } from "../../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
getSyntheticReport,
isSyntheticGameId,
} from "../../../api/synthetic-report";
let { children } = $props();
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector");
// Phase 12 ships the prop wiring; Phase 26 replaces this constant
// with the real history-mode signal from `lib/history-mode.ts`.
const historyMode = false;
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
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);
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;
activeTab = "inspector";
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);
}
onMount(() => {
(async (): Promise<void> => {
// DEV-only synthetic-report path. The lobby's "Load
// synthetic report" affordance navigates here 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 redirects to /lobby
// so the user reloads the JSON.
if (isSyntheticGameId(gameId)) {
const report = getSyntheticReport(gameId);
if (report === undefined) {
await goto("/lobby");
return;
}
try {
const { cache } = await loadStore();
await Promise.all([
gameState.initSynthetic({ cache, gameId, report }),
orderDraft.init({ cache, gameId }),
]);
// 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: createEdgeGatewayClient(GATEWAY_BASE_URL),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
await Promise.all([
gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }),
]);
galaxyClient.set(client);
orderDraft.bindClient(client);
// 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));
}
})();
});
onDestroy(() => {
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">
<Header
{gameId}
{sidebarOpen}
onToggleSidebar={toggleSidebar}
/>
<div class="body">
<main class="active-view-host" data-testid="active-view-host">
{#if effectiveTool === "calc"}
<Calculator />
{:else if effectiveTool === "order"}
<Order />
{:else}
{@render children()}
{/if}
</main>
<Sidebar
open={sidebarOpen}
onClose={() => (sidebarOpen = false)}
{historyMode}
bind:activeTab
/>
</div>
<BottomTabs
{gameId}
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: #0a0e1a;
color: #e8eaf6;
}
.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>