ui/phase-16: cargo routes inspector + map pick foundation

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>
This commit is contained in:
Ilia Denisov
2026-05-09 20:01:34 +02:00
parent 5fd67ed958
commit 7c8b5aeb23
43 changed files with 4559 additions and 98 deletions
+188 -3
View File
@@ -30,10 +30,12 @@ import {
Report,
} from "../proto/galaxy/fbs/report";
import type {
CargoLoadType,
CommandStatus,
OrderCommand,
ProductionType,
} from "../sync/order-types";
import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType } from "../sync/order-types";
const MESSAGE_TYPE = "user.games.report";
@@ -82,6 +84,30 @@ export interface ShipClassSummary {
name: string;
}
/**
* ReportRouteEntry is one slot of a planet's cargo-route table —
* a (loadType, destinationPlanetNumber) pair. The engine stores
* the entries as `map[RouteType]uint` per planet
* (`game/internal/model/game/planet.go`); this type flattens that
* map into an array so iteration order is stable for tests and
* the map-arrow renderer.
*/
export interface ReportRouteEntry {
loadType: CargoLoadType;
destinationPlanetNumber: number;
}
/**
* ReportRoute groups every cargo-route slot configured on a
* single source planet. `entries` is sorted by
* `CARGO_LOAD_TYPE_VALUES` priority (COL → CAP → MAT → EMP) so
* the inspector and the map renderer see deterministic order.
*/
export interface ReportRoute {
sourcePlanetNumber: number;
entries: ReportRouteEntry[];
}
export interface GameReport {
turn: number;
mapWidth: number;
@@ -102,6 +128,24 @@ export interface GameReport {
* empty.
*/
localShipClass: ShipClassSummary[];
/**
* routes lists every cargo route the player has configured.
* Each entry is keyed by source planet; the per-planet
* `entries` array is sorted in turn-cutoff load order
* (`CARGO_LOAD_TYPE_VALUES`). Empty when no routes are set or
* when the report does not carry the route field.
*/
routes: ReportRoute[];
/**
* localPlayerDrive is the local player's drive tech level. The
* engine's reach formula is `40 * driveTech`
* (`game/internal/model/game/race.go.FlightDistance`); the
* cargo-route picker filters destinations through it, so the
* value is propagated all the way through `applyOrderOverlay`
* to the inspector subsection. Zero on boot or when the
* report's player block is missing the local entry.
*/
localPlayerDrive: number;
}
export async function fetchGameReport(
@@ -225,17 +269,94 @@ function decodeReport(report: Report): GameReport {
localShipClass.push({ name: sc.name() ?? "" });
}
const raceName = report.race() ?? "";
const routes = decodeReportRoutes(report);
const localPlayerDrive = findLocalPlayerDrive(report, raceName);
return {
turn: Number(report.turn()),
mapWidth: report.width(),
mapHeight: report.height(),
planetCount: report.planetCount(),
planets,
race: report.race() ?? "",
race: raceName,
localShipClass,
routes,
localPlayerDrive,
};
}
/**
* decodeReportRoutes flattens `report.route()[]` into the typed
* `ReportRoute[]`. Each `Route` carries `planet` (source) and an
* array of `RouteEntry` rows where `key` is the destination
* planet number and `value` is the load-type string. Entries
* with unknown load-types are dropped with a `console.warn` so a
* future schema bump never silently corrupts the inspector.
*/
function decodeReportRoutes(report: Report): ReportRoute[] {
const out: ReportRoute[] = [];
for (let i = 0; i < report.routeLength(); i++) {
const route = report.route(i);
if (route === null) continue;
const sourcePlanetNumber = Number(route.planet());
const entries: ReportRouteEntry[] = [];
for (let j = 0; j < route.routeLength(); j++) {
const entry = route.route(j);
if (entry === null) continue;
const value = entry.value() ?? "";
if (!isCargoLoadType(value)) {
console.warn(
`decodeReport: skipping RouteEntry with unknown load-type "${value}"`,
);
continue;
}
entries.push({
loadType: value,
destinationPlanetNumber: Number(entry.key()),
});
}
entries.sort(compareRouteEntriesByLoadType);
out.push({ sourcePlanetNumber, entries });
}
return out;
}
const LOAD_TYPE_ORDER: Record<CargoLoadType, number> = (() => {
const map = {} as Record<CargoLoadType, number>;
CARGO_LOAD_TYPE_VALUES.forEach((value, index) => {
map[value] = index;
});
return map;
})();
function compareRouteEntriesByLoadType(
a: ReportRouteEntry,
b: ReportRouteEntry,
): number {
return LOAD_TYPE_ORDER[a.loadType] - LOAD_TYPE_ORDER[b.loadType];
}
/**
* findLocalPlayerDrive locates the local player's drive tech
* level by matching `Player.name` against the report's `race`
* field (the engine uses race name as the runtime player
* identifier). Returns 0 when the lookup fails — boot state, an
* incomplete report, or a future schema bump that switches to
* UUIDs. Wrapping the lookup in one helper keeps the migration
* cost contained.
*/
function findLocalPlayerDrive(report: Report, raceName: string): number {
if (raceName === "") return 0;
for (let i = 0; i < report.playerLength(); i++) {
const player = report.player(i);
if (player === null) continue;
if ((player.name() ?? "") !== raceName) continue;
return player.drive();
}
return 0;
}
/**
* uuidToHiLo splits the canonical 36-character UUID string
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
@@ -280,6 +401,7 @@ export function applyOrderOverlay(
): GameReport {
if (commands.length === 0) return report;
let mutatedPlanets: ReportPlanet[] | null = null;
let mutatedRoutes: ReportRoute[] | null = null;
for (const cmd of commands) {
const status = statuses[cmd.id];
if (
@@ -317,9 +439,72 @@ export function applyOrderOverlay(
};
continue;
}
if (cmd.kind === "setCargoRoute") {
if (mutatedRoutes === null) {
mutatedRoutes = cloneRoutes(report.routes);
}
upsertRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, {
loadType: cmd.loadType,
destinationPlanetNumber: cmd.destinationPlanetNumber,
});
continue;
}
if (cmd.kind === "removeCargoRoute") {
if (mutatedRoutes === null) {
mutatedRoutes = cloneRoutes(report.routes);
}
deleteRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, cmd.loadType);
continue;
}
}
if (mutatedPlanets === null && mutatedRoutes === null) return report;
return {
...report,
planets: mutatedPlanets ?? report.planets,
routes: mutatedRoutes ?? report.routes,
};
}
function cloneRoutes(routes: ReportRoute[]): ReportRoute[] {
return routes.map((r) => ({
sourcePlanetNumber: r.sourcePlanetNumber,
entries: r.entries.map((e) => ({ ...e })),
}));
}
function upsertRouteEntry(
routes: ReportRoute[],
sourcePlanetNumber: number,
entry: ReportRouteEntry,
): void {
let route = routes.find((r) => r.sourcePlanetNumber === sourcePlanetNumber);
if (route === undefined) {
route = { sourcePlanetNumber, entries: [] };
routes.push(route);
}
const idx = route.entries.findIndex((e) => e.loadType === entry.loadType);
if (idx >= 0) {
route.entries[idx] = entry;
} else {
route.entries.push(entry);
}
route.entries.sort(compareRouteEntriesByLoadType);
}
function deleteRouteEntry(
routes: ReportRoute[],
sourcePlanetNumber: number,
loadType: CargoLoadType,
): void {
const routeIndex = routes.findIndex(
(r) => r.sourcePlanetNumber === sourcePlanetNumber,
);
if (routeIndex < 0) return;
const route = routes[routeIndex]!;
route.entries = route.entries.filter((e) => e.loadType !== loadType);
if (route.entries.length === 0) {
routes.splice(routeIndex, 1);
}
if (mutatedPlanets === null) return report;
return { ...report, planets: mutatedPlanets };
}
/**
+237 -9
View File
@@ -27,6 +27,7 @@ preference the store already manages.
minScaleNoWrap,
type RendererHandle,
} from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { reportToWorld } from "../../map/state-binding";
import {
GAME_STATE_CONTEXT_KEY,
@@ -40,12 +41,35 @@ preference the store already manages.
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
MAP_PICK_CONTEXT_KEY,
MapPickService,
} from "$lib/map-pick.svelte";
import {
installRendererDebugSurface,
registerMapCameraProvider,
registerMapPickStateProvider,
registerMapPrimitivesProvider,
type MapCameraSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "$lib/debug-surface.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const renderedReport = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
// `MapPickService` is owned by the in-game shell layout (set on
// the context tree the inspector subsections also descend from).
// Renderer changes attach / detach via `bindResolver` so a
// remount mid-pick does not orphan a pending promise. The map
// view is mounted only beneath the layout, so the service is
// always present in production; tests render the map in isolation
// and may omit it.
const pickService = getContext<MapPickService | undefined>(
MAP_PICK_CONTEXT_KEY,
);
let canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null);
@@ -56,7 +80,23 @@ preference the store already manages.
let mountedGameId: string | null = null;
let onResize: (() => void) | null = null;
let detachClick: (() => void) | null = null;
let detachDebugProviders: (() => void) | null = null;
let detachDebugSurface: (() => void) | null = null;
let mounted = false;
// Mount serialization. The `$effect` may re-fire while the
// async `mountRenderer` is mid-flight (e.g. report transitions
// from null → populated → overlay-mutated during boot). Without
// the in-progress gate, parallel `createRenderer` awaits would
// leave both old and new viewport listeners on the canvas,
// double-firing every click. The gate is intentionally a plain
// `let` (not `$state`) so reads from the effect do not register
// as a reactive dependency.
let mountInProgress = false;
let pendingMountSignal = $state(0);
// Track the latest cargo-route fingerprint we pushed to the
// renderer so a no-op push (e.g. report refresh that yields the
// same overlay) doesn't churn Pixi graphics needlessly.
let lastExtrasFingerprint: string | null = null;
$effect(() => {
// Read the overlay-applied report so the map labels reflect
@@ -72,31 +112,102 @@ preference the store already manages.
if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report) return;
// Skip a re-mount when the same turn is reloaded for the same
// game and the wrap mode did not change. The store's `refresh`
// path lands here on tab focus; an unchanged snapshot must not
// flicker the canvas.
// Cargo-route arrows are pushed onto the live renderer via
// `setExtraPrimitives` so the overlay can change inside a
// single turn without disposing the Pixi `Application` —
// Pixi 8 does not reliably re-init on the same canvas. The
// fingerprint guard avoids redundant Pixi rebuilds when the
// overlay computation re-runs but the routes content is
// unchanged (e.g. status transitions valid → submitting →
// applied for the same command).
const extrasFingerprint = computeRoutesFingerprint(report.routes);
const sameSnapshot =
mountedTurn === report.turn &&
mountedGameId === gameId &&
handle !== null &&
handle.getMode() === mode;
if (sameSnapshot) return;
if (sameSnapshot) {
if (lastExtrasFingerprint !== extrasFingerprint) {
untrack(() => {
handle?.setExtraPrimitives(buildCargoRouteLines(report));
});
lastExtrasFingerprint = extrasFingerprint;
}
return;
}
// Read the pending-mount signal so the effect re-runs after
// the in-flight mount completes (it bumps the signal in its
// finally block). Without this, a dep change observed while
// `mountInProgress` is true would be silently dropped.
void pendingMountSignal;
if (mountInProgress) return;
untrack(() => {
void mountRenderer(report, mode);
void runSerializedMount(report, mode, extrasFingerprint);
});
});
async function runSerializedMount(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
routesFingerprint: string,
): Promise<void> {
mountInProgress = true;
try {
await mountRenderer(report, mode, routesFingerprint);
} finally {
mountInProgress = false;
// Bump the reactive signal so any dep change observed
// while the gate was up gets a fresh effect run with the
// current state.
pendingMountSignal += 1;
}
}
function computeRoutesFingerprint(
routes: NonNullable<GameStateStore["report"]>["routes"],
): string {
if (routes.length === 0) return "";
const parts = routes.map((route) => {
const entries = route.entries
.map((entry) => `${entry.loadType}->${entry.destinationPlanetNumber}`)
.join(",");
return `${route.sourcePlanetNumber}:${entries}`;
});
return parts.join(";");
}
async function mountRenderer(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
routesFingerprint: string,
): Promise<void> {
if (canvasEl === null || containerEl === null) return;
// Capture camera state before disposing so a remount inside
// the same game (e.g. cargo-route overlay change) keeps the
// user's pan/zoom. A new game / first mount has no prior
// camera, so `previousCamera` stays null and the default
// centring path runs.
const previousGameId = mountedGameId;
const targetGameId = store?.gameId ?? "";
const previousCamera =
handle !== null && previousGameId === targetGameId
? handle.getCamera()
: null;
if (detachClick !== null) {
detachClick();
detachClick = null;
}
// Detach the previous resolver before disposing — the
// renderer's `dispose` already calls `onPick(null)` on any
// open session, which `bindResolver(null)` would also do, so
// we route the cancel through one path only.
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
@@ -109,7 +220,6 @@ preference the store already manages.
mode,
preference: ["webgpu", "webgl"],
});
handle.viewport.moveCenter(world.width / 2, world.height / 2);
const minScale = minScaleNoWrap(
{
widthPx: containerEl.clientWidth,
@@ -117,12 +227,113 @@ preference the store already manages.
},
world,
);
handle.viewport.setZoom(minScale * 1.05, true);
if (previousCamera !== null) {
// Same-game remount — preserve pan/zoom. Clamp zoom
// to `minScale` so a remount that re-derives the
// minimum (e.g. a viewport resize between renderers)
// does not strand the user below the current floor.
handle.viewport.moveCenter(
previousCamera.centerX,
previousCamera.centerY,
);
handle.viewport.setZoom(
Math.max(previousCamera.scale, minScale),
true,
);
} else {
handle.viewport.moveCenter(world.width / 2, world.height / 2);
handle.viewport.setZoom(minScale * 1.05, true);
}
if (mode === "no-wrap") handle.setMode("no-wrap");
detachClick = handle.onClick(handleMapClick);
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
if (handle === null) {
onResolve(null);
return null;
}
const planet = report.planets.find(
(p) => p.number === sourcePlanetNumber,
);
if (planet === undefined) {
onResolve(null);
return null;
}
return handle.setPickMode({
sourcePrimitiveId: sourcePlanetNumber,
sourceX: planet.x,
sourceY: planet.y,
reachableIds,
onPick: onResolve,
});
});
const detachPrim = registerMapPrimitivesProvider(() => {
const h = handle;
if (h === null) return [];
return h.getPrimitives().map<MapPrimitiveSnapshot>((p) => ({
id: p.id,
kind: p.kind,
priority: p.priority,
alpha: h.getPrimitiveAlpha(p.id),
fillColor: p.style.fillColor ?? null,
strokeColor: p.style.strokeColor ?? null,
x: p.kind === "point" ? p.x : null,
y: p.kind === "point" ? p.y : null,
}));
});
const detachPick = registerMapPickStateProvider(() => {
const h = handle;
if (h === null) {
return {
active: false,
sourcePlanetNumber: null,
reachableIds: [],
hoveredId: null,
} satisfies MapPickStateSnapshot;
}
const state = h.getPickState();
return {
active: state.active,
sourcePlanetNumber:
state.sourcePrimitiveId === null
? null
: Number(state.sourcePrimitiveId),
reachableIds:
state.reachableIds === null
? []
: Array.from(state.reachableIds).map((id) => Number(id)),
hoveredId:
state.hoveredId === null ? null : Number(state.hoveredId),
} satisfies MapPickStateSnapshot;
});
const detachCamera = registerMapCameraProvider(() => {
const h = handle;
if (h === null) return null;
const camera = h.getCamera();
const viewport = h.getViewport();
const rect = canvasEl?.getBoundingClientRect();
return {
camera,
viewport,
canvasOrigin: {
x: rect?.left ?? 0,
y: rect?.top ?? 0,
},
} satisfies MapCameraSnapshot;
});
detachDebugProviders = (): void => {
detachPrim();
detachPick();
detachCamera();
};
mountedTurn = report.turn;
mountedGameId = store?.gameId ?? "";
mountedGameId = targetGameId;
// Initial mount carries no extras yet; the post-mount
// effect run pushes the current cargo-route lines via
// `setExtraPrimitives` once `lastExtrasFingerprint`
// disagrees with the freshly computed fingerprint.
lastExtrasFingerprint = null;
mountError = null;
void routesFingerprint;
} catch (err) {
mountError = err instanceof Error ? err.message : String(err);
}
@@ -154,6 +365,14 @@ preference the store already manages.
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
};
window.addEventListener("resize", onResize);
// In DEV the in-game shell mounts on a fresh document load
// (`page.goto`), which discards anything the
// `/__debug/store` route may have installed earlier in the
// session. The renderer-side accessors are still useful for
// e2e specs driving the map, so we install them here too.
if (import.meta.env.DEV) {
detachDebugSurface = installRendererDebugSurface();
}
});
onDestroy(() => {
@@ -166,6 +385,15 @@ preference the store already manages.
detachClick();
detachClick = null;
}
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (detachDebugSurface !== null) {
detachDebugSurface();
detachDebugSurface = null;
}
if (handle !== null) {
handle.dispose();
handle = null;
+174
View File
@@ -0,0 +1,174 @@
// Module-scoped registry the in-game shell uses to expose live
// renderer state to the DEV-only `__galaxyDebug` surface defined in
// `routes/__debug/store/+page.svelte`. Tests open the debug route
// once to grab the surface, then drive the in-game routes; the
// registered providers stay alive across SvelteKit navigations and
// surface the current map state without forcing the renderer to
// know about the debug API directly.
//
// Providers are functions, not snapshots: the surface invokes them
// lazily on every read so the returned data always reflects the
// current frame, not the value at registration time.
import type { Primitive, PrimitiveID } from "../map/world";
/** Snapshot returned by `getMapPrimitives()`. The renderer applies
* pick-mode dimming via the underlying `Graphics.alpha`, so the
* `alpha` field captures what is actually drawn (1.0 normally,
* `PICK_OVERLAY_STYLE.dimAlpha` while a pick session is active).
* Style colours come straight from the primitive style (no theme
* fallback) so e2e specs can assert exact colour identity. `x` and
* `y` are populated for `point` primitives (single anchor); other
* kinds leave them `null`. */
export interface MapPrimitiveSnapshot {
readonly id: PrimitiveID;
readonly kind: Primitive["kind"];
readonly priority: number;
readonly alpha: number;
readonly fillColor: number | null;
readonly strokeColor: number | null;
readonly x: number | null;
readonly y: number | null;
}
/** Snapshot returned by `getMapCamera()`. Mirrors the renderer's
* `getCamera` / `getViewport` plus a bounding-rect snapshot of the
* underlying canvas, so e2e specs can project a known world-space
* coordinate to a click target without rebuilding the projection
* maths themselves. */
export interface MapCameraSnapshot {
readonly camera: { readonly centerX: number; readonly centerY: number; readonly scale: number };
readonly viewport: { readonly widthPx: number; readonly heightPx: number };
readonly canvasOrigin: { readonly x: number; readonly y: number };
}
/** Snapshot returned by `getMapPickState()`. */
export interface MapPickStateSnapshot {
readonly active: boolean;
readonly sourcePlanetNumber: number | null;
readonly reachableIds: readonly number[];
readonly hoveredId: number | null;
}
type PrimitivesProvider = () => readonly MapPrimitiveSnapshot[];
type PickStateProvider = () => MapPickStateSnapshot;
type CameraProvider = () => MapCameraSnapshot | null;
let primitivesProvider: PrimitivesProvider | null = null;
let pickStateProvider: PickStateProvider | null = null;
let cameraProvider: CameraProvider | null = null;
/**
* registerMapPrimitivesProvider attaches a provider that yields the
* current `Primitive` snapshots. Idempotent — a previously-bound
* provider is replaced. Returns a deregister function the caller
* runs on dispose.
*/
export function registerMapPrimitivesProvider(
provider: PrimitivesProvider,
): () => void {
primitivesProvider = provider;
return () => {
if (primitivesProvider === provider) primitivesProvider = null;
};
}
/**
* registerMapPickStateProvider attaches a provider for the current
* pick-mode state. Same idempotent semantics as the primitives
* provider.
*/
export function registerMapPickStateProvider(
provider: PickStateProvider,
): () => void {
pickStateProvider = provider;
return () => {
if (pickStateProvider === provider) pickStateProvider = null;
};
}
/**
* registerMapCameraProvider attaches a provider for the current
* camera + viewport + canvas-origin snapshot. Same idempotent
* semantics as the other providers.
*/
export function registerMapCameraProvider(
provider: CameraProvider,
): () => void {
cameraProvider = provider;
return () => {
if (cameraProvider === provider) cameraProvider = null;
};
}
const EMPTY_PICK_STATE: MapPickStateSnapshot = {
active: false,
sourcePlanetNumber: null,
reachableIds: [],
hoveredId: null,
};
/** Pulls the current snapshot. Returns an empty array when no map
* view is mounted. */
export function getMapPrimitives(): readonly MapPrimitiveSnapshot[] {
return primitivesProvider?.() ?? [];
}
/** Pulls the current pick state. Returns the inactive sentinel
* snapshot when no map view is mounted. */
export function getMapPickState(): MapPickStateSnapshot {
return pickStateProvider?.() ?? EMPTY_PICK_STATE;
}
/** Pulls the current camera + viewport snapshot, or `null` when
* no map view is mounted. */
export function getMapCamera(): MapCameraSnapshot | null {
return cameraProvider?.() ?? null;
}
interface RendererDebugWindow {
__galaxyDebug?: {
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
getMapPickState?: () => MapPickStateSnapshot;
getMapCamera?: () => MapCameraSnapshot | null;
[key: string]: unknown;
};
}
/**
* installRendererDebugSurface stitches the renderer accessors onto
* `window.__galaxyDebug`. The DEV-only `/__debug/store` route
* already registers the keystore / order helpers; navigating to
* `/games/...` resets the window-bound surface, so the in-game
* shell calls this on map mount to keep the renderer state
* accessible to e2e specs that drive the map. Idempotent — repeated
* calls override the same three methods.
*/
export function installRendererDebugSurface(): () => void {
if (typeof window === "undefined") return () => {};
const win = window as unknown as RendererDebugWindow;
const existing = win.__galaxyDebug ?? {};
const surface = {
...existing,
getMapPrimitives,
getMapPickState,
getMapCamera,
};
win.__galaxyDebug = surface;
return (): void => {
// Detach only the renderer-owned methods; preserve any
// keystore / order surface the debug route may have
// installed earlier in the session.
const current = win.__galaxyDebug;
if (current === undefined) return;
if (current.getMapPrimitives === getMapPrimitives) {
delete current.getMapPrimitives;
}
if (current.getMapPickState === getMapPickState) {
delete current.getMapPickState;
}
if (current.getMapCamera === getMapCamera) {
delete current.getMapCamera;
}
};
}
+14
View File
@@ -178,6 +178,20 @@ const en = {
"game.inspector.planet.production.research.shields": "shields",
"game.inspector.planet.production.research.cargo": "cargo",
"game.inspector.planet.production.ship.no_classes": "no ship classes designed yet",
"game.inspector.planet.cargo.title": "cargo routes",
"game.inspector.planet.cargo.slot.col": "colonists",
"game.inspector.planet.cargo.slot.cap": "industry",
"game.inspector.planet.cargo.slot.mat": "materials",
"game.inspector.planet.cargo.slot.emp": "empty ships",
"game.inspector.planet.cargo.empty": "(no route)",
"game.inspector.planet.cargo.add": "add",
"game.inspector.planet.cargo.edit": "edit",
"game.inspector.planet.cargo.remove": "remove",
"game.inspector.planet.cargo.pick.prompt": "pick a destination on the map (Esc to cancel)",
"game.inspector.planet.cargo.pick.cancel": "cancel pick",
"game.inspector.planet.cargo.pick.no_destinations": "no reachable destinations within {reach} world units",
"game.sidebar.order.label.cargo_route_set": "set {loadType} route from planet {source} → planet {destination}",
"game.sidebar.order.label.cargo_route_remove": "remove {loadType} route from planet {source}",
} as const;
export default en;
+14
View File
@@ -179,6 +179,20 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.planet.production.research.shields": "щиты",
"game.inspector.planet.production.research.cargo": "трюм",
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы",
"game.inspector.planet.cargo.title": "грузовые маршруты",
"game.inspector.planet.cargo.slot.col": "колонисты",
"game.inspector.planet.cargo.slot.cap": "промышленность",
"game.inspector.planet.cargo.slot.mat": "сырьё",
"game.inspector.planet.cargo.slot.emp": "пустые корабли",
"game.inspector.planet.cargo.empty": "(маршрута нет)",
"game.inspector.planet.cargo.add": "добавить",
"game.inspector.planet.cargo.edit": "изменить",
"game.inspector.planet.cargo.remove": "удалить",
"game.inspector.planet.cargo.pick.prompt": "выбери цель на карте (Esc — отмена)",
"game.inspector.planet.cargo.pick.cancel": "отменить выбор",
"game.inspector.planet.cargo.pick.no_destinations": "нет планет в зоне полёта {reach} ед.",
"game.sidebar.order.label.cargo_route_set": "маршрут {loadType} с планеты {source} → планета {destination}",
"game.sidebar.order.label.cargo_route_remove": "удалить маршрут {loadType} с планеты {source}",
};
export default ru;
@@ -13,6 +13,7 @@ dismiss from the IA section §6 land in Phase 35 polish.
<script lang="ts">
import type {
ReportPlanet,
ReportRoute,
ShipClassSummary,
} from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
@@ -21,10 +22,25 @@ dismiss from the IA section §6 land in Phase 35 polish.
type Props = {
planet: ReportPlanet | null;
localShipClass: ShipClassSummary[];
routes: ReportRoute[];
planets: ReportPlanet[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
onMap: boolean;
onClose: () => void;
};
let { planet, localShipClass, onMap, onClose }: Props = $props();
let {
planet,
localShipClass,
routes,
planets,
mapWidth,
mapHeight,
localPlayerDrive,
onMap,
onClose,
}: Props = $props();
</script>
{#if planet !== null && onMap}
@@ -42,7 +58,15 @@ dismiss from the IA section §6 land in Phase 35 polish.
>
</button>
<Planet {planet} {localShipClass} />
<Planet
{planet}
{localShipClass}
{routes}
{planets}
{mapWidth}
{mapHeight}
{localPlayerDrive}
/>
</section>
{/if}
+24 -1
View File
@@ -16,6 +16,7 @@ field with five buttons.
import { getContext, tick } from "svelte";
import type {
ReportPlanet,
ReportRoute,
ShipClassSummary,
} from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -27,13 +28,27 @@ field with five buttons.
validateEntityName,
type EntityNameInvalidReason,
} from "$lib/util/entity-name";
import CargoRoutes from "./planet/cargo-routes.svelte";
import Production from "./planet/production.svelte";
type Props = {
planet: ReportPlanet;
localShipClass: ShipClassSummary[];
routes: ReportRoute[];
planets: ReportPlanet[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
};
let { planet, localShipClass }: Props = $props();
let {
planet,
localShipClass,
routes,
planets,
mapWidth,
mapHeight,
localPlayerDrive,
}: Props = $props();
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
local: "game.inspector.planet.kind.local",
@@ -198,6 +213,14 @@ field with five buttons.
{#if planet.kind === "local"}
<Production {planet} {localShipClass} />
<CargoRoutes
{planet}
{routes}
{planets}
{mapWidth}
{mapHeight}
{localPlayerDrive}
/>
{/if}
<dl class="fields">
@@ -0,0 +1,331 @@
<!--
Phase 16 cargo-routes subsection of the planet inspector. Shows a
fixed COL/CAP/MAT/EMP four-slot table for the active local planet,
each slot either empty (with a single Add button) or filled (with
the destination planet's name plus Edit and Remove buttons). Add
and Edit hand off to the renderer-driven `MapPickService`: the map
dims out-of-reach planets, draws the cursor-line anchor, and
resolves with either a chosen destination id or `null` (cancel).
The component is purposely deferential to the existing infrastructure:
- `OrderDraftStore` enforces the `(source, loadType)` collapse rule,
so the optimistic overlay always matches what the server sees.
- `MapPickService.pick(...)` is a renderer-side abstraction; its
source/destination semantics live in `lib/active-view/map.svelte`.
- Reach (`40 * driveTech` per `game/internal/model/game/race.go`)
is computed inline using `torusShortestDelta` to mirror the
engine's torus distance — see `pkg/util/map.go.deltas`.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { ReportPlanet, ReportRoute } from "../../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { torusShortestDelta } from "../../../map/math";
import {
MAP_PICK_CONTEXT_KEY,
type MapPickService,
} from "$lib/map-pick.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
import {
CARGO_LOAD_TYPE_VALUES,
type CargoLoadType,
} from "../../../sync/order-types";
type Props = {
planet: ReportPlanet;
routes: ReportRoute[];
planets: ReportPlanet[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
};
let {
planet,
routes,
planets,
mapWidth,
mapHeight,
localPlayerDrive,
}: Props = $props();
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
const pick = getContext<MapPickService | undefined>(MAP_PICK_CONTEXT_KEY);
const disabled = $derived(draft === undefined || pick === undefined);
let pendingSlot: CargoLoadType | null = $state(null);
$effect(() => {
// Reset the in-flight slot whenever the inspector switches to a
// different planet so a stale "pick in progress" prompt does
// not leak across the selection boundary.
void planet.number;
pendingSlot = null;
});
const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = {
COL: "game.inspector.planet.cargo.slot.col",
CAP: "game.inspector.planet.cargo.slot.cap",
MAT: "game.inspector.planet.cargo.slot.mat",
EMP: "game.inspector.planet.cargo.slot.emp",
};
const currentEntries = $derived(
routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [],
);
// Per-slot derived map keeps the template's {#each} block free of
// the {@const}/`find` chain that Svelte 5 sometimes mis-tracks
// when the source array is freshly cloned by `applyOrderOverlay`.
const slotEntries = $derived.by(() => {
const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = {
COL: null,
CAP: null,
MAT: null,
EMP: null,
};
for (const entry of currentEntries) {
map[entry.loadType] = entry;
}
return map;
});
function destinationName(planetNumber: number): string {
const target = planets.find((p) => p.number === planetNumber);
if (target === undefined) return `#${planetNumber}`;
if (target.kind === "unidentified") return `#${planetNumber}`;
return target.name === "" ? `#${planetNumber}` : target.name;
}
const reach = $derived(40 * localPlayerDrive);
function reachableSet(): Set<number> {
const ids = new Set<number>();
if (reach <= 0) return ids;
for (const candidate of planets) {
if (candidate.number === planet.number) continue;
if (candidate.kind === "unidentified") continue;
const dx = torusShortestDelta(planet.x, candidate.x, mapWidth);
const dy = torusShortestDelta(planet.y, candidate.y, mapHeight);
if (Math.hypot(dx, dy) <= reach) {
ids.add(candidate.number);
}
}
return ids;
}
async function startPick(loadType: CargoLoadType): Promise<void> {
if (draft === undefined || pick === undefined) return;
if (pendingSlot !== null) return;
const reachable = reachableSet();
if (reachable.size === 0) return;
pendingSlot = loadType;
try {
const destination = await pick.pick({
sourcePlanetNumber: planet.number,
reachableIds: reachable,
});
if (destination === null) return;
await draft.add({
kind: "setCargoRoute",
id: crypto.randomUUID(),
sourcePlanetNumber: planet.number,
destinationPlanetNumber: destination,
loadType,
});
} finally {
pendingSlot = null;
}
}
async function removeRoute(loadType: CargoLoadType): Promise<void> {
if (draft === undefined) return;
await draft.add({
kind: "removeCargoRoute",
id: crypto.randomUUID(),
sourcePlanetNumber: planet.number,
loadType,
});
}
function cancelPick(): void {
pick?.cancel();
}
</script>
<section class="cargo" data-testid="inspector-planet-cargo">
<h4 class="title">
{i18n.t("game.inspector.planet.cargo.title")}
</h4>
<dl class="slots">
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
{@const entry = slotEntries[loadType]}
{@const slug = loadType.toLowerCase()}
<div class="slot" data-testid={`inspector-planet-cargo-slot-${slug}`}>
<dt class="slot-label" data-testid={`inspector-planet-cargo-slot-${slug}-label`}>
{i18n.t(SLOT_LABELS[loadType])}
</dt>
<dd class="slot-body">
{#if entry === null}
<span
class="empty"
data-testid={`inspector-planet-cargo-slot-${slug}-empty`}
>
{i18n.t("game.inspector.planet.cargo.empty")}
</span>
<button
type="button"
class="action add"
data-testid={`inspector-planet-cargo-slot-${slug}-add`}
disabled={disabled || pendingSlot !== null}
onclick={() => void startPick(loadType)}
>
{i18n.t("game.inspector.planet.cargo.add")}
</button>
{:else}
<span
class="destination"
data-testid={`inspector-planet-cargo-slot-${slug}-destination`}
>
{destinationName(entry.destinationPlanetNumber)}
</span>
<button
type="button"
class="action edit"
data-testid={`inspector-planet-cargo-slot-${slug}-edit`}
disabled={disabled || pendingSlot !== null}
onclick={() => void startPick(loadType)}
>
{i18n.t("game.inspector.planet.cargo.edit")}
</button>
<button
type="button"
class="action remove"
data-testid={`inspector-planet-cargo-slot-${slug}-remove`}
disabled={disabled || pendingSlot !== null}
onclick={() => void removeRoute(loadType)}
>
{i18n.t("game.inspector.planet.cargo.remove")}
</button>
{/if}
</dd>
</div>
{/each}
</dl>
{#if pendingSlot !== null}
<div
class="pick-prompt"
data-testid="inspector-planet-cargo-pick-prompt"
role="status"
>
<span class="pick-message">
{i18n.t("game.inspector.planet.cargo.pick.prompt")}
</span>
<button
type="button"
class="action cancel"
data-testid="inspector-planet-cargo-pick-cancel"
onclick={cancelPick}
>
{i18n.t("game.inspector.planet.cargo.pick.cancel")}
</button>
</div>
{:else if reach > 0 && reachableSet().size === 0}
<p
class="no-destinations"
data-testid="inspector-planet-cargo-no-destinations"
>
{i18n.t("game.inspector.planet.cargo.pick.no_destinations", {
reach: reach.toFixed(1),
})}
</p>
{/if}
</section>
<style>
.cargo {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.title {
margin: 0;
font-size: 0.85rem;
color: #aab;
font-weight: 500;
}
.slots {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.25rem;
column-gap: 0.6rem;
}
.slot {
display: contents;
}
.slot-label {
color: #aab;
font-size: 0.85rem;
align-self: center;
}
.slot-body {
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
}
.empty {
color: #888;
font-style: italic;
}
.destination {
color: #e8eaf6;
}
.action {
font: inherit;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.action:not(:disabled):hover {
color: #e8eaf6;
border-color: #6d8cff;
}
.action:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.pick-prompt {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
padding: 0.3rem 0.5rem;
background: rgba(255, 224, 130, 0.1);
border: 1px solid #ffe082;
border-radius: 4px;
}
.pick-message {
color: #ffe082;
font-size: 0.85rem;
flex: 1;
}
.no-destinations {
margin: 0;
font-size: 0.8rem;
color: #888;
font-style: italic;
}
</style>
+133
View File
@@ -0,0 +1,133 @@
// `MapPickService` is the Svelte-side adapter the inspector uses to
// drive a map-driven destination pick. The service owns the
// promise-shaped contract (`pick()` returns the picked planet
// number or `null` on cancel) and a reactive `active` flag for any
// surface that wants to disable other UI while a session is open.
//
// The actual renderer plumbing — dim outside `reachableIds`, anchor
// ring, cursor line, hover outline, click + Escape resolution —
// lives in `ui/frontend/src/map/render.ts.setPickMode`. The map
// active view (`lib/active-view/map.svelte`) is the only producer:
// it constructs the service, sets it on the layout context with
// `MAP_PICK_CONTEXT_KEY`, and binds a resolver that translates the
// service-level request into a `PickModeOptions` payload for the
// current renderer handle.
export const MAP_PICK_CONTEXT_KEY = Symbol("map-pick");
/** High-level pick request the inspector composes. The renderer
* resolver (registered by the map view) is responsible for turning
* `sourcePlanetNumber` into the underlying `PickModeOptions`. */
export interface MapPickRequest {
readonly sourcePlanetNumber: number;
readonly reachableIds: ReadonlySet<number>;
}
/** A renderer-side resolver registered by the map view. Returns an
* imperative cancel hook the service uses for `cancel()`, or `null`
* when the renderer cannot open a session right now (e.g. the
* source planet is missing from the world). When `null` is
* returned, the service resolves the pending promise with `null`
* straight away. */
export type MapPickResolver = (input: {
sourcePlanetNumber: number;
reachableIds: ReadonlySet<number>;
onResolve: (id: number | null) => void;
}) => { cancel(): void } | null;
/**
* MapPickService coordinates pick-mode sessions between the Svelte
* inspector and the renderer. Lives for the lifetime of the
* in-game shell layout; renderer handles come and go through
* `bindResolver` as the map remounts.
*/
export class MapPickService {
/** Reactive flag — true while a pick session is open. The
* inspector reads this to render its "pick prompt" status line
* and to keep the slot button disabled until resolution. */
active = $state(false);
private resolver: MapPickResolver | null = null;
private currentHandle: { cancel(): void } | null = null;
private currentResolve: ((id: number | null) => void) | null = null;
/**
* bindResolver attaches a renderer-side handler that opens
* pick-mode sessions. Pass `null` to detach (the map view does
* this on dispose); a detach with a session in progress
* resolves the pending promise with `null` so callers do not
* deadlock waiting for a renderer that no longer exists.
*/
bindResolver(resolver: MapPickResolver | null): void {
if (resolver === null && this.currentResolve !== null) {
const r = this.currentResolve;
this.currentResolve = null;
this.currentHandle = null;
this.active = false;
r(null);
}
this.resolver = resolver;
}
/**
* pick opens a pick session. Resolves to the picked planet
* number on a successful pick, or `null` when the player
* cancels via Escape, the inspector calls `cancel()`, or the
* renderer detaches mid-session.
*
* Calling `pick` while a session is already active cancels the
* old one first (its promise resolves to `null`). The
* inspector should normally guard against this via the
* reactive `active` flag, but the service stays defensive.
*/
pick(request: MapPickRequest): Promise<number | null> {
return new Promise((resolve) => {
if (this.resolver === null) {
resolve(null);
return;
}
if (this.currentHandle !== null) {
const previousHandle = this.currentHandle;
this.currentHandle = null;
previousHandle.cancel();
}
this.currentResolve = resolve;
this.active = true;
const handle = this.resolver({
sourcePlanetNumber: request.sourcePlanetNumber,
reachableIds: request.reachableIds,
onResolve: (id) => {
// Guard against late notifications from a stale
// session (e.g. resolver swapped while a pick was
// in flight).
if (this.currentResolve !== resolve) return;
this.currentResolve = null;
this.currentHandle = null;
this.active = false;
resolve(id);
},
});
if (handle === null) {
if (this.currentResolve === resolve) {
this.currentResolve = null;
this.active = false;
resolve(null);
}
return;
}
this.currentHandle = handle;
});
}
/**
* cancel terminates the active session, if any. Safe to call
* when no session is open — it is a no-op then. The pending
* promise resolves with `null`.
*/
cancel(): void {
if (this.currentHandle === null) return;
const handle = this.currentHandle;
this.currentHandle = null;
handle.cancel();
}
}
@@ -41,11 +41,26 @@ from the Phase 10 stub.
const localShipClass = $derived(
renderedReport?.report?.localShipClass ?? [],
);
const allPlanets = $derived(renderedReport?.report?.planets ?? []);
const routes = $derived(renderedReport?.report?.routes ?? []);
const mapWidth = $derived(renderedReport?.report?.mapWidth ?? 1);
const mapHeight = $derived(renderedReport?.report?.mapHeight ?? 1);
const localPlayerDrive = $derived(
renderedReport?.report?.localPlayerDrive ?? 0,
);
</script>
<section class="tool" data-testid="sidebar-tool-inspector">
{#if selectedPlanet !== null}
<Planet planet={selectedPlanet} {localShipClass} />
<Planet
planet={selectedPlanet}
{localShipClass}
{routes}
planets={allPlanets}
{mapWidth}
{mapHeight}
{localPlayerDrive}
/>
{:else}
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
@@ -58,6 +58,17 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
cmd.subject,
),
});
case "setCargoRoute":
return i18n.t("game.sidebar.order.label.cargo_route_set", {
loadType: cmd.loadType,
source: String(cmd.sourcePlanetNumber),
destination: String(cmd.destinationPlanetNumber),
});
case "removeCargoRoute":
return i18n.t("game.sidebar.order.label.cargo_route_remove", {
loadType: cmd.loadType,
source: String(cmd.sourcePlanetNumber),
});
}
}
+175
View File
@@ -0,0 +1,175 @@
// Map-side cargo-route arrows. Each `ReportRouteEntry` becomes a
// short arrow from the source planet to its destination, drawn as
// three `LinePrim` segments — one shaft and two arrowhead wings —
// styled per load type so the four cargo kinds are
// distinguishable at a glance. Phase 16 ships placeholder
// colours; Phase 35 polish picks final values.
//
// Geometry uses `torusShortestDelta` so an arrow that crosses the
// torus seam takes the wrap, not the long way round, matching the
// engine's reach test (`util.ShortDistance`,
// `pkg/util/map.go.deltas`).
import type { GameReport, ReportPlanet } from "../api/game-state";
import type { CargoLoadType } from "../sync/order-types";
import { torusShortestDelta } from "./math";
import type { LinePrim, PrimitiveID, Style } from "./world";
export const STYLE_ROUTE_COL: Style = {
strokeColor: 0x4fc3f7,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_CAP: Style = {
strokeColor: 0xffb74d,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_MAT: Style = {
strokeColor: 0x81c784,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_EMP: Style = {
strokeColor: 0x90a4ae,
strokeAlpha: 0.85,
strokeWidthPx: 1,
};
const STYLE_BY_LOAD_TYPE: Record<CargoLoadType, Style> = {
COL: STYLE_ROUTE_COL,
CAP: STYLE_ROUTE_CAP,
MAT: STYLE_ROUTE_MAT,
EMP: STYLE_ROUTE_EMP,
};
/** Per-load-type priority. Higher wins hit-test ties; planets sit
* at 1..4 (`state-binding.ts.priorityFor`), so route arrows always
* lose to planet primitives. The internal ordering follows the
* engine's COL > CAP > MAT > EMP preference so when two arrows
* overlap exactly, the higher-priority cargo wins the click. */
const PRIORITY_BY_LOAD_TYPE: Record<CargoLoadType, number> = {
COL: 8,
CAP: 7,
MAT: 6,
EMP: 5,
};
const LOAD_TYPE_INDEX: Record<CargoLoadType, number> = {
COL: 0,
CAP: 1,
MAT: 2,
EMP: 3,
};
/** High-bit prefix on every cargo-route line id so it cannot
* collide with a planet number (planets use uint64 numbers ≪
* 2^31). The renderer's hit-test treats ids opaquely; the
* inspector never resolves a planet by a line id, so the prefix
* is internal-only. */
export const ROUTE_LINE_ID_PREFIX = 0x80000000;
const SHAFT_OFFSET = 0;
const WING_LEFT_OFFSET = 1;
const WING_RIGHT_OFFSET = 2;
/** Arrowhead size in world units. Picked so the head is visible
* at default zoom but does not eat the destination planet glyph. */
const HEAD_LENGTH_WORLD = 6;
/** Half-angle of the arrowhead opening, in radians (~25°). */
const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
/**
* buildCargoRouteLines emits one `LinePrim` per shaft + two per
* arrowhead wing for every (source, loadType, destination) entry
* in `report.routes`. Skips routes whose source or destination is
* not present in the planet list (e.g. a destination newly
* unidentified after a turn cutoff). Pure: relies only on the
* report; no DOM access; no Pixi calls.
*/
export function buildCargoRouteLines(report: GameReport): LinePrim[] {
if (report.routes.length === 0) return [];
const planetById = new Map<number, ReportPlanet>();
for (const planet of report.planets) {
planetById.set(planet.number, planet);
}
const lines: LinePrim[] = [];
for (const route of report.routes) {
const source = planetById.get(route.sourcePlanetNumber);
if (source === undefined) continue;
for (const entry of route.entries) {
const dest = planetById.get(entry.destinationPlanetNumber);
if (dest === undefined) continue;
const dx = torusShortestDelta(source.x, dest.x, report.mapWidth);
const dy = torusShortestDelta(source.y, dest.y, report.mapHeight);
const length = Math.hypot(dx, dy);
if (length === 0) continue;
const headX = source.x + dx;
const headY = source.y + dy;
const ux = dx / length;
const uy = dy / length;
const cosA = Math.cos(HEAD_HALF_ANGLE);
const sinA = Math.sin(HEAD_HALF_ANGLE);
const leftX = headX - HEAD_LENGTH_WORLD * (ux * cosA + uy * sinA);
const leftY = headY - HEAD_LENGTH_WORLD * (uy * cosA - ux * sinA);
const rightX = headX - HEAD_LENGTH_WORLD * (ux * cosA - uy * sinA);
const rightY = headY - HEAD_LENGTH_WORLD * (uy * cosA + ux * sinA);
const baseId = routeLineBaseId(
route.sourcePlanetNumber,
entry.loadType,
);
const style = STYLE_BY_LOAD_TYPE[entry.loadType];
const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType];
lines.push({
kind: "line",
id: baseId + SHAFT_OFFSET,
priority,
style,
hitSlopPx: 0,
x1: source.x,
y1: source.y,
x2: headX,
y2: headY,
});
lines.push({
kind: "line",
id: baseId + WING_LEFT_OFFSET,
priority,
style,
hitSlopPx: 0,
x1: headX,
y1: headY,
x2: leftX,
y2: leftY,
});
lines.push({
kind: "line",
id: baseId + WING_RIGHT_OFFSET,
priority,
style,
hitSlopPx: 0,
x1: headX,
y1: headY,
x2: rightX,
y2: rightY,
});
}
}
return lines;
}
/** Unique numeric id for a route's three line primitives. The
* three segments occupy `baseId + 0..2`. Encoded as
* `prefix | (source << 8) | (loadTypeIndex << 4)` so a planet
* number up to 2^23 and the four load-type slots fit without
* collision. */
function routeLineBaseId(
sourcePlanetNumber: number,
loadType: CargoLoadType,
): PrimitiveID {
return (
ROUTE_LINE_ID_PREFIX |
((sourcePlanetNumber & 0x7fffff) << 8) |
(LOAD_TYPE_INDEX[loadType] << 4)
);
}
+6 -1
View File
@@ -14,6 +14,7 @@
import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
import {
DEFAULT_HIT_SLOP_PX,
DEFAULT_POINT_RADIUS_PX,
KIND_ORDER,
type Camera,
type CirclePrim,
@@ -100,7 +101,11 @@ function matchPoint(
): number | null {
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
const distSq = dx * dx + dy * dy;
const r = slopWorld;
// The visible disc is `pointRadiusPx` world units; the hit zone
// is the disc plus a small ergonomic slop on top. A click on any
// painted pixel of the planet must register as a hit.
const visibleRadius = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
const r = visibleRadius + slopWorld;
if (distSq <= r * r) return distSq;
return null;
}
+160
View File
@@ -0,0 +1,160 @@
// Map pick-mode contract: a generic "pick a destination on the map"
// interaction the inspector triggers and the renderer drives. Phase
// 16 adds the cargo-route picker on top of this; later phases
// (19/20) drive ship-group dispatch through the same surface.
//
// The renderer-facing API lives on `RendererHandle.setPickMode`
// (see `render.ts`); this module owns the option / handle types and
// the pure overlay-draw helper that translates the pick state into a
// drawing spec the renderer can lift straight onto a Pixi `Graphics`.
// Keeping the math here means the lifecycle (dim / cursor line /
// hover outline / click+Escape resolution) can be tested without
// booting a Pixi `Application`.
import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID } from "./world";
/**
* PickModeOptions configures a pick-mode session. The caller is
* responsible for computing `reachableIds` from the current report
* (e.g. cargo routes apply the `40 * driveTech` rule before opening
* the picker). The renderer never validates reach itself — it only
* dims primitives whose id is missing from this set.
*/
export interface PickModeOptions {
/** Numeric id of the source planet primitive. Stays full-alpha
* during the session and anchors the cursor line. */
readonly sourcePrimitiveId: PrimitiveID;
/** World coordinates of the source. Pre-computed so the renderer
* can draw the anchor ring and the line endpoint without
* crawling the primitive list. */
readonly sourceX: number;
readonly sourceY: number;
/** Ids whose primitives stay full-alpha and accept clicks. */
readonly reachableIds: ReadonlySet<PrimitiveID>;
/** Resolution callback. Fires with the chosen primitive id on a
* successful pick, or `null` when the player cancels via Escape
* or the imperative `cancel()` handle. */
readonly onPick: (id: PrimitiveID | null) => void;
}
export interface PickModeHandle {
/**
* cancel terminates the session immediately and resolves
* `onPick(null)`. Idempotent — repeated calls are no-ops.
*/
cancel(): void;
}
/**
* PickOverlaySpec is the pure description the renderer paints onto
* its overlay graphic each frame. Keeps the lifecycle logic
* Pixi-free so it can be exercised by Vitest.
*/
export interface PickOverlaySpec {
/** Highlight ring around the source planet (slightly outside the
* visible disc). */
readonly anchor: {
readonly x: number;
readonly y: number;
readonly radius: number;
};
/** Line from source to current cursor; `null` while the cursor
* is off-canvas. */
readonly line: {
readonly x1: number;
readonly y1: number;
readonly x2: number;
readonly y2: number;
} | null;
/** Outline circle around the hovered reachable planet; `null`
* when the hover is empty or aimed at a non-reachable primitive. */
readonly hoverOutline: {
readonly x: number;
readonly y: number;
readonly radius: number;
} | null;
/** Ids to dim (alpha 0.3). Everything not in `reachableIds` and
* not the source. */
readonly dimmedIds: ReadonlySet<PrimitiveID>;
}
/** Anchor / hover outline padding in world units (the rings sit
* outside the visible disc so the planet stays clearly visible). */
export const ANCHOR_PADDING_WORLD = 6;
export const HOVER_PADDING_WORLD = 4;
/**
* computePickOverlay produces a `PickOverlaySpec` for the current
* pick state. Pure: no DOM access, no Pixi calls. Callers prepare
* `pointPrimitivesById` from the active world before invoking.
*/
export function computePickOverlay(
options: PickModeOptions,
cursorWorld: { x: number; y: number } | null,
hoveredId: PrimitiveID | null,
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
allPrimitiveIds: Iterable<PrimitiveID>,
): PickOverlaySpec {
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
const sourceRadius =
(sourcePrim?.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
ANCHOR_PADDING_WORLD;
const dimmed = new Set<PrimitiveID>();
for (const id of allPrimitiveIds) {
if (id === options.sourcePrimitiveId) continue;
if (options.reachableIds.has(id)) continue;
dimmed.add(id);
}
const line =
cursorWorld === null
? null
: {
x1: options.sourceX,
y1: options.sourceY,
x2: cursorWorld.x,
y2: cursorWorld.y,
};
let hoverOutline: PickOverlaySpec["hoverOutline"] = null;
if (
hoveredId !== null &&
hoveredId !== options.sourcePrimitiveId &&
options.reachableIds.has(hoveredId)
) {
const target = pointPrimitivesById.get(hoveredId);
if (target !== undefined) {
hoverOutline = {
x: target.x,
y: target.y,
radius:
(target.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
HOVER_PADDING_WORLD,
};
}
}
return {
anchor: {
x: options.sourceX,
y: options.sourceY,
radius: sourceRadius,
},
line,
hoverOutline,
dimmedIds: dimmed,
};
}
/**
* PICK_OVERLAY_STYLE captures the colours / widths the renderer
* applies to each spec channel. Exported so tests and future themes
* can read the same values.
*/
export const PICK_OVERLAY_STYLE = {
anchor: { color: 0xffe082, alpha: 0.9, width: 2 },
line: { color: 0xffe082, alpha: 0.5, width: 1 },
hover: { color: 0xffe082, alpha: 1, width: 2 },
dimAlpha: 0.3,
} as const;
+408 -11
View File
@@ -21,18 +21,27 @@ import { Application, Container, Graphics, type Renderer, type RendererType } fr
import { Viewport as PixiViewport } from "pixi-viewport";
import { hitTest, type Hit } from "./hit-test";
import { screenToWorld } from "./math";
import { minScaleNoWrap } from "./no-wrap";
import {
computePickOverlay,
PICK_OVERLAY_STYLE,
type PickModeHandle,
type PickModeOptions,
} from "./pick-mode";
import { wrapCameraTorus } from "./torus";
import {
DARK_THEME,
DEFAULT_POINT_RADIUS_PX,
World,
type Camera,
type CirclePrim,
type LinePrim,
type PointPrim,
type Primitive,
type PrimitiveID,
type Theme,
type Viewport,
type World,
type WrapMode,
} from "./world";
@@ -58,6 +67,26 @@ export interface RendererHandle {
getViewport(): Viewport;
getBackend(): "webgl" | "webgpu" | "canvas";
hitAt(cursorPx: { x: number; y: number }): Hit | null;
/**
* setExtraPrimitives replaces the current overlay primitive layer
* with `prims`. The base world (passed to `createRenderer`) is
* preserved; only the extras layer changes. Used by the in-game
* shell to project order-overlay-driven artefacts (Phase 16
* cargo-route arrows) onto the live renderer without disposing
* and recreating the Pixi `Application` — which Pixi 8 does not
* reliably support on the same canvas.
*
* Hit-test, `getPrimitives`, and pick mode all see the union of
* base + extras after the call returns. Repeated calls
* remount-replace the extras atomically.
*/
setExtraPrimitives(prims: readonly Primitive[]): void;
/**
* getPrimitives returns the live union of base + extras. The
* order is base-first, extras-last (mirroring the draw order).
* Reads stay in sync with `setExtraPrimitives`.
*/
getPrimitives(): readonly Primitive[];
/**
* onClick subscribes `cb` to a click on the map (a pointer-down /
* pointer-up pair without enough drag to trigger pan). The cursor
@@ -70,6 +99,62 @@ export interface RendererHandle {
* click here will not race a pan gesture.
*/
onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void;
/**
* onPointerMove subscribes `cb` to every pointer-move event on
* the canvas. The callback receives the cursor in canvas-local
* pixel coordinates so callers can hand it straight to `hitAt`.
* Touch drags also emit pointer-move while a finger is pressed.
* The returned function detaches the listener; idempotent.
*/
onPointerMove(cb: (cursorPx: { x: number; y: number }) => void): () => void;
/**
* onHoverChange subscribes `cb` to changes in the primitive
* currently under the cursor. The callback fires only when the
* id transitions (deduped) and is invoked with `null` when the
* cursor moves into empty space. Driven by the same pointer-move
* stream as `onPointerMove`, so subscribing to both does not
* double-cost the pointer event.
*/
onHoverChange(cb: (id: PrimitiveID | null) => void): () => void;
/**
* setPickMode opens (or, with `null`, closes) a map-driven
* destination pick. While a session is active the renderer dims
* primitives outside `reachableIds`, mounts an overlay drawing
* the source-anchor ring, the cursor line, and the
* hover-highlight ring, suppresses regular `onClick` consumers,
* and listens for Escape on `document`. The session resolves via
* `opts.onPick(id)` on a click hitting a reachable planet, or
* `opts.onPick(null)` on Escape / handle.cancel().
*
* Returns the imperative cancel handle when a session was opened
* (i.e. `opts !== null`), otherwise `null`. Calling the function
* again with `null` closes any active session and is idempotent.
*/
setPickMode(opts: PickModeOptions | null): PickModeHandle | null;
/**
* isPickModeActive reports whether a `setPickMode` session is
* currently open. The standard `onClick` path is suppressed
* while this returns `true`.
*/
isPickModeActive(): boolean;
/**
* getPickState returns a defensive snapshot of the pick-mode
* session for debugging surfaces. `sourcePrimitiveId` and
* `reachableIds` are `null` while no session is open.
*/
getPickState(): {
active: boolean;
sourcePrimitiveId: PrimitiveID | null;
reachableIds: ReadonlySet<PrimitiveID> | null;
hoveredId: PrimitiveID | null;
};
/**
* getPrimitiveAlpha returns the current rendered alpha of the
* primitive `id` (in the central tile). Used by the debug
* surface to report dimmed-state for e2e assertions. Returns 1
* for unknown ids.
*/
getPrimitiveAlpha(id: PrimitiveID): number;
resize(widthPx: number, heightPx: number): void;
dispose(): void;
}
@@ -132,10 +217,31 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
return c;
});
for (const c of copies) {
for (const p of opts.world.primitives) {
c.addChild(buildGraphics(p, theme));
// Per-id `Graphics` lookup. Each primitive lives in nine copies
// (one per torus tile); pick-mode dims them by id, so the lookup
// indexes the full set of `Graphics` instances per primitive id.
const primitiveGraphics = new Map<PrimitiveID, Graphics[]>();
const pointPrimitivesById = new Map<PrimitiveID, PointPrim>();
const allPrimitiveIds: PrimitiveID[] = [];
const extraPrimitiveIds = new Set<PrimitiveID>();
let currentWorld: World = opts.world;
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
for (const c of copies) {
const g = buildGraphics(prim, theme);
c.addChild(g);
let list = primitiveGraphics.get(prim.id);
if (list === undefined) {
list = [];
primitiveGraphics.set(prim.id, list);
}
list.push(g);
}
allPrimitiveIds.push(prim.id);
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
if (isExtra) extraPrimitiveIds.add(prim.id);
};
for (const p of opts.world.primitives) {
populatePrimitives(p, false);
}
let mode: WrapMode = opts.mode;
@@ -217,6 +323,208 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
applyMode(mode);
// Pointer-move + hover plumbing. Listening on the underlying
// canvas keeps the renderer agnostic of pixi-viewport's plugin
// chain (drag/pinch can swallow Pixi-level pointer events while
// a gesture is in progress; the DOM event still fires).
const pointerMoveCallbacks = new Set<
(cursorPx: { x: number; y: number }) => void
>();
const hoverChangeCallbacks = new Set<(id: PrimitiveID | null) => void>();
let lastHoveredId: PrimitiveID | null = null;
let lastCursorPx: { x: number; y: number } | null = null;
const handlePointerMove = (event: PointerEvent): void => {
const rect = canvas.getBoundingClientRect();
const cursorPx = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
lastCursorPx = cursorPx;
for (const cb of pointerMoveCallbacks) cb(cursorPx);
const hit = hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
);
const hoveredId = hit?.primitive.id ?? null;
if (hoveredId === lastHoveredId) return;
lastHoveredId = hoveredId;
for (const cb of hoverChangeCallbacks) cb(hoveredId);
};
const handlePointerLeave = (): void => {
lastCursorPx = null;
if (hoverChangeCallbacks.size === 0 || lastHoveredId === null) return;
lastHoveredId = null;
for (const cb of hoverChangeCallbacks) cb(null);
};
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerleave", handlePointerLeave);
// Click dispatch. The renderer owns one `viewport.clicked`
// listener and fans the event out to either the pick-mode
// resolver (when a session is open) or the standard `onClick`
// subscribers — never both. Routing through one listener makes
// the gating race-proof: a pick-mode resolution + teardown runs
// in the same tick as the click, and the standard subscribers
// do not see the post-teardown state.
const clickSubscribers = new Set<
(cursorPx: { x: number; y: number }) => void
>();
// Pick-mode state. Owned by the renderer so all callers funnel
// through `setPickMode`; tests for the pure overlay math live in
// `pick-mode.ts`.
let pickModeActive = false;
let pickOptions: PickModeOptions | null = null;
let pickOverlay: Graphics | null = null;
const dimmedAlphaBackup = new Map<Graphics, number>();
const detachPickListeners: Array<() => void> = [];
const handleViewportClicked = (e: {
screen: { x: number; y: number };
}): void => {
const cursorPx = { x: e.screen.x, y: e.screen.y };
if (pickModeActive) {
const session = pickOptions;
if (session === null) return;
const hit = hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
);
const hitId = hit?.primitive.id ?? null;
if (hitId === null) return;
if (hitId === session.sourcePrimitiveId) return;
if (!session.reachableIds.has(hitId)) return;
const cb = session.onPick;
teardownPickMode();
cb(hitId);
return;
}
for (const cb of clickSubscribers) cb(cursorPx);
};
viewport.on("clicked", handleViewportClicked);
const redrawPickOverlay = (): void => {
if (pickOverlay === null || pickOptions === null) return;
const cursorWorld =
lastCursorPx === null
? null
: screenToWorld(
lastCursorPx,
handle.getCamera(),
handle.getViewport(),
);
const spec = computePickOverlay(
pickOptions,
cursorWorld,
lastHoveredId,
pointPrimitivesById,
allPrimitiveIds,
);
const g = pickOverlay;
g.clear();
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
g.stroke({
color: PICK_OVERLAY_STYLE.anchor.color,
alpha: PICK_OVERLAY_STYLE.anchor.alpha,
width: PICK_OVERLAY_STYLE.anchor.width,
});
if (spec.line !== null) {
g.moveTo(spec.line.x1, spec.line.y1);
g.lineTo(spec.line.x2, spec.line.y2);
g.stroke({
color: PICK_OVERLAY_STYLE.line.color,
alpha: PICK_OVERLAY_STYLE.line.alpha,
width: PICK_OVERLAY_STYLE.line.width,
});
}
if (spec.hoverOutline !== null) {
g.circle(
spec.hoverOutline.x,
spec.hoverOutline.y,
spec.hoverOutline.radius,
);
g.stroke({
color: PICK_OVERLAY_STYLE.hover.color,
alpha: PICK_OVERLAY_STYLE.hover.alpha,
width: PICK_OVERLAY_STYLE.hover.width,
});
}
};
const teardownPickMode = (): void => {
if (!pickModeActive) return;
pickModeActive = false;
for (const detach of detachPickListeners) detach();
detachPickListeners.length = 0;
for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha;
dimmedAlphaBackup.clear();
if (pickOverlay !== null) {
pickOverlay.destroy();
pickOverlay = null;
}
pickOptions = null;
};
const openPickMode = (options: PickModeOptions): PickModeHandle => {
// An existing session is cancelled first so the previous
// `onPick(null)` is delivered before the new one starts.
if (pickModeActive) {
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
}
pickOptions = options;
pickModeActive = true;
// Dim every primitive that's not the source and not reachable.
for (const [id, list] of primitiveGraphics) {
if (id === options.sourcePrimitiveId) continue;
if (options.reachableIds.has(id)) continue;
for (const g of list) {
dimmedAlphaBackup.set(g, g.alpha);
g.alpha = PICK_OVERLAY_STYLE.dimAlpha;
}
}
// Overlay graphic. Lives in the origin copy so the central
// tile owns it; the camera always wraps back into this tile
// (`wrapTorusCamera`), so the user sees the overlay
// regardless of how far they have panned.
pickOverlay = new Graphics();
copies[ORIGIN_COPY_INDEX]!.addChild(pickOverlay);
redrawPickOverlay();
// Pointer-move drives the cursor line; hover changes drive
// the outline. Both go through the renderer's existing
// callback registries.
detachPickListeners.push(handle.onPointerMove(redrawPickOverlay));
detachPickListeners.push(handle.onHoverChange(redrawPickOverlay));
// Click resolution is handled by the shared
// `handleViewportClicked` dispatcher above; pick mode does
// not subscribe its own `clicked` listener — see the
// rationale in the dispatcher's comment.
const keyHandler = (event: KeyboardEvent): void => {
if (event.key !== "Escape") return;
if (pickOptions === null) return;
event.preventDefault();
const cb = pickOptions.onPick;
teardownPickMode();
cb(null);
};
document.addEventListener("keydown", keyHandler);
detachPickListeners.push(() =>
document.removeEventListener("keydown", keyHandler),
);
return {
cancel: (): void => {
if (pickOptions === null) return;
const cb = pickOptions.onPick;
teardownPickMode();
cb(null);
},
};
};
const handle: RendererHandle = {
app,
viewport,
@@ -233,16 +541,89 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}),
getBackend: () => rendererBackendName(app.renderer),
hitAt: (cursorPx) =>
hitTest(opts.world, handle.getCamera(), handle.getViewport(), cursorPx, mode),
hitTest(
currentWorld,
handle.getCamera(),
handle.getViewport(),
cursorPx,
mode,
),
setExtraPrimitives: (prims) => {
// Drop the previous extras layer.
for (const id of extraPrimitiveIds) {
const list = primitiveGraphics.get(id);
if (list !== undefined) {
for (const g of list) {
g.parent?.removeChild(g);
g.destroy();
}
primitiveGraphics.delete(id);
}
pointPrimitivesById.delete(id);
const idx = allPrimitiveIds.indexOf(id);
if (idx >= 0) allPrimitiveIds.splice(idx, 1);
}
extraPrimitiveIds.clear();
// Add the new extras.
for (const p of prims) {
populatePrimitives(p, true);
}
// Rebuild the snapshot World hit-test reads from. The
// renderer keeps `currentWorld` mutable so the live
// extras participate in click/hover tests on the same
// frame they're drawn.
currentWorld = new World(opts.world.width, opts.world.height, [
...opts.world.primitives,
...prims,
]);
},
getPrimitives: () => currentWorld.primitives,
onClick: (cb) => {
const handler = (e: { screen: { x: number; y: number } }): void => {
cb({ x: e.screen.x, y: e.screen.y });
};
viewport.on("clicked", handler);
clickSubscribers.add(cb);
return () => {
viewport.off("clicked", handler);
clickSubscribers.delete(cb);
};
},
onPointerMove: (cb) => {
pointerMoveCallbacks.add(cb);
return () => {
pointerMoveCallbacks.delete(cb);
};
},
onHoverChange: (cb) => {
hoverChangeCallbacks.add(cb);
// Fire the current state once so subscribers do not have to
// wait for the next pointer movement to learn what's under
// the cursor.
cb(lastHoveredId);
return () => {
hoverChangeCallbacks.delete(cb);
};
},
setPickMode: (options) => {
if (options === null) {
if (!pickModeActive) return null;
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
return null;
}
return openPickMode(options);
},
isPickModeActive: () => pickModeActive,
getPickState: () => ({
active: pickModeActive,
sourcePrimitiveId: pickOptions?.sourcePrimitiveId ?? null,
reachableIds: pickOptions?.reachableIds ?? null,
hoveredId: lastHoveredId,
}),
getPrimitiveAlpha: (id) => {
const list = primitiveGraphics.get(id);
if (list === undefined || list.length === 0) return 1;
// All copies share the same alpha (dim is applied to every
// torus tile), so the central-tile entry is representative.
return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha;
},
resize: (w, h) => {
app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height);
@@ -255,8 +636,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}
},
dispose: () => {
// Tear down any open pick session before destroying the
// app — the resolution callback might reference Svelte
// stores that disappear next tick on dispose, but
// `onPick(null)` here is a synchronous notification the
// caller is responsible for handling.
if (pickModeActive) {
const previous = pickOptions;
teardownPickMode();
previous?.onPick(null);
}
viewport.off("moved", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera);
viewport.off("clicked", handleViewportClicked);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerleave", handlePointerLeave);
pointerMoveCallbacks.clear();
hoverChangeCallbacks.clear();
clickSubscribers.clear();
app.destroy({ removeView: false }, { children: true });
},
};
@@ -283,7 +680,7 @@ function buildGraphics(p: Primitive, theme: Theme): Graphics {
function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
const color = p.style.fillColor ?? theme.pointFill;
const alpha = p.style.fillAlpha ?? 1;
const radiusPx = p.style.pointRadiusPx ?? 3;
const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
g.circle(p.x, p.y, radiusPx);
g.fill({ color, alpha });
}
+12 -3
View File
@@ -63,14 +63,23 @@ export type Primitive = PointPrim | CirclePrim | LinePrim;
export type PrimitiveKind = Primitive["kind"];
// Default hit slop in screen pixels per primitive kind. Chosen for
// touch ergonomics; per-primitive `hitSlopPx` overrides the default.
// Default hit slop in screen pixels per primitive kind. Added on top
// of the visible footprint of each primitive — for points, the
// effective hit radius is `pointRadiusPx + slopPx`. Chosen for touch
// ergonomics; per-primitive `hitSlopPx` overrides the default.
export const DEFAULT_HIT_SLOP_PX: Record<PrimitiveKind, number> = {
point: 8,
point: 4,
circle: 6,
line: 6,
};
// Default world-unit radius drawn for a `PointPrim` when its
// `style.pointRadiusPx` is unset. Shared between the renderer
// (`render.ts.drawPoint`) and the hit-test
// (`hit-test.ts.matchPoint`) so the click target always covers the
// visible disc.
export const DEFAULT_POINT_RADIUS_PX = 3;
// kindOrder is the deterministic tie-break order used during hit-test
// when two primitives match a cursor at identical priority and
// distance. Smaller value wins.
@@ -7,6 +7,14 @@
} from "../../../api/session";
import { loadStore } from "../../../platform/store/index";
import type { OrderCommand } from "../../../sync/order-types";
import {
getMapCamera,
getMapPickState,
getMapPrimitives,
type MapCameraSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "../../../lib/debug-surface.svelte";
interface DebugSnapshot {
publicKey: number[];
@@ -28,6 +36,9 @@
commands: OrderCommand[],
): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>;
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null;
}
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
@@ -116,6 +127,15 @@
throw new Error(`clearOrderDraft: ${describe(err)}`);
}
},
getMapPrimitives() {
return getMapPrimitives();
},
getMapPickState() {
return getMapPickState();
},
getMapCamera() {
return getMapCamera();
},
};
(window as DebugWindow).__galaxyDebug = surface;
ready = true;
@@ -65,6 +65,10 @@ fresh.
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,
@@ -101,6 +105,13 @@ fresh.
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
@@ -120,6 +131,13 @@ fresh.
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
@@ -228,6 +246,11 @@ fresh.
<PlanetSheet
planet={selectedPlanet}
{localShipClass}
routes={inspectorRoutes}
planets={inspectorPlanets}
mapWidth={inspectorMapWidth}
mapHeight={inspectorMapHeight}
localPlayerDrive={inspectorLocalDrive}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
+49 -6
View File
@@ -174,12 +174,20 @@ export class OrderDraftStore {
* Mutations made before `init` resolves are ignored — the layout
* always awaits `init` before exposing the store.
*
* `setProductionType` carries a collapse-by-`planetNumber` rule:
* a new entry supersedes any prior `setProductionType` for the
* same planet, so the draft holds at most one production choice
* per planet at any time. Other variants append unconditionally —
* `planetRename` keeps its append-only behaviour because each
* rename is a distinct user-visible action.
* Collapse rules:
*
* - `setProductionType` collapses by `planetNumber`: a new
* entry supersedes any prior `setProductionType` for the
* same planet, so the draft holds at most one production
* choice per planet.
* - `setCargoRoute` and `removeCargoRoute` share a collapse
* key on `(sourcePlanetNumber, loadType)` — the engine
* stores a single (planet, type) → destination mapping, so
* a newer entry for the same slot supersedes any prior
* `set` or `remove` for that slot. Different load-types or
* different sources coexist.
* - `planetRename` and `placeholder` append unconditionally;
* each rename is a distinct user-visible action.
*/
async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return;
@@ -198,6 +206,24 @@ export class OrderDraftStore {
nextCommands.push(existing);
}
nextCommands.push(command);
} else if (
command.kind === "setCargoRoute" ||
command.kind === "removeCargoRoute"
) {
nextCommands = [];
for (const existing of this.commands) {
if (
(existing.kind === "setCargoRoute" ||
existing.kind === "removeCargoRoute") &&
existing.sourcePlanetNumber === command.sourcePlanetNumber &&
existing.loadType === command.loadType
) {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else {
nextCommands = [...this.commands, command];
}
@@ -444,6 +470,23 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
return validateEntityName(cmd.subject).ok ? "valid" : "invalid";
}
return "valid";
case "setCargoRoute":
// The picker pre-checks reach (and so refuses to emit a
// route to an unreachable destination) and the engine
// re-validates ownership / reach server-side. Locally we
// only refuse a self-route — the FBS validator
// (`pkg/model/order/order.go`) accepts every other
// (origin, destination, load_type) triple.
if (cmd.sourcePlanetNumber === cmd.destinationPlanetNumber) {
return "invalid";
}
return "valid";
case "removeCargoRoute":
// `removeCargoRoute` carries no destination; the only
// engine-side check is ownership of the source planet,
// which the inspector enforces by only mounting the
// component on `kind === "local"`.
return "valid";
case "placeholder":
// Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable.
+68 -1
View File
@@ -14,11 +14,18 @@ import {
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
PlanetProduction,
PlanetRouteLoadType,
UserGamesOrderGet,
UserGamesOrderGetResponse,
} from "../proto/galaxy/fbs/order";
import type { OrderCommand, ProductionType } from "./order-types";
import type {
CargoLoadType,
OrderCommand,
ProductionType,
} from "./order-types";
const MESSAGE_TYPE = "user.games.order.get";
@@ -155,6 +162,41 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
subject: inner.subject() ?? "",
};
}
case CommandPayload.CommandPlanetRouteSet: {
const inner = new CommandPlanetRouteSet();
item.payload(inner);
const loadType = cargoLoadTypeFromFBS(inner.loadType());
if (loadType === null) {
console.warn(
`fetchOrder: skipping CommandPlanetRouteSet with unknown load_type enum (${inner.loadType()})`,
);
return null;
}
return {
kind: "setCargoRoute",
id,
sourcePlanetNumber: Number(inner.origin()),
destinationPlanetNumber: Number(inner.destination()),
loadType,
};
}
case CommandPayload.CommandPlanetRouteRemove: {
const inner = new CommandPlanetRouteRemove();
item.payload(inner);
const loadType = cargoLoadTypeFromFBS(inner.loadType());
if (loadType === null) {
console.warn(
`fetchOrder: skipping CommandPlanetRouteRemove with unknown load_type enum (${inner.loadType()})`,
);
return null;
}
return {
kind: "removeCargoRoute",
id,
sourcePlanetNumber: Number(inner.origin()),
loadType,
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
@@ -196,6 +238,31 @@ export function productionTypeFromFBS(
}
}
/**
* cargoLoadTypeFromFBS reverses `cargoLoadTypeToFBS` from
* `submit.ts`. `PlanetRouteLoadType.UNKNOWN` and any out-of-band
* value yield `null` so the caller drops the entry rather than
* fabricating a synthetic load type.
*/
export function cargoLoadTypeFromFBS(
value: PlanetRouteLoadType,
): CargoLoadType | null {
switch (value) {
case PlanetRouteLoadType.COL:
return "COL";
case PlanetRouteLoadType.CAP:
return "CAP";
case PlanetRouteLoadType.MAT:
return "MAT";
case PlanetRouteLoadType.EMP:
return "EMP";
case PlanetRouteLoadType.UNKNOWN:
return null;
default:
return null;
}
}
function decodeError(
payload: Uint8Array,
resultCode: string,
+71 -1
View File
@@ -84,6 +84,49 @@ export interface SetProductionTypeCommand {
readonly subject: string;
}
/**
* CargoLoadType mirrors the engine `PlanetRouteLoadType` enum
* (`pkg/schema/fbs/order.fbs`). The values are wire-stable: the
* submit encoder maps them to the FBS enum and the read-back
* decoder maps them back. The four members enumerate the four
* mutually-exclusive cargo-route slots a planet can drive at any
* one time.
*
* `COL` — colonists (highest priority on load),
* `CAP` — capital / industry crates,
* `MAT` — raw materials,
* `EMP` — empty ships returning to a producer.
*/
export type CargoLoadType = "COL" | "CAP" | "MAT" | "EMP";
/**
* SetCargoRouteCommand binds a (source, loadType) slot to a
* destination planet. Phase 16 carries a collapse-by-(source,
* loadType) rule: at most one entry per slot lives in the draft at
* any time. A `removeCargoRoute` for the same slot supersedes a
* pending set (the engine accepts either order, but keeping the
* draft minimal avoids confusing the order tab).
*/
export interface SetCargoRouteCommand {
readonly kind: "setCargoRoute";
readonly id: string;
readonly sourcePlanetNumber: number;
readonly destinationPlanetNumber: number;
readonly loadType: CargoLoadType;
}
/**
* RemoveCargoRouteCommand drops the (source, loadType) slot. Same
* collapse rule as `SetCargoRouteCommand` — a later `set` for the
* same slot supersedes the remove, and vice versa.
*/
export interface RemoveCargoRouteCommand {
readonly kind: "removeCargoRoute";
readonly id: string;
readonly sourcePlanetNumber: number;
readonly loadType: CargoLoadType;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
@@ -93,7 +136,9 @@ export interface SetProductionTypeCommand {
export type OrderCommand =
| PlaceholderCommand
| PlanetRenameCommand
| SetProductionTypeCommand;
| SetProductionTypeCommand
| SetCargoRouteCommand
| RemoveCargoRouteCommand;
/**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
@@ -120,6 +165,31 @@ export function isProductionType(value: string): value is ProductionType {
return (PRODUCTION_TYPE_VALUES as readonly string[]).includes(value);
}
/**
* CARGO_LOAD_TYPE_VALUES is the canonical tuple of `CargoLoadType`
* literals in turn-cutoff priority order
* (`game/internal/controller/route.go.SendRoutedGroups`):
* colonists first, then capital, then materials, then empty ships.
* The inspector renders slots in this order so visual order
* matches engine behaviour. Used by validators and by the FBS
* converters in `submit.ts` and `order-load.ts`.
*/
export const CARGO_LOAD_TYPE_VALUES = [
"COL",
"CAP",
"MAT",
"EMP",
] as const satisfies readonly CargoLoadType[];
/**
* isCargoLoadType narrows an arbitrary string to the
* `CargoLoadType` union. The decoder uses this when the engine
* report's `RouteEntry.value` carries the load-type string.
*/
export function isCargoLoadType(value: string): value is CargoLoadType {
return (CARGO_LOAD_TYPE_VALUES as readonly string[]).includes(value);
}
/**
* CommandStatus is the lifecycle of a single command from the moment
* it lands in the draft to the moment the server resolves it. The
+49 -1
View File
@@ -29,11 +29,18 @@ import {
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
PlanetProduction,
PlanetRouteLoadType,
UserGamesOrder,
UserGamesOrderResponse,
} from "../proto/galaxy/fbs/order";
import type { OrderCommand, ProductionType } from "./order-types";
import type {
CargoLoadType,
OrderCommand,
ProductionType,
} from "./order-types";
const MESSAGE_TYPE = "user.games.order";
@@ -163,6 +170,29 @@ function encodeCommandPayload(
payloadOffset: offset,
};
}
case "setCargoRoute": {
const offset = CommandPlanetRouteSet.createCommandPlanetRouteSet(
builder,
BigInt(cmd.sourcePlanetNumber),
BigInt(cmd.destinationPlanetNumber),
cargoLoadTypeToFBS(cmd.loadType),
);
return {
payloadType: CommandPayload.CommandPlanetRouteSet,
payloadOffset: offset,
};
}
case "removeCargoRoute": {
const offset = CommandPlanetRouteRemove.createCommandPlanetRouteRemove(
builder,
BigInt(cmd.sourcePlanetNumber),
cargoLoadTypeToFBS(cmd.loadType),
);
return {
payloadType: CommandPayload.CommandPlanetRouteRemove,
payloadOffset: offset,
};
}
case "placeholder":
throw new SubmitError(
"invalid_request",
@@ -200,6 +230,24 @@ export function productionTypeToFBS(value: ProductionType): PlanetProduction {
}
}
/**
* cargoLoadTypeToFBS converts the wire-stable `CargoLoadType` literal
* to the FlatBuffers enum value. Mirrors the engine
* `PlanetRouteLoadType` enum (`pkg/schema/fbs/order.fbs`).
*/
export function cargoLoadTypeToFBS(value: CargoLoadType): PlanetRouteLoadType {
switch (value) {
case "COL":
return PlanetRouteLoadType.COL;
case "CAP":
return PlanetRouteLoadType.CAP;
case "MAT":
return PlanetRouteLoadType.MAT;
case "EMP":
return PlanetRouteLoadType.EMP;
}
}
function decodeOrderResponse(
payload: Uint8Array,
commands: OrderCommand[],