ui: plan 01-27 done #1
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -104,11 +104,18 @@ The component is purposely deferential to the existing infrastructure:
|
||||
const reach = $derived(40 * localPlayerDrive);
|
||||
|
||||
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>();
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -380,6 +380,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
let pickOptions: PickModeOptions | null = null;
|
||||
let pickOverlay: Graphics | null = null;
|
||||
const dimmedAlphaBackup = new Map<Graphics, number>();
|
||||
const dimmedTintBackup = new Map<Graphics, number>();
|
||||
const detachPickListeners: Array<() => void> = [];
|
||||
|
||||
const handleViewportClicked = (e: {
|
||||
@@ -462,6 +463,8 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
detachPickListeners.length = 0;
|
||||
for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha;
|
||||
dimmedAlphaBackup.clear();
|
||||
for (const [g, tint] of dimmedTintBackup) g.tint = tint;
|
||||
dimmedTintBackup.clear();
|
||||
if (pickOverlay !== null) {
|
||||
pickOverlay.destroy();
|
||||
pickOverlay = null;
|
||||
@@ -484,7 +487,9 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
if (options.reachableIds.has(id)) continue;
|
||||
for (const g of list) {
|
||||
dimmedAlphaBackup.set(g, g.alpha);
|
||||
dimmedTintBackup.set(g, g.tint as number);
|
||||
g.alpha = PICK_OVERLAY_STYLE.dimAlpha;
|
||||
g.tint = PICK_OVERLAY_STYLE.dimTint;
|
||||
}
|
||||
}
|
||||
// Overlay graphic. Lives in the origin copy so the central
|
||||
|
||||
@@ -221,6 +221,100 @@ describe("planet inspector — cargo routes", () => {
|
||||
).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 }),
|
||||
|
||||
Reference in New Issue
Block a user