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>
8.4 KiB
Cargo routes UX
This document covers the cargo-route surface added in Phase 16: the
four-slot inspector subsection, the map-driven destination pick, and
the optimistic overlay that keeps the inspector and the map in lock-
step with the local order draft. The user-visible spec lives in
../PLAN.md Phase 16; the engine semantics are quoted
from game/rules.txt section "Грузовые
маршруты" (lines 808–843); this file is the source of truth for how
the UI surfaces those rules.
Engine semantics in one paragraph
A cargo route on a planet you own pairs a load-type slot
(COL/CAP/MAT/EMP) with a destination planet. Once set, the
engine loads transport ships at the source on every turn cutoff and
sends them to the destination, draining the load-type stockpile
("colonists" → population pool, "capital" → industry crates, "mat" →
raw materials, "empty" → ships returning unloaded). When several
slots are configured the engine processes them in
COL > CAP > MAT > EMP priority order
(game/internal/controller/route.go.SendRoutedGroups, line 101).
Routes are constrained by reach: the destination must be no further
than 40 × driveTech world units along the torus-shortest path
(util.ShortDistance ≤ Race.FlightDistance()), and a route whose
destination becomes unreachable at the next turn is auto-removed
(RemoveUnreachableRoutes).
Four-slot inspector subsection
The cargo-routes subsection renders below the production controls
on every owned planet inspector. Slots appear in
CARGO_LOAD_TYPE_VALUES order (COL, CAP, MAT, EMP) so visual order
matches the engine's load priority — players who scan top-down see
the highest-priority cargo first.
Slot states:
- Empty —
(no route)text plus a singleAddbutton. - Filled —
→ {destination name}plusEditandRemove.
Add and Edit open a renderer-driven destination pick (see next
section). Remove emits a removeCargoRoute command. The collapse
rule on the order draft store ensures only one entry per
(source, loadType) slot survives in the draft at any time, so a
sequence of Add → Edit → Remove collapses to the latest verb only
(matching the production-controls pattern from Phase 15).
Disabled state: every button is disabled when the
OrderDraftStore or MapPickService context is missing (the
component is mounted outside the in-game shell, in tests, etc.).
Map-driven destination pick
The picker is renderer-side: the inspector calls
MapPickService.pick({sourcePlanetNumber, reachableIds}) and awaits a
planet number (or null on cancel). Reach is computed inline against
localPlayerDrive — see the calc-bridge waiver in
calc-bridge.md.
While a session is active:
- All planets whose ids are not in
reachableIdsand are not the source render atalpha = 0.3. The visual fade signals "not a valid destination" without removing the planet from the map. - The source planet keeps full alpha and gains an outline ring so the player sees where the route originates.
- A line is drawn from the source to the cursor. On touch devices the line follows the finger during drag.
- Hover over a reachable planet adds an outline highlight at
pointRadiusPx + 4world units so the player can confirm which planet they are about to pick.
Resolution paths:
- Click on a reachable planet →
setCargoRouteenters the draft; the inspector slot fills; the arrow appears immediately on the map (overlay route applied on the next render). - Click on empty space or a non-reachable planet → no-op (a forgiving rule for accidental taps mid-pan).
- Press Escape, click the inspector's
Cancel pickbutton, or unmount the active map view (e.g. switch tools, navigate away) → session cancels; the slot stays as it was.
The inspector renders an inline status line pick a destination on the map (Esc to cancel) while the session is active so the player
knows the click target and the cancel hotkey. The line vanishes as
soon as the picker resolves.
Optimistic map overlay
applyOrderOverlay projects every locally-valid, in-flight, or
applied setCargoRoute / removeCargoRoute onto report.routes,
re-runs reportToWorld (which appends LinePrim arrows from
buildCargoRouteLines), and remounts the renderer when the routes-
content fingerprint changes. The map active view captures camera
centre + zoom before each remount and restores them when the game
id is unchanged, so adding a route mid-pan does not jolt the view.
Arrows are drawn as a shaft plus two short arrowhead wings. Per-type styling (placeholder Phase 16 colours; final values land in Phase 35 polish):
| Load type | Stroke colour | Notes |
|---|---|---|
| COL | #4FC3F7 |
brightest blue, highest priority |
| CAP | #FFB74D |
warm orange |
| MAT | #81C784 |
green |
| EMP | #90A4AE |
dim grey, thinner stroke |
Arrow priority (COL=8, CAP=7, MAT=6, EMP=5) sits above all planet
priorities (1..4), so two arrows that overlap exactly resolve to the
higher-priority load type during hit-test. Planet primitives still
win over arrows because the line ids carry a high-bit prefix
(0x80000000) and the renderer keeps points at the kind tie-break
position 0.
Reach computation
Implementation: inline TypeScript using torusShortestDelta for
each axis and Math.hypot for the distance, compared against 40 × localPlayerDrive. The local player's drive comes from the report's
Player block, looked up by name === report.race
(api/game-state.ts.findLocalPlayerDrive).
Why inline rather than via a Go calc bridge? See the Phase 15 / 16
deferral note in calc-bridge.md. The formula
is trivial (tech × 40) and the WASM glue would be premature
infrastructure; when the calc bridge phase lands the shared
pkg/calc.Reach will replace this implementation.
Tests
| Layer | File | What it covers |
|---|---|---|
| Order draft collapse | tests/order-draft.test.ts |
(source, loadType) collapse rules across set and remove. |
| Encode / decode round-trip | tests/submit.test.ts, tests/order-load.test.ts |
setCargoRoute and removeCargoRoute ↔ FBS payloads; UNKNOWN load-type drops with a warn. |
| Overlay | tests/order-overlay.test.ts |
applyOrderOverlay upserts / drops route entries for valid / submitting / applied statuses. |
| Inspector subsection | tests/inspector-planet-cargo-routes.test.ts |
Slot rendering, pick invocation, emit, cancel, edit, remove, per-type independence. |
| Map arrows | tests/map-cargo-routes.test.ts |
buildCargoRouteLines shape on torus + no-wrap fixtures, per-type style, priority ordering. |
| End-to-end | tests/e2e/cargo-routes.spec.ts |
Mocked gateway: open inspector, dim outside reach, pick destination, arrow appears, reload. |
File index
ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte— inspector subsection.ui/frontend/src/sync/order-types.ts—CargoLoadType,SetCargoRouteCommand,RemoveCargoRouteCommand.ui/frontend/src/sync/order-draft.svelte.ts— collapse rule.ui/frontend/src/sync/submit.ts— encoder.ui/frontend/src/sync/order-load.ts— decoder.ui/frontend/src/api/game-state.ts—routesandlocalPlayerDrivedecoding plus overlay extension.ui/frontend/src/map/cargo-routes.ts— arrow geometry.ui/frontend/src/map/state-binding.ts— appends route lines.ui/frontend/src/lib/active-view/map.svelte— fingerprint guard- camera preservation.
ui/frontend/src/map/pick-mode.tsandui/frontend/src/map/render.ts— pick-mode foundation.ui/frontend/src/lib/map-pick.svelte.ts— Svelte adapter.ui/docs/renderer.md— pick-mode and debug-surface contract.