ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
8 changed files with 164 additions and 16 deletions
Showing only changes of commit 8a236bef14 - Show all commits
+3 -2
View File
@@ -1,6 +1,7 @@
package game package game
import ( import (
"galaxy/calc"
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
@@ -54,9 +55,9 @@ func (r Race) TechLevel(t Tech) float64 {
} }
func (r Race) FlightDistance() float64 { func (r Race) FlightDistance() float64 {
return r.TechLevel(TechDrive) * 40 return calc.FligthDistance(r.TechLevel(TechDrive))
} }
func (r Race) VisibilityDistance() float64 { func (r Race) VisibilityDistance() float64 {
return r.TechLevel(TechDrive) * 30 return calc.VisibilityDistance(r.TechLevel(TechDrive))
} }
+13
View File
@@ -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
}
+20 -11
View File
@@ -72,12 +72,21 @@ destination picker. The engine formula is trivial:
flightDistance = driveTech * 40 flightDistance = driveTech * 40
``` ```
(`game/internal/model/game/race.go.FlightDistance`). The original The Go-side reference now lives in
Phase 16 stage text described surfacing this through `pkg/calc/` [`pkg/calc/race.go`](../../pkg/calc/race.go) as
and `ui/core/calc/`; with the calc-bridge phase still deferred, `FligthDistance(driveTech) float64` (alongside the matching
implementing the bridge for one constant-time multiplication would `VisibilityDistance` for in-space group reports — used in later
be premature scaffolding. The picker therefore computes reach phases). The engine call sites
inline in TypeScript using (`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 `torusShortestDelta(planet.x, candidate.x, mapWidth)` and
`Math.hypot` against `40 * report.localPlayerDrive`, where `Math.hypot` against `40 * report.localPlayerDrive`, where
`localPlayerDrive` is decoded from the report's `Player` block by `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`). (`api/game-state.ts.findLocalPlayerDrive`).
When the calc-bridge phase ships, the inline formula is replaced When the calc-bridge phase ships, the inline formula is replaced
with a single call into the bridge: `calc.Reach(driveTech)` becomes with a single call into the bridge `calc.FligthDistance(driveTech)`
the source of truth for both the picker and the cargo-route arrow becomes the source of truth for both the picker and the
auto-removal at turn cutoff. Until then, the UI duplicates cargo-route auto-removal at turn cutoff. Until then, the UI
`flightDistance` knowingly — same precedent as the production duplicates `flightDistance` knowingly — same precedent as the
forecast deferral above. production forecast deferral above.
## Planned bridge shape (follow-up phase) ## Planned bridge shape (follow-up phase)
+10 -1
View File
@@ -123,11 +123,20 @@ localPlayerDrive`. The local player's drive comes from the report's
`Player` block, looked up by `name === report.race` `Player` block, looked up by `name === report.race`
(`api/game-state.ts.findLocalPlayerDrive`). (`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 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 deferral note in [`calc-bridge.md`](./calc-bridge.md). The formula
is trivial (`tech × 40`) and the WASM glue would be premature is trivial (`tech × 40`) and the WASM glue would be premature
infrastructure; when the calc bridge phase lands the shared infrastructure; when the calc bridge phase lands the shared
`pkg/calc.Reach` will replace this implementation. `pkg/calc.FligthDistance` will replace this implementation.
## Tests ## Tests
@@ -104,11 +104,18 @@ The component is purposely deferential to the existing infrastructure:
const reach = $derived(40 * localPlayerDrive); const reach = $derived(40 * localPlayerDrive);
function reachableSet(): Set<number> { function reachableSet(): Set<number> {
// 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<number>(); const ids = new Set<number>();
if (reach <= 0) return ids; if (reach <= 0) return ids;
for (const candidate of planets) { for (const candidate of planets) {
if (candidate.number === planet.number) continue; if (candidate.number === planet.number) continue;
if (candidate.kind === "unidentified") continue;
const dx = torusShortestDelta(planet.x, candidate.x, mapWidth); const dx = torusShortestDelta(planet.x, candidate.x, mapWidth);
const dy = torusShortestDelta(planet.y, candidate.y, mapHeight); const dy = torusShortestDelta(planet.y, candidate.y, mapHeight);
if (Math.hypot(dx, dy) <= reach) { if (Math.hypot(dx, dy) <= reach) {
+11 -1
View File
@@ -151,10 +151,20 @@ export function computePickOverlay(
* PICK_OVERLAY_STYLE captures the colours / widths the renderer * PICK_OVERLAY_STYLE captures the colours / widths the renderer
* applies to each spec channel. Exported so tests and future themes * applies to each spec channel. Exported so tests and future themes
* can read the same values. * 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 = { export const PICK_OVERLAY_STYLE = {
anchor: { color: 0xffe082, alpha: 0.9, width: 2 }, anchor: { color: 0xffe082, alpha: 0.9, width: 2 },
line: { color: 0xffe082, alpha: 0.5, width: 1 }, line: { color: 0xffe082, alpha: 0.5, width: 1 },
hover: { color: 0xffe082, alpha: 1, width: 2 }, hover: { color: 0xffe082, alpha: 1, width: 2 },
dimAlpha: 0.3, dimAlpha: 0.35,
dimTint: 0x303841,
} as const; } as const;
+5
View File
@@ -380,6 +380,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
let pickOptions: PickModeOptions | null = null; let pickOptions: PickModeOptions | null = null;
let pickOverlay: Graphics | null = null; let pickOverlay: Graphics | null = null;
const dimmedAlphaBackup = new Map<Graphics, number>(); const dimmedAlphaBackup = new Map<Graphics, number>();
const dimmedTintBackup = new Map<Graphics, number>();
const detachPickListeners: Array<() => void> = []; const detachPickListeners: Array<() => void> = [];
const handleViewportClicked = (e: { const handleViewportClicked = (e: {
@@ -462,6 +463,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
detachPickListeners.length = 0; detachPickListeners.length = 0;
for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha; for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha;
dimmedAlphaBackup.clear(); dimmedAlphaBackup.clear();
for (const [g, tint] of dimmedTintBackup) g.tint = tint;
dimmedTintBackup.clear();
if (pickOverlay !== null) { if (pickOverlay !== null) {
pickOverlay.destroy(); pickOverlay.destroy();
pickOverlay = null; pickOverlay = null;
@@ -484,7 +487,9 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
if (options.reachableIds.has(id)) continue; if (options.reachableIds.has(id)) continue;
for (const g of list) { for (const g of list) {
dimmedAlphaBackup.set(g, g.alpha); dimmedAlphaBackup.set(g, g.alpha);
dimmedTintBackup.set(g, g.tint as number);
g.alpha = PICK_OVERLAY_STYLE.dimAlpha; g.alpha = PICK_OVERLAY_STYLE.dimAlpha;
g.tint = PICK_OVERLAY_STYLE.dimTint;
} }
} }
// Overlay graphic. Lives in the origin copy so the central // Overlay graphic. Lives in the origin copy so the central
@@ -221,6 +221,100 @@ describe("planet inspector — cargo routes", () => {
).toBeInTheDocument(); ).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 () => { test("a successful pick emits setCargoRoute and closes the prompt", async () => {
const { ui, pick } = mount( const { ui, pick } = mount(
makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }), makePlanet({ number: 1, name: "Earth", x: 100, y: 100 }),