915b4372dd
Adds the second end-to-end command (`setProductionType`) with a collapse-by-`planetNumber` rule on the order draft, the segmented production-controls component on the planet inspector, the FBS encoder/decoder pair for `CommandPlanetProduce`, and the `localShipClass` projection on `GameReport`. Forecast number is deferred and tracked in the new `ui/docs/calc-bridge.md`.
260 lines
8.6 KiB
Svelte
260 lines
8.6 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 { 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 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 {
|
|
GALAXY_CLIENT_CONTEXT_KEY,
|
|
GalaxyClientHolder,
|
|
} from "$lib/galaxy-client-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";
|
|
|
|
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);
|
|
|
|
// 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 localShipClass = $derived(
|
|
renderedReport.report?.localShipClass ?? [],
|
|
);
|
|
|
|
// 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> => {
|
|
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()]);
|
|
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}
|
|
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>
|