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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user