8f320010c6
Adds api/synthetic-report.ts, an in-memory registry + JSON->GameReport decoder for synthetic-mode game sessions. The lobby grows a import.meta.env.DEV-gated "Synthetic test reports" section with a JSON file picker; loading a file registers the decoded report under a synthetic-<uuid> id and navigates to /games/<id>/map. The in-game shell layout detects the synthetic id range, takes the report straight from the registry via gameState.initSynthetic, and deliberately skips both galaxyClient.set and orderDraft.bindClient. Order auto-sync stays silent: scheduleSync already short-circuits on non-UUID game ids, and without a bound client the network path is unreachable. applyOrderOverlay continues to project locally-valid draft commands onto the rendered report so renames / production choices / route edits are visible immediately. A page reload loses the in-memory entry and redirects to /lobby — synthetic mode is a debug affordance, not a session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
325 lines
11 KiB
Svelte
325 lines
11 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 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 localShipClass = $derived(
|
|
renderedReport.report?.localShipClass ?? [],
|
|
);
|
|
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,
|
|
);
|
|
|
|
// 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}
|
|
routes={inspectorRoutes}
|
|
planets={inspectorPlanets}
|
|
mapWidth={inspectorMapWidth}
|
|
mapHeight={inspectorMapHeight}
|
|
localPlayerDrive={inspectorLocalDrive}
|
|
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>
|