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