7c8b5aeb23
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with a renderer-driven destination picker (faded out-of-reach planets, cursor-line anchor, hover-highlight) and per-route arrows on the map. The pick-mode primitives are exposed via `MapPickService` so ship-group dispatch in Phase 19/20 can reuse the same surface. Pass A — generic map foundation: - hit-test now sizes the click zone to `pointRadiusPx + slopPx` so the visible disc is always part of the target. - `RendererHandle` gains `onPointerMove`, `onHoverChange`, `setPickMode`, `getPickState`, `getPrimitiveAlpha`, `setExtraPrimitives`, `getPrimitives`. The click dispatcher is centralised: pick-mode swallows clicks atomically so the standard selection consumers do not race against teardown. - `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer contract in a promise-shaped `pick(...)`. The in-game shell layout owns the service so sidebar and bottom-sheet inspectors see the same instance. - Debug-surface registry exposes `getMapPrimitives`, `getMapPickState`, `getMapCamera` to e2e specs without spawning a separate debug page after navigation. Pass B — cargo-route feature: - `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed variants with `(source, loadType)` collapse rule on the order draft; round-trip through the FBS encoder/decoder. - `GameReport` decodes `routes` and the local player's drive tech for the inline reach formula (40 × drive). `applyOrderOverlay` upserts/drops route entries for valid/submitting/applied commands. - `lib/inspectors/planet/cargo-routes.svelte` renders the four-slot section. `Add` / `Edit` call `MapPickService.pick`, `Remove` emits `removeCargoRoute`. - `map/cargo-routes.ts` builds shaft + arrowhead primitives per cargo type; the map view pushes them through `setExtraPrimitives` so the renderer never re-inits Pixi on route mutations (Pixi 8 doesn't support that on a reused canvas). Docs: - `docs/cargo-routes-ux.md` covers engine semantics + UI map. - `docs/renderer.md` documents pick mode and the debug surface. - `docs/calc-bridge.md` records the Phase 16 reach waiver. - `PLAN.md` rewrites Phase 16 to reflect the foundation + feature split and the decisions baked in (map-driven picker, inline reach, optimistic overlay via `setExtraPrimitives`). Tests: - `tests/map-pick-mode.test.ts` — pure overlay-spec helper. - `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`. - `tests/inspector-planet-cargo-routes.test.ts` — slot rendering, picker invocation, collapse, cancel, remove. - Extensions to `order-draft`, `submit`, `order-load`, `order-overlay`, `state-binding`, `inspector-planet`, `inspector-overlay`, `game-shell-sidebar`, `game-shell-header`. - `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add COL, add CAP, remove COL, asserting both the inspector and the arrow count via `__galaxyDebug.getMapPrimitives()`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
283 lines
9.6 KiB
Svelte
283 lines
9.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 {
|
|
MAP_PICK_CONTEXT_KEY,
|
|
MapPickService,
|
|
} from "$lib/map-pick.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);
|
|
// `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> => {
|
|
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}
|
|
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>
|