From 8a236bef14faba39d4fe0590d4221deb46c285e7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 9 May 2026 20:48:42 +0200 Subject: [PATCH] ui/phase-16: pick any planet in reach + stronger pick-mode dim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cargo-route picker filtered out unidentified planets, so an early-game player who had spotted but not surveyed a destination could not configure a route to it — the engine has no such restriction (`game/internal/controller/route.go.PlanetRouteSet` only checks ownership of the origin and `util.ShortDistance(...) <= FligthDistance`). Drop the unidentified guard and document the contract in `cargo-routes-ux.md` plus a comment over `reachableSet()`. Pick-mode dim now drops both alpha and tint on out-of-reach planets so bright shapes (`STYLE_LOCAL` is `0x6dd2ff`) collapse into a single muted gray. The single-channel `dimAlpha=0.3` was too gentle against the dark theme — the user reported the dim wasn't visible. Tighten to `dimAlpha=0.35 + dimTint=0x303841`; restore both on tear-down. Also threads through the user's `pkg/calc/race.go.FligthDistance` addition: `calc-bridge.md` records the new Go-side reference (the engine's `Race.FlightDistance()` already wraps it), and the picker comment points at the canonical formula location. Tests: - `inspector-planet-cargo-routes.test.ts` adds two cases — a reach-spans-every-kind case (own + foreign + uninhabited + unidentified all picked when in range) and a successful pick to an unidentified destination. - All 356 vitest cases + chromium-desktop / webkit-desktop e2e cargo-routes pass. Co-Authored-By: Claude Opus 4.7 --- game/internal/model/game/race.go | 5 +- pkg/calc/race.go | 13 +++ ui/docs/calc-bridge.md | 31 +++--- ui/docs/cargo-routes-ux.md | 11 ++- .../lib/inspectors/planet/cargo-routes.svelte | 9 +- ui/frontend/src/map/pick-mode.ts | 12 ++- ui/frontend/src/map/render.ts | 5 + .../inspector-planet-cargo-routes.test.ts | 94 +++++++++++++++++++ 8 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 pkg/calc/race.go diff --git a/game/internal/model/game/race.go b/game/internal/model/game/race.go index dd7f211..49fd88d 100644 --- a/game/internal/model/game/race.go +++ b/game/internal/model/game/race.go @@ -1,6 +1,7 @@ package game import ( + "galaxy/calc" "strings" "github.com/google/uuid" @@ -54,9 +55,9 @@ func (r Race) TechLevel(t Tech) float64 { } func (r Race) FlightDistance() float64 { - return r.TechLevel(TechDrive) * 40 + return calc.FligthDistance(r.TechLevel(TechDrive)) } func (r Race) VisibilityDistance() float64 { - return r.TechLevel(TechDrive) * 30 + return calc.VisibilityDistance(r.TechLevel(TechDrive)) } diff --git a/pkg/calc/race.go b/pkg/calc/race.go new file mode 100644 index 0000000..8d515e1 --- /dev/null +++ b/pkg/calc/race.go @@ -0,0 +1,13 @@ +package calc + +// max flight distance for race's driveTech level. +// applies for sending ships and setting routes. +func FligthDistance(driveTech float64) float64 { + return driveTech * 40 +} + +// max visible distance for race's driveTech level. +// applies for all race's planets to show foreign in-space groups in report. +func VisibilityDistance(driveTech float64) float64 { + return driveTech * 30 +} diff --git a/ui/docs/calc-bridge.md b/ui/docs/calc-bridge.md index 4cbe41a..bf66321 100644 --- a/ui/docs/calc-bridge.md +++ b/ui/docs/calc-bridge.md @@ -72,12 +72,21 @@ 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 +The Go-side reference now lives in +[`pkg/calc/race.go`](../../pkg/calc/race.go) as +`FligthDistance(driveTech) float64` (alongside the matching +`VisibilityDistance` for in-space group reports — used in later +phases). The engine call sites +(`game/internal/model/game/race.go.FlightDistance`, +`game/internal/controller/route.go.PlanetRouteSet`) still wrap the +Go formula directly; promoting them to call `pkg/calc/` is a +follow-up cleanup outside Phase 16's scope. + +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 WASM glue 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 @@ -85,11 +94,11 @@ 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. +with a single call into the bridge — `calc.FligthDistance(driveTech)` +becomes the source of truth for both the picker and the +cargo-route 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) diff --git a/ui/docs/cargo-routes-ux.md b/ui/docs/cargo-routes-ux.md index 4ca9c4a..8d128bc 100644 --- a/ui/docs/cargo-routes-ux.md +++ b/ui/docs/cargo-routes-ux.md @@ -123,11 +123,20 @@ localPlayerDrive`. The local player's drive comes from the report's `Player` block, looked up by `name === report.race` (`api/game-state.ts.findLocalPlayerDrive`). +The Go-side counterpart is `pkg/calc/race.go.FligthDistance`. The +engine accepts a route from a player-owned planet to **any** planet +inside that distance — own, foreign-race, uninhabited, or +unidentified all qualify +(`game/internal/controller/route.go.PlanetRouteSet` only enforces +ownership of the *origin*). The picker mirrors that contract: the +`reachableSet()` in `cargo-routes.svelte` filters out only the +source planet itself. + 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. +`pkg/calc.FligthDistance` will replace this implementation. ## Tests diff --git a/ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte b/ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte index 068cf50..06f01fb 100644 --- a/ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte +++ b/ui/frontend/src/lib/inspectors/planet/cargo-routes.svelte @@ -104,11 +104,18 @@ The component is purposely deferential to the existing infrastructure: const reach = $derived(40 * localPlayerDrive); function reachableSet(): Set { + // The engine accepts a route from a player-owned planet to any + // planet inside the source's flight distance — own, foreign, + // uninhabited, and unidentified all qualify (`game/internal/ + // controller/route.go.PlanetRouteSet` only checks ownership of + // the origin and `util.ShortDistance(...) <= FligthDistance`, + // see `pkg/calc/race.go`). The picker mirrors that contract; + // only the source itself is excluded so a self-route cannot be + // emitted. const ids = new Set(); 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) { diff --git a/ui/frontend/src/map/pick-mode.ts b/ui/frontend/src/map/pick-mode.ts index fabefd7..6f54aa5 100644 --- a/ui/frontend/src/map/pick-mode.ts +++ b/ui/frontend/src/map/pick-mode.ts @@ -151,10 +151,20 @@ export function computePickOverlay( * 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. + * + * `dimAlpha` and `dimTint` are applied together to non-reachable + * primitives during a pick session: the alpha drops their + * brightness, and the tint multiplies their fill colour toward dark + * gray so the colour identity (planet kind) collapses into a + * single muted shade. The combination has to read as "obviously + * disabled" against the dark theme — bright planets such as + * `STYLE_LOCAL` (`0x6dd2ff`) survive a 0.3 alpha alone too + * comfortably, so the tint pulls them down too. */ 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, + dimAlpha: 0.35, + dimTint: 0x303841, } as const; diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 229cfbe..c263eeb 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -380,6 +380,7 @@ export async function createRenderer(opts: RendererOptions): Promise(); + const dimmedTintBackup = new Map(); const detachPickListeners: Array<() => void> = []; const handleViewportClicked = (e: { @@ -462,6 +463,8 @@ export async function createRenderer(opts: RendererOptions): Promise { ).toBeInTheDocument(); }); + test("the reachable set spans every planet kind in range, not only own", async () => { + // Reach = 40 * 1.5 = 60. Each candidate at distance 50 — in + // reach. The picker must include the foreign-race planet, + // the uninhabited rock, and the unidentified target so the + // engine's "destinations may be any planet" rule is honoured + // (route.go: only the source's ownership is enforced). + const { ui, pick } = mount( + makePlanet({ + number: 1, + name: "Earth", + x: 100, + y: 100, + kind: "local", + }), + [ + makePlanet({ + number: 1, + name: "Earth", + x: 100, + y: 100, + kind: "local", + }), + makePlanet({ + number: 2, + name: "Alpha", + x: 150, + y: 100, + kind: "other", + owner: "Aliens", + }), + makePlanet({ + number: 3, + name: "Rock", + x: 100, + y: 150, + kind: "uninhabited", + }), + makePlanet({ + number: 4, + name: "", + x: 50, + y: 100, + kind: "unidentified", + }), + ], + [], + 1.5, + ); + await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-col-add")); + await waitFor(() => expect(pick.invocations.length).toBe(1)); + expect( + Array.from(pick.invocations[0]!.request.reachableIds).sort(), + ).toEqual([2, 3, 4]); + }); + + test("the picker accepts an unidentified destination", async () => { + const { ui, pick } = mount( + makePlanet({ + number: 1, + name: "Earth", + x: 100, + y: 100, + kind: "local", + }), + [ + makePlanet({ + number: 1, + name: "Earth", + x: 100, + y: 100, + kind: "local", + }), + makePlanet({ + number: 9, + name: "", + x: 130, + y: 100, + kind: "unidentified", + }), + ], + [], + 1.5, + ); + await fireEvent.click(ui.getByTestId("inspector-planet-cargo-slot-mat-add")); + await waitFor(() => expect(pick.invocations.length).toBe(1)); + pick.invocations[0]!.resolve(9); + 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(9); + expect(cmd.loadType).toBe("MAT"); + }); + test("a successful pick emits setCargoRoute and closes the prompt", async () => { const { ui, pick } = mount( makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),