ui: plan 01-27 done #1
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user