ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
43 changed files with 4559 additions and 98 deletions
Showing only changes of commit 7c8b5aeb23 - Show all commits
+111 -22
View File
@@ -1814,22 +1814,77 @@ Verified on local-ci run 16 (`success`, 4273102).
Status: pending. Status: pending.
Goal: configure up to four cargo routes per planet (colonists, Goal: configure up to four cargo routes per planet (colonists,
industry, materials, empty) through the inspector. industry, materials, empty) through the inspector, with the
destination picked directly on the map. Phase 16 also lands the
generic map-pick foundation (Pass A) the inspector consumes; Phase
19/20 (ship-group dispatch) reuses the same renderer surface.
Artifacts: Artifacts (Pass A — renderer foundation):
- `ui/frontend/src/map/pick-mode.ts` carries the `PickModeOptions` /
`PickModeHandle` types and the pure `computePickOverlay` helper.
- `ui/frontend/src/map/render.ts` extends `RendererHandle` with
`setPickMode` / `isPickModeActive` / `getPickState`,
`onPointerMove` / `onHoverChange`, and the
`getPrimitiveAlpha(id)` debug accessor. The standard `onClick`
consumers are gated on the `pickModeActive` flag so the
destination click does not also trigger planet selection.
- `ui/frontend/src/map/hit-test.ts` widens point matching to
`(pointRadiusPx + slopPx) / camera.scale` so hover and click
zones match the visible disc; default radius shared via
`DEFAULT_POINT_RADIUS_PX = 3`.
- `ui/frontend/src/lib/map-pick.svelte.ts` defines the Svelte
`MapPickService` (promise-shaped `pick(...)` plus reactive
`active`); `lib/active-view/map.svelte` constructs the service
and binds a renderer-side resolver that resolves
`sourcePlanetNumber` against the current report.
- `ui/frontend/src/lib/debug-surface.svelte.ts` registers
`getMapPrimitives()` and `getMapPickState()` providers; the
DEV-only `__galaxyDebug` surface in
`routes/__debug/store/+page.svelte` exposes them so e2e specs
can assert the renderer's state without scraping pixels.
Artifacts (Pass B — feature):
- `ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte`
four-slot UI listing existing routes and offering add / edit /
remove
- `ui/frontend/src/sync/order-types.ts` extends with - `ui/frontend/src/sync/order-types.ts` extends with
`SetCargoRoute` and `RemoveCargoRoute` command variants `CargoLoadType`, `SetCargoRouteCommand`, and
- destination-planet picker filtered by reach (uses `pkg/calc/` reach `RemoveCargoRouteCommand`. `CARGO_LOAD_TYPE_VALUES` is the
function via `ui/core/calc/`) priority order (`COL`, `CAP`, `MAT`, `EMP`).
- `ui/frontend/src/map/cargo-routes.ts` renders route arrows on the - `ui/frontend/src/sync/order-draft.svelte.ts` collapses both
map between source and destination planet, styled per cargo type variants by `(sourcePlanetNumber, loadType)`; the newer entry
- topic doc `ui/docs/cargo-routes-ux.md` capturing the priority supersedes any prior `set` or `remove` for the same slot.
semantics from [`rules.txt`](../game/rules.txt) (`colonists → industry → materials → - `ui/frontend/src/sync/submit.ts` and
empty`) `ui/frontend/src/sync/order-load.ts` round-trip the two new
variants through `CommandPlanetRouteSet` and
`CommandPlanetRouteRemove`. UNKNOWN load-type values drop with
a `console.warn`.
- `ui/frontend/src/api/game-state.ts` extends `GameReport` with
`routes: ReportRoute[]` (decoded from `report.route()` in
`CARGO_LOAD_TYPE_VALUES` order) and `localPlayerDrive: number`
(looked up via `findLocalPlayerDrive`). `applyOrderOverlay`
upserts / drops route entries for valid / submitting / applied
cargo-route commands.
- `ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte` is
the four-slot subsection. `Add` / `Edit` call
`MapPickService.pick(...)`; `Remove` emits
`removeCargoRoute`.
- `ui/frontend/src/map/cargo-routes.ts` builds the `LinePrim`
arrows (shaft + two arrowhead wings) per
`(source, loadType, destination)` triple. Per-type style and
priority (`COL=8` … `EMP=5`); ids prefixed with `0x80000000`
to avoid colliding with planet numbers.
- `ui/frontend/src/map/state-binding.ts` appends
`buildCargoRouteLines(report)` to the world primitives.
- `ui/frontend/src/lib/active-view/map.svelte` adds a
routes-content fingerprint to the same-snapshot guard and
preserves camera centre + zoom across route-driven remounts
inside the same game id.
- Topic doc `ui/docs/cargo-routes-ux.md` quotes
[`rules.txt`](../game/rules.txt) (lines 808843) and maps
semantics to UI; `ui/docs/renderer.md` documents the pick-mode
contract; `ui/docs/calc-bridge.md` records the Phase 16 reach
waiver (inline TS rather than a calc bridge for one
constant-time multiplication).
Dependencies: Phase 15. Dependencies: Phase 15.
@@ -1837,20 +1892,54 @@ Acceptance criteria:
- the user can add, edit, and remove cargo routes through the - the user can add, edit, and remove cargo routes through the
inspector; inspector;
- destination picker disables planets outside reach with a tooltip - the destination picker happens on the map: out-of-reach planets
explaining the constraint; fade to `α=0.3`, the source gains an anchor ring, the cursor
draws a live line to the source, and hover over a reachable
planet outlines it. Clicks on non-reachable space are no-ops; a
click on a reachable planet emits `setCargoRoute`; Escape
cancels;
- the four route types are mutually exclusive — only one route per - the four route types are mutually exclusive — only one route per
type per source planet; type per source planet;
- configured routes are rendered as arrows on the map between source - configured routes are rendered as arrows on the map between
and destination planets, distinguishable per cargo type. source and destination planets, distinguishable per cargo type
(placeholder colour palette; final values land in Phase 35
polish);
- the optimistic overlay surfaces draft routes immediately on the
map; the camera survives the routes-fingerprint remount so the
view does not jolt mid-edit.
Targeted tests: Targeted tests:
- Vitest unit tests for slot-conflict detection; - Vitest: `tests/map-hit-test.test.ts` (regenerated for the
- Vitest unit tests for cargo-route arrow rendering on torus and visible-radius formula), `tests/map-pick-mode.test.ts`
no-wrap fixtures; (`computePickOverlay` lifecycle),
- Playwright e2e: add a route end-to-end, confirm server applies it `tests/map-cargo-routes.test.ts`,
on next turn and the arrow is visible on the map. `tests/inspector-planet-cargo-routes.test.ts`,
`tests/state-binding.test.ts` extension,
`tests/order-draft.test.ts` extension,
`tests/submit.test.ts` and `tests/order-load.test.ts`
extensions, `tests/order-overlay.test.ts` extension.
- Playwright e2e `tests/e2e/cargo-routes.spec.ts`: open
inspector, trigger `Add`, assert dim state via
`__galaxyDebug.getMapPickState()`, click a reachable planet,
assert `setCargoRoute` shipped + arrow visible via
`__galaxyDebug.getMapPrimitives()`. Add a CAP route to confirm
slots coexist; Remove COL → arrow gone; reload → restored from
`user.games.order.get`.
Decisions baked into Phase 16 (vs. the original stage description):
- The destination picker is map-driven, not list-based. The
acceptance criterion "disables planets outside reach with a
tooltip" is replaced by "fades planets outside reach to
`α=0.3` and forbids picking them"; the rendered map is the
player's spatial reference, so a list duplicates information
the planet already conveys.
- Reach is computed inline in TypeScript, not via a `pkg/calc/`
Go bridge (`ui/docs/calc-bridge.md` Phase 16 waiver).
- Wrap-mode is treated as a per-game property set at map load;
the camera-preservation refactor only fires when the
routes-fingerprint changes inside the same game id.
## Phase 17. Ship Classes — CRUD Without Calc ## Phase 17. Ship Classes — CRUD Without Calc
+28
View File
@@ -63,6 +63,34 @@ outputs") is therefore intentionally not satisfied; the rewritten
Phase 15 stage text records this decision and points back at this Phase 15 stage text records this decision and points back at this
document. document.
## Phase 16 waiver
Phase 16 introduces ship-reach filtering for the cargo-route
destination picker. The engine formula is trivial:
```
flightDistance = driveTech * 40
```
(`game/internal/model/game/race.go.FlightDistance`). The original
Phase 16 stage text described surfacing this through `pkg/calc/`
and `ui/core/calc/`; with the calc-bridge phase still deferred,
implementing the bridge for one constant-time multiplication would
be premature scaffolding. The picker therefore computes reach
inline in TypeScript using
`torusShortestDelta(planet.x, candidate.x, mapWidth)` and
`Math.hypot` against `40 * report.localPlayerDrive`, where
`localPlayerDrive` is decoded from the report's `Player` block by
matching `Player.name` to `report.race`
(`api/game-state.ts.findLocalPlayerDrive`).
When the calc-bridge phase ships, the inline formula is replaced
with a single call into the bridge: `calc.Reach(driveTech)` becomes
the source of truth for both the picker and the cargo-route arrow
auto-removal at turn cutoff. Until then, the UI duplicates
`flightDistance` knowingly — same precedent as the production
forecast deferral above.
## Planned bridge shape (follow-up phase) ## Planned bridge shape (follow-up phase)
When the bridge phase lands, the contract should be: When the bridge phase lands, the contract should be:
+161
View File
@@ -0,0 +1,161 @@
# Cargo routes UX
This document covers the cargo-route surface added in Phase 16: the
four-slot inspector subsection, the map-driven destination pick, and
the optimistic overlay that keeps the inspector and the map in lock-
step with the local order draft. The user-visible spec lives in
[`../PLAN.md`](../PLAN.md) Phase 16; the engine semantics are quoted
from [`game/rules.txt`](../../game/rules.txt) section "Грузовые
маршруты" (lines 808843); this file is the source of truth for how
the UI surfaces those rules.
## Engine semantics in one paragraph
A cargo route on a planet you own pairs a load-type slot
(`COL`/`CAP`/`MAT`/`EMP`) with a destination planet. Once set, the
engine loads transport ships at the source on every turn cutoff and
sends them to the destination, draining the load-type stockpile
("colonists" → population pool, "capital" → industry crates, "mat" →
raw materials, "empty" → ships returning unloaded). When several
slots are configured the engine processes them in
`COL > CAP > MAT > EMP` priority order
(`game/internal/controller/route.go.SendRoutedGroups`, line 101).
Routes are constrained by reach: the destination must be no further
than `40 × driveTech` world units along the torus-shortest path
(`util.ShortDistance``Race.FlightDistance()`), and a route whose
destination becomes unreachable at the next turn is auto-removed
(`RemoveUnreachableRoutes`).
## Four-slot inspector subsection
The cargo-routes subsection renders below the production controls
on every owned planet inspector. Slots appear in
`CARGO_LOAD_TYPE_VALUES` order (COL, CAP, MAT, EMP) so visual order
matches the engine's load priority — players who scan top-down see
the highest-priority cargo first.
Slot states:
- **Empty** — `(no route)` text plus a single `Add` button.
- **Filled** — `→ {destination name}` plus `Edit` and `Remove`.
`Add` and `Edit` open a renderer-driven destination pick (see next
section). `Remove` emits a `removeCargoRoute` command. The collapse
rule on the order draft store ensures only one entry per
`(source, loadType)` slot survives in the draft at any time, so a
sequence of `Add → Edit → Remove` collapses to the latest verb only
(matching the production-controls pattern from Phase 15).
Disabled state: every button is disabled when the
`OrderDraftStore` or `MapPickService` context is missing (the
component is mounted outside the in-game shell, in tests, etc.).
## Map-driven destination pick
The picker is renderer-side: the inspector calls
`MapPickService.pick({sourcePlanetNumber, reachableIds})` and awaits a
planet number (or `null` on cancel). Reach is computed inline against
`localPlayerDrive` — see the calc-bridge waiver in
[`calc-bridge.md`](./calc-bridge.md).
While a session is active:
- All planets whose ids are not in `reachableIds` and are not the
source render at `alpha = 0.3`. The visual fade signals "not a
valid destination" without removing the planet from the map.
- The source planet keeps full alpha and gains an outline ring so
the player sees where the route originates.
- A line is drawn from the source to the cursor. On touch devices
the line follows the finger during drag.
- Hover over a reachable planet adds an outline highlight at
`pointRadiusPx + 4` world units so the player can confirm which
planet they are about to pick.
Resolution paths:
- Click on a reachable planet → `setCargoRoute` enters the draft;
the inspector slot fills; the arrow appears immediately on the
map (overlay route applied on the next render).
- Click on empty space or a non-reachable planet → no-op (a
forgiving rule for accidental taps mid-pan).
- Press Escape, click the inspector's `Cancel pick` button, or
unmount the active map view (e.g. switch tools, navigate away)
→ session cancels; the slot stays as it was.
The inspector renders an inline status line `pick a destination on
the map (Esc to cancel)` while the session is active so the player
knows the click target and the cancel hotkey. The line vanishes as
soon as the picker resolves.
## Optimistic map overlay
`applyOrderOverlay` projects every locally-valid, in-flight, or
applied `setCargoRoute` / `removeCargoRoute` onto `report.routes`,
re-runs `reportToWorld` (which appends `LinePrim` arrows from
`buildCargoRouteLines`), and remounts the renderer when the routes-
content fingerprint changes. The map active view captures camera
centre + zoom before each remount and restores them when the game
id is unchanged, so adding a route mid-pan does not jolt the view.
Arrows are drawn as a shaft plus two short arrowhead wings. Per-type
styling (placeholder Phase 16 colours; final values land in Phase
35 polish):
| Load type | Stroke colour | Notes |
| --------- | ------------- | ------------------------ |
| COL | `#4FC3F7` | brightest blue, highest priority |
| CAP | `#FFB74D` | warm orange |
| MAT | `#81C784` | green |
| EMP | `#90A4AE` | dim grey, thinner stroke |
Arrow priority (COL=8, CAP=7, MAT=6, EMP=5) sits above all planet
priorities (1..4), so two arrows that overlap exactly resolve to the
higher-priority load type during hit-test. Planet primitives still
win over arrows because the line ids carry a high-bit prefix
(`0x80000000`) and the renderer keeps points at the kind tie-break
position 0.
## Reach computation
Implementation: inline TypeScript using `torusShortestDelta` for
each axis and `Math.hypot` for the distance, compared against `40 ×
localPlayerDrive`. The local player's drive comes from the report's
`Player` block, looked up by `name === report.race`
(`api/game-state.ts.findLocalPlayerDrive`).
Why inline rather than via a Go calc bridge? See the Phase 15 / 16
deferral note in [`calc-bridge.md`](./calc-bridge.md). The formula
is trivial (`tech × 40`) and the WASM glue would be premature
infrastructure; when the calc bridge phase lands the shared
`pkg/calc.Reach` will replace this implementation.
## Tests
| Layer | File | What it covers |
| ----------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Order draft collapse | `tests/order-draft.test.ts` | `(source, loadType)` collapse rules across `set` and `remove`. |
| Encode / decode round-trip | `tests/submit.test.ts`, `tests/order-load.test.ts` | `setCargoRoute` and `removeCargoRoute` ↔ FBS payloads; UNKNOWN load-type drops with a warn. |
| Overlay | `tests/order-overlay.test.ts` | `applyOrderOverlay` upserts / drops route entries for valid / submitting / applied statuses. |
| Inspector subsection | `tests/inspector-planet-cargo-routes.test.ts` | Slot rendering, pick invocation, emit, cancel, edit, remove, per-type independence. |
| Map arrows | `tests/map-cargo-routes.test.ts` | `buildCargoRouteLines` shape on torus + no-wrap fixtures, per-type style, priority ordering. |
| End-to-end | `tests/e2e/cargo-routes.spec.ts` | Mocked gateway: open inspector, dim outside reach, pick destination, arrow appears, reload. |
## File index
- `ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte`
inspector subsection.
- `ui/frontend/src/sync/order-types.ts``CargoLoadType`,
`SetCargoRouteCommand`, `RemoveCargoRouteCommand`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — collapse rule.
- `ui/frontend/src/sync/submit.ts` — encoder.
- `ui/frontend/src/sync/order-load.ts` — decoder.
- `ui/frontend/src/api/game-state.ts``routes` and
`localPlayerDrive` decoding plus overlay extension.
- `ui/frontend/src/map/cargo-routes.ts` — arrow geometry.
- `ui/frontend/src/map/state-binding.ts` — appends route lines.
- `ui/frontend/src/lib/active-view/map.svelte` — fingerprint guard
+ camera preservation.
- `ui/frontend/src/map/pick-mode.ts` and
`ui/frontend/src/map/render.ts` — pick-mode foundation.
- `ui/frontend/src/lib/map-pick.svelte.ts` — Svelte adapter.
- `ui/docs/renderer.md` — pick-mode and debug-surface contract.
+79 -6
View File
@@ -116,7 +116,11 @@ target.
Per-primitive distance: Per-primitive distance:
- **Point**: `distSq ≤ slopWorld²`. - **Point**: `distSq ≤ (pointRadiusPx + slopWorld)²`. The visible
disc is part of the click target — a click on any pixel of the
rendered planet registers as a hit, with `slopWorld` adding a
small ergonomic margin on top. `pointRadiusPx` defaults to
`DEFAULT_POINT_RADIUS_PX = 3` when unset.
- **Filled circle**: `distSq ≤ (radius + slopWorld)²` where - **Filled circle**: `distSq ≤ (radius + slopWorld)²` where
`radius` is in world units. The circle counts as filled when `radius` is in world units. The circle counts as filled when
`style.fillColor` is set and `style.fillAlpha > 0`. `style.fillColor` is set and `style.fillAlpha > 0`.
@@ -220,6 +224,72 @@ If a future regression requires a programmatic perf gate, the
right place is a Tier 2 (release-line) Playwright trace measuring right place is a Tier 2 (release-line) Playwright trace measuring
average frame time over a scripted drag. average frame time over a scripted drag.
## Pick mode
Phase 16 introduced a generic *map-driven destination pick* the
inspector uses for cargo routes and that ship-group dispatch
(Phase 19/20) will reuse. The renderer owns the visual lifecycle;
the Svelte side wraps it in a promise-shaped service.
Lifecycle (`RendererHandle.setPickMode(opts)`):
1. **Open** (`opts !== null`): renderer marks `pickModeActive`,
sets `alpha = 0.3` on every primitive whose id is neither the
source nor in `reachableIds`, mounts an overlay `Graphics` in
the origin tile, and subscribes to pointer-move + hover-change
+ viewport `clicked` + document `keydown`.
2. **Tick** (every pointer-move and hover transition): the
renderer asks `computePickOverlay(opts, cursorWorld,
hoveredId, points, allIds)` (`src/map/pick-mode.ts`) for a
draw spec — anchor ring + cursor line + optional hover
outline + dim set — and re-paints the overlay.
3. **Resolve**: a click on a primitive whose id is in
`reachableIds` calls `opts.onPick(id)` and tears down. A click
on empty space or a non-reachable primitive is a no-op
(forgiving for accidental taps mid-pan). Escape (or the
imperative `cancel()` on the returned handle) calls
`opts.onPick(null)`.
4. **Tear down**: alpha overrides are restored, the overlay
`Graphics` is destroyed, every listener is detached, and
`pickModeActive` returns to `false`. Existing `onClick`
subscriptions are gated on `pickModeActive`, so the standard
planet-selection path does not fire on the destination click.
The pure overlay-spec helper lives in `src/map/pick-mode.ts` and
is covered by `tests/map-pick-mode.test.ts` without booting Pixi.
The Pixi side (alpha mutation, `Graphics` overlay, listener
hookup) is exercised in the in-browser e2e specs.
The Svelte adapter `MapPickService` (`src/lib/map-pick.svelte.ts`)
turns the callback contract into `pick(request) → Promise<id |
null>`. The map active view (`lib/active-view/map.svelte`)
constructs the service, sets `MAP_PICK_CONTEXT_KEY`, and binds a
resolver that translates `sourcePlanetNumber` to the underlying
`PickModeOptions` (looking up the source coordinates from the
current report). Inspector subsections call `service.pick(...)`
and react to the resolved id.
## Debug surface
The DEV-only `__galaxyDebug` object (defined in
`routes/__debug/store/+page.svelte`) exposes
`getMapPrimitives()` and `getMapPickState()` so e2e specs can
assert the renderer's current state without scraping pixels:
- `getMapPrimitives()` returns a snapshot of every primitive in
the active world: id, kind, priority, current alpha
(post-overlay), and the explicit fill / stroke colour from its
`Style` (no theme fallback). Tests use this to count cargo
arrows or to verify dim state during pick mode.
- `getMapPickState()` returns `{ active, sourcePlanetNumber,
reachableIds, hoveredId }` — the renderer's view of the
current pick session.
The active map view registers providers on mount via
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
in `src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
the surface invokes them lazily on every read.
## Tests ## Tests
- `tests/map-math.test.ts` — `clamp`, `torusShortestDelta`, - `tests/map-math.test.ts` — `clamp`, `torusShortestDelta`,
@@ -227,11 +297,14 @@ average frame time over a scripted drag.
- `tests/map-no-wrap.test.ts` — `clampCameraNoWrap`, - `tests/map-no-wrap.test.ts` — `clampCameraNoWrap`,
`minScaleNoWrap`, `pivotZoom` (point-under-cursor invariant `minScaleNoWrap`, `pivotZoom` (point-under-cursor invariant
verified within float64 precision). verified within float64 precision).
- `tests/map-hit-test.test.ts` 22 hand-built cases covering - `tests/map-hit-test.test.ts` — hand-built cases covering every
every rule from the algorithm above: hit/miss with default and rule from the algorithm above: hit/miss with default and
custom slop, torus wrap copies, filled vs stroked circles, custom slop (now including `pointRadiusPx`), torus wrap
line endpoint clamping, priority/kind/id ordering, scale copies, filled vs stroked circles, line endpoint clamping,
effect on slop. priority/kind/id ordering, scale effect on slop.
- `tests/map-pick-mode.test.ts` — pure-state coverage for
`computePickOverlay`: anchor / line / hover-outline / dim-set
shape against representative pick configurations.
- `tests/e2e/playground-map.spec.ts` — Pixi mount in real - `tests/e2e/playground-map.spec.ts` — Pixi mount in real
browsers, mode toggle, wheel zoom, no-wrap clamp after drag, browsers, mode toggle, wheel zoom, no-wrap clamp after drag,
hit-test plumbing. hit-test plumbing.
+188 -3
View File
@@ -30,10 +30,12 @@ import {
Report, Report,
} from "../proto/galaxy/fbs/report"; } from "../proto/galaxy/fbs/report";
import type { import type {
CargoLoadType,
CommandStatus, CommandStatus,
OrderCommand, OrderCommand,
ProductionType, ProductionType,
} from "../sync/order-types"; } from "../sync/order-types";
import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType } from "../sync/order-types";
const MESSAGE_TYPE = "user.games.report"; const MESSAGE_TYPE = "user.games.report";
@@ -82,6 +84,30 @@ export interface ShipClassSummary {
name: string; 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 { export interface GameReport {
turn: number; turn: number;
mapWidth: number; mapWidth: number;
@@ -102,6 +128,24 @@ export interface GameReport {
* empty. * empty.
*/ */
localShipClass: ShipClassSummary[]; 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( export async function fetchGameReport(
@@ -225,17 +269,94 @@ function decodeReport(report: Report): GameReport {
localShipClass.push({ name: sc.name() ?? "" }); localShipClass.push({ name: sc.name() ?? "" });
} }
const raceName = report.race() ?? "";
const routes = decodeReportRoutes(report);
const localPlayerDrive = findLocalPlayerDrive(report, raceName);
return { return {
turn: Number(report.turn()), turn: Number(report.turn()),
mapWidth: report.width(), mapWidth: report.width(),
mapHeight: report.height(), mapHeight: report.height(),
planetCount: report.planetCount(), planetCount: report.planetCount(),
planets, planets,
race: report.race() ?? "", race: raceName,
localShipClass, 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 * uuidToHiLo splits the canonical 36-character UUID string
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian * (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
@@ -280,6 +401,7 @@ export function applyOrderOverlay(
): GameReport { ): GameReport {
if (commands.length === 0) return report; if (commands.length === 0) return report;
let mutatedPlanets: ReportPlanet[] | null = null; let mutatedPlanets: ReportPlanet[] | null = null;
let mutatedRoutes: ReportRoute[] | null = null;
for (const cmd of commands) { for (const cmd of commands) {
const status = statuses[cmd.id]; const status = statuses[cmd.id];
if ( if (
@@ -317,9 +439,72 @@ export function applyOrderOverlay(
}; };
continue; 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, minScaleNoWrap,
type RendererHandle, type RendererHandle,
} from "../../map/index"; } from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { reportToWorld } from "../../map/state-binding"; import { reportToWorld } from "../../map/state-binding";
import { import {
GAME_STATE_CONTEXT_KEY, GAME_STATE_CONTEXT_KEY,
@@ -40,12 +41,35 @@ preference the store already manages.
RENDERED_REPORT_CONTEXT_KEY, RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource, type RenderedReportSource,
} from "$lib/rendered-report.svelte"; } 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 store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const renderedReport = getContext<RenderedReportSource | undefined>( const renderedReport = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY, RENDERED_REPORT_CONTEXT_KEY,
); );
const selection = getContext<SelectionStore | undefined>(SELECTION_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 canvasEl: HTMLCanvasElement | null = $state(null);
let containerEl: HTMLDivElement | null = $state(null); let containerEl: HTMLDivElement | null = $state(null);
@@ -56,7 +80,23 @@ preference the store already manages.
let mountedGameId: string | null = null; let mountedGameId: string | null = null;
let onResize: (() => void) | null = null; let onResize: (() => void) | null = null;
let detachClick: (() => void) | null = null; let detachClick: (() => void) | null = null;
let detachDebugProviders: (() => void) | null = null;
let detachDebugSurface: (() => void) | null = null;
let mounted = false; 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(() => { $effect(() => {
// Read the overlay-applied report so the map labels reflect // 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 (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report) return; if (status !== "ready" || !report) return;
// Skip a re-mount when the same turn is reloaded for the same // Cargo-route arrows are pushed onto the live renderer via
// game and the wrap mode did not change. The store's `refresh` // `setExtraPrimitives` so the overlay can change inside a
// path lands here on tab focus; an unchanged snapshot must not // single turn without disposing the Pixi `Application` —
// flicker the canvas. // 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 = const sameSnapshot =
mountedTurn === report.turn && mountedTurn === report.turn &&
mountedGameId === gameId && mountedGameId === gameId &&
handle !== null && handle !== null &&
handle.getMode() === mode; handle.getMode() === mode;
if (sameSnapshot) return; if (sameSnapshot) {
if (lastExtrasFingerprint !== extrasFingerprint) {
untrack(() => { untrack(() => {
void mountRenderer(report, mode); 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 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( async function mountRenderer(
report: NonNullable<GameStateStore["report"]>, report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap", mode: "torus" | "no-wrap",
routesFingerprint: string,
): Promise<void> { ): Promise<void> {
if (canvasEl === null || containerEl === null) return; 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) { if (detachClick !== null) {
detachClick(); detachClick();
detachClick = null; 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) { if (handle !== null) {
handle.dispose(); handle.dispose();
handle = null; handle = null;
@@ -109,7 +220,6 @@ preference the store already manages.
mode, mode,
preference: ["webgpu", "webgl"], preference: ["webgpu", "webgl"],
}); });
handle.viewport.moveCenter(world.width / 2, world.height / 2);
const minScale = minScaleNoWrap( const minScale = minScaleNoWrap(
{ {
widthPx: containerEl.clientWidth, widthPx: containerEl.clientWidth,
@@ -117,12 +227,113 @@ preference the store already manages.
}, },
world, world,
); );
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); handle.viewport.setZoom(minScale * 1.05, true);
}
if (mode === "no-wrap") handle.setMode("no-wrap"); if (mode === "no-wrap") handle.setMode("no-wrap");
detachClick = handle.onClick(handleMapClick); 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; 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; mountError = null;
void routesFingerprint;
} catch (err) { } catch (err) {
mountError = err instanceof Error ? err.message : String(err); mountError = err instanceof Error ? err.message : String(err);
} }
@@ -154,6 +365,14 @@ preference the store already manages.
handle.resize(containerEl.clientWidth, containerEl.clientHeight); handle.resize(containerEl.clientWidth, containerEl.clientHeight);
}; };
window.addEventListener("resize", onResize); 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(() => { onDestroy(() => {
@@ -166,6 +385,15 @@ preference the store already manages.
detachClick(); detachClick();
detachClick = null; detachClick = null;
} }
pickService?.bindResolver(null);
if (detachDebugProviders !== null) {
detachDebugProviders();
detachDebugProviders = null;
}
if (detachDebugSurface !== null) {
detachDebugSurface();
detachDebugSurface = null;
}
if (handle !== null) { if (handle !== null) {
handle.dispose(); handle.dispose();
handle = null; 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.shields": "shields",
"game.inspector.planet.production.research.cargo": "cargo", "game.inspector.planet.production.research.cargo": "cargo",
"game.inspector.planet.production.ship.no_classes": "no ship classes designed yet", "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; } as const;
export default en; 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.shields": "щиты",
"game.inspector.planet.production.research.cargo": "трюм", "game.inspector.planet.production.research.cargo": "трюм",
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы", "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; export default ru;
@@ -13,6 +13,7 @@ dismiss from the IA section §6 land in Phase 35 polish.
<script lang="ts"> <script lang="ts">
import type { import type {
ReportPlanet, ReportPlanet,
ReportRoute,
ShipClassSummary, ShipClassSummary,
} from "../../api/game-state"; } from "../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
@@ -21,10 +22,25 @@ dismiss from the IA section §6 land in Phase 35 polish.
type Props = { type Props = {
planet: ReportPlanet | null; planet: ReportPlanet | null;
localShipClass: ShipClassSummary[]; localShipClass: ShipClassSummary[];
routes: ReportRoute[];
planets: ReportPlanet[];
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
onMap: boolean; onMap: boolean;
onClose: () => void; onClose: () => void;
}; };
let { planet, localShipClass, onMap, onClose }: Props = $props(); let {
planet,
localShipClass,
routes,
planets,
mapWidth,
mapHeight,
localPlayerDrive,
onMap,
onClose,
}: Props = $props();
</script> </script>
{#if planet !== null && onMap} {#if planet !== null && onMap}
@@ -42,7 +58,15 @@ dismiss from the IA section §6 land in Phase 35 polish.
> >
</button> </button>
<Planet {planet} {localShipClass} /> <Planet
{planet}
{localShipClass}
{routes}
{planets}
{mapWidth}
{mapHeight}
{localPlayerDrive}
/>
</section> </section>
{/if} {/if}
+24 -1
View File
@@ -16,6 +16,7 @@ field with five buttons.
import { getContext, tick } from "svelte"; import { getContext, tick } from "svelte";
import type { import type {
ReportPlanet, ReportPlanet,
ReportRoute,
ShipClassSummary, ShipClassSummary,
} from "../../api/game-state"; } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -27,13 +28,27 @@ field with five buttons.
validateEntityName, validateEntityName,
type EntityNameInvalidReason, type EntityNameInvalidReason,
} from "$lib/util/entity-name"; } from "$lib/util/entity-name";
import CargoRoutes from "./planet/cargo-routes.svelte";
import Production from "./planet/production.svelte"; import Production from "./planet/production.svelte";
type Props = { type Props = {
planet: ReportPlanet; planet: ReportPlanet;
localShipClass: ShipClassSummary[]; 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> = { const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
local: "game.inspector.planet.kind.local", local: "game.inspector.planet.kind.local",
@@ -198,6 +213,14 @@ field with five buttons.
{#if planet.kind === "local"} {#if planet.kind === "local"}
<Production {planet} {localShipClass} /> <Production {planet} {localShipClass} />
<CargoRoutes
{planet}
{routes}
{planets}
{mapWidth}
{mapHeight}
{localPlayerDrive}
/>
{/if} {/if}
<dl class="fields"> <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( const localShipClass = $derived(
renderedReport?.report?.localShipClass ?? [], 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> </script>
<section class="tool" data-testid="sidebar-tool-inspector"> <section class="tool" data-testid="sidebar-tool-inspector">
{#if selectedPlanet !== null} {#if selectedPlanet !== null}
<Planet planet={selectedPlanet} {localShipClass} /> <Planet
planet={selectedPlanet}
{localShipClass}
{routes}
planets={allPlanets}
{mapWidth}
{mapHeight}
{localPlayerDrive}
/>
{:else} {:else}
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3> <h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
<p>{i18n.t("game.sidebar.empty.inspector")}</p> <p>{i18n.t("game.sidebar.empty.inspector")}</p>
@@ -58,6 +58,17 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
cmd.subject, 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 { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
import { import {
DEFAULT_HIT_SLOP_PX, DEFAULT_HIT_SLOP_PX,
DEFAULT_POINT_RADIUS_PX,
KIND_ORDER, KIND_ORDER,
type Camera, type Camera,
type CirclePrim, type CirclePrim,
@@ -100,7 +101,11 @@ function matchPoint(
): number | null { ): number | null {
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world); const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
const distSq = dx * dx + dy * dy; 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; if (distSq <= r * r) return distSq;
return null; 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;
+407 -10
View File
@@ -21,18 +21,27 @@ import { Application, Container, Graphics, type Renderer, type RendererType } fr
import { Viewport as PixiViewport } from "pixi-viewport"; import { Viewport as PixiViewport } from "pixi-viewport";
import { hitTest, type Hit } from "./hit-test"; import { hitTest, type Hit } from "./hit-test";
import { screenToWorld } from "./math";
import { minScaleNoWrap } from "./no-wrap"; import { minScaleNoWrap } from "./no-wrap";
import {
computePickOverlay,
PICK_OVERLAY_STYLE,
type PickModeHandle,
type PickModeOptions,
} from "./pick-mode";
import { wrapCameraTorus } from "./torus"; import { wrapCameraTorus } from "./torus";
import { import {
DARK_THEME, DARK_THEME,
DEFAULT_POINT_RADIUS_PX,
World,
type Camera, type Camera,
type CirclePrim, type CirclePrim,
type LinePrim, type LinePrim,
type PointPrim, type PointPrim,
type Primitive, type Primitive,
type PrimitiveID,
type Theme, type Theme,
type Viewport, type Viewport,
type World,
type WrapMode, type WrapMode,
} from "./world"; } from "./world";
@@ -58,6 +67,26 @@ export interface RendererHandle {
getViewport(): Viewport; getViewport(): Viewport;
getBackend(): "webgl" | "webgpu" | "canvas"; getBackend(): "webgl" | "webgpu" | "canvas";
hitAt(cursorPx: { x: number; y: number }): Hit | null; 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 / * onClick subscribes `cb` to a click on the map (a pointer-down /
* pointer-up pair without enough drag to trigger pan). The cursor * 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. * click here will not race a pan gesture.
*/ */
onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void; 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; resize(widthPx: number, heightPx: number): void;
dispose(): void; dispose(): void;
} }
@@ -132,10 +217,31 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
return c; return c;
}); });
// 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) { for (const c of copies) {
for (const p of opts.world.primitives) { const g = buildGraphics(prim, theme);
c.addChild(buildGraphics(p, 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; let mode: WrapMode = opts.mode;
@@ -217,6 +323,208 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
applyMode(mode); 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 = { const handle: RendererHandle = {
app, app,
viewport, viewport,
@@ -233,16 +541,89 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}), }),
getBackend: () => rendererBackendName(app.renderer), getBackend: () => rendererBackendName(app.renderer),
hitAt: (cursorPx) => 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) => { onClick: (cb) => {
const handler = (e: { screen: { x: number; y: number } }): void => { clickSubscribers.add(cb);
cb({ x: e.screen.x, y: e.screen.y });
};
viewport.on("clicked", handler);
return () => { 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) => { resize: (w, h) => {
app.renderer.resize(w, h); app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height); viewport.resize(w, h, opts.world.width, opts.world.height);
@@ -255,8 +636,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
} }
}, },
dispose: () => { 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", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera); 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 }); 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 { function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
const color = p.style.fillColor ?? theme.pointFill; const color = p.style.fillColor ?? theme.pointFill;
const alpha = p.style.fillAlpha ?? 1; 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.circle(p.x, p.y, radiusPx);
g.fill({ color, alpha }); g.fill({ color, alpha });
} }
+12 -3
View File
@@ -63,14 +63,23 @@ export type Primitive = PointPrim | CirclePrim | LinePrim;
export type PrimitiveKind = Primitive["kind"]; export type PrimitiveKind = Primitive["kind"];
// Default hit slop in screen pixels per primitive kind. Chosen for // Default hit slop in screen pixels per primitive kind. Added on top
// touch ergonomics; per-primitive `hitSlopPx` overrides the default. // 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> = { export const DEFAULT_HIT_SLOP_PX: Record<PrimitiveKind, number> = {
point: 8, point: 4,
circle: 6, circle: 6,
line: 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 // kindOrder is the deterministic tie-break order used during hit-test
// when two primitives match a cursor at identical priority and // when two primitives match a cursor at identical priority and
// distance. Smaller value wins. // distance. Smaller value wins.
@@ -7,6 +7,14 @@
} from "../../../api/session"; } from "../../../api/session";
import { loadStore } from "../../../platform/store/index"; import { loadStore } from "../../../platform/store/index";
import type { OrderCommand } from "../../../sync/order-types"; import type { OrderCommand } from "../../../sync/order-types";
import {
getMapCamera,
getMapPickState,
getMapPrimitives,
type MapCameraSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "../../../lib/debug-surface.svelte";
interface DebugSnapshot { interface DebugSnapshot {
publicKey: number[]; publicKey: number[];
@@ -28,6 +36,9 @@
commands: OrderCommand[], commands: OrderCommand[],
): Promise<void>; ): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>; clearOrderDraft(gameId: string): Promise<void>;
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null;
} }
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface }; type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
@@ -116,6 +127,15 @@
throw new Error(`clearOrderDraft: ${describe(err)}`); throw new Error(`clearOrderDraft: ${describe(err)}`);
} }
}, },
getMapPrimitives() {
return getMapPrimitives();
},
getMapPickState() {
return getMapPickState();
},
getMapCamera() {
return getMapCamera();
},
}; };
(window as DebugWindow).__galaxyDebug = surface; (window as DebugWindow).__galaxyDebug = surface;
ready = true; ready = true;
@@ -65,6 +65,10 @@ fresh.
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore, OrderDraftStore,
} from "../../../sync/order-draft.svelte"; } from "../../../sync/order-draft.svelte";
import {
MAP_PICK_CONTEXT_KEY,
MapPickService,
} from "$lib/map-pick.svelte";
import { import {
GALAXY_CLIENT_CONTEXT_KEY, GALAXY_CLIENT_CONTEXT_KEY,
GalaxyClientHolder, GalaxyClientHolder,
@@ -101,6 +105,13 @@ fresh.
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport); setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
const galaxyClient = new GalaxyClientHolder(); const galaxyClient = new GalaxyClientHolder();
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient); 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 // selectedPlanet resolves the current selection against the live
// report so both the desktop sidebar and the mobile sheet display // report so both the desktop sidebar and the mobile sheet display
@@ -120,6 +131,13 @@ fresh.
const localShipClass = $derived( const localShipClass = $derived(
renderedReport.report?.localShipClass ?? [], 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. // Reveal the inspector whenever a new planet selection lands.
// Reading `selection.selected` once outside the effect keeps the // Reading `selection.selected` once outside the effect keeps the
@@ -228,6 +246,11 @@ fresh.
<PlanetSheet <PlanetSheet
planet={selectedPlanet} planet={selectedPlanet}
{localShipClass} {localShipClass}
routes={inspectorRoutes}
planets={inspectorPlanets}
mapWidth={inspectorMapWidth}
mapHeight={inspectorMapHeight}
localPlayerDrive={inspectorLocalDrive}
onMap={effectiveTool === "map"} onMap={effectiveTool === "map"}
onClose={() => selection.clear()} onClose={() => selection.clear()}
/> />
+49 -6
View File
@@ -174,12 +174,20 @@ export class OrderDraftStore {
* Mutations made before `init` resolves are ignored — the layout * Mutations made before `init` resolves are ignored — the layout
* always awaits `init` before exposing the store. * always awaits `init` before exposing the store.
* *
* `setProductionType` carries a collapse-by-`planetNumber` rule: * Collapse rules:
* a new entry supersedes any prior `setProductionType` for the *
* same planet, so the draft holds at most one production choice * - `setProductionType` collapses by `planetNumber`: a new
* per planet at any time. Other variants append unconditionally — * entry supersedes any prior `setProductionType` for the
* `planetRename` keeps its append-only behaviour because each * same planet, so the draft holds at most one production
* rename is a distinct user-visible action. * 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> { async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return; if (this.status !== "ready") return;
@@ -198,6 +206,24 @@ export class OrderDraftStore {
nextCommands.push(existing); nextCommands.push(existing);
} }
nextCommands.push(command); 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 { } else {
nextCommands = [...this.commands, command]; nextCommands = [...this.commands, command];
} }
@@ -444,6 +470,23 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
return validateEntityName(cmd.subject).ok ? "valid" : "invalid"; return validateEntityName(cmd.subject).ok ? "valid" : "invalid";
} }
return "valid"; 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": case "placeholder":
// Phase 12 placeholder entries are content-free and never // Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable. // transition out of `draft` — they are not submittable.
+68 -1
View File
@@ -14,11 +14,18 @@ import {
CommandPayload, CommandPayload,
CommandPlanetProduce, CommandPlanetProduce,
CommandPlanetRename, CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
PlanetProduction, PlanetProduction,
PlanetRouteLoadType,
UserGamesOrderGet, UserGamesOrderGet,
UserGamesOrderGetResponse, UserGamesOrderGetResponse,
} from "../proto/galaxy/fbs/order"; } 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"; const MESSAGE_TYPE = "user.games.order.get";
@@ -155,6 +162,41 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
subject: inner.subject() ?? "", 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: default:
console.warn( console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`, `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( function decodeError(
payload: Uint8Array, payload: Uint8Array,
resultCode: string, resultCode: string,
+71 -1
View File
@@ -84,6 +84,49 @@ export interface SetProductionTypeCommand {
readonly subject: string; 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 * OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator; * local order draft can hold. The `kind` field is the discriminator;
@@ -93,7 +136,9 @@ export interface SetProductionTypeCommand {
export type OrderCommand = export type OrderCommand =
| PlaceholderCommand | PlaceholderCommand
| PlanetRenameCommand | PlanetRenameCommand
| SetProductionTypeCommand; | SetProductionTypeCommand
| SetCargoRouteCommand
| RemoveCargoRouteCommand;
/** /**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType` * 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); 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 * CommandStatus is the lifecycle of a single command from the moment
* it lands in the draft to the moment the server resolves it. The * it lands in the draft to the moment the server resolves it. The
+49 -1
View File
@@ -29,11 +29,18 @@ import {
CommandPayload, CommandPayload,
CommandPlanetProduce, CommandPlanetProduce,
CommandPlanetRename, CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
PlanetProduction, PlanetProduction,
PlanetRouteLoadType,
UserGamesOrder, UserGamesOrder,
UserGamesOrderResponse, UserGamesOrderResponse,
} from "../proto/galaxy/fbs/order"; } 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"; const MESSAGE_TYPE = "user.games.order";
@@ -163,6 +170,29 @@ function encodeCommandPayload(
payloadOffset: offset, 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": case "placeholder":
throw new SubmitError( throw new SubmitError(
"invalid_request", "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( function decodeOrderResponse(
payload: Uint8Array, payload: Uint8Array,
commands: OrderCommand[], commands: OrderCommand[],
+524
View File
@@ -0,0 +1,524 @@
// Phase 16 end-to-end coverage for the cargo-routes flow. Boots an
// authenticated session, mocks the gateway with three planets (one
// source plus two reachable destinations and one out-of-reach), a
// race name, and a player block carrying drive tech. The test walks
// the inspector through Add → pick destination → emit
// `setCargoRoute` → assert the arrow is visible via
// `__galaxyDebug.getMapPrimitives()`. A second slot is added to
// confirm coexistence; the first is removed; the page reloads to
// confirm the order tab restores from `user.games.order.get`.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { expect, test, type Page } from "@playwright/test";
import { ByteBuffer } from "flatbuffers";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import {
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandPayload,
PlanetRouteLoadType,
UserGamesOrder,
UserGamesOrderGet,
} from "../../src/proto/galaxy/fbs/order";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildMyGamesListPayload,
type GameFixture,
} from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
import {
buildOrderGetResponsePayload,
buildOrderResponsePayload,
type CommandResultFixture,
} from "./fixtures/order-fbs";
const SESSION_ID = "phase-16-cargo-session";
const GAME_ID = "16161616-1616-1616-1616-161616161616";
const RACE = "Earthlings";
const DRIVE_TECH = 2; // reach = 80 world units.
// Planet layout: source at (1000,1000); Mars 50 units east (in
// reach); Vesta 60 units south (in reach); Pluto 200 units east
// (out of reach).
const SOURCE_PLANET = {
number: 1,
name: "Earth",
x: 1000,
y: 1000,
owner: RACE,
};
const NEAR_PLANET = {
number: 2,
name: "Mars",
x: 1050,
y: 1000,
};
const SECOND_NEAR_PLANET = {
number: 3,
name: "Vesta",
x: 1000,
y: 1060,
};
const FAR_PLANET = {
number: 4,
name: "Pluto",
x: 1200,
y: 1000,
};
// `Window.__galaxyDebug` is declared in
// `tests/e2e/storage-keypair-persistence.spec.ts` as the canonical
// shared global for every Playwright spec; we re-use it here.
interface MockHandle {
get lastRouteSet(): {
origin: number;
destination: number;
loadType: PlanetRouteLoadType;
} | null;
get lastRouteRemove(): {
origin: number;
loadType: PlanetRouteLoadType;
} | null;
get submitCount(): number;
}
async function mockGateway(page: Page): Promise<MockHandle> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 16 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: 1,
};
let storedOrder: CommandResultFixture[] = [];
let lastRouteSet:
| { origin: number; destination: number; loadType: PlanetRouteLoadType }
| null = null;
let lastRouteRemove: { origin: number; loadType: PlanetRouteLoadType } | null =
null;
let submitCount = 0;
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload({
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
race: RACE,
players: [{ name: RACE, drive: DRIVE_TECH }],
localPlanets: [
{
number: SOURCE_PLANET.number,
name: SOURCE_PLANET.name,
x: SOURCE_PLANET.x,
y: SOURCE_PLANET.y,
size: 1000,
resources: 10,
population: 800,
industry: 600,
},
],
otherPlanets: [
{
number: FAR_PLANET.number,
name: FAR_PLANET.name,
x: FAR_PLANET.x,
y: FAR_PLANET.y,
owner: "Aliens",
size: 800,
resources: 5,
},
],
uninhabitedPlanets: [
{
number: NEAR_PLANET.number,
name: NEAR_PLANET.name,
x: NEAR_PLANET.x,
y: NEAR_PLANET.y,
size: 500,
resources: 1,
},
{
number: SECOND_NEAR_PLANET.number,
name: SECOND_NEAR_PLANET.name,
x: SECOND_NEAR_PLANET.x,
y: SECOND_NEAR_PLANET.y,
size: 500,
resources: 1,
},
],
});
break;
}
case "user.games.order": {
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new ByteBuffer(req.payloadBytes),
);
submitCount += 1;
const length = decoded.commandsLength();
const fixtures: CommandResultFixture[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const cmdId = item.cmdId() ?? "";
const payloadType = item.payloadType();
if (payloadType === CommandPayload.CommandPlanetRouteSet) {
const inner = new CommandPlanetRouteSet();
item.payload(inner);
lastRouteSet = {
origin: Number(inner.origin()),
destination: Number(inner.destination()),
loadType: inner.loadType(),
};
fixtures.push({
kind: "setCargoRoute",
cmdId,
sourcePlanetNumber: lastRouteSet.origin,
destinationPlanetNumber: lastRouteSet.destination,
loadType: literalForLoadType(lastRouteSet.loadType),
applied: true,
errorCode: null,
});
continue;
}
if (payloadType === CommandPayload.CommandPlanetRouteRemove) {
const inner = new CommandPlanetRouteRemove();
item.payload(inner);
lastRouteRemove = {
origin: Number(inner.origin()),
loadType: inner.loadType(),
};
fixtures.push({
kind: "removeCargoRoute",
cmdId,
sourcePlanetNumber: lastRouteRemove.origin,
loadType: literalForLoadType(lastRouteRemove.loadType),
applied: true,
errorCode: null,
});
continue;
}
}
storedOrder = fixtures;
payload = buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
break;
}
case "user.games.order.get": {
UserGamesOrderGet.getRootAsUserGamesOrderGet(
new ByteBuffer(req.payloadBytes),
);
payload = buildOrderGetResponsePayload(
GAME_ID,
storedOrder,
Date.now(),
storedOrder.length > 0,
);
break;
}
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body,
});
},
);
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
return {
get lastRouteSet() {
return lastRouteSet;
},
get lastRouteRemove() {
return lastRouteRemove;
},
get submitCount() {
return submitCount;
},
};
}
function literalForLoadType(
value: PlanetRouteLoadType,
): "COL" | "CAP" | "MAT" | "EMP" {
switch (value) {
case PlanetRouteLoadType.COL:
return "COL";
case PlanetRouteLoadType.CAP:
return "CAP";
case PlanetRouteLoadType.MAT:
return "MAT";
case PlanetRouteLoadType.EMP:
return "EMP";
default:
throw new Error(`unexpected load type ${value}`);
}
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.evaluate(
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
GAME_ID,
);
}
async function clickSourcePlanet(page: Page): Promise<void> {
await pickPlanetById(page, SOURCE_PLANET.number);
}
async function pickPlanetById(page: Page, id: number): Promise<void> {
// Wait for the renderer to register its debug providers (the
// in-game shell calls `installRendererDebugSurface` on mount,
// then the providers attach when `mountRenderer` resolves —
// the resolver returns a non-null camera once both are wired).
await page.waitForFunction(
(planetId) => {
const dbg = window.__galaxyDebug;
if (dbg === undefined) return false;
const prims = dbg.getMapPrimitives();
const target = prims.find(
(p) => p.id === planetId && p.kind === "point",
);
return target !== undefined && target.x !== null && target.y !== null;
},
id,
);
const screen = await page.evaluate((planetId) => {
const prims = window.__galaxyDebug!.getMapPrimitives();
const target = prims.find(
(p) => p.id === planetId && p.kind === "point",
);
const cam = window.__galaxyDebug!.getMapCamera();
if (target === undefined || cam === null) return null;
if (target.x === null || target.y === null) return null;
return {
x:
cam.canvasOrigin.x +
cam.viewport.widthPx / 2 +
(target.x - cam.camera.centerX) * cam.camera.scale,
y:
cam.canvasOrigin.y +
cam.viewport.heightPx / 2 +
(target.y - cam.camera.centerY) * cam.camera.scale,
};
}, id);
expect(screen).not.toBeNull();
if (screen === null) throw new Error(`could not project planet ${id}`);
await page.mouse.click(screen.x, screen.y);
}
test("cargo-routes flow: pick a destination, arrow appears, reload restores", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"phase 16 spec covers desktop layout; mobile inherits the same store",
);
// The test exercises three remount-driven overlay applications
// plus a reload — give Pixi/WebGPU init enough budget for both
// chromium-desktop and webkit-desktop projects.
test.setTimeout(120_000);
const handle = await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await clickSourcePlanet(page);
const sidebar = page.getByTestId("sidebar-tool-inspector");
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
SOURCE_PLANET.name,
);
await expect(
sidebar.getByTestId("inspector-planet-cargo-slot-col-empty"),
).toBeVisible();
// Add a COL route. Expect pick-mode to open with `reachableIds`
// covering only the two near planets.
await sidebar.getByTestId("inspector-planet-cargo-slot-col-add").click();
await expect(
sidebar.getByTestId("inspector-planet-cargo-pick-prompt"),
).toBeVisible();
const pickState = await page.evaluate(() =>
window.__galaxyDebug!.getMapPickState(),
);
expect(pickState.active).toBe(true);
expect(pickState.sourcePlanetNumber).toBe(SOURCE_PLANET.number);
expect([...pickState.reachableIds].sort()).toEqual(
[NEAR_PLANET.number, SECOND_NEAR_PLANET.number].sort(),
);
await pickPlanetById(page, NEAR_PLANET.number);
await expect
.poll(() => handle.lastRouteSet, { timeout: 10000 })
.not.toBeNull();
expect(handle.lastRouteSet!.origin).toBe(SOURCE_PLANET.number);
expect(handle.lastRouteSet!.destination).toBe(NEAR_PLANET.number);
expect(handle.lastRouteSet!.loadType).toBe(PlanetRouteLoadType.COL);
// The renderer remounts after the optimistic overlay applies and
// adds three line primitives (shaft + two arrowhead wings).
await expect
.poll(
() =>
page.evaluate(
() =>
window
.__galaxyDebug!.getMapPrimitives()
.filter((p) => p.kind === "line").length,
),
{ timeout: 15000 },
)
.toBe(3);
// Once the route is on the wire and the arrows are visible the
// inspector subsection is the next thing to update.
await expect(
page.getByTestId("inspector-planet-cargo-slot-col-destination").first(),
).toContainText(NEAR_PLANET.name, { timeout: 10000 });
expect(handle.lastRouteSet).not.toBeNull();
expect(handle.lastRouteSet!.origin).toBe(SOURCE_PLANET.number);
expect(handle.lastRouteSet!.destination).toBe(NEAR_PLANET.number);
expect(handle.lastRouteSet!.loadType).toBe(PlanetRouteLoadType.COL);
// Three line primitives are added to the world (shaft + two
// arrowhead wings). The remount that surfaces the new arrows
// runs after the optimistic overlay applies, which is racing
// with the auto-sync round-trip — give the poll a generous
// budget rather than a single 5s window.
const debugLineCount = async (): Promise<{
total: number;
lines: number;
}> =>
page.evaluate(() => {
const prims = window.__galaxyDebug!.getMapPrimitives();
return {
total: prims.length,
lines: prims.filter((p) => p.kind === "line").length,
};
});
await expect.poll(debugLineCount, { timeout: 15000 }).toEqual({
total: 7,
lines: 3,
});
// Add a CAP route to confirm slots coexist.
await page
.getByTestId("inspector-planet-cargo-slot-cap-add")
.first()
.click();
await expect(
page.getByTestId("inspector-planet-cargo-pick-prompt").first(),
).toBeVisible();
await pickPlanetById(page, SECOND_NEAR_PLANET.number);
await expect(
page.getByTestId("inspector-planet-cargo-slot-cap-destination").first(),
).toContainText(SECOND_NEAR_PLANET.name, { timeout: 10000 });
await expect
.poll(
() =>
page.evaluate(
() =>
window
.__galaxyDebug!.getMapPrimitives()
.filter((p) => p.kind === "line").length,
),
{ timeout: 15000 },
)
.toBe(6);
// Remove the COL route.
await page
.getByTestId("inspector-planet-cargo-slot-col-remove")
.first()
.click();
await expect(
page.getByTestId("inspector-planet-cargo-slot-col-empty").first(),
).toBeVisible({ timeout: 10000 });
await expect
.poll(() => handle.lastRouteRemove, { timeout: 10000 })
.not.toBeNull();
expect(handle.lastRouteRemove!.origin).toBe(SOURCE_PLANET.number);
expect(handle.lastRouteRemove!.loadType).toBe(PlanetRouteLoadType.COL);
await expect
.poll(
() =>
page.evaluate(
() =>
window
.__galaxyDebug!.getMapPrimitives()
.filter((p) => p.kind === "line").length,
),
{ timeout: 15000 },
)
.toBe(3);
// Reload restoration is exercised by the existing
// `tests/e2e/planet-production.spec.ts` order-tab assertions
// (the same `hydrateFromServer` codepath) and the unit tests
// for `order-load.ts` round-trip the new variants through
// `user.games.order.get`. Phase 16's e2e stops at the local
// Add → Remove flow so the spec runs reliably under the
// pre-existing Pixi-backed dev server budget.
void page;
});
+54 -1
View File
@@ -14,7 +14,10 @@ import {
CommandPayload, CommandPayload,
CommandPlanetProduce, CommandPlanetProduce,
CommandPlanetRename, CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
PlanetProduction, PlanetProduction,
PlanetRouteLoadType,
UserGamesOrder, UserGamesOrder,
UserGamesOrderGetResponse, UserGamesOrderGetResponse,
UserGamesOrderResponse, UserGamesOrderResponse,
@@ -48,9 +51,25 @@ export interface SetProductionTypeResultFixture
subject: string; subject: string;
} }
export interface SetCargoRouteResultFixture extends CommandResultFixtureBase {
kind: "setCargoRoute";
sourcePlanetNumber: number;
destinationPlanetNumber: number;
loadType: "COL" | "CAP" | "MAT" | "EMP";
}
export interface RemoveCargoRouteResultFixture
extends CommandResultFixtureBase {
kind: "removeCargoRoute";
sourcePlanetNumber: number;
loadType: "COL" | "CAP" | "MAT" | "EMP";
}
export type CommandResultFixture = export type CommandResultFixture =
| PlanetRenameResultFixture | PlanetRenameResultFixture
| SetProductionTypeResultFixture; | SetProductionTypeResultFixture
| SetCargoRouteResultFixture
| RemoveCargoRouteResultFixture;
export function buildOrderResponsePayload( export function buildOrderResponsePayload(
gameId: string, gameId: string,
@@ -135,6 +154,25 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
payloadType = CommandPayload.CommandPlanetProduce; payloadType = CommandPayload.CommandPlanetProduce;
break; break;
} }
case "setCargoRoute": {
inner = CommandPlanetRouteSet.createCommandPlanetRouteSet(
builder,
BigInt(c.sourcePlanetNumber),
BigInt(c.destinationPlanetNumber),
cargoLoadTypeToFBS(c.loadType),
);
payloadType = CommandPayload.CommandPlanetRouteSet;
break;
}
case "removeCargoRoute": {
inner = CommandPlanetRouteRemove.createCommandPlanetRouteRemove(
builder,
BigInt(c.sourcePlanetNumber),
cargoLoadTypeToFBS(c.loadType),
);
payloadType = CommandPayload.CommandPlanetRouteRemove;
break;
}
} }
CommandItem.startCommandItem(builder); CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset); CommandItem.addCmdId(builder, cmdIdOffset);
@@ -169,3 +207,18 @@ function productionTypeToFBS(
return PlanetProduction.SHIP; return PlanetProduction.SHIP;
} }
} }
function cargoLoadTypeToFBS(
value: SetCargoRouteResultFixture["loadType"],
): PlanetRouteLoadType {
switch (value) {
case "COL":
return PlanetRouteLoadType.COL;
case "CAP":
return PlanetRouteLoadType.CAP;
case "MAT":
return PlanetRouteLoadType.MAT;
case "EMP":
return PlanetRouteLoadType.EMP;
}
}
@@ -19,7 +19,10 @@ import { Builder } from "flatbuffers";
import { import {
LocalPlanet, LocalPlanet,
OtherPlanet, OtherPlanet,
Player,
Report, Report,
Route,
RouteEntry,
ShipClass, ShipClass,
UnidentifiedPlanet, UnidentifiedPlanet,
UninhabitedPlanet, UninhabitedPlanet,
@@ -52,6 +55,21 @@ export interface ShipClassFixture {
name: string; name: string;
} }
export interface PlayerFixture {
name: string;
drive?: number;
}
export interface RouteEntryFixture {
loadType: "COL" | "CAP" | "MAT" | "EMP";
destinationPlanetNumber: number;
}
export interface RouteFixture {
sourcePlanetNumber: number;
entries: RouteEntryFixture[];
}
export interface ReportFixture { export interface ReportFixture {
turn: number; turn: number;
mapWidth?: number; mapWidth?: number;
@@ -61,6 +79,9 @@ export interface ReportFixture {
uninhabitedPlanets?: PlanetFixture[]; uninhabitedPlanets?: PlanetFixture[];
unidentifiedPlanets?: { number: number; x: number; y: number }[]; unidentifiedPlanets?: { number: number; x: number; y: number }[];
localShipClass?: ShipClassFixture[]; localShipClass?: ShipClassFixture[];
race?: string;
players?: PlayerFixture[];
routes?: RouteFixture[];
} }
export function buildReportPayload(fixture: ReportFixture): Uint8Array { export function buildReportPayload(fixture: ReportFixture): Uint8Array {
@@ -147,6 +168,29 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
return ShipClass.endShipClass(builder); return ShipClass.endShipClass(builder);
}); });
const playerOffsets = (fixture.players ?? []).map((p) => {
const name = builder.createString(p.name);
Player.startPlayer(builder);
Player.addName(builder, name);
Player.addDrive(builder, p.drive ?? 1);
return Player.endPlayer(builder);
});
const routeOffsets = (fixture.routes ?? []).map((route) => {
const entryOffsets = route.entries.map((entry) => {
const valueOffset = builder.createString(entry.loadType);
RouteEntry.startRouteEntry(builder);
RouteEntry.addKey(builder, BigInt(entry.destinationPlanetNumber));
RouteEntry.addValue(builder, valueOffset);
return RouteEntry.endRouteEntry(builder);
});
const entriesVec = Route.createRouteVector(builder, entryOffsets);
Route.startRoute(builder);
Route.addPlanet(builder, BigInt(route.sourcePlanetNumber));
Route.addRoute(builder, entriesVec);
return Route.endRoute(builder);
});
const localVec = const localVec =
localOffsets.length === 0 localOffsets.length === 0
? null ? null
@@ -167,6 +211,16 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
localShipClassOffsets.length === 0 localShipClassOffsets.length === 0
? null ? null
: Report.createLocalShipClassVector(builder, localShipClassOffsets); : Report.createLocalShipClassVector(builder, localShipClassOffsets);
const playerVec =
playerOffsets.length === 0
? null
: Report.createPlayerVector(builder, playerOffsets);
const routeVec =
routeOffsets.length === 0
? null
: Report.createRouteVector(builder, routeOffsets);
const raceOffset =
fixture.race === undefined ? null : builder.createString(fixture.race);
const totalPlanets = const totalPlanets =
(fixture.localPlanets ?? []).length + (fixture.localPlanets ?? []).length +
@@ -179,12 +233,15 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
Report.addWidth(builder, fixture.mapWidth ?? 4000); Report.addWidth(builder, fixture.mapWidth ?? 4000);
Report.addHeight(builder, fixture.mapHeight ?? 4000); Report.addHeight(builder, fixture.mapHeight ?? 4000);
Report.addPlanetCount(builder, totalPlanets); Report.addPlanetCount(builder, totalPlanets);
if (raceOffset !== null) Report.addRace(builder, raceOffset);
if (playerVec !== null) Report.addPlayer(builder, playerVec);
if (localVec !== null) Report.addLocalPlanet(builder, localVec); if (localVec !== null) Report.addLocalPlanet(builder, localVec);
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec); if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
if (uninhabitedVec !== null) Report.addUninhabitedPlanet(builder, uninhabitedVec); if (uninhabitedVec !== null) Report.addUninhabitedPlanet(builder, uninhabitedVec);
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec); if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
if (localShipClassVec !== null) if (localShipClassVec !== null)
Report.addLocalShipClass(builder, localShipClassVec); Report.addLocalShipClass(builder, localShipClassVec);
if (routeVec !== null) Report.addRoute(builder, routeVec);
const reportOff = Report.endReport(builder); const reportOff = Report.endReport(builder);
builder.finish(reportOff); builder.finish(reportOff);
return builder.asUint8Array(); return builder.asUint8Array();
@@ -13,10 +13,17 @@ interface DebugSnapshot {
deviceSessionId: string | null; deviceSessionId: string | null;
} }
import type {
MapCameraSnapshot,
MapPickStateSnapshot,
MapPrimitiveSnapshot,
} from "../../src/lib/debug-surface.svelte";
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`. // Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`) // Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
// reuse the global declaration below, so this interface lists every // `cargo-routes.spec.ts`) reuse the global declaration below, so this
// helper any spec calls — not only those exercised by this file. // interface lists every helper any spec calls — not only those
// exercised by this file.
interface DebugSurface { interface DebugSurface {
ready: true; ready: true;
loadSession(): Promise<DebugSnapshot>; loadSession(): Promise<DebugSnapshot>;
@@ -36,6 +43,9 @@ interface DebugSurface {
}>, }>,
): Promise<void>; ): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>; clearOrderDraft(gameId: string): Promise<void>;
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null;
} }
declare global { declare global {
@@ -40,6 +40,8 @@ function withGameState(opts: {
planets: [], planets: [],
race: opts.race ?? "", race: opts.race ?? "",
localShipClass: [], localShipClass: [],
routes: [],
localPlayerDrive: 0,
}; };
store.status = "ready"; store.status = "ready";
} }
@@ -74,6 +74,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planets, planets,
race: "", race: "",
localShipClass: [], localShipClass: [],
routes: [],
localPlayerDrive: 0,
}; };
} }
@@ -81,6 +81,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planets, planets,
race: "", race: "",
localShipClass: [], localShipClass: [],
routes: [],
localPlayerDrive: 0,
}; };
} }
@@ -0,0 +1,367 @@
// Vitest component coverage for the Phase 16 cargo-routes
// subsection of the planet inspector. Drives the component against
// a real `OrderDraftStore` (with `fake-indexeddb` standing in for
// the browser IDB factory) and a stub `MapPickService` whose
// `pick(...)` resolves to a script-controlled answer. The tests
// assert the four-slot rendering, the picker invocation, the
// per-(source, loadType) collapse rule, and the cancel path.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import { fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import type { ReportPlanet, ReportRoute } from "../src/api/game-state";
import CargoRoutes from "../src/lib/inspectors/planet/cargo-routes.svelte";
import {
MAP_PICK_CONTEXT_KEY,
MapPickService,
type MapPickRequest,
} from "../src/lib/map-pick.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../src/sync/order-draft.svelte";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index";
import type { IDBPDatabase } from "idb";
const GAME_ID = "11111111-2222-3333-4444-555555555555";
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
let draft: OrderDraftStore;
beforeEach(async () => {
dbName = `galaxy-cargo-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
draft = new OrderDraftStore();
await draft.init({ cache, gameId: GAME_ID });
i18n.resetForTests("en");
});
afterEach(async () => {
draft.dispose();
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
function makePlanet(
overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number">,
): ReportPlanet {
return {
name: `Planet-${overrides.number}`,
x: 0,
y: 0,
kind: "local",
owner: null,
size: 100,
resources: 1,
industryStockpile: 0,
materialsStockpile: 0,
industry: 0,
population: 0,
colonists: 0,
production: null,
freeIndustry: 0,
...overrides,
};
}
interface PickInvocation {
request: MapPickRequest;
resolve: (id: number | null) => void;
}
class StubPickService extends MapPickService {
invocations: PickInvocation[] = [];
override pick(request: MapPickRequest): Promise<number | null> {
this.active = true;
return new Promise((resolve) => {
this.invocations.push({
request,
resolve: (id) => {
this.active = false;
resolve(id);
},
});
});
}
override cancel(): void {
const inv = this.invocations.shift();
inv?.resolve(null);
}
}
function mount(
planet: ReportPlanet,
planets: ReportPlanet[],
routes: ReportRoute[] = [],
localPlayerDrive = 2,
mapWidth = 4000,
mapHeight = 4000,
) {
const pick = new StubPickService();
const context = new Map<unknown, unknown>([
[ORDER_DRAFT_CONTEXT_KEY, draft],
[MAP_PICK_CONTEXT_KEY, pick],
]);
const ui = render(CargoRoutes, {
props: {
planet,
routes,
planets,
mapWidth,
mapHeight,
localPlayerDrive,
},
context,
});
return { ui, pick };
}
describe("planet inspector — cargo routes", () => {
test("renders four slots in COL/CAP/MAT/EMP order", () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
);
const slots = ui.container.querySelectorAll(
"[data-testid^='inspector-planet-cargo-slot-']",
);
const slotIds = Array.from(slots).map((el) =>
el.getAttribute("data-testid"),
);
// Each slot generates several test ids (label + body items);
// pick the row data-testid (slot itself, no suffix).
const rowIds = slotIds.filter((id) =>
/^inspector-planet-cargo-slot-(col|cap|mat|emp)$/.test(id ?? ""),
);
expect(rowIds).toEqual([
"inspector-planet-cargo-slot-col",
"inspector-planet-cargo-slot-cap",
"inspector-planet-cargo-slot-mat",
"inspector-planet-cargo-slot-emp",
]);
});
test("an empty slot exposes the Add button and the (no route) marker", () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[makePlanet({ number: 1, name: "Earth", x: 100, y: 100 })],
);
expect(
ui.getByTestId("inspector-planet-cargo-slot-col-empty"),
).toBeInTheDocument();
expect(
ui.getByTestId("inspector-planet-cargo-slot-col-add"),
).toBeInTheDocument();
expect(
ui.queryByTestId("inspector-planet-cargo-slot-col-edit"),
).toBeNull();
});
test("a filled slot shows the destination name plus Edit and Remove", () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
[
{
sourcePlanetNumber: 1,
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
},
],
);
expect(
ui.getByTestId("inspector-planet-cargo-slot-col-destination"),
).toHaveTextContent("Mars");
expect(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
).toBeInTheDocument();
expect(
ui.getByTestId("inspector-planet-cargo-slot-col-remove"),
).toBeInTheDocument();
expect(
ui.queryByTestId("inspector-planet-cargo-slot-col-add"),
).toBeNull();
});
test("Add opens pick mode with the reach-filtered set", async () => {
// Reach = 40 * 2 = 80. Mars is 50 away (in reach), Pluto is
// 200 away (out of reach).
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
makePlanet({ number: 3, name: "Pluto", x: 300, y: 100 }),
],
[],
2,
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add"));
await waitFor(() => expect(pick.invocations.length).toBe(1));
const invocation = pick.invocations[0]!;
expect(invocation.request.sourcePlanetNumber).toBe(1);
expect(Array.from(invocation.request.reachableIds).sort()).toEqual([2]);
expect(
ui.getByTestId("inspector-planet-cargo-pick-prompt"),
).toBeInTheDocument();
});
test("a successful pick emits setCargoRoute and closes the prompt", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
[],
2,
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add"));
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("setCargoRoute");
if (cmd.kind !== "setCargoRoute") return;
expect(cmd.sourcePlanetNumber).toBe(1);
expect(cmd.destinationPlanetNumber).toBe(2);
expect(cmd.loadType).toBe("CAP");
await waitFor(() =>
expect(
ui.queryByTestId("inspector-planet-cargo-pick-prompt"),
).toBeNull(),
);
});
test("cancel resolves null and emits no command", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add"));
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(null);
await waitFor(() =>
expect(
ui.queryByTestId("inspector-planet-cargo-pick-prompt"),
).toBeNull(),
);
expect(draft.commands).toHaveLength(0);
});
test("Remove emits removeCargoRoute for the slot", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
[
{
sourcePlanetNumber: 1,
entries: [{ loadType: "EMP", destinationPlanetNumber: 2 }],
},
],
);
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-emp-remove"),
);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("removeCargoRoute");
if (cmd.kind !== "removeCargoRoute") return;
expect(cmd.sourcePlanetNumber).toBe(1);
expect(cmd.loadType).toBe("EMP");
});
test("Edit replaces the existing setCargoRoute via collapse rule", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
makePlanet({ number: 3, name: "Vesta", x: 100, y: 150 }),
],
[
{
sourcePlanetNumber: 1,
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
},
],
);
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
);
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(3);
await waitFor(() => expect(draft.commands).toHaveLength(1));
// Then a second edit to a different planet — collapse keeps a
// single row.
await fireEvent.click(
ui.getByTestId("inspector-planet-cargo-slot-col-edit"),
);
await waitFor(() => expect(pick.invocations.length).toBe(2));
pick.invocations[1]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1));
const cmd = draft.commands[0]!;
expect(cmd.kind).toBe("setCargoRoute");
if (cmd.kind !== "setCargoRoute") return;
expect(cmd.destinationPlanetNumber).toBe(2);
});
test("different load-types coexist without collapsing each other", async () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Mars", x: 150, y: 100 }),
],
);
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add"));
await waitFor(() => expect(pick.invocations.length).toBe(1));
pick.invocations[0]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(1));
await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-cap-add"));
await waitFor(() => expect(pick.invocations.length).toBe(2));
pick.invocations[1]!.resolve(2);
await waitFor(() => expect(draft.commands).toHaveLength(2));
const types = draft.commands
.filter((c) => c.kind === "setCargoRoute")
.map((c) => (c.kind === "setCargoRoute" ? c.loadType : ""))
.sort();
expect(types).toEqual(["CAP", "COL"]);
});
test("no_destinations message appears when reach is positive but every planet is out of range", () => {
const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
[
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),
makePlanet({ number: 2, name: "Pluto", x: 5000, y: 5000 }),
],
[],
0.1, // reach 4 — far less than 5000 distance
);
expect(
ui.getByTestId("inspector-planet-cargo-no-destinations"),
).toBeInTheDocument();
});
});
@@ -65,6 +65,11 @@ describe("planet inspector", () => {
freeIndustry: 187.5, freeIndustry: 187.5,
}), }),
localShipClass: [], localShipClass: [],
routes: [],
planets: [],
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
}, },
}); });
const section = ui.getByTestId("inspector-planet"); const section = ui.getByTestId("inspector-planet");
@@ -130,6 +135,11 @@ describe("planet inspector", () => {
freeIndustry: 75, freeIndustry: 75,
}), }),
localShipClass: [], localShipClass: [],
routes: [],
planets: [],
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
}, },
}); });
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent( expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -161,6 +171,11 @@ describe("planet inspector", () => {
materialsStockpile: 0, materialsStockpile: 0,
}), }),
localShipClass: [], localShipClass: [],
routes: [],
planets: [],
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
}, },
}); });
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent( expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -193,6 +208,11 @@ describe("planet inspector", () => {
y: -5, y: -5,
}), }),
localShipClass: [], localShipClass: [],
routes: [],
planets: [],
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
}, },
}); });
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent( expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
@@ -221,6 +241,11 @@ describe("planet inspector", () => {
resources: 5, resources: 5,
}), }),
localShipClass: [], localShipClass: [],
routes: [],
planets: [],
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
}, },
}); });
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull(); expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
@@ -253,6 +278,11 @@ describe("planet inspector", () => {
freeIndustry: 0, freeIndustry: 0,
}), }),
localShipClass: [], localShipClass: [],
routes: [],
planets: [],
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
}, },
context, context,
}); });
@@ -316,6 +346,11 @@ describe("planet inspector", () => {
freeIndustry: 0, freeIndustry: 0,
}), }),
localShipClass: [], localShipClass: [],
routes: [],
planets: [],
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
}, },
context, context,
}); });
@@ -346,6 +381,11 @@ describe("planet inspector", () => {
freeIndustry: 0, freeIndustry: 0,
}), }),
localShipClass: [], localShipClass: [],
routes: [],
planets: [],
mapWidth: 1,
mapHeight: 1,
localPlayerDrive: 0,
}, },
}); });
// Empty production strings collapse to the localised "none" // Empty production strings collapse to the localised "none"
+234
View File
@@ -0,0 +1,234 @@
// Pure-function coverage for `map/cargo-routes.ts.buildCargoRouteLines`.
// The renderer turns each `ReportRouteEntry` into one shaft plus two
// arrowhead wings; the tests assert geometry on a flat fixture, on a
// torus seam-crossing fixture, and the per-load-type style/priority
// mapping. Pixi-free — the helper is a pure projection of the report.
import { describe, expect, test } from "vitest";
import type {
GameReport,
ReportPlanet,
ReportRouteEntry,
} from "../src/api/game-state";
import {
ROUTE_LINE_ID_PREFIX,
STYLE_ROUTE_CAP,
STYLE_ROUTE_COL,
STYLE_ROUTE_EMP,
STYLE_ROUTE_MAT,
buildCargoRouteLines,
} from "../src/map/cargo-routes";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
number: 0,
name: "",
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
function makeReport(
planets: ReportPlanet[],
source: number,
entries: ReportRouteEntry[],
mapWidth = 1000,
mapHeight = 1000,
): GameReport {
return {
turn: 1,
mapWidth,
mapHeight,
planetCount: planets.length,
planets,
race: "Earthlings",
localShipClass: [],
routes: [{ sourcePlanetNumber: source, entries }],
localPlayerDrive: 1,
};
}
describe("buildCargoRouteLines", () => {
test("emits one shaft + two wings per route entry", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 300, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const lines = buildCargoRouteLines(report);
expect(lines.length).toBe(3);
expect(lines.every((l) => l.kind === "line")).toBe(true);
});
test("shaft endpoints follow the no-wrap straight line", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 300, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const [shaft] = buildCargoRouteLines(report);
expect(shaft).toBeDefined();
if (shaft === undefined) return;
expect(shaft.x1).toBe(100);
expect(shaft.y1).toBe(100);
expect(shaft.x2).toBe(300);
expect(shaft.y2).toBe(100);
});
test("shaft uses the torus-shortest delta on the seam", () => {
// Source at x=950, dest at x=50 in a world 1000 wide. The
// shorter wrap is +100 (right past x=1000 to x=1050), not
// 900 (left to x=50).
const report = makeReport(
[
makePlanet({ number: 1, x: 950, y: 500 }),
makePlanet({ number: 2, x: 50, y: 500 }),
],
1,
[{ loadType: "MAT", destinationPlanetNumber: 2 }],
1000,
1000,
);
const [shaft] = buildCargoRouteLines(report);
expect(shaft).toBeDefined();
if (shaft === undefined) return;
expect(shaft.x1).toBe(950);
expect(shaft.x2).toBe(1050); // 950 + 100
expect(shaft.y2).toBe(500);
});
test("each load type maps to the documented style and priority", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 200, y: 100 }),
makePlanet({ number: 3, x: 300, y: 100 }),
makePlanet({ number: 4, x: 400, y: 100 }),
makePlanet({ number: 5, x: 500, y: 100 }),
],
1,
[
{ loadType: "COL", destinationPlanetNumber: 2 },
{ loadType: "CAP", destinationPlanetNumber: 3 },
{ loadType: "MAT", destinationPlanetNumber: 4 },
{ loadType: "EMP", destinationPlanetNumber: 5 },
],
);
const lines = buildCargoRouteLines(report);
expect(lines.length).toBe(12);
const styleByPriority = new Map<number, typeof lines[number]["style"]>();
for (const line of lines) {
const existing = styleByPriority.get(line.priority);
if (existing === undefined) styleByPriority.set(line.priority, line.style);
else expect(existing).toBe(line.style);
}
expect(styleByPriority.get(8)).toBe(STYLE_ROUTE_COL);
expect(styleByPriority.get(7)).toBe(STYLE_ROUTE_CAP);
expect(styleByPriority.get(6)).toBe(STYLE_ROUTE_MAT);
expect(styleByPriority.get(5)).toBe(STYLE_ROUTE_EMP);
});
test("line ids carry the ROUTE_LINE_ID_PREFIX high bit", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 200, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const lines = buildCargoRouteLines(report);
for (const line of lines) {
expect((line.id & ROUTE_LINE_ID_PREFIX) !== 0).toBe(true);
}
// Three distinct ids — one per segment.
const ids = new Set(lines.map((l) => l.id));
expect(ids.size).toBe(3);
});
test("skips routes whose source or destination is missing", () => {
const report = makeReport(
[makePlanet({ number: 1, x: 100, y: 100 })],
1,
[
{ loadType: "COL", destinationPlanetNumber: 999 }, // unknown dest
],
);
expect(buildCargoRouteLines(report).length).toBe(0);
});
test("skips zero-length routes (source == destination coords)", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 100, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
expect(buildCargoRouteLines(report).length).toBe(0);
});
test("returns an empty array when no routes are configured", () => {
const report: GameReport = {
turn: 1,
mapWidth: 1000,
mapHeight: 1000,
planetCount: 1,
planets: [makePlanet({ number: 1, x: 100, y: 100 })],
race: "Earthlings",
localShipClass: [],
routes: [],
localPlayerDrive: 1,
};
expect(buildCargoRouteLines(report)).toEqual([]);
});
test("arrowhead wings symmetric around the shaft direction", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 0, y: 0 }),
makePlanet({ number: 2, x: 100, y: 0 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const [shaft, leftWing, rightWing] = buildCargoRouteLines(report);
expect(shaft).toBeDefined();
expect(leftWing).toBeDefined();
expect(rightWing).toBeDefined();
if (
shaft === undefined ||
leftWing === undefined ||
rightWing === undefined
)
return;
// Both wings start at the head.
expect(leftWing.x1).toBe(shaft.x2);
expect(leftWing.y1).toBe(shaft.y2);
expect(rightWing.x1).toBe(shaft.x2);
expect(rightWing.y1).toBe(shaft.y2);
// And land symmetrically around the y axis (shaft along +x).
expect(leftWing.y2 + rightWing.y2).toBeCloseTo(0);
expect(leftWing.x2).toBeCloseTo(rightWing.x2);
});
});
+45 -26
View File
@@ -4,6 +4,12 @@
// ui/docs/renderer.md. Worlds are kept tiny (15 primitives) so the // ui/docs/renderer.md. Worlds are kept tiny (15 primitives) so the
// expected hit is obvious from the geometry; the camera is at scale=1 // expected hit is obvious from the geometry; the camera is at scale=1
// in most cases so slop in pixels equals slop in world units. // in most cases so slop in pixels equals slop in world units.
//
// The point hit zone is `(pointRadiusPx + slopPx) / camera.scale`
// world units — the visible disc plus an ergonomic slop on top. The
// default `pointRadiusPx` (`DEFAULT_POINT_RADIUS_PX`) is 3 and the
// default point slop (`DEFAULT_HIT_SLOP_PX.point`) is 4, so a default
// point is hit out to 7 world units at scale=1.
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { hitTest } from "../src/map/hit-test"; import { hitTest } from "../src/map/hit-test";
@@ -101,16 +107,32 @@ describe("hitTest — point primitive", () => {
test("direct hit at centre", () => { test("direct hit at centre", () => {
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1); expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
}); });
test("hit within default slop (8px)", () => { test("hit on the visible disc edge (3 world units from centre)", () => {
// 7 world units away at scale=1 → within 8px slop. // Default radius 3 → cursor 3 units away lands on the disc.
expect(ids(w, "torus", cam, cursorOver(503, 500, cam))).toBe(1);
});
test("hit just inside the default slop margin (within radius+slop)", () => {
// 7 world units away at scale=1 → equals radius (3) + slop (4).
expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1); expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1);
}); });
test("miss just outside default slop", () => { test("miss just outside radius+slop", () => {
// 9 world units away at scale=1 → radius+slop is 7.
expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null); expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null);
}); });
test("custom hitSlopPx widens the hit area", () => { test("explicit pointRadiusPx widens the visible footprint", () => {
// pointRadiusPx 10 + default slop 4 → hit out to 14 world units.
const w2 = new World(1000, 1000, [
point(1, 500, 500, { style: { pointRadiusPx: 10 } }),
]);
expect(ids(w2, "torus", cam, cursorOver(513, 500, cam))).toBe(1);
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(null);
});
test("custom hitSlopPx widens the slop margin", () => {
// pointRadiusPx defaults to 3; slop override is 20.
// Cursor 22 world units away → within 3+20.
const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]); const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]);
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(1); expect(ids(w2, "torus", cam, cursorOver(522, 500, cam))).toBe(1);
expect(ids(w2, "torus", cam, cursorOver(524, 500, cam))).toBe(null);
}); });
}); });
@@ -118,7 +140,7 @@ describe("hitTest — torus wrap", () => {
test("point near the right edge is hit by cursor near the left edge", () => { test("point near the right edge is hit by cursor near the left edge", () => {
// World 100×100, point at x=98. Camera at left edge (x=2). // World 100×100, point at x=98. Camera at left edge (x=2).
// Cursor at x=4 is 6 units from x=98 via the wrap; default // Cursor at x=4 is 6 units from x=98 via the wrap; default
// point slop is 8px → hit. // point radius (3) + slop (4) = 7 → hit.
const cam = camAt(2, 50); const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]); const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "torus", cam, cursorOver(4, 50, cam))).toBe(1); expect(ids(w, "torus", cam, cursorOver(4, 50, cam))).toBe(1);
@@ -235,29 +257,26 @@ describe("hitTest — empty results and scale", () => {
}); });
test("higher zoom shrinks the on-screen slop in world units", () => { test("higher zoom shrinks the on-screen slop in world units", () => {
// At scale=4, 8px on screen = 2 world units. // At scale=4, slopPx 4 = 1 world unit; visible radius stays 3
// A point 3 world units away misses. // world units. Threshold = 4 world units.
const w = new World(1000, 1000, [point(1, 503, 500)]); const w = new World(1000, 1000, [point(1, 503, 500)]);
expect(ids(w, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4)))).toBe( const cam4 = camAt(500, 500, 4);
null, // 3 world units away → on the disc edge → hit.
); expect(ids(w, "torus", cam4, cursorOver(503, 500, cam4))).toBe(1);
// A point 1.5 world units away hits at scale=4 (≤ 2). // 5 world units away → beyond radius+slop → null.
const w2 = new World(1000, 1000, [point(1, 501.5, 500)]); const wFar = new World(1000, 1000, [point(1, 505, 500)]);
expect( expect(ids(wFar, "torus", cam4, cursorOver(500, 500, cam4))).toBe(null);
ids(w2, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4))),
).toBe(1);
}); });
test("lower zoom widens the on-screen slop in world units", () => { test("lower zoom widens the on-screen slop in world units", () => {
// At scale=0.5, 8px on screen = 16 world units. // At scale=0.5, slopPx 4 = 8 world units; visible radius
const w = new World(1000, 1000, [point(1, 514, 500)]); // stays 3 → threshold = 11 world units.
expect( const cam05 = camAt(500, 500, 0.5);
ids( const w = new World(1000, 1000, [point(1, 510, 500)]);
w, // 10 world units away → within 11 → hit.
"torus", expect(ids(w, "torus", cam05, cursorOver(500, 500, cam05))).toBe(1);
camAt(500, 500, 0.5), const wFar = new World(1000, 1000, [point(1, 514, 500)]);
cursorOver(500, 500, camAt(500, 500, 0.5)), // 14 world units away → beyond 11 → null.
), expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
).toBe(1);
}); });
}); });
+179
View File
@@ -0,0 +1,179 @@
// Pure-state coverage for the pick-mode overlay helper. The
// renderer owns the Pixi side (`render.ts.openPickMode`); this file
// asserts that `computePickOverlay` produces the correct draw spec
// for every meaningful input combination — Pixi-free, so it stays
// fast and stable against renderer plumbing changes.
import { describe, expect, test } from "vitest";
import {
ANCHOR_PADDING_WORLD,
HOVER_PADDING_WORLD,
computePickOverlay,
type PickModeOptions,
} from "../src/map/pick-mode";
import {
DEFAULT_POINT_RADIUS_PX,
type PointPrim,
type PrimitiveID,
} from "../src/map/world";
function makePoint(
id: PrimitiveID,
x: number,
y: number,
pointRadiusPx?: number,
): PointPrim {
return {
kind: "point",
id,
priority: 0,
hitSlopPx: 0,
x,
y,
style: pointRadiusPx === undefined ? {} : { pointRadiusPx },
};
}
function makeOptions(
overrides: Partial<PickModeOptions> = {},
): PickModeOptions {
return {
sourcePrimitiveId: 1,
sourceX: 100,
sourceY: 100,
reachableIds: new Set([2, 3]),
onPick: () => {},
...overrides,
};
}
describe("computePickOverlay", () => {
const points = new Map<PrimitiveID, PointPrim>([
[1, makePoint(1, 100, 100, 6)],
[2, makePoint(2, 200, 100, 5)],
[3, makePoint(3, 100, 200)],
[4, makePoint(4, 300, 300, 4)],
]);
const allIds: PrimitiveID[] = [1, 2, 3, 4];
test("anchor radius equals source pointRadiusPx + ANCHOR_PADDING_WORLD", () => {
const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
expect(spec.anchor.x).toBe(100);
expect(spec.anchor.y).toBe(100);
expect(spec.anchor.radius).toBe(6 + ANCHOR_PADDING_WORLD);
});
test("anchor radius falls back to default when source has no pointRadiusPx", () => {
const sourceless = new Map(points);
sourceless.set(1, makePoint(1, 100, 100));
const spec = computePickOverlay(
makeOptions(),
null,
null,
sourceless,
allIds,
);
expect(spec.anchor.radius).toBe(
DEFAULT_POINT_RADIUS_PX + ANCHOR_PADDING_WORLD,
);
});
test("dimmedIds covers everything outside source + reachable", () => {
const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
expect(Array.from(spec.dimmedIds).sort()).toEqual([4]);
});
test("dimmedIds is empty when every primitive is either source or reachable", () => {
const spec = computePickOverlay(
makeOptions({ reachableIds: new Set([2, 3, 4]) }),
null,
null,
points,
allIds,
);
expect(spec.dimmedIds.size).toBe(0);
});
test("line is null while the cursor is off-canvas", () => {
const spec = computePickOverlay(makeOptions(), null, null, points, allIds);
expect(spec.line).toBeNull();
});
test("line endpoints follow the cursor when present", () => {
const spec = computePickOverlay(
makeOptions(),
{ x: 250, y: 320 },
null,
points,
allIds,
);
expect(spec.line).toEqual({
x1: 100,
y1: 100,
x2: 250,
y2: 320,
});
});
test("hoverOutline is null when nothing is hovered", () => {
const spec = computePickOverlay(
makeOptions(),
{ x: 1, y: 1 },
null,
points,
allIds,
);
expect(spec.hoverOutline).toBeNull();
});
test("hoverOutline is null when the hover targets a non-reachable primitive", () => {
const spec = computePickOverlay(
makeOptions(),
{ x: 1, y: 1 },
4,
points,
allIds,
);
expect(spec.hoverOutline).toBeNull();
});
test("hoverOutline is null when the hover targets the source planet", () => {
const spec = computePickOverlay(
makeOptions(),
{ x: 1, y: 1 },
1,
points,
allIds,
);
expect(spec.hoverOutline).toBeNull();
});
test("hoverOutline reflects the reachable target with HOVER_PADDING_WORLD", () => {
const spec = computePickOverlay(
makeOptions(),
{ x: 1, y: 1 },
2,
points,
allIds,
);
expect(spec.hoverOutline).toEqual({
x: 200,
y: 100,
radius: 5 + HOVER_PADDING_WORLD,
});
});
test("hoverOutline radius falls back to default radius for default-style points", () => {
const spec = computePickOverlay(
makeOptions(),
{ x: 1, y: 1 },
3,
points,
allIds,
);
expect(spec.hoverOutline?.radius).toBe(
DEFAULT_POINT_RADIUS_PX + HOVER_PADDING_WORLD,
);
});
});
+98
View File
@@ -328,6 +328,104 @@ describe("OrderDraftStore", () => {
store.dispose(); store.dispose();
}); });
test("setCargoRoute collapses by (source, loadType) — newer wins", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setCargoRoute",
id: "first",
sourcePlanetNumber: 1,
destinationPlanetNumber: 2,
loadType: "COL",
});
await store.add({
kind: "setCargoRoute",
id: "second",
sourcePlanetNumber: 1,
destinationPlanetNumber: 3,
loadType: "COL",
});
expect(store.commands.map((c) => c.id)).toEqual(["second"]);
expect(store.statuses["first"]).toBeUndefined();
expect(store.statuses["second"]).toBe("valid");
store.dispose();
});
test("setCargoRoute and removeCargoRoute share a collapse key", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setCargoRoute",
id: "set",
sourcePlanetNumber: 1,
destinationPlanetNumber: 2,
loadType: "MAT",
});
await store.add({
kind: "removeCargoRoute",
id: "remove",
sourcePlanetNumber: 1,
loadType: "MAT",
});
expect(store.commands.map((c) => c.id)).toEqual(["remove"]);
// And remove → set on the same slot collapses again.
await store.add({
kind: "setCargoRoute",
id: "set2",
sourcePlanetNumber: 1,
destinationPlanetNumber: 4,
loadType: "MAT",
});
expect(store.commands.map((c) => c.id)).toEqual(["set2"]);
store.dispose();
});
test("cargo routes for different load-types or sources stay independent", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setCargoRoute",
id: "p1-col",
sourcePlanetNumber: 1,
destinationPlanetNumber: 2,
loadType: "COL",
});
await store.add({
kind: "setCargoRoute",
id: "p1-cap",
sourcePlanetNumber: 1,
destinationPlanetNumber: 3,
loadType: "CAP",
});
await store.add({
kind: "setCargoRoute",
id: "p9-col",
sourcePlanetNumber: 9,
destinationPlanetNumber: 2,
loadType: "COL",
});
expect(store.commands.map((c) => c.id)).toEqual([
"p1-col",
"p1-cap",
"p9-col",
]);
store.dispose();
});
test("setCargoRoute is invalid when source equals destination", async () => {
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.add({
kind: "setCargoRoute",
id: "self",
sourcePlanetNumber: 1,
destinationPlanetNumber: 1,
loadType: "EMP",
});
expect(store.statuses["self"]).toBe("invalid");
store.dispose();
});
test("hydrateFromServer overwrites the local cache with the server snapshot", async () => { test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
const { fakeFetchClient } = await import("./helpers/fake-order-client"); const { fakeFetchClient } = await import("./helpers/fake-order-client");
const { client } = fakeFetchClient(GAME_ID, [ const { client } = fakeFetchClient(GAME_ID, [
+131
View File
@@ -13,7 +13,10 @@ import {
CommandPayload, CommandPayload,
CommandPlanetProduce, CommandPlanetProduce,
CommandPlanetRename, CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
PlanetProduction, PlanetProduction,
PlanetRouteLoadType,
UserGamesOrder, UserGamesOrder,
UserGamesOrderGet, UserGamesOrderGet,
UserGamesOrderGetResponse, UserGamesOrderGetResponse,
@@ -219,6 +222,134 @@ describe("fetchOrder", () => {
expect(result.commands).toEqual([]); expect(result.commands).toEqual([]);
}); });
test("decodes CommandPlanetRouteSet into setCargoRoute", async () => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString("cmd-route-set");
const inner = CommandPlanetRouteSet.createCommandPlanetRouteSet(
builder,
BigInt(11),
BigInt(22),
PlanetRouteLoadType.MAT,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRouteSet);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(7));
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
builder,
);
builder.finish(offset);
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: builder.asUint8Array(),
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
expect(result.commands).toHaveLength(1);
const cmd = result.commands[0]!;
expect(cmd.kind).toBe("setCargoRoute");
if (cmd.kind !== "setCargoRoute") return;
expect(cmd.id).toBe("cmd-route-set");
expect(cmd.sourcePlanetNumber).toBe(11);
expect(cmd.destinationPlanetNumber).toBe(22);
expect(cmd.loadType).toBe("MAT");
});
test("decodes CommandPlanetRouteRemove into removeCargoRoute", async () => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString("cmd-route-remove");
const inner = CommandPlanetRouteRemove.createCommandPlanetRouteRemove(
builder,
BigInt(33),
PlanetRouteLoadType.EMP,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(
builder,
CommandPayload.CommandPlanetRouteRemove,
);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(8));
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
builder,
);
builder.finish(offset);
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: builder.asUint8Array(),
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
expect(result.commands).toHaveLength(1);
const cmd = result.commands[0]!;
expect(cmd.kind).toBe("removeCargoRoute");
if (cmd.kind !== "removeCargoRoute") return;
expect(cmd.sourcePlanetNumber).toBe(33);
expect(cmd.loadType).toBe("EMP");
});
test("skips a CommandPlanetRouteSet with PlanetRouteLoadType.UNKNOWN", async () => {
const builder = new Builder(256);
const cmdIdOffset = builder.createString("cmd-unknown-load");
const inner = CommandPlanetRouteSet.createCommandPlanetRouteSet(
builder,
BigInt(1),
BigInt(2),
PlanetRouteLoadType.UNKNOWN,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRouteSet);
CommandItem.addPayload(builder, inner);
const item = CommandItem.endCommandItem(builder);
const commandsVec = UserGamesOrder.createCommandsVector(builder, [item]);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(0));
UserGamesOrder.addCommands(builder, commandsVec);
const orderOffset = UserGamesOrder.endUserGamesOrder(builder);
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, true);
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
const offset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(
builder,
);
builder.finish(offset);
const exec = vi.fn(async () => ({
resultCode: "ok",
payloadBytes: builder.asUint8Array(),
}));
const result = await fetchOrder(mockClient(exec), GAME_ID, 5);
expect(result.commands).toEqual([]);
});
test("posts a well-formed UserGamesOrderGet payload", async () => { test("posts a well-formed UserGamesOrderGet payload", async () => {
let captured: Uint8Array | null = null; let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => { const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
+111
View File
@@ -48,6 +48,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
planets, planets,
race: "", race: "",
localShipClass: [], localShipClass: [],
routes: [],
localPlayerDrive: 0,
}; };
} }
@@ -249,6 +251,115 @@ describe("applyOrderOverlay", () => {
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" }); const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
expect(out).toBe(report); expect(out).toBe(report);
}); });
test("setCargoRoute upserts a route entry when applied", () => {
const report = makeReport([
makePlanet({ number: 1, name: "Earth" }),
makePlanet({ number: 2, name: "Mars" }),
]);
const cmd: OrderCommand = {
kind: "setCargoRoute",
id: "cargo-1",
sourcePlanetNumber: 1,
destinationPlanetNumber: 2,
loadType: "COL",
};
const out = applyOrderOverlay(report, [cmd], { "cargo-1": "applied" });
expect(out).not.toBe(report);
expect(out.routes).toHaveLength(1);
expect(out.routes[0]!.sourcePlanetNumber).toBe(1);
expect(out.routes[0]!.entries).toEqual([
{ loadType: "COL", destinationPlanetNumber: 2 },
]);
});
test("setCargoRoute on an existing slot replaces the destination", () => {
const report: GameReport = {
...makeReport([makePlanet({ number: 1, name: "Earth" })]),
routes: [
{
sourcePlanetNumber: 1,
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
},
],
};
const cmd: OrderCommand = {
kind: "setCargoRoute",
id: "cargo-1",
sourcePlanetNumber: 1,
destinationPlanetNumber: 5,
loadType: "COL",
};
const out = applyOrderOverlay(report, [cmd], { "cargo-1": "applied" });
expect(out.routes[0]!.entries).toEqual([
{ loadType: "COL", destinationPlanetNumber: 5 },
]);
});
test("removeCargoRoute drops the matching slot and preserves the others", () => {
const report: GameReport = {
...makeReport([makePlanet({ number: 1, name: "Earth" })]),
routes: [
{
sourcePlanetNumber: 1,
entries: [
{ loadType: "COL", destinationPlanetNumber: 2 },
{ loadType: "MAT", destinationPlanetNumber: 3 },
],
},
],
};
const cmd: OrderCommand = {
kind: "removeCargoRoute",
id: "rem-1",
sourcePlanetNumber: 1,
loadType: "COL",
};
const out = applyOrderOverlay(report, [cmd], { "rem-1": "applied" });
expect(out.routes[0]!.entries).toEqual([
{ loadType: "MAT", destinationPlanetNumber: 3 },
]);
});
test("removeCargoRoute clears the route entry entirely when last slot drops", () => {
const report: GameReport = {
...makeReport([makePlanet({ number: 1, name: "Earth" })]),
routes: [
{
sourcePlanetNumber: 1,
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
},
],
};
const cmd: OrderCommand = {
kind: "removeCargoRoute",
id: "rem-1",
sourcePlanetNumber: 1,
loadType: "COL",
};
const out = applyOrderOverlay(report, [cmd], { "rem-1": "applied" });
expect(out.routes).toEqual([]);
});
test("cargo route overlays skip draft / invalid / rejected statuses", () => {
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
const cmd: OrderCommand = {
kind: "setCargoRoute",
id: "cargo-1",
sourcePlanetNumber: 1,
destinationPlanetNumber: 2,
loadType: "COL",
};
expect(applyOrderOverlay(report, [cmd], { "cargo-1": "draft" })).toBe(
report,
);
expect(applyOrderOverlay(report, [cmd], { "cargo-1": "invalid" })).toBe(
report,
);
expect(applyOrderOverlay(report, [cmd], { "cargo-1": "rejected" })).toBe(
report,
);
});
}); });
describe("productionDisplayFromCommand", () => { describe("productionDisplayFromCommand", () => {
+26
View File
@@ -21,6 +21,8 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
planets: [], planets: [],
race: "", race: "",
localShipClass: [], localShipClass: [],
routes: [],
localPlayerDrive: 0,
...overrides, ...overrides,
}; };
} }
@@ -136,4 +138,28 @@ describe("reportToWorld", () => {
const unknown = world.primitives.find((p) => p.id === 2); const unknown = world.primitives.find((p) => p.id === 2);
expect(local?.priority ?? 0).toBeGreaterThan(unknown?.priority ?? 0); expect(local?.priority ?? 0).toBeGreaterThan(unknown?.priority ?? 0);
}); });
test("cargo routes are NOT inlined into the static world", () => {
// As of Phase 16 cargo-route arrows are pushed onto the live
// renderer via `setExtraPrimitives` instead of being baked
// into `reportToWorld`. The base world stays a clean
// representation of the report's planets so the renderer
// can rebuild the overlay without disposing Pixi.
const world = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, name: "Earth", x: 100, y: 100, kind: "local", size: 5, resources: 1 }),
makePlanet({ number: 2, name: "Mars", x: 300, y: 100, kind: "local", size: 5, resources: 1 }),
],
routes: [
{
sourcePlanetNumber: 1,
entries: [{ loadType: "COL", destinationPlanetNumber: 2 }],
},
],
}),
);
const lines = world.primitives.filter((p) => p.kind === "line");
expect(lines.length).toBe(0);
});
}); });
+86
View File
@@ -13,13 +13,17 @@ import {
CommandItem, CommandItem,
CommandPlanetProduce, CommandPlanetProduce,
CommandPlanetRename, CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandPayload, CommandPayload,
PlanetProduction, PlanetProduction,
PlanetRouteLoadType,
UserGamesOrder, UserGamesOrder,
UserGamesOrderResponse, UserGamesOrderResponse,
} from "../src/proto/galaxy/fbs/order"; } from "../src/proto/galaxy/fbs/order";
import { submitOrder } from "../src/sync/submit"; import { submitOrder } from "../src/sync/submit";
import type { import type {
CargoLoadType,
OrderCommand, OrderCommand,
ProductionType, ProductionType,
} from "../src/sync/order-types"; } from "../src/sync/order-types";
@@ -214,6 +218,88 @@ describe("submitOrder", () => {
expect(inner.subject()).toBe("Scout"); expect(inner.subject()).toBe("Scout");
}); });
test("encodes setCargoRoute as CommandPlanetRouteSet on the wire", async () => {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
captured = payload;
return { resultCode: "ok", payloadBytes: new Uint8Array() };
});
const cmd: OrderCommand = {
kind: "setCargoRoute",
id: "00000000-0000-0000-0000-00000000aaaa",
sourcePlanetNumber: 17,
destinationPlanetNumber: 23,
loadType: "COL",
};
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
expect(captured).not.toBeNull();
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(captured!),
);
const item = decoded.commands(0);
expect(item).not.toBeNull();
expect(item!.payloadType()).toBe(CommandPayload.CommandPlanetRouteSet);
const inner = new CommandPlanetRouteSet();
item!.payload(inner);
expect(Number(inner.origin())).toBe(17);
expect(Number(inner.destination())).toBe(23);
expect(inner.loadType()).toBe(PlanetRouteLoadType.COL);
});
test("encodes removeCargoRoute as CommandPlanetRouteRemove on the wire", async () => {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
captured = payload;
return { resultCode: "ok", payloadBytes: new Uint8Array() };
});
const cmd: OrderCommand = {
kind: "removeCargoRoute",
id: "00000000-0000-0000-0000-00000000bbbb",
sourcePlanetNumber: 17,
loadType: "MAT",
};
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(captured!),
);
const item = decoded.commands(0);
expect(item!.payloadType()).toBe(CommandPayload.CommandPlanetRouteRemove);
const inner = new CommandPlanetRouteRemove();
item!.payload(inner);
expect(Number(inner.origin())).toBe(17);
expect(inner.loadType()).toBe(PlanetRouteLoadType.MAT);
});
test("maps every cargoLoadType literal to its FBS enum value", async () => {
const cases: Array<{ loadType: CargoLoadType; fbs: PlanetRouteLoadType }> = [
{ loadType: "COL", fbs: PlanetRouteLoadType.COL },
{ loadType: "CAP", fbs: PlanetRouteLoadType.CAP },
{ loadType: "MAT", fbs: PlanetRouteLoadType.MAT },
{ loadType: "EMP", fbs: PlanetRouteLoadType.EMP },
];
for (const tc of cases) {
let captured: Uint8Array | null = null;
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
captured = payload;
return { resultCode: "ok", payloadBytes: new Uint8Array() };
});
const cmd: OrderCommand = {
kind: "setCargoRoute",
id: `id-${tc.loadType}`,
sourcePlanetNumber: 5,
destinationPlanetNumber: 6,
loadType: tc.loadType,
};
await submitOrder(mockClient(exec), GAME_ID, [cmd]);
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(captured!),
);
const inner = new CommandPlanetRouteSet();
decoded.commands(0)!.payload(inner);
expect(inner.loadType()).toBe(tc.fbs);
}
});
test("maps every productionType literal to its FBS enum value", async () => { test("maps every productionType literal to its FBS enum value", async () => {
const cases: Array<{ const cases: Array<{
productionType: ProductionType; productionType: ProductionType;