ui/phase-16: cargo routes inspector + map pick foundation
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with a renderer-driven destination picker (faded out-of-reach planets, cursor-line anchor, hover-highlight) and per-route arrows on the map. The pick-mode primitives are exposed via `MapPickService` so ship-group dispatch in Phase 19/20 can reuse the same surface. Pass A — generic map foundation: - hit-test now sizes the click zone to `pointRadiusPx + slopPx` so the visible disc is always part of the target. - `RendererHandle` gains `onPointerMove`, `onHoverChange`, `setPickMode`, `getPickState`, `getPrimitiveAlpha`, `setExtraPrimitives`, `getPrimitives`. The click dispatcher is centralised: pick-mode swallows clicks atomically so the standard selection consumers do not race against teardown. - `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer contract in a promise-shaped `pick(...)`. The in-game shell layout owns the service so sidebar and bottom-sheet inspectors see the same instance. - Debug-surface registry exposes `getMapPrimitives`, `getMapPickState`, `getMapCamera` to e2e specs without spawning a separate debug page after navigation. Pass B — cargo-route feature: - `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed variants with `(source, loadType)` collapse rule on the order draft; round-trip through the FBS encoder/decoder. - `GameReport` decodes `routes` and the local player's drive tech for the inline reach formula (40 × drive). `applyOrderOverlay` upserts/drops route entries for valid/submitting/applied commands. - `lib/inspectors/planet/cargo-routes.svelte` renders the four-slot section. `Add` / `Edit` call `MapPickService.pick`, `Remove` emits `removeCargoRoute`. - `map/cargo-routes.ts` builds shaft + arrowhead primitives per cargo type; the map view pushes them through `setExtraPrimitives` so the renderer never re-inits Pixi on route mutations (Pixi 8 doesn't support that on a reused canvas). Docs: - `docs/cargo-routes-ux.md` covers engine semantics + UI map. - `docs/renderer.md` documents pick mode and the debug surface. - `docs/calc-bridge.md` records the Phase 16 reach waiver. - `PLAN.md` rewrites Phase 16 to reflect the foundation + feature split and the decisions baked in (map-driven picker, inline reach, optimistic overlay via `setExtraPrimitives`). Tests: - `tests/map-pick-mode.test.ts` — pure overlay-spec helper. - `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`. - `tests/inspector-planet-cargo-routes.test.ts` — slot rendering, picker invocation, collapse, cancel, remove. - Extensions to `order-draft`, `submit`, `order-load`, `order-overlay`, `state-binding`, `inspector-planet`, `inspector-overlay`, `game-shell-sidebar`, `game-shell-header`. - `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add COL, add CAP, remove COL, asserting both the inspector and the arrow count via `__galaxyDebug.getMapPrimitives()`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+111
-22
@@ -1814,22 +1814,77 @@ Verified on local-ci run 16 (`success`, 4273102).
|
||||
Status: pending.
|
||||
|
||||
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
|
||||
`SetCargoRoute` and `RemoveCargoRoute` command variants
|
||||
- destination-planet picker filtered by reach (uses `pkg/calc/` reach
|
||||
function via `ui/core/calc/`)
|
||||
- `ui/frontend/src/map/cargo-routes.ts` renders route arrows on the
|
||||
map between source and destination planet, styled per cargo type
|
||||
- topic doc `ui/docs/cargo-routes-ux.md` capturing the priority
|
||||
semantics from [`rules.txt`](../game/rules.txt) (`colonists → industry → materials →
|
||||
empty`)
|
||||
`CargoLoadType`, `SetCargoRouteCommand`, and
|
||||
`RemoveCargoRouteCommand`. `CARGO_LOAD_TYPE_VALUES` is the
|
||||
priority order (`COL`, `CAP`, `MAT`, `EMP`).
|
||||
- `ui/frontend/src/sync/order-draft.svelte.ts` collapses both
|
||||
variants by `(sourcePlanetNumber, loadType)`; the newer entry
|
||||
supersedes any prior `set` or `remove` for the same slot.
|
||||
- `ui/frontend/src/sync/submit.ts` and
|
||||
`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 808–843) 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.
|
||||
|
||||
@@ -1837,20 +1892,54 @@ Acceptance criteria:
|
||||
|
||||
- the user can add, edit, and remove cargo routes through the
|
||||
inspector;
|
||||
- destination picker disables planets outside reach with a tooltip
|
||||
explaining the constraint;
|
||||
- the destination picker happens on the map: out-of-reach planets
|
||||
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
|
||||
type per source planet;
|
||||
- configured routes are rendered as arrows on the map between source
|
||||
and destination planets, distinguishable per cargo type.
|
||||
- configured routes are rendered as arrows on the map between
|
||||
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:
|
||||
|
||||
- Vitest unit tests for slot-conflict detection;
|
||||
- Vitest unit tests for cargo-route arrow rendering on torus and
|
||||
no-wrap fixtures;
|
||||
- Playwright e2e: add a route end-to-end, confirm server applies it
|
||||
on next turn and the arrow is visible on the map.
|
||||
- Vitest: `tests/map-hit-test.test.ts` (regenerated for the
|
||||
visible-radius formula), `tests/map-pick-mode.test.ts`
|
||||
(`computePickOverlay` lifecycle),
|
||||
`tests/map-cargo-routes.test.ts`,
|
||||
`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
|
||||
|
||||
|
||||
@@ -63,6 +63,34 @@ outputs") is therefore intentionally not satisfied; the rewritten
|
||||
Phase 15 stage text records this decision and points back at this
|
||||
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)
|
||||
|
||||
When the bridge phase lands, the contract should be:
|
||||
|
||||
@@ -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 808–843); this file is the source of truth for how
|
||||
the UI surfaces those rules.
|
||||
|
||||
## Engine semantics in one paragraph
|
||||
|
||||
A cargo route on a planet you own pairs a load-type slot
|
||||
(`COL`/`CAP`/`MAT`/`EMP`) with a destination planet. Once set, the
|
||||
engine loads transport ships at the source on every turn cutoff and
|
||||
sends them to the destination, draining the load-type stockpile
|
||||
("colonists" → population pool, "capital" → industry crates, "mat" →
|
||||
raw materials, "empty" → ships returning unloaded). When several
|
||||
slots are configured the engine processes them in
|
||||
`COL > CAP > MAT > EMP` priority order
|
||||
(`game/internal/controller/route.go.SendRoutedGroups`, line 101).
|
||||
Routes are constrained by reach: the destination must be no further
|
||||
than `40 × driveTech` world units along the torus-shortest path
|
||||
(`util.ShortDistance` ≤ `Race.FlightDistance()`), and a route whose
|
||||
destination becomes unreachable at the next turn is auto-removed
|
||||
(`RemoveUnreachableRoutes`).
|
||||
|
||||
## Four-slot inspector subsection
|
||||
|
||||
The cargo-routes subsection renders below the production controls
|
||||
on every owned planet inspector. Slots appear in
|
||||
`CARGO_LOAD_TYPE_VALUES` order (COL, CAP, MAT, EMP) so visual order
|
||||
matches the engine's load priority — players who scan top-down see
|
||||
the highest-priority cargo first.
|
||||
|
||||
Slot states:
|
||||
|
||||
- **Empty** — `(no route)` text plus a 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
@@ -116,7 +116,11 @@ target.
|
||||
|
||||
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
|
||||
`radius` is in world units. The circle counts as filled when
|
||||
`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
|
||||
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/map-math.test.ts` — `clamp`, `torusShortestDelta`,
|
||||
@@ -227,11 +297,14 @@ average frame time over a scripted drag.
|
||||
- `tests/map-no-wrap.test.ts` — `clampCameraNoWrap`,
|
||||
`minScaleNoWrap`, `pivotZoom` (point-under-cursor invariant
|
||||
verified within float64 precision).
|
||||
- `tests/map-hit-test.test.ts` — 22 hand-built cases covering
|
||||
every rule from the algorithm above: hit/miss with default and
|
||||
custom slop, torus wrap copies, filled vs stroked circles,
|
||||
line endpoint clamping, priority/kind/id ordering, scale
|
||||
effect on slop.
|
||||
- `tests/map-hit-test.test.ts` — hand-built cases covering every
|
||||
rule from the algorithm above: hit/miss with default and
|
||||
custom slop (now including `pointRadiusPx`), torus wrap
|
||||
copies, filled vs stroked circles, line endpoint clamping,
|
||||
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
|
||||
browsers, mode toggle, wheel zoom, no-wrap clamp after drag,
|
||||
hit-test plumbing.
|
||||
|
||||
@@ -30,10 +30,12 @@ import {
|
||||
Report,
|
||||
} from "../proto/galaxy/fbs/report";
|
||||
import type {
|
||||
CargoLoadType,
|
||||
CommandStatus,
|
||||
OrderCommand,
|
||||
ProductionType,
|
||||
} from "../sync/order-types";
|
||||
import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType } from "../sync/order-types";
|
||||
|
||||
const MESSAGE_TYPE = "user.games.report";
|
||||
|
||||
@@ -82,6 +84,30 @@ export interface ShipClassSummary {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportRouteEntry is one slot of a planet's cargo-route table —
|
||||
* a (loadType, destinationPlanetNumber) pair. The engine stores
|
||||
* the entries as `map[RouteType]uint` per planet
|
||||
* (`game/internal/model/game/planet.go`); this type flattens that
|
||||
* map into an array so iteration order is stable for tests and
|
||||
* the map-arrow renderer.
|
||||
*/
|
||||
export interface ReportRouteEntry {
|
||||
loadType: CargoLoadType;
|
||||
destinationPlanetNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReportRoute groups every cargo-route slot configured on a
|
||||
* single source planet. `entries` is sorted by
|
||||
* `CARGO_LOAD_TYPE_VALUES` priority (COL → CAP → MAT → EMP) so
|
||||
* the inspector and the map renderer see deterministic order.
|
||||
*/
|
||||
export interface ReportRoute {
|
||||
sourcePlanetNumber: number;
|
||||
entries: ReportRouteEntry[];
|
||||
}
|
||||
|
||||
export interface GameReport {
|
||||
turn: number;
|
||||
mapWidth: number;
|
||||
@@ -102,6 +128,24 @@ export interface GameReport {
|
||||
* empty.
|
||||
*/
|
||||
localShipClass: ShipClassSummary[];
|
||||
/**
|
||||
* routes lists every cargo route the player has configured.
|
||||
* Each entry is keyed by source planet; the per-planet
|
||||
* `entries` array is sorted in turn-cutoff load order
|
||||
* (`CARGO_LOAD_TYPE_VALUES`). Empty when no routes are set or
|
||||
* when the report does not carry the route field.
|
||||
*/
|
||||
routes: ReportRoute[];
|
||||
/**
|
||||
* localPlayerDrive is the local player's drive tech level. The
|
||||
* engine's reach formula is `40 * driveTech`
|
||||
* (`game/internal/model/game/race.go.FlightDistance`); the
|
||||
* cargo-route picker filters destinations through it, so the
|
||||
* value is propagated all the way through `applyOrderOverlay`
|
||||
* to the inspector subsection. Zero on boot or when the
|
||||
* report's player block is missing the local entry.
|
||||
*/
|
||||
localPlayerDrive: number;
|
||||
}
|
||||
|
||||
export async function fetchGameReport(
|
||||
@@ -225,17 +269,94 @@ function decodeReport(report: Report): GameReport {
|
||||
localShipClass.push({ name: sc.name() ?? "" });
|
||||
}
|
||||
|
||||
const raceName = report.race() ?? "";
|
||||
const routes = decodeReportRoutes(report);
|
||||
const localPlayerDrive = findLocalPlayerDrive(report, raceName);
|
||||
|
||||
return {
|
||||
turn: Number(report.turn()),
|
||||
mapWidth: report.width(),
|
||||
mapHeight: report.height(),
|
||||
planetCount: report.planetCount(),
|
||||
planets,
|
||||
race: report.race() ?? "",
|
||||
race: raceName,
|
||||
localShipClass,
|
||||
routes,
|
||||
localPlayerDrive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* decodeReportRoutes flattens `report.route()[]` into the typed
|
||||
* `ReportRoute[]`. Each `Route` carries `planet` (source) and an
|
||||
* array of `RouteEntry` rows where `key` is the destination
|
||||
* planet number and `value` is the load-type string. Entries
|
||||
* with unknown load-types are dropped with a `console.warn` so a
|
||||
* future schema bump never silently corrupts the inspector.
|
||||
*/
|
||||
function decodeReportRoutes(report: Report): ReportRoute[] {
|
||||
const out: ReportRoute[] = [];
|
||||
for (let i = 0; i < report.routeLength(); i++) {
|
||||
const route = report.route(i);
|
||||
if (route === null) continue;
|
||||
const sourcePlanetNumber = Number(route.planet());
|
||||
const entries: ReportRouteEntry[] = [];
|
||||
for (let j = 0; j < route.routeLength(); j++) {
|
||||
const entry = route.route(j);
|
||||
if (entry === null) continue;
|
||||
const value = entry.value() ?? "";
|
||||
if (!isCargoLoadType(value)) {
|
||||
console.warn(
|
||||
`decodeReport: skipping RouteEntry with unknown load-type "${value}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
loadType: value,
|
||||
destinationPlanetNumber: Number(entry.key()),
|
||||
});
|
||||
}
|
||||
entries.sort(compareRouteEntriesByLoadType);
|
||||
out.push({ sourcePlanetNumber, entries });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const LOAD_TYPE_ORDER: Record<CargoLoadType, number> = (() => {
|
||||
const map = {} as Record<CargoLoadType, number>;
|
||||
CARGO_LOAD_TYPE_VALUES.forEach((value, index) => {
|
||||
map[value] = index;
|
||||
});
|
||||
return map;
|
||||
})();
|
||||
|
||||
function compareRouteEntriesByLoadType(
|
||||
a: ReportRouteEntry,
|
||||
b: ReportRouteEntry,
|
||||
): number {
|
||||
return LOAD_TYPE_ORDER[a.loadType] - LOAD_TYPE_ORDER[b.loadType];
|
||||
}
|
||||
|
||||
/**
|
||||
* findLocalPlayerDrive locates the local player's drive tech
|
||||
* level by matching `Player.name` against the report's `race`
|
||||
* field (the engine uses race name as the runtime player
|
||||
* identifier). Returns 0 when the lookup fails — boot state, an
|
||||
* incomplete report, or a future schema bump that switches to
|
||||
* UUIDs. Wrapping the lookup in one helper keeps the migration
|
||||
* cost contained.
|
||||
*/
|
||||
function findLocalPlayerDrive(report: Report, raceName: string): number {
|
||||
if (raceName === "") return 0;
|
||||
for (let i = 0; i < report.playerLength(); i++) {
|
||||
const player = report.player(i);
|
||||
if (player === null) continue;
|
||||
if ((player.name() ?? "") !== raceName) continue;
|
||||
return player.drive();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* uuidToHiLo splits the canonical 36-character UUID string
|
||||
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
|
||||
@@ -280,6 +401,7 @@ export function applyOrderOverlay(
|
||||
): GameReport {
|
||||
if (commands.length === 0) return report;
|
||||
let mutatedPlanets: ReportPlanet[] | null = null;
|
||||
let mutatedRoutes: ReportRoute[] | null = null;
|
||||
for (const cmd of commands) {
|
||||
const status = statuses[cmd.id];
|
||||
if (
|
||||
@@ -317,9 +439,72 @@ export function applyOrderOverlay(
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (cmd.kind === "setCargoRoute") {
|
||||
if (mutatedRoutes === null) {
|
||||
mutatedRoutes = cloneRoutes(report.routes);
|
||||
}
|
||||
upsertRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, {
|
||||
loadType: cmd.loadType,
|
||||
destinationPlanetNumber: cmd.destinationPlanetNumber,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (cmd.kind === "removeCargoRoute") {
|
||||
if (mutatedRoutes === null) {
|
||||
mutatedRoutes = cloneRoutes(report.routes);
|
||||
}
|
||||
deleteRouteEntry(mutatedRoutes, cmd.sourcePlanetNumber, cmd.loadType);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (mutatedPlanets === null && mutatedRoutes === null) return report;
|
||||
return {
|
||||
...report,
|
||||
planets: mutatedPlanets ?? report.planets,
|
||||
routes: mutatedRoutes ?? report.routes,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRoutes(routes: ReportRoute[]): ReportRoute[] {
|
||||
return routes.map((r) => ({
|
||||
sourcePlanetNumber: r.sourcePlanetNumber,
|
||||
entries: r.entries.map((e) => ({ ...e })),
|
||||
}));
|
||||
}
|
||||
|
||||
function upsertRouteEntry(
|
||||
routes: ReportRoute[],
|
||||
sourcePlanetNumber: number,
|
||||
entry: ReportRouteEntry,
|
||||
): void {
|
||||
let route = routes.find((r) => r.sourcePlanetNumber === sourcePlanetNumber);
|
||||
if (route === undefined) {
|
||||
route = { sourcePlanetNumber, entries: [] };
|
||||
routes.push(route);
|
||||
}
|
||||
const idx = route.entries.findIndex((e) => e.loadType === entry.loadType);
|
||||
if (idx >= 0) {
|
||||
route.entries[idx] = entry;
|
||||
} else {
|
||||
route.entries.push(entry);
|
||||
}
|
||||
route.entries.sort(compareRouteEntriesByLoadType);
|
||||
}
|
||||
|
||||
function deleteRouteEntry(
|
||||
routes: ReportRoute[],
|
||||
sourcePlanetNumber: number,
|
||||
loadType: CargoLoadType,
|
||||
): void {
|
||||
const routeIndex = routes.findIndex(
|
||||
(r) => r.sourcePlanetNumber === sourcePlanetNumber,
|
||||
);
|
||||
if (routeIndex < 0) return;
|
||||
const route = routes[routeIndex]!;
|
||||
route.entries = route.entries.filter((e) => e.loadType !== loadType);
|
||||
if (route.entries.length === 0) {
|
||||
routes.splice(routeIndex, 1);
|
||||
}
|
||||
if (mutatedPlanets === null) return report;
|
||||
return { ...report, planets: mutatedPlanets };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,6 +27,7 @@ preference the store already manages.
|
||||
minScaleNoWrap,
|
||||
type RendererHandle,
|
||||
} from "../../map/index";
|
||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
||||
import { reportToWorld } from "../../map/state-binding";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
@@ -40,12 +41,35 @@ preference the store already manages.
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
type RenderedReportSource,
|
||||
} from "$lib/rendered-report.svelte";
|
||||
import {
|
||||
MAP_PICK_CONTEXT_KEY,
|
||||
MapPickService,
|
||||
} from "$lib/map-pick.svelte";
|
||||
import {
|
||||
installRendererDebugSurface,
|
||||
registerMapCameraProvider,
|
||||
registerMapPickStateProvider,
|
||||
registerMapPrimitivesProvider,
|
||||
type MapCameraSnapshot,
|
||||
type MapPickStateSnapshot,
|
||||
type MapPrimitiveSnapshot,
|
||||
} from "$lib/debug-surface.svelte";
|
||||
|
||||
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
|
||||
const renderedReport = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||
// `MapPickService` is owned by the in-game shell layout (set on
|
||||
// the context tree the inspector subsections also descend from).
|
||||
// Renderer changes attach / detach via `bindResolver` so a
|
||||
// remount mid-pick does not orphan a pending promise. The map
|
||||
// view is mounted only beneath the layout, so the service is
|
||||
// always present in production; tests render the map in isolation
|
||||
// and may omit it.
|
||||
const pickService = getContext<MapPickService | undefined>(
|
||||
MAP_PICK_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
let canvasEl: HTMLCanvasElement | null = $state(null);
|
||||
let containerEl: HTMLDivElement | null = $state(null);
|
||||
@@ -56,7 +80,23 @@ preference the store already manages.
|
||||
let mountedGameId: string | null = null;
|
||||
let onResize: (() => void) | null = null;
|
||||
let detachClick: (() => void) | null = null;
|
||||
let detachDebugProviders: (() => void) | null = null;
|
||||
let detachDebugSurface: (() => void) | null = null;
|
||||
let mounted = false;
|
||||
// Mount serialization. The `$effect` may re-fire while the
|
||||
// async `mountRenderer` is mid-flight (e.g. report transitions
|
||||
// from null → populated → overlay-mutated during boot). Without
|
||||
// the in-progress gate, parallel `createRenderer` awaits would
|
||||
// leave both old and new viewport listeners on the canvas,
|
||||
// double-firing every click. The gate is intentionally a plain
|
||||
// `let` (not `$state`) so reads from the effect do not register
|
||||
// as a reactive dependency.
|
||||
let mountInProgress = false;
|
||||
let pendingMountSignal = $state(0);
|
||||
// Track the latest cargo-route fingerprint we pushed to the
|
||||
// renderer so a no-op push (e.g. report refresh that yields the
|
||||
// same overlay) doesn't churn Pixi graphics needlessly.
|
||||
let lastExtrasFingerprint: string | null = null;
|
||||
|
||||
$effect(() => {
|
||||
// Read the overlay-applied report so the map labels reflect
|
||||
@@ -72,31 +112,102 @@ preference the store already manages.
|
||||
if (!mounted || canvasEl === null || containerEl === null) return;
|
||||
if (status !== "ready" || !report) return;
|
||||
|
||||
// Skip a re-mount when the same turn is reloaded for the same
|
||||
// game and the wrap mode did not change. The store's `refresh`
|
||||
// path lands here on tab focus; an unchanged snapshot must not
|
||||
// flicker the canvas.
|
||||
// Cargo-route arrows are pushed onto the live renderer via
|
||||
// `setExtraPrimitives` so the overlay can change inside a
|
||||
// single turn without disposing the Pixi `Application` —
|
||||
// Pixi 8 does not reliably re-init on the same canvas. The
|
||||
// fingerprint guard avoids redundant Pixi rebuilds when the
|
||||
// overlay computation re-runs but the routes content is
|
||||
// unchanged (e.g. status transitions valid → submitting →
|
||||
// applied for the same command).
|
||||
const extrasFingerprint = computeRoutesFingerprint(report.routes);
|
||||
|
||||
const sameSnapshot =
|
||||
mountedTurn === report.turn &&
|
||||
mountedGameId === gameId &&
|
||||
handle !== null &&
|
||||
handle.getMode() === mode;
|
||||
if (sameSnapshot) return;
|
||||
if (sameSnapshot) {
|
||||
if (lastExtrasFingerprint !== extrasFingerprint) {
|
||||
untrack(() => {
|
||||
handle?.setExtraPrimitives(buildCargoRouteLines(report));
|
||||
});
|
||||
lastExtrasFingerprint = extrasFingerprint;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the pending-mount signal so the effect re-runs after
|
||||
// the in-flight mount completes (it bumps the signal in its
|
||||
// finally block). Without this, a dep change observed while
|
||||
// `mountInProgress` is true would be silently dropped.
|
||||
void pendingMountSignal;
|
||||
if (mountInProgress) return;
|
||||
untrack(() => {
|
||||
void mountRenderer(report, mode);
|
||||
void runSerializedMount(report, mode, extrasFingerprint);
|
||||
});
|
||||
});
|
||||
|
||||
async function runSerializedMount(
|
||||
report: NonNullable<GameStateStore["report"]>,
|
||||
mode: "torus" | "no-wrap",
|
||||
routesFingerprint: string,
|
||||
): Promise<void> {
|
||||
mountInProgress = true;
|
||||
try {
|
||||
await mountRenderer(report, mode, routesFingerprint);
|
||||
} finally {
|
||||
mountInProgress = false;
|
||||
// Bump the reactive signal so any dep change observed
|
||||
// while the gate was up gets a fresh effect run with the
|
||||
// current state.
|
||||
pendingMountSignal += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function computeRoutesFingerprint(
|
||||
routes: NonNullable<GameStateStore["report"]>["routes"],
|
||||
): string {
|
||||
if (routes.length === 0) return "";
|
||||
const parts = routes.map((route) => {
|
||||
const entries = route.entries
|
||||
.map((entry) => `${entry.loadType}->${entry.destinationPlanetNumber}`)
|
||||
.join(",");
|
||||
return `${route.sourcePlanetNumber}:${entries}`;
|
||||
});
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
async function mountRenderer(
|
||||
report: NonNullable<GameStateStore["report"]>,
|
||||
mode: "torus" | "no-wrap",
|
||||
routesFingerprint: string,
|
||||
): Promise<void> {
|
||||
if (canvasEl === null || containerEl === null) return;
|
||||
// Capture camera state before disposing so a remount inside
|
||||
// the same game (e.g. cargo-route overlay change) keeps the
|
||||
// user's pan/zoom. A new game / first mount has no prior
|
||||
// camera, so `previousCamera` stays null and the default
|
||||
// centring path runs.
|
||||
const previousGameId = mountedGameId;
|
||||
const targetGameId = store?.gameId ?? "";
|
||||
const previousCamera =
|
||||
handle !== null && previousGameId === targetGameId
|
||||
? handle.getCamera()
|
||||
: null;
|
||||
if (detachClick !== null) {
|
||||
detachClick();
|
||||
detachClick = null;
|
||||
}
|
||||
// Detach the previous resolver before disposing — the
|
||||
// renderer's `dispose` already calls `onPick(null)` on any
|
||||
// open session, which `bindResolver(null)` would also do, so
|
||||
// we route the cancel through one path only.
|
||||
pickService?.bindResolver(null);
|
||||
if (detachDebugProviders !== null) {
|
||||
detachDebugProviders();
|
||||
detachDebugProviders = null;
|
||||
}
|
||||
if (handle !== null) {
|
||||
handle.dispose();
|
||||
handle = null;
|
||||
@@ -109,7 +220,6 @@ preference the store already manages.
|
||||
mode,
|
||||
preference: ["webgpu", "webgl"],
|
||||
});
|
||||
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
||||
const minScale = minScaleNoWrap(
|
||||
{
|
||||
widthPx: containerEl.clientWidth,
|
||||
@@ -117,12 +227,113 @@ preference the store already manages.
|
||||
},
|
||||
world,
|
||||
);
|
||||
handle.viewport.setZoom(minScale * 1.05, true);
|
||||
if (previousCamera !== null) {
|
||||
// Same-game remount — preserve pan/zoom. Clamp zoom
|
||||
// to `minScale` so a remount that re-derives the
|
||||
// minimum (e.g. a viewport resize between renderers)
|
||||
// does not strand the user below the current floor.
|
||||
handle.viewport.moveCenter(
|
||||
previousCamera.centerX,
|
||||
previousCamera.centerY,
|
||||
);
|
||||
handle.viewport.setZoom(
|
||||
Math.max(previousCamera.scale, minScale),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
||||
handle.viewport.setZoom(minScale * 1.05, true);
|
||||
}
|
||||
if (mode === "no-wrap") handle.setMode("no-wrap");
|
||||
detachClick = handle.onClick(handleMapClick);
|
||||
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
|
||||
if (handle === null) {
|
||||
onResolve(null);
|
||||
return null;
|
||||
}
|
||||
const planet = report.planets.find(
|
||||
(p) => p.number === sourcePlanetNumber,
|
||||
);
|
||||
if (planet === undefined) {
|
||||
onResolve(null);
|
||||
return null;
|
||||
}
|
||||
return handle.setPickMode({
|
||||
sourcePrimitiveId: sourcePlanetNumber,
|
||||
sourceX: planet.x,
|
||||
sourceY: planet.y,
|
||||
reachableIds,
|
||||
onPick: onResolve,
|
||||
});
|
||||
});
|
||||
const detachPrim = registerMapPrimitivesProvider(() => {
|
||||
const h = handle;
|
||||
if (h === null) return [];
|
||||
return h.getPrimitives().map<MapPrimitiveSnapshot>((p) => ({
|
||||
id: p.id,
|
||||
kind: p.kind,
|
||||
priority: p.priority,
|
||||
alpha: h.getPrimitiveAlpha(p.id),
|
||||
fillColor: p.style.fillColor ?? null,
|
||||
strokeColor: p.style.strokeColor ?? null,
|
||||
x: p.kind === "point" ? p.x : null,
|
||||
y: p.kind === "point" ? p.y : null,
|
||||
}));
|
||||
});
|
||||
const detachPick = registerMapPickStateProvider(() => {
|
||||
const h = handle;
|
||||
if (h === null) {
|
||||
return {
|
||||
active: false,
|
||||
sourcePlanetNumber: null,
|
||||
reachableIds: [],
|
||||
hoveredId: null,
|
||||
} satisfies MapPickStateSnapshot;
|
||||
}
|
||||
const state = h.getPickState();
|
||||
return {
|
||||
active: state.active,
|
||||
sourcePlanetNumber:
|
||||
state.sourcePrimitiveId === null
|
||||
? null
|
||||
: Number(state.sourcePrimitiveId),
|
||||
reachableIds:
|
||||
state.reachableIds === null
|
||||
? []
|
||||
: Array.from(state.reachableIds).map((id) => Number(id)),
|
||||
hoveredId:
|
||||
state.hoveredId === null ? null : Number(state.hoveredId),
|
||||
} satisfies MapPickStateSnapshot;
|
||||
});
|
||||
const detachCamera = registerMapCameraProvider(() => {
|
||||
const h = handle;
|
||||
if (h === null) return null;
|
||||
const camera = h.getCamera();
|
||||
const viewport = h.getViewport();
|
||||
const rect = canvasEl?.getBoundingClientRect();
|
||||
return {
|
||||
camera,
|
||||
viewport,
|
||||
canvasOrigin: {
|
||||
x: rect?.left ?? 0,
|
||||
y: rect?.top ?? 0,
|
||||
},
|
||||
} satisfies MapCameraSnapshot;
|
||||
});
|
||||
detachDebugProviders = (): void => {
|
||||
detachPrim();
|
||||
detachPick();
|
||||
detachCamera();
|
||||
};
|
||||
mountedTurn = report.turn;
|
||||
mountedGameId = store?.gameId ?? "";
|
||||
mountedGameId = targetGameId;
|
||||
// Initial mount carries no extras yet; the post-mount
|
||||
// effect run pushes the current cargo-route lines via
|
||||
// `setExtraPrimitives` once `lastExtrasFingerprint`
|
||||
// disagrees with the freshly computed fingerprint.
|
||||
lastExtrasFingerprint = null;
|
||||
mountError = null;
|
||||
void routesFingerprint;
|
||||
} catch (err) {
|
||||
mountError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -154,6 +365,14 @@ preference the store already manages.
|
||||
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
// In DEV the in-game shell mounts on a fresh document load
|
||||
// (`page.goto`), which discards anything the
|
||||
// `/__debug/store` route may have installed earlier in the
|
||||
// session. The renderer-side accessors are still useful for
|
||||
// e2e specs driving the map, so we install them here too.
|
||||
if (import.meta.env.DEV) {
|
||||
detachDebugSurface = installRendererDebugSurface();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -166,6 +385,15 @@ preference the store already manages.
|
||||
detachClick();
|
||||
detachClick = null;
|
||||
}
|
||||
pickService?.bindResolver(null);
|
||||
if (detachDebugProviders !== null) {
|
||||
detachDebugProviders();
|
||||
detachDebugProviders = null;
|
||||
}
|
||||
if (detachDebugSurface !== null) {
|
||||
detachDebugSurface();
|
||||
detachDebugSurface = null;
|
||||
}
|
||||
if (handle !== null) {
|
||||
handle.dispose();
|
||||
handle = null;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Module-scoped registry the in-game shell uses to expose live
|
||||
// renderer state to the DEV-only `__galaxyDebug` surface defined in
|
||||
// `routes/__debug/store/+page.svelte`. Tests open the debug route
|
||||
// once to grab the surface, then drive the in-game routes; the
|
||||
// registered providers stay alive across SvelteKit navigations and
|
||||
// surface the current map state without forcing the renderer to
|
||||
// know about the debug API directly.
|
||||
//
|
||||
// Providers are functions, not snapshots: the surface invokes them
|
||||
// lazily on every read so the returned data always reflects the
|
||||
// current frame, not the value at registration time.
|
||||
|
||||
import type { Primitive, PrimitiveID } from "../map/world";
|
||||
|
||||
/** Snapshot returned by `getMapPrimitives()`. The renderer applies
|
||||
* pick-mode dimming via the underlying `Graphics.alpha`, so the
|
||||
* `alpha` field captures what is actually drawn (1.0 normally,
|
||||
* `PICK_OVERLAY_STYLE.dimAlpha` while a pick session is active).
|
||||
* Style colours come straight from the primitive style (no theme
|
||||
* fallback) so e2e specs can assert exact colour identity. `x` and
|
||||
* `y` are populated for `point` primitives (single anchor); other
|
||||
* kinds leave them `null`. */
|
||||
export interface MapPrimitiveSnapshot {
|
||||
readonly id: PrimitiveID;
|
||||
readonly kind: Primitive["kind"];
|
||||
readonly priority: number;
|
||||
readonly alpha: number;
|
||||
readonly fillColor: number | null;
|
||||
readonly strokeColor: number | null;
|
||||
readonly x: number | null;
|
||||
readonly y: number | null;
|
||||
}
|
||||
|
||||
/** Snapshot returned by `getMapCamera()`. Mirrors the renderer's
|
||||
* `getCamera` / `getViewport` plus a bounding-rect snapshot of the
|
||||
* underlying canvas, so e2e specs can project a known world-space
|
||||
* coordinate to a click target without rebuilding the projection
|
||||
* maths themselves. */
|
||||
export interface MapCameraSnapshot {
|
||||
readonly camera: { readonly centerX: number; readonly centerY: number; readonly scale: number };
|
||||
readonly viewport: { readonly widthPx: number; readonly heightPx: number };
|
||||
readonly canvasOrigin: { readonly x: number; readonly y: number };
|
||||
}
|
||||
|
||||
/** Snapshot returned by `getMapPickState()`. */
|
||||
export interface MapPickStateSnapshot {
|
||||
readonly active: boolean;
|
||||
readonly sourcePlanetNumber: number | null;
|
||||
readonly reachableIds: readonly number[];
|
||||
readonly hoveredId: number | null;
|
||||
}
|
||||
|
||||
type PrimitivesProvider = () => readonly MapPrimitiveSnapshot[];
|
||||
type PickStateProvider = () => MapPickStateSnapshot;
|
||||
type CameraProvider = () => MapCameraSnapshot | null;
|
||||
|
||||
let primitivesProvider: PrimitivesProvider | null = null;
|
||||
let pickStateProvider: PickStateProvider | null = null;
|
||||
let cameraProvider: CameraProvider | null = null;
|
||||
|
||||
/**
|
||||
* registerMapPrimitivesProvider attaches a provider that yields the
|
||||
* current `Primitive` snapshots. Idempotent — a previously-bound
|
||||
* provider is replaced. Returns a deregister function the caller
|
||||
* runs on dispose.
|
||||
*/
|
||||
export function registerMapPrimitivesProvider(
|
||||
provider: PrimitivesProvider,
|
||||
): () => void {
|
||||
primitivesProvider = provider;
|
||||
return () => {
|
||||
if (primitivesProvider === provider) primitivesProvider = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* registerMapPickStateProvider attaches a provider for the current
|
||||
* pick-mode state. Same idempotent semantics as the primitives
|
||||
* provider.
|
||||
*/
|
||||
export function registerMapPickStateProvider(
|
||||
provider: PickStateProvider,
|
||||
): () => void {
|
||||
pickStateProvider = provider;
|
||||
return () => {
|
||||
if (pickStateProvider === provider) pickStateProvider = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* registerMapCameraProvider attaches a provider for the current
|
||||
* camera + viewport + canvas-origin snapshot. Same idempotent
|
||||
* semantics as the other providers.
|
||||
*/
|
||||
export function registerMapCameraProvider(
|
||||
provider: CameraProvider,
|
||||
): () => void {
|
||||
cameraProvider = provider;
|
||||
return () => {
|
||||
if (cameraProvider === provider) cameraProvider = null;
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_PICK_STATE: MapPickStateSnapshot = {
|
||||
active: false,
|
||||
sourcePlanetNumber: null,
|
||||
reachableIds: [],
|
||||
hoveredId: null,
|
||||
};
|
||||
|
||||
/** Pulls the current snapshot. Returns an empty array when no map
|
||||
* view is mounted. */
|
||||
export function getMapPrimitives(): readonly MapPrimitiveSnapshot[] {
|
||||
return primitivesProvider?.() ?? [];
|
||||
}
|
||||
|
||||
/** Pulls the current pick state. Returns the inactive sentinel
|
||||
* snapshot when no map view is mounted. */
|
||||
export function getMapPickState(): MapPickStateSnapshot {
|
||||
return pickStateProvider?.() ?? EMPTY_PICK_STATE;
|
||||
}
|
||||
|
||||
/** Pulls the current camera + viewport snapshot, or `null` when
|
||||
* no map view is mounted. */
|
||||
export function getMapCamera(): MapCameraSnapshot | null {
|
||||
return cameraProvider?.() ?? null;
|
||||
}
|
||||
|
||||
interface RendererDebugWindow {
|
||||
__galaxyDebug?: {
|
||||
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
|
||||
getMapPickState?: () => MapPickStateSnapshot;
|
||||
getMapCamera?: () => MapCameraSnapshot | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* installRendererDebugSurface stitches the renderer accessors onto
|
||||
* `window.__galaxyDebug`. The DEV-only `/__debug/store` route
|
||||
* already registers the keystore / order helpers; navigating to
|
||||
* `/games/...` resets the window-bound surface, so the in-game
|
||||
* shell calls this on map mount to keep the renderer state
|
||||
* accessible to e2e specs that drive the map. Idempotent — repeated
|
||||
* calls override the same three methods.
|
||||
*/
|
||||
export function installRendererDebugSurface(): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const win = window as unknown as RendererDebugWindow;
|
||||
const existing = win.__galaxyDebug ?? {};
|
||||
const surface = {
|
||||
...existing,
|
||||
getMapPrimitives,
|
||||
getMapPickState,
|
||||
getMapCamera,
|
||||
};
|
||||
win.__galaxyDebug = surface;
|
||||
return (): void => {
|
||||
// Detach only the renderer-owned methods; preserve any
|
||||
// keystore / order surface the debug route may have
|
||||
// installed earlier in the session.
|
||||
const current = win.__galaxyDebug;
|
||||
if (current === undefined) return;
|
||||
if (current.getMapPrimitives === getMapPrimitives) {
|
||||
delete current.getMapPrimitives;
|
||||
}
|
||||
if (current.getMapPickState === getMapPickState) {
|
||||
delete current.getMapPickState;
|
||||
}
|
||||
if (current.getMapCamera === getMapCamera) {
|
||||
delete current.getMapCamera;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -178,6 +178,20 @@ const en = {
|
||||
"game.inspector.planet.production.research.shields": "shields",
|
||||
"game.inspector.planet.production.research.cargo": "cargo",
|
||||
"game.inspector.planet.production.ship.no_classes": "no ship classes designed yet",
|
||||
"game.inspector.planet.cargo.title": "cargo routes",
|
||||
"game.inspector.planet.cargo.slot.col": "colonists",
|
||||
"game.inspector.planet.cargo.slot.cap": "industry",
|
||||
"game.inspector.planet.cargo.slot.mat": "materials",
|
||||
"game.inspector.planet.cargo.slot.emp": "empty ships",
|
||||
"game.inspector.planet.cargo.empty": "(no route)",
|
||||
"game.inspector.planet.cargo.add": "add",
|
||||
"game.inspector.planet.cargo.edit": "edit",
|
||||
"game.inspector.planet.cargo.remove": "remove",
|
||||
"game.inspector.planet.cargo.pick.prompt": "pick a destination on the map (Esc to cancel)",
|
||||
"game.inspector.planet.cargo.pick.cancel": "cancel pick",
|
||||
"game.inspector.planet.cargo.pick.no_destinations": "no reachable destinations within {reach} world units",
|
||||
"game.sidebar.order.label.cargo_route_set": "set {loadType} route from planet {source} → planet {destination}",
|
||||
"game.sidebar.order.label.cargo_route_remove": "remove {loadType} route from planet {source}",
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -179,6 +179,20 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.inspector.planet.production.research.shields": "щиты",
|
||||
"game.inspector.planet.production.research.cargo": "трюм",
|
||||
"game.inspector.planet.production.ship.no_classes": "классы кораблей ещё не спроектированы",
|
||||
"game.inspector.planet.cargo.title": "грузовые маршруты",
|
||||
"game.inspector.planet.cargo.slot.col": "колонисты",
|
||||
"game.inspector.planet.cargo.slot.cap": "промышленность",
|
||||
"game.inspector.planet.cargo.slot.mat": "сырьё",
|
||||
"game.inspector.planet.cargo.slot.emp": "пустые корабли",
|
||||
"game.inspector.planet.cargo.empty": "(маршрута нет)",
|
||||
"game.inspector.planet.cargo.add": "добавить",
|
||||
"game.inspector.planet.cargo.edit": "изменить",
|
||||
"game.inspector.planet.cargo.remove": "удалить",
|
||||
"game.inspector.planet.cargo.pick.prompt": "выбери цель на карте (Esc — отмена)",
|
||||
"game.inspector.planet.cargo.pick.cancel": "отменить выбор",
|
||||
"game.inspector.planet.cargo.pick.no_destinations": "нет планет в зоне полёта {reach} ед.",
|
||||
"game.sidebar.order.label.cargo_route_set": "маршрут {loadType} с планеты {source} → планета {destination}",
|
||||
"game.sidebar.order.label.cargo_route_remove": "удалить маршрут {loadType} с планеты {source}",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -13,6 +13,7 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
||||
<script lang="ts">
|
||||
import type {
|
||||
ReportPlanet,
|
||||
ReportRoute,
|
||||
ShipClassSummary,
|
||||
} from "../../api/game-state";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
@@ -21,10 +22,25 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
||||
type Props = {
|
||||
planet: ReportPlanet | null;
|
||||
localShipClass: ShipClassSummary[];
|
||||
routes: ReportRoute[];
|
||||
planets: ReportPlanet[];
|
||||
mapWidth: number;
|
||||
mapHeight: number;
|
||||
localPlayerDrive: number;
|
||||
onMap: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
let { planet, localShipClass, onMap, onClose }: Props = $props();
|
||||
let {
|
||||
planet,
|
||||
localShipClass,
|
||||
routes,
|
||||
planets,
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
localPlayerDrive,
|
||||
onMap,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if planet !== null && onMap}
|
||||
@@ -42,7 +58,15 @@ dismiss from the IA section §6 land in Phase 35 polish.
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<Planet {planet} {localShipClass} />
|
||||
<Planet
|
||||
{planet}
|
||||
{localShipClass}
|
||||
{routes}
|
||||
{planets}
|
||||
{mapWidth}
|
||||
{mapHeight}
|
||||
{localPlayerDrive}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ field with five buttons.
|
||||
import { getContext, tick } from "svelte";
|
||||
import type {
|
||||
ReportPlanet,
|
||||
ReportRoute,
|
||||
ShipClassSummary,
|
||||
} from "../../api/game-state";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
@@ -27,13 +28,27 @@ field with five buttons.
|
||||
validateEntityName,
|
||||
type EntityNameInvalidReason,
|
||||
} from "$lib/util/entity-name";
|
||||
import CargoRoutes from "./planet/cargo-routes.svelte";
|
||||
import Production from "./planet/production.svelte";
|
||||
|
||||
type Props = {
|
||||
planet: ReportPlanet;
|
||||
localShipClass: ShipClassSummary[];
|
||||
routes: ReportRoute[];
|
||||
planets: ReportPlanet[];
|
||||
mapWidth: number;
|
||||
mapHeight: number;
|
||||
localPlayerDrive: number;
|
||||
};
|
||||
let { planet, localShipClass }: Props = $props();
|
||||
let {
|
||||
planet,
|
||||
localShipClass,
|
||||
routes,
|
||||
planets,
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
localPlayerDrive,
|
||||
}: Props = $props();
|
||||
|
||||
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
|
||||
local: "game.inspector.planet.kind.local",
|
||||
@@ -198,6 +213,14 @@ field with five buttons.
|
||||
|
||||
{#if planet.kind === "local"}
|
||||
<Production {planet} {localShipClass} />
|
||||
<CargoRoutes
|
||||
{planet}
|
||||
{routes}
|
||||
{planets}
|
||||
{mapWidth}
|
||||
{mapHeight}
|
||||
{localPlayerDrive}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<dl class="fields">
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
<!--
|
||||
Phase 16 cargo-routes subsection of the planet inspector. Shows a
|
||||
fixed COL/CAP/MAT/EMP four-slot table for the active local planet,
|
||||
each slot either empty (with a single Add button) or filled (with
|
||||
the destination planet's name plus Edit and Remove buttons). Add
|
||||
and Edit hand off to the renderer-driven `MapPickService`: the map
|
||||
dims out-of-reach planets, draws the cursor-line anchor, and
|
||||
resolves with either a chosen destination id or `null` (cancel).
|
||||
|
||||
The component is purposely deferential to the existing infrastructure:
|
||||
- `OrderDraftStore` enforces the `(source, loadType)` collapse rule,
|
||||
so the optimistic overlay always matches what the server sees.
|
||||
- `MapPickService.pick(...)` is a renderer-side abstraction; its
|
||||
source/destination semantics live in `lib/active-view/map.svelte`.
|
||||
- Reach (`40 * driveTech` per `game/internal/model/game/race.go`)
|
||||
is computed inline using `torusShortestDelta` to mirror the
|
||||
engine's torus distance — see `pkg/util/map.go.deltas`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import type { ReportPlanet, ReportRoute } from "../../../api/game-state";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { torusShortestDelta } from "../../../map/math";
|
||||
import {
|
||||
MAP_PICK_CONTEXT_KEY,
|
||||
type MapPickService,
|
||||
} from "$lib/map-pick.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../../../sync/order-draft.svelte";
|
||||
import {
|
||||
CARGO_LOAD_TYPE_VALUES,
|
||||
type CargoLoadType,
|
||||
} from "../../../sync/order-types";
|
||||
|
||||
type Props = {
|
||||
planet: ReportPlanet;
|
||||
routes: ReportRoute[];
|
||||
planets: ReportPlanet[];
|
||||
mapWidth: number;
|
||||
mapHeight: number;
|
||||
localPlayerDrive: number;
|
||||
};
|
||||
let {
|
||||
planet,
|
||||
routes,
|
||||
planets,
|
||||
mapWidth,
|
||||
mapHeight,
|
||||
localPlayerDrive,
|
||||
}: Props = $props();
|
||||
|
||||
const draft = getContext<OrderDraftStore | undefined>(
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
const pick = getContext<MapPickService | undefined>(MAP_PICK_CONTEXT_KEY);
|
||||
const disabled = $derived(draft === undefined || pick === undefined);
|
||||
|
||||
let pendingSlot: CargoLoadType | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
// Reset the in-flight slot whenever the inspector switches to a
|
||||
// different planet so a stale "pick in progress" prompt does
|
||||
// not leak across the selection boundary.
|
||||
void planet.number;
|
||||
pendingSlot = null;
|
||||
});
|
||||
|
||||
const SLOT_LABELS: Record<CargoLoadType, TranslationKey> = {
|
||||
COL: "game.inspector.planet.cargo.slot.col",
|
||||
CAP: "game.inspector.planet.cargo.slot.cap",
|
||||
MAT: "game.inspector.planet.cargo.slot.mat",
|
||||
EMP: "game.inspector.planet.cargo.slot.emp",
|
||||
};
|
||||
|
||||
const currentEntries = $derived(
|
||||
routes.find((r) => r.sourcePlanetNumber === planet.number)?.entries ?? [],
|
||||
);
|
||||
// Per-slot derived map keeps the template's {#each} block free of
|
||||
// the {@const}/`find` chain that Svelte 5 sometimes mis-tracks
|
||||
// when the source array is freshly cloned by `applyOrderOverlay`.
|
||||
const slotEntries = $derived.by(() => {
|
||||
const map: Record<CargoLoadType, ReportRoute["entries"][number] | null> = {
|
||||
COL: null,
|
||||
CAP: null,
|
||||
MAT: null,
|
||||
EMP: null,
|
||||
};
|
||||
for (const entry of currentEntries) {
|
||||
map[entry.loadType] = entry;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
function destinationName(planetNumber: number): string {
|
||||
const target = planets.find((p) => p.number === planetNumber);
|
||||
if (target === undefined) return `#${planetNumber}`;
|
||||
if (target.kind === "unidentified") return `#${planetNumber}`;
|
||||
return target.name === "" ? `#${planetNumber}` : target.name;
|
||||
}
|
||||
|
||||
const reach = $derived(40 * localPlayerDrive);
|
||||
|
||||
function reachableSet(): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
if (reach <= 0) return ids;
|
||||
for (const candidate of planets) {
|
||||
if (candidate.number === planet.number) continue;
|
||||
if (candidate.kind === "unidentified") continue;
|
||||
const dx = torusShortestDelta(planet.x, candidate.x, mapWidth);
|
||||
const dy = torusShortestDelta(planet.y, candidate.y, mapHeight);
|
||||
if (Math.hypot(dx, dy) <= reach) {
|
||||
ids.add(candidate.number);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function startPick(loadType: CargoLoadType): Promise<void> {
|
||||
if (draft === undefined || pick === undefined) return;
|
||||
if (pendingSlot !== null) return;
|
||||
const reachable = reachableSet();
|
||||
if (reachable.size === 0) return;
|
||||
pendingSlot = loadType;
|
||||
try {
|
||||
const destination = await pick.pick({
|
||||
sourcePlanetNumber: planet.number,
|
||||
reachableIds: reachable,
|
||||
});
|
||||
if (destination === null) return;
|
||||
await draft.add({
|
||||
kind: "setCargoRoute",
|
||||
id: crypto.randomUUID(),
|
||||
sourcePlanetNumber: planet.number,
|
||||
destinationPlanetNumber: destination,
|
||||
loadType,
|
||||
});
|
||||
} finally {
|
||||
pendingSlot = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRoute(loadType: CargoLoadType): Promise<void> {
|
||||
if (draft === undefined) return;
|
||||
await draft.add({
|
||||
kind: "removeCargoRoute",
|
||||
id: crypto.randomUUID(),
|
||||
sourcePlanetNumber: planet.number,
|
||||
loadType,
|
||||
});
|
||||
}
|
||||
|
||||
function cancelPick(): void {
|
||||
pick?.cancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="cargo" data-testid="inspector-planet-cargo">
|
||||
<h4 class="title">
|
||||
{i18n.t("game.inspector.planet.cargo.title")}
|
||||
</h4>
|
||||
<dl class="slots">
|
||||
{#each CARGO_LOAD_TYPE_VALUES as loadType (loadType)}
|
||||
{@const entry = slotEntries[loadType]}
|
||||
{@const slug = loadType.toLowerCase()}
|
||||
<div class="slot" data-testid={`inspector-planet-cargo-slot-${slug}`}>
|
||||
<dt class="slot-label" data-testid={`inspector-planet-cargo-slot-${slug}-label`}>
|
||||
{i18n.t(SLOT_LABELS[loadType])}
|
||||
</dt>
|
||||
<dd class="slot-body">
|
||||
{#if entry === null}
|
||||
<span
|
||||
class="empty"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-empty`}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.empty")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action add"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-add`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void startPick(loadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.add")}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="destination"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-destination`}
|
||||
>
|
||||
→ {destinationName(entry.destinationPlanetNumber)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action edit"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-edit`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void startPick(loadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action remove"
|
||||
data-testid={`inspector-planet-cargo-slot-${slug}-remove`}
|
||||
disabled={disabled || pendingSlot !== null}
|
||||
onclick={() => void removeRoute(loadType)}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.remove")}
|
||||
</button>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{#if pendingSlot !== null}
|
||||
<div
|
||||
class="pick-prompt"
|
||||
data-testid="inspector-planet-cargo-pick-prompt"
|
||||
role="status"
|
||||
>
|
||||
<span class="pick-message">
|
||||
{i18n.t("game.inspector.planet.cargo.pick.prompt")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action cancel"
|
||||
data-testid="inspector-planet-cargo-pick-cancel"
|
||||
onclick={cancelPick}
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.pick.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
{:else if reach > 0 && reachableSet().size === 0}
|
||||
<p
|
||||
class="no-destinations"
|
||||
data-testid="inspector-planet-cargo-no-destinations"
|
||||
>
|
||||
{i18n.t("game.inspector.planet.cargo.pick.no_destinations", {
|
||||
reach: reach.toFixed(1),
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.cargo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #aab;
|
||||
font-weight: 500;
|
||||
}
|
||||
.slots {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
row-gap: 0.25rem;
|
||||
column-gap: 0.6rem;
|
||||
}
|
||||
.slot {
|
||||
display: contents;
|
||||
}
|
||||
.slot-label {
|
||||
color: #aab;
|
||||
font-size: 0.85rem;
|
||||
align-self: center;
|
||||
}
|
||||
.slot-body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.empty {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
.destination {
|
||||
color: #e8eaf6;
|
||||
}
|
||||
.action {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action:not(:disabled):hover {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
.action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.pick-prompt {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: rgba(255, 224, 130, 0.1);
|
||||
border: 1px solid #ffe082;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pick-message {
|
||||
color: #ffe082;
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
}
|
||||
.no-destinations {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
// `MapPickService` is the Svelte-side adapter the inspector uses to
|
||||
// drive a map-driven destination pick. The service owns the
|
||||
// promise-shaped contract (`pick()` returns the picked planet
|
||||
// number or `null` on cancel) and a reactive `active` flag for any
|
||||
// surface that wants to disable other UI while a session is open.
|
||||
//
|
||||
// The actual renderer plumbing — dim outside `reachableIds`, anchor
|
||||
// ring, cursor line, hover outline, click + Escape resolution —
|
||||
// lives in `ui/frontend/src/map/render.ts.setPickMode`. The map
|
||||
// active view (`lib/active-view/map.svelte`) is the only producer:
|
||||
// it constructs the service, sets it on the layout context with
|
||||
// `MAP_PICK_CONTEXT_KEY`, and binds a resolver that translates the
|
||||
// service-level request into a `PickModeOptions` payload for the
|
||||
// current renderer handle.
|
||||
|
||||
export const MAP_PICK_CONTEXT_KEY = Symbol("map-pick");
|
||||
|
||||
/** High-level pick request the inspector composes. The renderer
|
||||
* resolver (registered by the map view) is responsible for turning
|
||||
* `sourcePlanetNumber` into the underlying `PickModeOptions`. */
|
||||
export interface MapPickRequest {
|
||||
readonly sourcePlanetNumber: number;
|
||||
readonly reachableIds: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
/** A renderer-side resolver registered by the map view. Returns an
|
||||
* imperative cancel hook the service uses for `cancel()`, or `null`
|
||||
* when the renderer cannot open a session right now (e.g. the
|
||||
* source planet is missing from the world). When `null` is
|
||||
* returned, the service resolves the pending promise with `null`
|
||||
* straight away. */
|
||||
export type MapPickResolver = (input: {
|
||||
sourcePlanetNumber: number;
|
||||
reachableIds: ReadonlySet<number>;
|
||||
onResolve: (id: number | null) => void;
|
||||
}) => { cancel(): void } | null;
|
||||
|
||||
/**
|
||||
* MapPickService coordinates pick-mode sessions between the Svelte
|
||||
* inspector and the renderer. Lives for the lifetime of the
|
||||
* in-game shell layout; renderer handles come and go through
|
||||
* `bindResolver` as the map remounts.
|
||||
*/
|
||||
export class MapPickService {
|
||||
/** Reactive flag — true while a pick session is open. The
|
||||
* inspector reads this to render its "pick prompt" status line
|
||||
* and to keep the slot button disabled until resolution. */
|
||||
active = $state(false);
|
||||
|
||||
private resolver: MapPickResolver | null = null;
|
||||
private currentHandle: { cancel(): void } | null = null;
|
||||
private currentResolve: ((id: number | null) => void) | null = null;
|
||||
|
||||
/**
|
||||
* bindResolver attaches a renderer-side handler that opens
|
||||
* pick-mode sessions. Pass `null` to detach (the map view does
|
||||
* this on dispose); a detach with a session in progress
|
||||
* resolves the pending promise with `null` so callers do not
|
||||
* deadlock waiting for a renderer that no longer exists.
|
||||
*/
|
||||
bindResolver(resolver: MapPickResolver | null): void {
|
||||
if (resolver === null && this.currentResolve !== null) {
|
||||
const r = this.currentResolve;
|
||||
this.currentResolve = null;
|
||||
this.currentHandle = null;
|
||||
this.active = false;
|
||||
r(null);
|
||||
}
|
||||
this.resolver = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* pick opens a pick session. Resolves to the picked planet
|
||||
* number on a successful pick, or `null` when the player
|
||||
* cancels via Escape, the inspector calls `cancel()`, or the
|
||||
* renderer detaches mid-session.
|
||||
*
|
||||
* Calling `pick` while a session is already active cancels the
|
||||
* old one first (its promise resolves to `null`). The
|
||||
* inspector should normally guard against this via the
|
||||
* reactive `active` flag, but the service stays defensive.
|
||||
*/
|
||||
pick(request: MapPickRequest): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.resolver === null) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
if (this.currentHandle !== null) {
|
||||
const previousHandle = this.currentHandle;
|
||||
this.currentHandle = null;
|
||||
previousHandle.cancel();
|
||||
}
|
||||
this.currentResolve = resolve;
|
||||
this.active = true;
|
||||
const handle = this.resolver({
|
||||
sourcePlanetNumber: request.sourcePlanetNumber,
|
||||
reachableIds: request.reachableIds,
|
||||
onResolve: (id) => {
|
||||
// Guard against late notifications from a stale
|
||||
// session (e.g. resolver swapped while a pick was
|
||||
// in flight).
|
||||
if (this.currentResolve !== resolve) return;
|
||||
this.currentResolve = null;
|
||||
this.currentHandle = null;
|
||||
this.active = false;
|
||||
resolve(id);
|
||||
},
|
||||
});
|
||||
if (handle === null) {
|
||||
if (this.currentResolve === resolve) {
|
||||
this.currentResolve = null;
|
||||
this.active = false;
|
||||
resolve(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.currentHandle = handle;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* cancel terminates the active session, if any. Safe to call
|
||||
* when no session is open — it is a no-op then. The pending
|
||||
* promise resolves with `null`.
|
||||
*/
|
||||
cancel(): void {
|
||||
if (this.currentHandle === null) return;
|
||||
const handle = this.currentHandle;
|
||||
this.currentHandle = null;
|
||||
handle.cancel();
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,26 @@ from the Phase 10 stub.
|
||||
const localShipClass = $derived(
|
||||
renderedReport?.report?.localShipClass ?? [],
|
||||
);
|
||||
const allPlanets = $derived(renderedReport?.report?.planets ?? []);
|
||||
const routes = $derived(renderedReport?.report?.routes ?? []);
|
||||
const mapWidth = $derived(renderedReport?.report?.mapWidth ?? 1);
|
||||
const mapHeight = $derived(renderedReport?.report?.mapHeight ?? 1);
|
||||
const localPlayerDrive = $derived(
|
||||
renderedReport?.report?.localPlayerDrive ?? 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="tool" data-testid="sidebar-tool-inspector">
|
||||
{#if selectedPlanet !== null}
|
||||
<Planet planet={selectedPlanet} {localShipClass} />
|
||||
<Planet
|
||||
planet={selectedPlanet}
|
||||
{localShipClass}
|
||||
{routes}
|
||||
planets={allPlanets}
|
||||
{mapWidth}
|
||||
{mapHeight}
|
||||
{localPlayerDrive}
|
||||
/>
|
||||
{:else}
|
||||
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
|
||||
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
|
||||
|
||||
@@ -58,6 +58,17 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
||||
cmd.subject,
|
||||
),
|
||||
});
|
||||
case "setCargoRoute":
|
||||
return i18n.t("game.sidebar.order.label.cargo_route_set", {
|
||||
loadType: cmd.loadType,
|
||||
source: String(cmd.sourcePlanetNumber),
|
||||
destination: String(cmd.destinationPlanetNumber),
|
||||
});
|
||||
case "removeCargoRoute":
|
||||
return i18n.t("game.sidebar.order.label.cargo_route_remove", {
|
||||
loadType: cmd.loadType,
|
||||
source: String(cmd.sourcePlanetNumber),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
import { distSqPointToSegment, screenToWorld, torusShortestDelta } from "./math";
|
||||
import {
|
||||
DEFAULT_HIT_SLOP_PX,
|
||||
DEFAULT_POINT_RADIUS_PX,
|
||||
KIND_ORDER,
|
||||
type Camera,
|
||||
type CirclePrim,
|
||||
@@ -100,7 +101,11 @@ function matchPoint(
|
||||
): number | null {
|
||||
const { dx, dy } = torusDelta(p.x, p.y, cursor.x, cursor.y, world);
|
||||
const distSq = dx * dx + dy * dy;
|
||||
const r = slopWorld;
|
||||
// The visible disc is `pointRadiusPx` world units; the hit zone
|
||||
// is the disc plus a small ergonomic slop on top. A click on any
|
||||
// painted pixel of the planet must register as a hit.
|
||||
const visibleRadius = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
|
||||
const r = visibleRadius + slopWorld;
|
||||
if (distSq <= r * r) return distSq;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Map pick-mode contract: a generic "pick a destination on the map"
|
||||
// interaction the inspector triggers and the renderer drives. Phase
|
||||
// 16 adds the cargo-route picker on top of this; later phases
|
||||
// (19/20) drive ship-group dispatch through the same surface.
|
||||
//
|
||||
// The renderer-facing API lives on `RendererHandle.setPickMode`
|
||||
// (see `render.ts`); this module owns the option / handle types and
|
||||
// the pure overlay-draw helper that translates the pick state into a
|
||||
// drawing spec the renderer can lift straight onto a Pixi `Graphics`.
|
||||
// Keeping the math here means the lifecycle (dim / cursor line /
|
||||
// hover outline / click+Escape resolution) can be tested without
|
||||
// booting a Pixi `Application`.
|
||||
|
||||
import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID } from "./world";
|
||||
|
||||
/**
|
||||
* PickModeOptions configures a pick-mode session. The caller is
|
||||
* responsible for computing `reachableIds` from the current report
|
||||
* (e.g. cargo routes apply the `40 * driveTech` rule before opening
|
||||
* the picker). The renderer never validates reach itself — it only
|
||||
* dims primitives whose id is missing from this set.
|
||||
*/
|
||||
export interface PickModeOptions {
|
||||
/** Numeric id of the source planet primitive. Stays full-alpha
|
||||
* during the session and anchors the cursor line. */
|
||||
readonly sourcePrimitiveId: PrimitiveID;
|
||||
/** World coordinates of the source. Pre-computed so the renderer
|
||||
* can draw the anchor ring and the line endpoint without
|
||||
* crawling the primitive list. */
|
||||
readonly sourceX: number;
|
||||
readonly sourceY: number;
|
||||
/** Ids whose primitives stay full-alpha and accept clicks. */
|
||||
readonly reachableIds: ReadonlySet<PrimitiveID>;
|
||||
/** Resolution callback. Fires with the chosen primitive id on a
|
||||
* successful pick, or `null` when the player cancels via Escape
|
||||
* or the imperative `cancel()` handle. */
|
||||
readonly onPick: (id: PrimitiveID | null) => void;
|
||||
}
|
||||
|
||||
export interface PickModeHandle {
|
||||
/**
|
||||
* cancel terminates the session immediately and resolves
|
||||
* `onPick(null)`. Idempotent — repeated calls are no-ops.
|
||||
*/
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PickOverlaySpec is the pure description the renderer paints onto
|
||||
* its overlay graphic each frame. Keeps the lifecycle logic
|
||||
* Pixi-free so it can be exercised by Vitest.
|
||||
*/
|
||||
export interface PickOverlaySpec {
|
||||
/** Highlight ring around the source planet (slightly outside the
|
||||
* visible disc). */
|
||||
readonly anchor: {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly radius: number;
|
||||
};
|
||||
/** Line from source to current cursor; `null` while the cursor
|
||||
* is off-canvas. */
|
||||
readonly line: {
|
||||
readonly x1: number;
|
||||
readonly y1: number;
|
||||
readonly x2: number;
|
||||
readonly y2: number;
|
||||
} | null;
|
||||
/** Outline circle around the hovered reachable planet; `null`
|
||||
* when the hover is empty or aimed at a non-reachable primitive. */
|
||||
readonly hoverOutline: {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly radius: number;
|
||||
} | null;
|
||||
/** Ids to dim (alpha 0.3). Everything not in `reachableIds` and
|
||||
* not the source. */
|
||||
readonly dimmedIds: ReadonlySet<PrimitiveID>;
|
||||
}
|
||||
|
||||
/** Anchor / hover outline padding in world units (the rings sit
|
||||
* outside the visible disc so the planet stays clearly visible). */
|
||||
export const ANCHOR_PADDING_WORLD = 6;
|
||||
export const HOVER_PADDING_WORLD = 4;
|
||||
|
||||
/**
|
||||
* computePickOverlay produces a `PickOverlaySpec` for the current
|
||||
* pick state. Pure: no DOM access, no Pixi calls. Callers prepare
|
||||
* `pointPrimitivesById` from the active world before invoking.
|
||||
*/
|
||||
export function computePickOverlay(
|
||||
options: PickModeOptions,
|
||||
cursorWorld: { x: number; y: number } | null,
|
||||
hoveredId: PrimitiveID | null,
|
||||
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
|
||||
allPrimitiveIds: Iterable<PrimitiveID>,
|
||||
): PickOverlaySpec {
|
||||
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
|
||||
const sourceRadius =
|
||||
(sourcePrim?.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
||||
ANCHOR_PADDING_WORLD;
|
||||
|
||||
const dimmed = new Set<PrimitiveID>();
|
||||
for (const id of allPrimitiveIds) {
|
||||
if (id === options.sourcePrimitiveId) continue;
|
||||
if (options.reachableIds.has(id)) continue;
|
||||
dimmed.add(id);
|
||||
}
|
||||
|
||||
const line =
|
||||
cursorWorld === null
|
||||
? null
|
||||
: {
|
||||
x1: options.sourceX,
|
||||
y1: options.sourceY,
|
||||
x2: cursorWorld.x,
|
||||
y2: cursorWorld.y,
|
||||
};
|
||||
|
||||
let hoverOutline: PickOverlaySpec["hoverOutline"] = null;
|
||||
if (
|
||||
hoveredId !== null &&
|
||||
hoveredId !== options.sourcePrimitiveId &&
|
||||
options.reachableIds.has(hoveredId)
|
||||
) {
|
||||
const target = pointPrimitivesById.get(hoveredId);
|
||||
if (target !== undefined) {
|
||||
hoverOutline = {
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
radius:
|
||||
(target.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
||||
HOVER_PADDING_WORLD,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
anchor: {
|
||||
x: options.sourceX,
|
||||
y: options.sourceY,
|
||||
radius: sourceRadius,
|
||||
},
|
||||
line,
|
||||
hoverOutline,
|
||||
dimmedIds: dimmed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PICK_OVERLAY_STYLE captures the colours / widths the renderer
|
||||
* applies to each spec channel. Exported so tests and future themes
|
||||
* can read the same values.
|
||||
*/
|
||||
export const PICK_OVERLAY_STYLE = {
|
||||
anchor: { color: 0xffe082, alpha: 0.9, width: 2 },
|
||||
line: { color: 0xffe082, alpha: 0.5, width: 1 },
|
||||
hover: { color: 0xffe082, alpha: 1, width: 2 },
|
||||
dimAlpha: 0.3,
|
||||
} as const;
|
||||
+408
-11
@@ -21,18 +21,27 @@ import { Application, Container, Graphics, type Renderer, type RendererType } fr
|
||||
import { Viewport as PixiViewport } from "pixi-viewport";
|
||||
|
||||
import { hitTest, type Hit } from "./hit-test";
|
||||
import { screenToWorld } from "./math";
|
||||
import { minScaleNoWrap } from "./no-wrap";
|
||||
import {
|
||||
computePickOverlay,
|
||||
PICK_OVERLAY_STYLE,
|
||||
type PickModeHandle,
|
||||
type PickModeOptions,
|
||||
} from "./pick-mode";
|
||||
import { wrapCameraTorus } from "./torus";
|
||||
import {
|
||||
DARK_THEME,
|
||||
DEFAULT_POINT_RADIUS_PX,
|
||||
World,
|
||||
type Camera,
|
||||
type CirclePrim,
|
||||
type LinePrim,
|
||||
type PointPrim,
|
||||
type Primitive,
|
||||
type PrimitiveID,
|
||||
type Theme,
|
||||
type Viewport,
|
||||
type World,
|
||||
type WrapMode,
|
||||
} from "./world";
|
||||
|
||||
@@ -58,6 +67,26 @@ export interface RendererHandle {
|
||||
getViewport(): Viewport;
|
||||
getBackend(): "webgl" | "webgpu" | "canvas";
|
||||
hitAt(cursorPx: { x: number; y: number }): Hit | null;
|
||||
/**
|
||||
* setExtraPrimitives replaces the current overlay primitive layer
|
||||
* with `prims`. The base world (passed to `createRenderer`) is
|
||||
* preserved; only the extras layer changes. Used by the in-game
|
||||
* shell to project order-overlay-driven artefacts (Phase 16
|
||||
* cargo-route arrows) onto the live renderer without disposing
|
||||
* and recreating the Pixi `Application` — which Pixi 8 does not
|
||||
* reliably support on the same canvas.
|
||||
*
|
||||
* Hit-test, `getPrimitives`, and pick mode all see the union of
|
||||
* base + extras after the call returns. Repeated calls
|
||||
* remount-replace the extras atomically.
|
||||
*/
|
||||
setExtraPrimitives(prims: readonly Primitive[]): void;
|
||||
/**
|
||||
* getPrimitives returns the live union of base + extras. The
|
||||
* order is base-first, extras-last (mirroring the draw order).
|
||||
* Reads stay in sync with `setExtraPrimitives`.
|
||||
*/
|
||||
getPrimitives(): readonly Primitive[];
|
||||
/**
|
||||
* onClick subscribes `cb` to a click on the map (a pointer-down /
|
||||
* pointer-up pair without enough drag to trigger pan). The cursor
|
||||
@@ -70,6 +99,62 @@ export interface RendererHandle {
|
||||
* click here will not race a pan gesture.
|
||||
*/
|
||||
onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void;
|
||||
/**
|
||||
* onPointerMove subscribes `cb` to every pointer-move event on
|
||||
* the canvas. The callback receives the cursor in canvas-local
|
||||
* pixel coordinates so callers can hand it straight to `hitAt`.
|
||||
* Touch drags also emit pointer-move while a finger is pressed.
|
||||
* The returned function detaches the listener; idempotent.
|
||||
*/
|
||||
onPointerMove(cb: (cursorPx: { x: number; y: number }) => void): () => void;
|
||||
/**
|
||||
* onHoverChange subscribes `cb` to changes in the primitive
|
||||
* currently under the cursor. The callback fires only when the
|
||||
* id transitions (deduped) and is invoked with `null` when the
|
||||
* cursor moves into empty space. Driven by the same pointer-move
|
||||
* stream as `onPointerMove`, so subscribing to both does not
|
||||
* double-cost the pointer event.
|
||||
*/
|
||||
onHoverChange(cb: (id: PrimitiveID | null) => void): () => void;
|
||||
/**
|
||||
* setPickMode opens (or, with `null`, closes) a map-driven
|
||||
* destination pick. While a session is active the renderer dims
|
||||
* primitives outside `reachableIds`, mounts an overlay drawing
|
||||
* the source-anchor ring, the cursor line, and the
|
||||
* hover-highlight ring, suppresses regular `onClick` consumers,
|
||||
* and listens for Escape on `document`. The session resolves via
|
||||
* `opts.onPick(id)` on a click hitting a reachable planet, or
|
||||
* `opts.onPick(null)` on Escape / handle.cancel().
|
||||
*
|
||||
* Returns the imperative cancel handle when a session was opened
|
||||
* (i.e. `opts !== null`), otherwise `null`. Calling the function
|
||||
* again with `null` closes any active session and is idempotent.
|
||||
*/
|
||||
setPickMode(opts: PickModeOptions | null): PickModeHandle | null;
|
||||
/**
|
||||
* isPickModeActive reports whether a `setPickMode` session is
|
||||
* currently open. The standard `onClick` path is suppressed
|
||||
* while this returns `true`.
|
||||
*/
|
||||
isPickModeActive(): boolean;
|
||||
/**
|
||||
* getPickState returns a defensive snapshot of the pick-mode
|
||||
* session for debugging surfaces. `sourcePrimitiveId` and
|
||||
* `reachableIds` are `null` while no session is open.
|
||||
*/
|
||||
getPickState(): {
|
||||
active: boolean;
|
||||
sourcePrimitiveId: PrimitiveID | null;
|
||||
reachableIds: ReadonlySet<PrimitiveID> | null;
|
||||
hoveredId: PrimitiveID | null;
|
||||
};
|
||||
/**
|
||||
* getPrimitiveAlpha returns the current rendered alpha of the
|
||||
* primitive `id` (in the central tile). Used by the debug
|
||||
* surface to report dimmed-state for e2e assertions. Returns 1
|
||||
* for unknown ids.
|
||||
*/
|
||||
getPrimitiveAlpha(id: PrimitiveID): number;
|
||||
resize(widthPx: number, heightPx: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -132,10 +217,31 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
return c;
|
||||
});
|
||||
|
||||
for (const c of copies) {
|
||||
for (const p of opts.world.primitives) {
|
||||
c.addChild(buildGraphics(p, theme));
|
||||
// Per-id `Graphics` lookup. Each primitive lives in nine copies
|
||||
// (one per torus tile); pick-mode dims them by id, so the lookup
|
||||
// indexes the full set of `Graphics` instances per primitive id.
|
||||
const primitiveGraphics = new Map<PrimitiveID, Graphics[]>();
|
||||
const pointPrimitivesById = new Map<PrimitiveID, PointPrim>();
|
||||
const allPrimitiveIds: PrimitiveID[] = [];
|
||||
const extraPrimitiveIds = new Set<PrimitiveID>();
|
||||
let currentWorld: World = opts.world;
|
||||
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
||||
for (const c of copies) {
|
||||
const g = buildGraphics(prim, theme);
|
||||
c.addChild(g);
|
||||
let list = primitiveGraphics.get(prim.id);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
primitiveGraphics.set(prim.id, list);
|
||||
}
|
||||
list.push(g);
|
||||
}
|
||||
allPrimitiveIds.push(prim.id);
|
||||
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
|
||||
if (isExtra) extraPrimitiveIds.add(prim.id);
|
||||
};
|
||||
for (const p of opts.world.primitives) {
|
||||
populatePrimitives(p, false);
|
||||
}
|
||||
|
||||
let mode: WrapMode = opts.mode;
|
||||
@@ -217,6 +323,208 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
|
||||
applyMode(mode);
|
||||
|
||||
// Pointer-move + hover plumbing. Listening on the underlying
|
||||
// canvas keeps the renderer agnostic of pixi-viewport's plugin
|
||||
// chain (drag/pinch can swallow Pixi-level pointer events while
|
||||
// a gesture is in progress; the DOM event still fires).
|
||||
const pointerMoveCallbacks = new Set<
|
||||
(cursorPx: { x: number; y: number }) => void
|
||||
>();
|
||||
const hoverChangeCallbacks = new Set<(id: PrimitiveID | null) => void>();
|
||||
let lastHoveredId: PrimitiveID | null = null;
|
||||
let lastCursorPx: { x: number; y: number } | null = null;
|
||||
const handlePointerMove = (event: PointerEvent): void => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const cursorPx = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
};
|
||||
lastCursorPx = cursorPx;
|
||||
for (const cb of pointerMoveCallbacks) cb(cursorPx);
|
||||
const hit = hitTest(
|
||||
currentWorld,
|
||||
handle.getCamera(),
|
||||
handle.getViewport(),
|
||||
cursorPx,
|
||||
mode,
|
||||
);
|
||||
const hoveredId = hit?.primitive.id ?? null;
|
||||
if (hoveredId === lastHoveredId) return;
|
||||
lastHoveredId = hoveredId;
|
||||
for (const cb of hoverChangeCallbacks) cb(hoveredId);
|
||||
};
|
||||
const handlePointerLeave = (): void => {
|
||||
lastCursorPx = null;
|
||||
if (hoverChangeCallbacks.size === 0 || lastHoveredId === null) return;
|
||||
lastHoveredId = null;
|
||||
for (const cb of hoverChangeCallbacks) cb(null);
|
||||
};
|
||||
canvas.addEventListener("pointermove", handlePointerMove);
|
||||
canvas.addEventListener("pointerleave", handlePointerLeave);
|
||||
|
||||
// Click dispatch. The renderer owns one `viewport.clicked`
|
||||
// listener and fans the event out to either the pick-mode
|
||||
// resolver (when a session is open) or the standard `onClick`
|
||||
// subscribers — never both. Routing through one listener makes
|
||||
// the gating race-proof: a pick-mode resolution + teardown runs
|
||||
// in the same tick as the click, and the standard subscribers
|
||||
// do not see the post-teardown state.
|
||||
const clickSubscribers = new Set<
|
||||
(cursorPx: { x: number; y: number }) => void
|
||||
>();
|
||||
|
||||
// Pick-mode state. Owned by the renderer so all callers funnel
|
||||
// through `setPickMode`; tests for the pure overlay math live in
|
||||
// `pick-mode.ts`.
|
||||
let pickModeActive = false;
|
||||
let pickOptions: PickModeOptions | null = null;
|
||||
let pickOverlay: Graphics | null = null;
|
||||
const dimmedAlphaBackup = new Map<Graphics, number>();
|
||||
const detachPickListeners: Array<() => void> = [];
|
||||
|
||||
const handleViewportClicked = (e: {
|
||||
screen: { x: number; y: number };
|
||||
}): void => {
|
||||
const cursorPx = { x: e.screen.x, y: e.screen.y };
|
||||
if (pickModeActive) {
|
||||
const session = pickOptions;
|
||||
if (session === null) return;
|
||||
const hit = hitTest(
|
||||
currentWorld,
|
||||
handle.getCamera(),
|
||||
handle.getViewport(),
|
||||
cursorPx,
|
||||
mode,
|
||||
);
|
||||
const hitId = hit?.primitive.id ?? null;
|
||||
if (hitId === null) return;
|
||||
if (hitId === session.sourcePrimitiveId) return;
|
||||
if (!session.reachableIds.has(hitId)) return;
|
||||
const cb = session.onPick;
|
||||
teardownPickMode();
|
||||
cb(hitId);
|
||||
return;
|
||||
}
|
||||
for (const cb of clickSubscribers) cb(cursorPx);
|
||||
};
|
||||
viewport.on("clicked", handleViewportClicked);
|
||||
const redrawPickOverlay = (): void => {
|
||||
if (pickOverlay === null || pickOptions === null) return;
|
||||
const cursorWorld =
|
||||
lastCursorPx === null
|
||||
? null
|
||||
: screenToWorld(
|
||||
lastCursorPx,
|
||||
handle.getCamera(),
|
||||
handle.getViewport(),
|
||||
);
|
||||
const spec = computePickOverlay(
|
||||
pickOptions,
|
||||
cursorWorld,
|
||||
lastHoveredId,
|
||||
pointPrimitivesById,
|
||||
allPrimitiveIds,
|
||||
);
|
||||
const g = pickOverlay;
|
||||
g.clear();
|
||||
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
|
||||
g.stroke({
|
||||
color: PICK_OVERLAY_STYLE.anchor.color,
|
||||
alpha: PICK_OVERLAY_STYLE.anchor.alpha,
|
||||
width: PICK_OVERLAY_STYLE.anchor.width,
|
||||
});
|
||||
if (spec.line !== null) {
|
||||
g.moveTo(spec.line.x1, spec.line.y1);
|
||||
g.lineTo(spec.line.x2, spec.line.y2);
|
||||
g.stroke({
|
||||
color: PICK_OVERLAY_STYLE.line.color,
|
||||
alpha: PICK_OVERLAY_STYLE.line.alpha,
|
||||
width: PICK_OVERLAY_STYLE.line.width,
|
||||
});
|
||||
}
|
||||
if (spec.hoverOutline !== null) {
|
||||
g.circle(
|
||||
spec.hoverOutline.x,
|
||||
spec.hoverOutline.y,
|
||||
spec.hoverOutline.radius,
|
||||
);
|
||||
g.stroke({
|
||||
color: PICK_OVERLAY_STYLE.hover.color,
|
||||
alpha: PICK_OVERLAY_STYLE.hover.alpha,
|
||||
width: PICK_OVERLAY_STYLE.hover.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
const teardownPickMode = (): void => {
|
||||
if (!pickModeActive) return;
|
||||
pickModeActive = false;
|
||||
for (const detach of detachPickListeners) detach();
|
||||
detachPickListeners.length = 0;
|
||||
for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha;
|
||||
dimmedAlphaBackup.clear();
|
||||
if (pickOverlay !== null) {
|
||||
pickOverlay.destroy();
|
||||
pickOverlay = null;
|
||||
}
|
||||
pickOptions = null;
|
||||
};
|
||||
const openPickMode = (options: PickModeOptions): PickModeHandle => {
|
||||
// An existing session is cancelled first so the previous
|
||||
// `onPick(null)` is delivered before the new one starts.
|
||||
if (pickModeActive) {
|
||||
const previous = pickOptions;
|
||||
teardownPickMode();
|
||||
previous?.onPick(null);
|
||||
}
|
||||
pickOptions = options;
|
||||
pickModeActive = true;
|
||||
// Dim every primitive that's not the source and not reachable.
|
||||
for (const [id, list] of primitiveGraphics) {
|
||||
if (id === options.sourcePrimitiveId) continue;
|
||||
if (options.reachableIds.has(id)) continue;
|
||||
for (const g of list) {
|
||||
dimmedAlphaBackup.set(g, g.alpha);
|
||||
g.alpha = PICK_OVERLAY_STYLE.dimAlpha;
|
||||
}
|
||||
}
|
||||
// Overlay graphic. Lives in the origin copy so the central
|
||||
// tile owns it; the camera always wraps back into this tile
|
||||
// (`wrapTorusCamera`), so the user sees the overlay
|
||||
// regardless of how far they have panned.
|
||||
pickOverlay = new Graphics();
|
||||
copies[ORIGIN_COPY_INDEX]!.addChild(pickOverlay);
|
||||
redrawPickOverlay();
|
||||
// Pointer-move drives the cursor line; hover changes drive
|
||||
// the outline. Both go through the renderer's existing
|
||||
// callback registries.
|
||||
detachPickListeners.push(handle.onPointerMove(redrawPickOverlay));
|
||||
detachPickListeners.push(handle.onHoverChange(redrawPickOverlay));
|
||||
// Click resolution is handled by the shared
|
||||
// `handleViewportClicked` dispatcher above; pick mode does
|
||||
// not subscribe its own `clicked` listener — see the
|
||||
// rationale in the dispatcher's comment.
|
||||
const keyHandler = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (pickOptions === null) return;
|
||||
event.preventDefault();
|
||||
const cb = pickOptions.onPick;
|
||||
teardownPickMode();
|
||||
cb(null);
|
||||
};
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
detachPickListeners.push(() =>
|
||||
document.removeEventListener("keydown", keyHandler),
|
||||
);
|
||||
return {
|
||||
cancel: (): void => {
|
||||
if (pickOptions === null) return;
|
||||
const cb = pickOptions.onPick;
|
||||
teardownPickMode();
|
||||
cb(null);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handle: RendererHandle = {
|
||||
app,
|
||||
viewport,
|
||||
@@ -233,16 +541,89 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
}),
|
||||
getBackend: () => rendererBackendName(app.renderer),
|
||||
hitAt: (cursorPx) =>
|
||||
hitTest(opts.world, handle.getCamera(), handle.getViewport(), cursorPx, mode),
|
||||
hitTest(
|
||||
currentWorld,
|
||||
handle.getCamera(),
|
||||
handle.getViewport(),
|
||||
cursorPx,
|
||||
mode,
|
||||
),
|
||||
setExtraPrimitives: (prims) => {
|
||||
// Drop the previous extras layer.
|
||||
for (const id of extraPrimitiveIds) {
|
||||
const list = primitiveGraphics.get(id);
|
||||
if (list !== undefined) {
|
||||
for (const g of list) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
primitiveGraphics.delete(id);
|
||||
}
|
||||
pointPrimitivesById.delete(id);
|
||||
const idx = allPrimitiveIds.indexOf(id);
|
||||
if (idx >= 0) allPrimitiveIds.splice(idx, 1);
|
||||
}
|
||||
extraPrimitiveIds.clear();
|
||||
// Add the new extras.
|
||||
for (const p of prims) {
|
||||
populatePrimitives(p, true);
|
||||
}
|
||||
// Rebuild the snapshot World hit-test reads from. The
|
||||
// renderer keeps `currentWorld` mutable so the live
|
||||
// extras participate in click/hover tests on the same
|
||||
// frame they're drawn.
|
||||
currentWorld = new World(opts.world.width, opts.world.height, [
|
||||
...opts.world.primitives,
|
||||
...prims,
|
||||
]);
|
||||
},
|
||||
getPrimitives: () => currentWorld.primitives,
|
||||
onClick: (cb) => {
|
||||
const handler = (e: { screen: { x: number; y: number } }): void => {
|
||||
cb({ x: e.screen.x, y: e.screen.y });
|
||||
};
|
||||
viewport.on("clicked", handler);
|
||||
clickSubscribers.add(cb);
|
||||
return () => {
|
||||
viewport.off("clicked", handler);
|
||||
clickSubscribers.delete(cb);
|
||||
};
|
||||
},
|
||||
onPointerMove: (cb) => {
|
||||
pointerMoveCallbacks.add(cb);
|
||||
return () => {
|
||||
pointerMoveCallbacks.delete(cb);
|
||||
};
|
||||
},
|
||||
onHoverChange: (cb) => {
|
||||
hoverChangeCallbacks.add(cb);
|
||||
// Fire the current state once so subscribers do not have to
|
||||
// wait for the next pointer movement to learn what's under
|
||||
// the cursor.
|
||||
cb(lastHoveredId);
|
||||
return () => {
|
||||
hoverChangeCallbacks.delete(cb);
|
||||
};
|
||||
},
|
||||
setPickMode: (options) => {
|
||||
if (options === null) {
|
||||
if (!pickModeActive) return null;
|
||||
const previous = pickOptions;
|
||||
teardownPickMode();
|
||||
previous?.onPick(null);
|
||||
return null;
|
||||
}
|
||||
return openPickMode(options);
|
||||
},
|
||||
isPickModeActive: () => pickModeActive,
|
||||
getPickState: () => ({
|
||||
active: pickModeActive,
|
||||
sourcePrimitiveId: pickOptions?.sourcePrimitiveId ?? null,
|
||||
reachableIds: pickOptions?.reachableIds ?? null,
|
||||
hoveredId: lastHoveredId,
|
||||
}),
|
||||
getPrimitiveAlpha: (id) => {
|
||||
const list = primitiveGraphics.get(id);
|
||||
if (list === undefined || list.length === 0) return 1;
|
||||
// All copies share the same alpha (dim is applied to every
|
||||
// torus tile), so the central-tile entry is representative.
|
||||
return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha;
|
||||
},
|
||||
resize: (w, h) => {
|
||||
app.renderer.resize(w, h);
|
||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||
@@ -255,8 +636,24 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
// Tear down any open pick session before destroying the
|
||||
// app — the resolution callback might reference Svelte
|
||||
// stores that disappear next tick on dispose, but
|
||||
// `onPick(null)` here is a synchronous notification the
|
||||
// caller is responsible for handling.
|
||||
if (pickModeActive) {
|
||||
const previous = pickOptions;
|
||||
teardownPickMode();
|
||||
previous?.onPick(null);
|
||||
}
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
viewport.off("moved", wrapTorusCamera);
|
||||
viewport.off("clicked", handleViewportClicked);
|
||||
canvas.removeEventListener("pointermove", handlePointerMove);
|
||||
canvas.removeEventListener("pointerleave", handlePointerLeave);
|
||||
pointerMoveCallbacks.clear();
|
||||
hoverChangeCallbacks.clear();
|
||||
clickSubscribers.clear();
|
||||
app.destroy({ removeView: false }, { children: true });
|
||||
},
|
||||
};
|
||||
@@ -283,7 +680,7 @@ function buildGraphics(p: Primitive, theme: Theme): Graphics {
|
||||
function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void {
|
||||
const color = p.style.fillColor ?? theme.pointFill;
|
||||
const alpha = p.style.fillAlpha ?? 1;
|
||||
const radiusPx = p.style.pointRadiusPx ?? 3;
|
||||
const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
|
||||
g.circle(p.x, p.y, radiusPx);
|
||||
g.fill({ color, alpha });
|
||||
}
|
||||
|
||||
@@ -63,14 +63,23 @@ export type Primitive = PointPrim | CirclePrim | LinePrim;
|
||||
|
||||
export type PrimitiveKind = Primitive["kind"];
|
||||
|
||||
// Default hit slop in screen pixels per primitive kind. Chosen for
|
||||
// touch ergonomics; per-primitive `hitSlopPx` overrides the default.
|
||||
// Default hit slop in screen pixels per primitive kind. Added on top
|
||||
// of the visible footprint of each primitive — for points, the
|
||||
// effective hit radius is `pointRadiusPx + slopPx`. Chosen for touch
|
||||
// ergonomics; per-primitive `hitSlopPx` overrides the default.
|
||||
export const DEFAULT_HIT_SLOP_PX: Record<PrimitiveKind, number> = {
|
||||
point: 8,
|
||||
point: 4,
|
||||
circle: 6,
|
||||
line: 6,
|
||||
};
|
||||
|
||||
// Default world-unit radius drawn for a `PointPrim` when its
|
||||
// `style.pointRadiusPx` is unset. Shared between the renderer
|
||||
// (`render.ts.drawPoint`) and the hit-test
|
||||
// (`hit-test.ts.matchPoint`) so the click target always covers the
|
||||
// visible disc.
|
||||
export const DEFAULT_POINT_RADIUS_PX = 3;
|
||||
|
||||
// kindOrder is the deterministic tie-break order used during hit-test
|
||||
// when two primitives match a cursor at identical priority and
|
||||
// distance. Smaller value wins.
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
} from "../../../api/session";
|
||||
import { loadStore } from "../../../platform/store/index";
|
||||
import type { OrderCommand } from "../../../sync/order-types";
|
||||
import {
|
||||
getMapCamera,
|
||||
getMapPickState,
|
||||
getMapPrimitives,
|
||||
type MapCameraSnapshot,
|
||||
type MapPickStateSnapshot,
|
||||
type MapPrimitiveSnapshot,
|
||||
} from "../../../lib/debug-surface.svelte";
|
||||
|
||||
interface DebugSnapshot {
|
||||
publicKey: number[];
|
||||
@@ -28,6 +36,9 @@
|
||||
commands: OrderCommand[],
|
||||
): Promise<void>;
|
||||
clearOrderDraft(gameId: string): Promise<void>;
|
||||
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
|
||||
getMapPickState(): MapPickStateSnapshot;
|
||||
getMapCamera(): MapCameraSnapshot | null;
|
||||
}
|
||||
|
||||
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
|
||||
@@ -116,6 +127,15 @@
|
||||
throw new Error(`clearOrderDraft: ${describe(err)}`);
|
||||
}
|
||||
},
|
||||
getMapPrimitives() {
|
||||
return getMapPrimitives();
|
||||
},
|
||||
getMapPickState() {
|
||||
return getMapPickState();
|
||||
},
|
||||
getMapCamera() {
|
||||
return getMapCamera();
|
||||
},
|
||||
};
|
||||
(window as DebugWindow).__galaxyDebug = surface;
|
||||
ready = true;
|
||||
|
||||
@@ -65,6 +65,10 @@ fresh.
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../../../sync/order-draft.svelte";
|
||||
import {
|
||||
MAP_PICK_CONTEXT_KEY,
|
||||
MapPickService,
|
||||
} from "$lib/map-pick.svelte";
|
||||
import {
|
||||
GALAXY_CLIENT_CONTEXT_KEY,
|
||||
GalaxyClientHolder,
|
||||
@@ -101,6 +105,13 @@ fresh.
|
||||
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
|
||||
const galaxyClient = new GalaxyClientHolder();
|
||||
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
|
||||
// `MapPickService` lives at the layout so both the active map
|
||||
// view (which binds the renderer-side resolver) and the
|
||||
// inspector subsections (which call `pick(...)`) see the same
|
||||
// instance via context — they sit on sibling branches of the
|
||||
// component tree.
|
||||
const mapPick = new MapPickService();
|
||||
setContext(MAP_PICK_CONTEXT_KEY, mapPick);
|
||||
|
||||
// selectedPlanet resolves the current selection against the live
|
||||
// report so both the desktop sidebar and the mobile sheet display
|
||||
@@ -120,6 +131,13 @@ fresh.
|
||||
const localShipClass = $derived(
|
||||
renderedReport.report?.localShipClass ?? [],
|
||||
);
|
||||
const inspectorPlanets = $derived(renderedReport.report?.planets ?? []);
|
||||
const inspectorRoutes = $derived(renderedReport.report?.routes ?? []);
|
||||
const inspectorMapWidth = $derived(renderedReport.report?.mapWidth ?? 1);
|
||||
const inspectorMapHeight = $derived(renderedReport.report?.mapHeight ?? 1);
|
||||
const inspectorLocalDrive = $derived(
|
||||
renderedReport.report?.localPlayerDrive ?? 0,
|
||||
);
|
||||
|
||||
// Reveal the inspector whenever a new planet selection lands.
|
||||
// Reading `selection.selected` once outside the effect keeps the
|
||||
@@ -228,6 +246,11 @@ fresh.
|
||||
<PlanetSheet
|
||||
planet={selectedPlanet}
|
||||
{localShipClass}
|
||||
routes={inspectorRoutes}
|
||||
planets={inspectorPlanets}
|
||||
mapWidth={inspectorMapWidth}
|
||||
mapHeight={inspectorMapHeight}
|
||||
localPlayerDrive={inspectorLocalDrive}
|
||||
onMap={effectiveTool === "map"}
|
||||
onClose={() => selection.clear()}
|
||||
/>
|
||||
|
||||
@@ -174,12 +174,20 @@ export class OrderDraftStore {
|
||||
* Mutations made before `init` resolves are ignored — the layout
|
||||
* always awaits `init` before exposing the store.
|
||||
*
|
||||
* `setProductionType` carries a collapse-by-`planetNumber` rule:
|
||||
* a new entry supersedes any prior `setProductionType` for the
|
||||
* same planet, so the draft holds at most one production choice
|
||||
* per planet at any time. Other variants append unconditionally —
|
||||
* `planetRename` keeps its append-only behaviour because each
|
||||
* rename is a distinct user-visible action.
|
||||
* Collapse rules:
|
||||
*
|
||||
* - `setProductionType` collapses by `planetNumber`: a new
|
||||
* entry supersedes any prior `setProductionType` for the
|
||||
* same planet, so the draft holds at most one production
|
||||
* choice per planet.
|
||||
* - `setCargoRoute` and `removeCargoRoute` share a collapse
|
||||
* key on `(sourcePlanetNumber, loadType)` — the engine
|
||||
* stores a single (planet, type) → destination mapping, so
|
||||
* a newer entry for the same slot supersedes any prior
|
||||
* `set` or `remove` for that slot. Different load-types or
|
||||
* different sources coexist.
|
||||
* - `planetRename` and `placeholder` append unconditionally;
|
||||
* each rename is a distinct user-visible action.
|
||||
*/
|
||||
async add(command: OrderCommand): Promise<void> {
|
||||
if (this.status !== "ready") return;
|
||||
@@ -198,6 +206,24 @@ export class OrderDraftStore {
|
||||
nextCommands.push(existing);
|
||||
}
|
||||
nextCommands.push(command);
|
||||
} else if (
|
||||
command.kind === "setCargoRoute" ||
|
||||
command.kind === "removeCargoRoute"
|
||||
) {
|
||||
nextCommands = [];
|
||||
for (const existing of this.commands) {
|
||||
if (
|
||||
(existing.kind === "setCargoRoute" ||
|
||||
existing.kind === "removeCargoRoute") &&
|
||||
existing.sourcePlanetNumber === command.sourcePlanetNumber &&
|
||||
existing.loadType === command.loadType
|
||||
) {
|
||||
removed.push(existing.id);
|
||||
continue;
|
||||
}
|
||||
nextCommands.push(existing);
|
||||
}
|
||||
nextCommands.push(command);
|
||||
} else {
|
||||
nextCommands = [...this.commands, command];
|
||||
}
|
||||
@@ -444,6 +470,23 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
|
||||
return validateEntityName(cmd.subject).ok ? "valid" : "invalid";
|
||||
}
|
||||
return "valid";
|
||||
case "setCargoRoute":
|
||||
// The picker pre-checks reach (and so refuses to emit a
|
||||
// route to an unreachable destination) and the engine
|
||||
// re-validates ownership / reach server-side. Locally we
|
||||
// only refuse a self-route — the FBS validator
|
||||
// (`pkg/model/order/order.go`) accepts every other
|
||||
// (origin, destination, load_type) triple.
|
||||
if (cmd.sourcePlanetNumber === cmd.destinationPlanetNumber) {
|
||||
return "invalid";
|
||||
}
|
||||
return "valid";
|
||||
case "removeCargoRoute":
|
||||
// `removeCargoRoute` carries no destination; the only
|
||||
// engine-side check is ownership of the source planet,
|
||||
// which the inspector enforces by only mounting the
|
||||
// component on `kind === "local"`.
|
||||
return "valid";
|
||||
case "placeholder":
|
||||
// Phase 12 placeholder entries are content-free and never
|
||||
// transition out of `draft` — they are not submittable.
|
||||
|
||||
@@ -14,11 +14,18 @@ import {
|
||||
CommandPayload,
|
||||
CommandPlanetProduce,
|
||||
CommandPlanetRename,
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
PlanetProduction,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrderGet,
|
||||
UserGamesOrderGetResponse,
|
||||
} from "../proto/galaxy/fbs/order";
|
||||
import type { OrderCommand, ProductionType } from "./order-types";
|
||||
import type {
|
||||
CargoLoadType,
|
||||
OrderCommand,
|
||||
ProductionType,
|
||||
} from "./order-types";
|
||||
|
||||
const MESSAGE_TYPE = "user.games.order.get";
|
||||
|
||||
@@ -155,6 +162,41 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
|
||||
subject: inner.subject() ?? "",
|
||||
};
|
||||
}
|
||||
case CommandPayload.CommandPlanetRouteSet: {
|
||||
const inner = new CommandPlanetRouteSet();
|
||||
item.payload(inner);
|
||||
const loadType = cargoLoadTypeFromFBS(inner.loadType());
|
||||
if (loadType === null) {
|
||||
console.warn(
|
||||
`fetchOrder: skipping CommandPlanetRouteSet with unknown load_type enum (${inner.loadType()})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "setCargoRoute",
|
||||
id,
|
||||
sourcePlanetNumber: Number(inner.origin()),
|
||||
destinationPlanetNumber: Number(inner.destination()),
|
||||
loadType,
|
||||
};
|
||||
}
|
||||
case CommandPayload.CommandPlanetRouteRemove: {
|
||||
const inner = new CommandPlanetRouteRemove();
|
||||
item.payload(inner);
|
||||
const loadType = cargoLoadTypeFromFBS(inner.loadType());
|
||||
if (loadType === null) {
|
||||
console.warn(
|
||||
`fetchOrder: skipping CommandPlanetRouteRemove with unknown load_type enum (${inner.loadType()})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "removeCargoRoute",
|
||||
id,
|
||||
sourcePlanetNumber: Number(inner.origin()),
|
||||
loadType,
|
||||
};
|
||||
}
|
||||
default:
|
||||
console.warn(
|
||||
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
|
||||
@@ -196,6 +238,31 @@ export function productionTypeFromFBS(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* cargoLoadTypeFromFBS reverses `cargoLoadTypeToFBS` from
|
||||
* `submit.ts`. `PlanetRouteLoadType.UNKNOWN` and any out-of-band
|
||||
* value yield `null` so the caller drops the entry rather than
|
||||
* fabricating a synthetic load type.
|
||||
*/
|
||||
export function cargoLoadTypeFromFBS(
|
||||
value: PlanetRouteLoadType,
|
||||
): CargoLoadType | null {
|
||||
switch (value) {
|
||||
case PlanetRouteLoadType.COL:
|
||||
return "COL";
|
||||
case PlanetRouteLoadType.CAP:
|
||||
return "CAP";
|
||||
case PlanetRouteLoadType.MAT:
|
||||
return "MAT";
|
||||
case PlanetRouteLoadType.EMP:
|
||||
return "EMP";
|
||||
case PlanetRouteLoadType.UNKNOWN:
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeError(
|
||||
payload: Uint8Array,
|
||||
resultCode: string,
|
||||
|
||||
@@ -84,6 +84,49 @@ export interface SetProductionTypeCommand {
|
||||
readonly subject: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CargoLoadType mirrors the engine `PlanetRouteLoadType` enum
|
||||
* (`pkg/schema/fbs/order.fbs`). The values are wire-stable: the
|
||||
* submit encoder maps them to the FBS enum and the read-back
|
||||
* decoder maps them back. The four members enumerate the four
|
||||
* mutually-exclusive cargo-route slots a planet can drive at any
|
||||
* one time.
|
||||
*
|
||||
* `COL` — colonists (highest priority on load),
|
||||
* `CAP` — capital / industry crates,
|
||||
* `MAT` — raw materials,
|
||||
* `EMP` — empty ships returning to a producer.
|
||||
*/
|
||||
export type CargoLoadType = "COL" | "CAP" | "MAT" | "EMP";
|
||||
|
||||
/**
|
||||
* SetCargoRouteCommand binds a (source, loadType) slot to a
|
||||
* destination planet. Phase 16 carries a collapse-by-(source,
|
||||
* loadType) rule: at most one entry per slot lives in the draft at
|
||||
* any time. A `removeCargoRoute` for the same slot supersedes a
|
||||
* pending set (the engine accepts either order, but keeping the
|
||||
* draft minimal avoids confusing the order tab).
|
||||
*/
|
||||
export interface SetCargoRouteCommand {
|
||||
readonly kind: "setCargoRoute";
|
||||
readonly id: string;
|
||||
readonly sourcePlanetNumber: number;
|
||||
readonly destinationPlanetNumber: number;
|
||||
readonly loadType: CargoLoadType;
|
||||
}
|
||||
|
||||
/**
|
||||
* RemoveCargoRouteCommand drops the (source, loadType) slot. Same
|
||||
* collapse rule as `SetCargoRouteCommand` — a later `set` for the
|
||||
* same slot supersedes the remove, and vice versa.
|
||||
*/
|
||||
export interface RemoveCargoRouteCommand {
|
||||
readonly kind: "removeCargoRoute";
|
||||
readonly id: string;
|
||||
readonly sourcePlanetNumber: number;
|
||||
readonly loadType: CargoLoadType;
|
||||
}
|
||||
|
||||
/**
|
||||
* OrderCommand is the discriminated union of every command shape the
|
||||
* local order draft can hold. The `kind` field is the discriminator;
|
||||
@@ -93,7 +136,9 @@ export interface SetProductionTypeCommand {
|
||||
export type OrderCommand =
|
||||
| PlaceholderCommand
|
||||
| PlanetRenameCommand
|
||||
| SetProductionTypeCommand;
|
||||
| SetProductionTypeCommand
|
||||
| SetCargoRouteCommand
|
||||
| RemoveCargoRouteCommand;
|
||||
|
||||
/**
|
||||
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
|
||||
@@ -120,6 +165,31 @@ export function isProductionType(value: string): value is ProductionType {
|
||||
return (PRODUCTION_TYPE_VALUES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* CARGO_LOAD_TYPE_VALUES is the canonical tuple of `CargoLoadType`
|
||||
* literals in turn-cutoff priority order
|
||||
* (`game/internal/controller/route.go.SendRoutedGroups`):
|
||||
* colonists first, then capital, then materials, then empty ships.
|
||||
* The inspector renders slots in this order so visual order
|
||||
* matches engine behaviour. Used by validators and by the FBS
|
||||
* converters in `submit.ts` and `order-load.ts`.
|
||||
*/
|
||||
export const CARGO_LOAD_TYPE_VALUES = [
|
||||
"COL",
|
||||
"CAP",
|
||||
"MAT",
|
||||
"EMP",
|
||||
] as const satisfies readonly CargoLoadType[];
|
||||
|
||||
/**
|
||||
* isCargoLoadType narrows an arbitrary string to the
|
||||
* `CargoLoadType` union. The decoder uses this when the engine
|
||||
* report's `RouteEntry.value` carries the load-type string.
|
||||
*/
|
||||
export function isCargoLoadType(value: string): value is CargoLoadType {
|
||||
return (CARGO_LOAD_TYPE_VALUES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* CommandStatus is the lifecycle of a single command from the moment
|
||||
* it lands in the draft to the moment the server resolves it. The
|
||||
|
||||
@@ -29,11 +29,18 @@ import {
|
||||
CommandPayload,
|
||||
CommandPlanetProduce,
|
||||
CommandPlanetRename,
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
PlanetProduction,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderResponse,
|
||||
} from "../proto/galaxy/fbs/order";
|
||||
import type { OrderCommand, ProductionType } from "./order-types";
|
||||
import type {
|
||||
CargoLoadType,
|
||||
OrderCommand,
|
||||
ProductionType,
|
||||
} from "./order-types";
|
||||
|
||||
const MESSAGE_TYPE = "user.games.order";
|
||||
|
||||
@@ -163,6 +170,29 @@ function encodeCommandPayload(
|
||||
payloadOffset: offset,
|
||||
};
|
||||
}
|
||||
case "setCargoRoute": {
|
||||
const offset = CommandPlanetRouteSet.createCommandPlanetRouteSet(
|
||||
builder,
|
||||
BigInt(cmd.sourcePlanetNumber),
|
||||
BigInt(cmd.destinationPlanetNumber),
|
||||
cargoLoadTypeToFBS(cmd.loadType),
|
||||
);
|
||||
return {
|
||||
payloadType: CommandPayload.CommandPlanetRouteSet,
|
||||
payloadOffset: offset,
|
||||
};
|
||||
}
|
||||
case "removeCargoRoute": {
|
||||
const offset = CommandPlanetRouteRemove.createCommandPlanetRouteRemove(
|
||||
builder,
|
||||
BigInt(cmd.sourcePlanetNumber),
|
||||
cargoLoadTypeToFBS(cmd.loadType),
|
||||
);
|
||||
return {
|
||||
payloadType: CommandPayload.CommandPlanetRouteRemove,
|
||||
payloadOffset: offset,
|
||||
};
|
||||
}
|
||||
case "placeholder":
|
||||
throw new SubmitError(
|
||||
"invalid_request",
|
||||
@@ -200,6 +230,24 @@ export function productionTypeToFBS(value: ProductionType): PlanetProduction {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* cargoLoadTypeToFBS converts the wire-stable `CargoLoadType` literal
|
||||
* to the FlatBuffers enum value. Mirrors the engine
|
||||
* `PlanetRouteLoadType` enum (`pkg/schema/fbs/order.fbs`).
|
||||
*/
|
||||
export function cargoLoadTypeToFBS(value: CargoLoadType): PlanetRouteLoadType {
|
||||
switch (value) {
|
||||
case "COL":
|
||||
return PlanetRouteLoadType.COL;
|
||||
case "CAP":
|
||||
return PlanetRouteLoadType.CAP;
|
||||
case "MAT":
|
||||
return PlanetRouteLoadType.MAT;
|
||||
case "EMP":
|
||||
return PlanetRouteLoadType.EMP;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeOrderResponse(
|
||||
payload: Uint8Array,
|
||||
commands: OrderCommand[],
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
CommandPayload,
|
||||
CommandPlanetProduce,
|
||||
CommandPlanetRename,
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
PlanetProduction,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGetResponse,
|
||||
UserGamesOrderResponse,
|
||||
@@ -48,9 +51,25 @@ export interface SetProductionTypeResultFixture
|
||||
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 =
|
||||
| PlanetRenameResultFixture
|
||||
| SetProductionTypeResultFixture;
|
||||
| SetProductionTypeResultFixture
|
||||
| SetCargoRouteResultFixture
|
||||
| RemoveCargoRouteResultFixture;
|
||||
|
||||
export function buildOrderResponsePayload(
|
||||
gameId: string,
|
||||
@@ -135,6 +154,25 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
|
||||
payloadType = CommandPayload.CommandPlanetProduce;
|
||||
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.addCmdId(builder, cmdIdOffset);
|
||||
@@ -169,3 +207,18 @@ function productionTypeToFBS(
|
||||
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 {
|
||||
LocalPlanet,
|
||||
OtherPlanet,
|
||||
Player,
|
||||
Report,
|
||||
Route,
|
||||
RouteEntry,
|
||||
ShipClass,
|
||||
UnidentifiedPlanet,
|
||||
UninhabitedPlanet,
|
||||
@@ -52,6 +55,21 @@ export interface ShipClassFixture {
|
||||
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 {
|
||||
turn: number;
|
||||
mapWidth?: number;
|
||||
@@ -61,6 +79,9 @@ export interface ReportFixture {
|
||||
uninhabitedPlanets?: PlanetFixture[];
|
||||
unidentifiedPlanets?: { number: number; x: number; y: number }[];
|
||||
localShipClass?: ShipClassFixture[];
|
||||
race?: string;
|
||||
players?: PlayerFixture[];
|
||||
routes?: RouteFixture[];
|
||||
}
|
||||
|
||||
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
@@ -147,6 +168,29 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
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 =
|
||||
localOffsets.length === 0
|
||||
? null
|
||||
@@ -167,6 +211,16 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
localShipClassOffsets.length === 0
|
||||
? null
|
||||
: 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 =
|
||||
(fixture.localPlanets ?? []).length +
|
||||
@@ -179,12 +233,15 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
Report.addWidth(builder, fixture.mapWidth ?? 4000);
|
||||
Report.addHeight(builder, fixture.mapHeight ?? 4000);
|
||||
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 (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
|
||||
if (uninhabitedVec !== null) Report.addUninhabitedPlanet(builder, uninhabitedVec);
|
||||
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
|
||||
if (localShipClassVec !== null)
|
||||
Report.addLocalShipClass(builder, localShipClassVec);
|
||||
if (routeVec !== null) Report.addRoute(builder, routeVec);
|
||||
const reportOff = Report.endReport(builder);
|
||||
builder.finish(reportOff);
|
||||
return builder.asUint8Array();
|
||||
|
||||
@@ -13,10 +13,17 @@ interface DebugSnapshot {
|
||||
deviceSessionId: string | null;
|
||||
}
|
||||
|
||||
import type {
|
||||
MapCameraSnapshot,
|
||||
MapPickStateSnapshot,
|
||||
MapPrimitiveSnapshot,
|
||||
} from "../../src/lib/debug-surface.svelte";
|
||||
|
||||
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
|
||||
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`)
|
||||
// reuse the global declaration below, so this interface lists every
|
||||
// helper any spec calls — not only those exercised by this file.
|
||||
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
|
||||
// `cargo-routes.spec.ts`) reuse the global declaration below, so this
|
||||
// interface lists every helper any spec calls — not only those
|
||||
// exercised by this file.
|
||||
interface DebugSurface {
|
||||
ready: true;
|
||||
loadSession(): Promise<DebugSnapshot>;
|
||||
@@ -36,6 +43,9 @@ interface DebugSurface {
|
||||
}>,
|
||||
): Promise<void>;
|
||||
clearOrderDraft(gameId: string): Promise<void>;
|
||||
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
|
||||
getMapPickState(): MapPickStateSnapshot;
|
||||
getMapCamera(): MapCameraSnapshot | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -40,6 +40,8 @@ function withGameState(opts: {
|
||||
planets: [],
|
||||
race: opts.race ?? "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
};
|
||||
store.status = "ready";
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
planets,
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
planets,
|
||||
race: "",
|
||||
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,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
const section = ui.getByTestId("inspector-planet");
|
||||
@@ -130,6 +135,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 75,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||
@@ -161,6 +171,11 @@ describe("planet inspector", () => {
|
||||
materialsStockpile: 0,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||
@@ -193,6 +208,11 @@ describe("planet inspector", () => {
|
||||
y: -5,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
expect(ui.getByTestId("inspector-planet-kind")).toHaveTextContent(
|
||||
@@ -221,6 +241,11 @@ describe("planet inspector", () => {
|
||||
resources: 5,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
expect(ui.queryByTestId("inspector-planet-rename-action")).toBeNull();
|
||||
@@ -253,6 +278,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 0,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
context,
|
||||
});
|
||||
@@ -316,6 +346,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 0,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
context,
|
||||
});
|
||||
@@ -346,6 +381,11 @@ describe("planet inspector", () => {
|
||||
freeIndustry: 0,
|
||||
}),
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
planets: [],
|
||||
mapWidth: 1,
|
||||
mapHeight: 1,
|
||||
localPlayerDrive: 0,
|
||||
},
|
||||
});
|
||||
// Empty production strings collapse to the localised "none"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,12 @@
|
||||
// ui/docs/renderer.md. Worlds are kept tiny (1–5 primitives) so the
|
||||
// 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.
|
||||
//
|
||||
// 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 { hitTest } from "../src/map/hit-test";
|
||||
@@ -101,16 +107,32 @@ describe("hitTest — point primitive", () => {
|
||||
test("direct hit at centre", () => {
|
||||
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
|
||||
});
|
||||
test("hit within default slop (8px)", () => {
|
||||
// 7 world units away at scale=1 → within 8px slop.
|
||||
test("hit on the visible disc edge (3 world units from centre)", () => {
|
||||
// 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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
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 })]);
|
||||
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", () => {
|
||||
// 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
|
||||
// point slop is 8px → hit.
|
||||
// point radius (3) + slop (4) = 7 → hit.
|
||||
const cam = camAt(2, 50);
|
||||
const w = new World(100, 100, [point(1, 98, 50)]);
|
||||
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", () => {
|
||||
// At scale=4, 8px on screen = 2 world units.
|
||||
// A point 3 world units away misses.
|
||||
// At scale=4, slopPx 4 = 1 world unit; visible radius stays 3
|
||||
// world units. Threshold = 4 world units.
|
||||
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(
|
||||
null,
|
||||
);
|
||||
// A point 1.5 world units away hits at scale=4 (≤ 2).
|
||||
const w2 = new World(1000, 1000, [point(1, 501.5, 500)]);
|
||||
expect(
|
||||
ids(w2, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4))),
|
||||
).toBe(1);
|
||||
const cam4 = camAt(500, 500, 4);
|
||||
// 3 world units away → on the disc edge → hit.
|
||||
expect(ids(w, "torus", cam4, cursorOver(503, 500, cam4))).toBe(1);
|
||||
// 5 world units away → beyond radius+slop → null.
|
||||
const wFar = new World(1000, 1000, [point(1, 505, 500)]);
|
||||
expect(ids(wFar, "torus", cam4, cursorOver(500, 500, cam4))).toBe(null);
|
||||
});
|
||||
|
||||
test("lower zoom widens the on-screen slop in world units", () => {
|
||||
// At scale=0.5, 8px on screen = 16 world units.
|
||||
const w = new World(1000, 1000, [point(1, 514, 500)]);
|
||||
expect(
|
||||
ids(
|
||||
w,
|
||||
"torus",
|
||||
camAt(500, 500, 0.5),
|
||||
cursorOver(500, 500, camAt(500, 500, 0.5)),
|
||||
),
|
||||
).toBe(1);
|
||||
// At scale=0.5, slopPx 4 = 8 world units; visible radius
|
||||
// stays 3 → threshold = 11 world units.
|
||||
const cam05 = camAt(500, 500, 0.5);
|
||||
const w = new World(1000, 1000, [point(1, 510, 500)]);
|
||||
// 10 world units away → within 11 → hit.
|
||||
expect(ids(w, "torus", cam05, cursorOver(500, 500, cam05))).toBe(1);
|
||||
const wFar = new World(1000, 1000, [point(1, 514, 500)]);
|
||||
// 14 world units away → beyond 11 → null.
|
||||
expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -328,6 +328,104 @@ describe("OrderDraftStore", () => {
|
||||
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 () => {
|
||||
const { fakeFetchClient } = await import("./helpers/fake-order-client");
|
||||
const { client } = fakeFetchClient(GAME_ID, [
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
CommandPayload,
|
||||
CommandPlanetProduce,
|
||||
CommandPlanetRename,
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
PlanetProduction,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderGet,
|
||||
UserGamesOrderGetResponse,
|
||||
@@ -219,6 +222,134 @@ describe("fetchOrder", () => {
|
||||
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 () => {
|
||||
let captured: Uint8Array | null = null;
|
||||
const exec = vi.fn(async (_messageType, payload: Uint8Array) => {
|
||||
|
||||
@@ -48,6 +48,8 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
||||
planets,
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,6 +251,115 @@ describe("applyOrderOverlay", () => {
|
||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "applied" });
|
||||
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", () => {
|
||||
|
||||
@@ -21,6 +21,8 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
||||
planets: [],
|
||||
race: "",
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -136,4 +138,28 @@ describe("reportToWorld", () => {
|
||||
const unknown = world.primitives.find((p) => p.id === 2);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,17 @@ import {
|
||||
CommandItem,
|
||||
CommandPlanetProduce,
|
||||
CommandPlanetRename,
|
||||
CommandPlanetRouteRemove,
|
||||
CommandPlanetRouteSet,
|
||||
CommandPayload,
|
||||
PlanetProduction,
|
||||
PlanetRouteLoadType,
|
||||
UserGamesOrder,
|
||||
UserGamesOrderResponse,
|
||||
} from "../src/proto/galaxy/fbs/order";
|
||||
import { submitOrder } from "../src/sync/submit";
|
||||
import type {
|
||||
CargoLoadType,
|
||||
OrderCommand,
|
||||
ProductionType,
|
||||
} from "../src/sync/order-types";
|
||||
@@ -214,6 +218,88 @@ describe("submitOrder", () => {
|
||||
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 () => {
|
||||
const cases: Array<{
|
||||
productionType: ProductionType;
|
||||
|
||||
Reference in New Issue
Block a user