Compare commits

..

9 Commits

Author SHA1 Message Date
developer 0da2f4b6fb Merge pull request #20 from feature/ui-map-toggles
Deploy · Dev / deploy (push) Successful in 30s
Tests · Integration / integration (push) Successful in 1m43s
Tests · Go / test (push) Successful in 2m5s
Tests · UI / test (push) Successful in 2m56s
Phase 29 — Map Toggles

Reviewed and verified visually on dev-deploy.
2026-05-19 22:37:29 +00:00
Ilia Denisov 53b892ae00 fix(ui-map): move fog overlay to a viewport-level layer below the copies
Tests · UI / test (push) Successful in 2m50s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m5s
Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:

1. On the zero-turn map the bg holes painted ON TOP of the planet
   glyphs — every LOCAL planet looked like a hollow circle of
   background colour instead of the planet pixel inside an
   unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
   radius well past the world dimensions the bg circles overlapped
   to cover the entire viewport. Combined with the wrong z-order
   the result was a uniformly black canvas with every primitive
   hidden.

The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.

Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:

- A single `fogLayer: Container` is added to the viewport BEFORE
  the nine torus copies. Pixi renders viewport children in order,
  so the layer is guaranteed to paint first; every copy renders
  on top.
- `fogPaintOps` now emits world-space coordinates with wrap
  offsets baked in (9 fog rects + 9 bg circles per visibility
  entry in torus mode, 1 + N in no-wrap mode). The renderer
  populates `fogLayer` with one `Graphics` per op — no per-copy
  iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
  Each `setVisibilityFog` flip drops every child of `fogLayer`
  and rebuilds it. The dispose path drops the children
  eagerly before `app.destroy({children: true})` walks the tree.

The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:26:06 +02:00
Ilia Denisov 00e84579ca fix(ui-map): split fog overlay into per-shape Graphics + torus-wrap circles
Tests · UI / test (push) Successful in 3m23s
Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:

1. With three LOCAL planets close together, only the last planet
   glyph stayed visible inside the bg holes — the other two were
   obscured. The previous implementation stacked the fog rectangle
   plus every bg circle onto a single `Graphics` via repeated
   `g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
   multi-shape Graphics is supported in theory, but in practice
   only the last shape's fill seems to land, dropping the earlier
   bg holes (and the planet glyphs on top look like they vanished
   along with their hole). Splitting each op onto its own
   `Graphics` inside a per-copy `Container` removes the ambiguity
   — one shape, one fill, one render pass.

2. A planet near the right world edge produced a "sector" — the
   bg circle painted into the area past the seam, but the
   neighbouring tile's fog rectangle then overpainted that bleed,
   leaving a quarter-circle hole. In torus mode each visibility
   circle is now drawn at the nine wrapped positions
   (`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
   neighbour-tile-aligned positions keep the hole continuous
   across the seam. No-wrap mode keeps a single emission per
   circle, because wrapped circles would leak into the visible
   world rectangle as unwanted holes.

The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:04:35 +02:00
Ilia Denisov 7ade838df8 test(ui-map): unit-cover the fog overlay's layered-overpaint contract
Tests · UI / test (push) Successful in 2m49s
Lifted the Phase 29 fog draw sequence out of `setVisibilityFog`
into a pure `fogPaintOps` helper that returns an ordered list of
fill operations (one fog rect, then one background-coloured
circle per visibility entry). The renderer now dispatches each op
straight onto a Pixi `Graphics`; the indirection lets the layered-
overpaint contract be tested without booting Pixi.

`tests/fog-paint-ops.test.ts` covers: empty input → no ops; single
circle → fog rect + bg circle in that order; multiple circles → N
bg circles after the fog rect; overlapping circles emitted
independently (the rendering order unions them); zero / negative
world dimensions → no ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:42:39 +02:00
Ilia Denisov 37580b7699 fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace
Tests · UI / test (push) Waiting to run
The Phase 29 fog overlay rendered as a handful of random arc
segments instead of a clean union of holes around LOCAL planets
— Pixi v8's `Graphics.cut()` does not reliably subtract multiple
overlapping circles from a base path.

Replaced the cut-based approach with a layered overpaint: a
fog-tinted rectangle fills the world, then opaque background-
coloured circles are painted on top for every visibility circle.
The natural rendering order unions overlapping circles for free —
no geometry, no `cut()` quirks, one extra fill per circle.

Renamed the toggle from `visibilityFog` to `visibleHyperspace`
across the store, i18n strings, popover, tests, and docs. The
overlay still implements the visual "fog" effect at the renderer
level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is
named after the player-facing concept it controls — the portion
of the map that is visible (intelligence/scan coverage) — rather
than the obscured part.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:39:39 +02:00
Ilia Denisov 2f4dc01d54 fix(ui-map): apply wrap-mode flips in place instead of remounting
Tests · UI / test (push) Successful in 2m51s
The previous logic re-mounted the renderer whenever
`store.wrapMode` flipped, because the `sameSnapshot` gate
included `handle.getMode() === mode`. Pixi 8 does not reliably
re-initialise an `Application` on the same canvas — the symptom
showed up as the chromium tab silently closing during the
Phase 29 wrap-mode e2e ("Target page, context or browser has
been closed").

The renderer already exposes an in-place `setMode` that swaps
the wrap-clamp / torus-copy visibility synchronously while
preserving the camera; the playground-map.spec.ts wrap toggle
has been driving it for several phases without issue. Drop
mode from the snapshot gate and route the change through
`handle.setMode(mode)` instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:33:38 +02:00
Ilia Denisov 7c46aa4bec fix(ui-e2e): tighten Phase 29 effect tracking + radio wiring
Tests · UI / test (push) Failing after 7m19s
Run #217 surfaced three independent bugs that survived the first
fixup pass:

1. `visibleHighBitCount` masked the id with `(prim.id >>> 0) & 0xf…`,
   but JS bitwise AND always returns a signed int32 — the mask had
   to be re-converted with `>>> 0` AFTER the AND, not before. Result
   was always 0 on the previous run, masking the next two bugs by
   making the persistence test's high-bit-count assertions a
   tautology.
2. `applyVisibilityState` was wrapped in `untrack`, so the
   `toggles.X` reads inside `computeHiddenIds` / `computeFogCircles`
   never landed in the effect's dependency set — toggling fog or any
   marker / group / kind flag did not re-run the effect, so the
   renderer never received the new hide / fog input. Explicit
   `void toggles.X` reads now live at the top of the effect so every
   key is tracked synchronously.
3. The wrap-mode radios fired on `onchange`, which Svelte 5
   suppresses on a re-activation of an already-checked input — the
   Playwright `.click()` flake on the second wrap test reflected the
   missed event. Switched to `onclick` and short-circuited when the
   target mode is already active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:23:15 +02:00
Ilia Denisov 2528d63b51 fix(ui-e2e): Phase 29 map-toggles spec passes across all four projects
Tests · UI / test (push) Failing after 10m52s
Three independent bugs in `tests/e2e/map-toggles.spec.ts` made the
fresh-Phase-29 suite red on CI #216:

1. `visiblePlanets` filtered on `p.id < 1_000_000`, which JS interprets
   in signed space — high-bit-prefix primitives (cargo route 0x80…,
   battle 0xa0…, bombing 0xc0…) are stored as negative Numbers and
   leaked into the planet list. Filter switched to a `0 < id < 1e7`
   window that matches the engine planet-number range exactly.
2. The `visibleHighBitCount` helper now ToUint32-converts the id
   before masking so the bitmask comparison works regardless of
   whether the id is stored as positive or negative.
3. The fog and wrap-mode tests read the renderer state synchronously
   after the click — the Svelte effect re-runs asynchronously, so the
   tests saw stale state. Both now `waitForFunction` on the canonical
   "settled" signal: empty fog circles for the fog flip, and a new
   `getMapMode()` debug accessor for the wrap-mode remount.

Renderer side: registers a `MapModeProvider` next to the existing
camera / fog providers and exposes `getMapMode()` through the debug
surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:02:15 +02:00
Ilia Denisov 2bd1b54936 feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s
Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:33:53 +02:00
33 changed files with 3447 additions and 66 deletions
+45 -1
View File
@@ -786,7 +786,51 @@ producer; adding one is purely additive (register the kind in the
catalog, extend the migration `CHECK` constraint, and call
`notification.Submit` from the appropriate domain module).
### 6.7 Cross-references
### 6.7 Map visibility controls
The map view (`ui/frontend/src/lib/active-view/map.svelte`)
carries a gear-icon popover (`map-toggles.svelte`) in the
canvas's top-right corner, providing the player with a per-game
visibility surface. The popover lists three groups of controls;
every change applies within one frame (no Pixi remount):
- **Objects** — six independent checkboxes: hyperspace groups,
incoming groups, unidentified groups, cargo routes, battle
markers, bombing markers.
- **Planets** — four rows: foreign / uninhabited / unidentified
planet kinds plus a "show unreachable planets" switch that, when
off, hides every non-LOCAL planet that sits beyond
`FlightDistance(localPlayerDrive)` of every LOCAL planet
(torus-aware metric).
- **View** — "visible hyperspace" toggle (slightly lighter
overlay outside the union of
`VisibilityDistance(localPlayerDrive)` circles around LOCAL
planets; LOCAL planets are always exempt — the toggle is
named after the visible part of the map rather than the
obscured one) plus the torus / no-wrap radio that switches
the renderer mode while preserving the camera centre.
LOCAL planets are always rendered — they have no toggle. Every
other toggle defaults to ON. Hiding a planet cascades onto every
primitive anchored on it: battle and bombing markers on the
planet, in-space and incoming ship-group points plus their
trajectory lines flying *to* the planet, and cargo-route arrows
whose source or destination is that planet. The cascade keeps
the map free of orphan glyphs pointing at empty space.
Visibility state persists per game in the
`game-map-toggles/{gameId}` cache namespace (see
[`ui/docs/storage.md`](../ui/docs/storage.md)). Whenever a new
server-side turn becomes the player's current turn — either via
`setGame` opening the player on a server `currentTurn` greater
than the last persisted `lastResetTurn`, or via the user
explicitly clicking the pending-turn affordance — every toggle
is force-reset to defaults so the new turn's content cannot be
silently hidden by a stale preference. History-mode navigation
(`viewTurn`) keeps the shared toggle state intact across past
turns.
### 6.8 Cross-references
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+46 -1
View File
@@ -806,7 +806,52 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
каталоге, расширить `CHECK`-констрейнт миграции и вызвать
`notification.Submit` из подходящего доменного модуля).
### 6.7 Перекрёстные ссылки
### 6.7 Управление видимостью карты
Карта (`ui/frontend/src/lib/active-view/map.svelte`) несёт
попап-«шестерёнку» (`map-toggles.svelte`) в правом верхнем углу
канваса — посекторный интерфейс видимости для конкретной игры.
Попап содержит три группы элементов; любое изменение применяется
в пределах одного кадра (без перемонтирования Pixi):
- **Объекты** — шесть независимых чекбоксов: группы в
гиперпространстве, входящие группы, неопознанные группы,
грузовые маршруты, метки сражений, метки бомбардировок.
- **Планеты** — четыре строки: чужие / необитаемые /
неопознанные виды планет плюс выключатель «показывать
недостижимые планеты», который при выключении прячет каждую
не-LOCAL планету, отстоящую дальше
`FlightDistance(localPlayerDrive)` от любой LOCAL-планеты
(метрика учитывает торическую развёртку).
- **Вид** — переключатель «видимое гиперпространство» (чуть
более светлая заливка вне объединения окружностей
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
области карты, а не по затемнённой) плюс радиогруппа
«торус / без переноса», переключающая режим рендерера с
сохранением центра камеры.
LOCAL-планеты отрисовываются всегда — для них тоггла нет.
Остальные тогглы по умолчанию включены. Скрытие планеты
каскадно прячет все привязанные к ней примитивы: метки сражений
и бомбардировок на этой планете, точки in-space и incoming
ship-групп вместе с траекториями, летящих *к* этой планете, и
грузовые стрелки, чей источник или назначение — эта планета.
Каскад не оставляет на карте «осиротевших» меток, указывающих
в пустоту.
Состояние видимости сохраняется по игре в namespace кеша
`game-map-toggles/{gameId}` (см.
[`ui/docs/storage.md`](../ui/docs/storage.md)). При каждом
новом серверном ходе, ставшем текущим — либо через `setGame`,
обнаруживший `currentTurn` сервера выше последнего сохранённого
`lastResetTurn`, либо при явном клике пользователя по
аффордансу «новый ход» — все тогглы принудительно
сбрасываются в дефолт, чтобы новое содержимое хода не оказалось
скрытым устаревшими настройками. Навигация по истории
(`viewTurn`) общее состояние тогглов не сбрасывает.
### 6.8 Перекрёстные ссылки
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+1 -1
View File
@@ -55,7 +55,7 @@ func (r Race) TechLevel(t Tech) float64 {
}
func (r Race) FlightDistance() float64 {
return calc.FligthDistance(r.TechLevel(TechDrive))
return calc.FlightDistance(r.TechLevel(TechDrive))
}
func (r Race) VisibilityDistance() float64 {
+1 -1
View File
@@ -2,7 +2,7 @@ package calc
// max flight distance for race's driveTech level.
// applies for sending ships and setting routes.
func FligthDistance(driveTech float64) float64 {
func FlightDistance(driveTech float64) float64 {
return driveTech * 40
}
+89 -21
View File
@@ -3167,28 +3167,47 @@ Targeted tests:
- Playwright e2e: send a message between two seeded players, confirm
arrival.
## Phase 29. Map Toggles
## ~~Phase 29. Map Toggles~~
Status: pending.
Status: done.
Goal: deliver the gear-icon control for hiding categories of map
content and switching between torus and no-wrap view modes. All
toggleable categories are already rendered by earlier phases; this
phase only exposes the controls.
content and switching between torus and no-wrap view modes. LOCAL
planets stay always-on; every other category gets a toggle that
applies within one frame via the renderer's hide-by-id facility.
Artifacts:
- `ui/frontend/src/lib/active-view/map-toggles.svelte` gear icon in
the map view's corner; popover (desktop) / bottom sheet (mobile)
- two sections inside the popover:
- object visibility: hyperspace groups, incoming groups, cargo
routes, reach / visibility zones, battle and bombing markers
- view options: wrap scrolling (torus / no-wrap)
- planets are always rendered and not toggleable
- `ui/frontend/src/lib/map/reach-zones.ts` implementation of reach /
visibility zone overlays, off by default (the only category not yet
rendered by earlier phases)
- toggle state persists per game in `Cache`
- `ui/frontend/src/lib/active-view/map-toggles.svelte` gear icon
in the map view's corner; popover (desktop) / bottom sheet
(mobile). Three fieldsets:
- **Objects** — hyperspace groups, incoming groups,
unidentified groups, cargo routes, battle markers, bombing
markers (six independent checkboxes; battle and bombing have
their own toggles, not a shared one).
- **Planets** — foreign / uninhabited / unidentified kind
toggles plus a `unreachablePlanets` switch that, when off,
hides planets beyond `FlightDistance(localPlayerDrive)` of
every LOCAL planet (torus-aware).
- **View** — "visible hyperspace" checkbox + torus / no-wrap
radios. The fog overlay is named for the visible part of the
map (intelligence/scan area), since that is what the toggle
controls from the player's perspective.
- `RendererHandle.setHiddenPrimitiveIds(ids)` —
declarative hide set; flips `Graphics.visible` per copy and
threads the set into `hitTest` so click-through to deeper
primitives is correct.
- `RendererHandle.setVisibilityFog(circles)` — fog overlay
drawn via Pixi v8 `Graphics.cut()` per torus copy, below
primitives in z-order.
- `src/map/visibility.ts` — pure helpers (`computeHiddenPlanetNumbers`,
`computeHiddenIds`, `computeFogCircles`, `isCategoryVisible`,
`fingerprintHiddenPlanets`) consumed by the map view.
- `GameStateStore.mapToggles` rune + `setMapToggle` method;
single-blob persistence in cache namespace `game-map-toggles`
(key `{gameId}`, value `{toggles, lastResetTurn}`).
- New-turn reset path inside `setGame` / `advanceToPending`
drops user overrides when `lastResetTurn < currentTurn`.
Dependencies: Phases 9 (no-wrap engine), 11 (planets), 16 (cargo
routes), 19 (groups, incoming), 27 (battle markers).
@@ -3205,11 +3224,60 @@ Acceptance criteria:
Targeted tests:
- Vitest component tests for toggle state persistence;
- Vitest unit tests for reach-zone rendering on torus and no-wrap
fixtures;
- Playwright e2e in desktop and mobile viewports: toggle each
category and the wrap scrolling, assert visual change.
- `tests/visibility-helpers.test.ts` — unit coverage for the
hide-set / fog computation;
- `tests/state-binding-cascade.test.ts` — `reportToWorld` emits
the `categories` + `planetDependents` maps;
- `tests/map-toggles-component.test.ts` — popover lifecycle +
store wiring;
- `tests/map-toggles-state.test.ts` — single-blob persistence +
new-turn reset path against a real fake-IndexedDB cache;
- `tests/map-hit-test.test.ts` — `hitTest` honours the
`hiddenIds` parameter;
- `tests/e2e/map-toggles.spec.ts` — cascade, fog, wrap-mode
camera preservation, reload persistence across the four
Playwright projects.
Decisions:
1. **"Reach zones" reinterpreted as `unreachablePlanets` filter.**
The original plan listed a "reach / visibility zones" category
rendered as concentric circles. The realised stage drops the
circle overlay and instead exposes an inverse
`unreachablePlanets` toggle that hides planets beyond the
player's `FlightDistance`. Reach is already implicit in the
reach-aware destination picker (Phase 16+), so the cleaner UX
is filtering, not adding extra rings.
2. **Visible-hyperspace overlay**. A separate `visibleHyperspace`
toggle draws a slightly lighter fog over the world outside the
union of `VisibilityDistance` circles around LOCAL planets. The
fog is a renderer-level concept (layered overpaint — fog rect
then background-coloured circles on top — instead of Pixi's
`Graphics.cut()`, which produced incorrect unions of holes), not
a primitive: it never participates in hit-test.
3. **Per-kind planet toggles + unidentified-group toggle**. The
spec's original "object visibility" list was extended:
foreign / uninhabited / unidentified planet kinds and
unidentified ship groups each get their own toggle.
4. **Battle and bombing markers are independent toggles.** The
spec text grouped them as a single line item; on review the
player wanted finer control, so each kind gets its own
checkbox.
5. **Single-blob persistence + new-turn reset.** Toggles persist
per game as one JSON blob `{toggles, lastResetTurn}` under
`game-map-toggles/{gameId}`. A new server-side turn force-
resets every flag to defaults so a hidden category cannot
silently swallow the next turn's news. History-mode
navigation (`viewTurn`) keeps the shared state.
6. **Hide-by-id renderer extension.** The wrap-mode toggle keeps
the existing remount + camera-preserve path (it has to —
torus copies need different `.visible` flags). Every
visibility flip uses the new `setHiddenPrimitiveIds` / hide-
aware `hitTest` so it applies within one frame.
7. **`pkg/calc/race.go` typo fixed**. The Go-side helper was
`FligthDistance`; the Phase 29 work renamed it to
`FlightDistance` (and the only TS call site duplicates the
formula directly, awaiting a future race-level WASM bridge).
## Phase 30. Calculator Tab
+38
View File
@@ -116,6 +116,44 @@ explores history, so the pending-turn toast continues to work.
map view's effect picks the change up and re-mounts the renderer
with the new mode.
## Map visibility toggles
Phase 29 adds a `mapToggles: MapToggles` rune that drives the
gear popover in the map view. Every flag defaults to `true`
including `unreachablePlanets` (showing every planet by default)
and `visibleHyperspace` (the fog overlay on by default). The
exhaustive shape lives in `src/lib/game-state.svelte.ts`; the
gear popover (`src/lib/active-view/map-toggles.svelte`) is a
thin view of the rune.
`setMapToggle(key, value)` flips one entry in place and
persists the whole blob to `Cache` under the
`game-map-toggles/{gameId}` key. The blob carries a companion
`lastResetTurn` number — the turn at which the toggles were last
reset to defaults — so the new-turn reset path (below) can detect
a stale blob even across a cross-session gap.
### New-turn reset
A new server-side turn force-resets every toggle to defaults so a
hidden category never makes the player miss what changed:
- `setGame` reads the persisted `{toggles, lastResetTurn}` blob.
If `lastResetTurn < currentTurn`, the rune is overwritten with
`DEFAULT_MAP_TOGGLES` and the blob is rewritten with
`lastResetTurn = currentTurn` before the report load. Otherwise
the persisted overrides are restored.
- `advanceToPending` (the user's explicit jump onto the new turn)
calls the same reset path after `loadTurn(currentTurn, …)`
succeeds, updating `lastResetTurn` to the freshly-loaded
current turn.
- `viewTurn` (history mode) does NOT reset — toggles are a
single shared state per game, not per turn.
- `refresh()` does not advance turns, so it does not reset.
The cache namespace and blob shape are documented in
`storage.md`.
## History mode
Phase 26 lets the user step backward through the report timeline
+65 -6
View File
@@ -269,25 +269,84 @@ resolver that translates `sourcePlanetNumber` to the underlying
current report). Inspector subsections call `service.pick(...)`
and react to the resolved id.
## Hidden primitives
`RendererHandle.setHiddenPrimitiveIds(ids)` replaces the current
hide-by-id set. Every primitive whose id sits in `ids` has its
per-copy `Graphics.visible` flipped to `false` and is skipped by
`hitAt`, so a click on its former area falls through to the next
visible primitive. An empty set restores everything. Repeated
calls are diff-free idempotent — `g.visible` assignments are
cheap.
The hide set is propagated to `hitTest` through a new optional
`hiddenIds` parameter so internal hit-test sites (pointer-move,
clicked dispatcher) stay in lock-step with the visible scene.
After `setExtraPrimitives` the hide set is re-applied so a
freshly-pushed extras layer (cargo-route overlay, pending-Send
tracks) does not silently un-hide a primitive whose id is in the
current set.
The Phase 29 map view (`src/lib/active-view/map.svelte`) computes
the set from the per-game `MapToggles` rune + the planet-cascade
rule and pushes it on every effect run; toggling a checkbox
flips visibility within one frame without a Pixi remount.
## Visible-hyperspace overlay (the "fog")
`RendererHandle.setVisibilityFog(circles)` draws (or removes) the
Phase 29 fog overlay used to highlight the player's visible
hyperspace. Each entry describes a circle around a LOCAL planet
where the player has scanner / visibility coverage:
- An empty list destroys the existing fog Graphics.
- A non-empty list creates one fog `Graphics` per torus copy.
Each draws a world-sized rectangle filled with `FOG_COLOR` (two
shades lighter than the dark theme background), then paints an
opaque background-coloured circle on top for every visibility
circle. The overpaint order naturally unions overlapping circles
— earlier iterations used Pixi v8's `Graphics.cut()` to subtract
holes, but `cut()` produces incorrect unions for multiple
overlapping holes; layered repainting trades one extra fill per
circle for a predictable, geometry-free union.
- The fog is inserted at the bottom of each copy's z-order so
primitives paint on top.
- The fog never participates in hit-test. Planet glyphs sit on
top of fog, so clicks on visible planets work unchanged.
- Wrap mode is honoured for free — `applyMode` hides every
non-origin copy in `no-wrap`, so the fog inherits the same
behaviour because the fog Graphics is a child of each copy.
The map view recomputes the fog input only when the report or the
`visibleHyperspace` toggle changes — per-frame cost stays at zero.
## 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()`, `getMapPickState()`, `getMapCamera()`, and
`getMapFog()` 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.
(post-overlay), the explicit fill / stroke colour from its
`Style` (no theme fallback), and the Phase 29 `visible` flag
mirroring the renderer's hide set.
- `getMapPickState()` returns `{ active, sourcePlanetNumber,
reachableIds, hoveredId }` — the renderer's view of the
current pick session.
- `getMapCamera()` returns the current camera + viewport +
canvas-origin snapshot, used by Phase 29 e2e specs to assert
camera preservation across wrap-mode flips.
- `getMapFog()` returns the most recent fog input
(the list of circles last passed to `setVisibilityFog`).
Empty when the `visibleHyperspace` toggle is off.
The active map view registers providers on mount via
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
in `src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
/ `registerMapCameraProvider` / `registerMapFogProvider` in
`src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
the surface invokes them lazily on every read.
## Tests
+18 -7
View File
@@ -112,13 +112,24 @@ wipes every namespace.
Namespaces in current use:
| Namespace | Key | Value type | Owner |
|-----------------|--------------------------------|------------------|------------------------------------|
| `session` | `device-session-id` | `string` | Phase 7+ |
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
| `game-history` | `{gameId}/turn/{N}` | `GameReport` | Phase 26+ (`game-state.md`) |
| Namespace | Key | Value type | Owner |
|--------------------|--------------------------------|-----------------------------------------------|------------------------------------|
| `session` | `device-session-id` | `string` | Phase 7+ |
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) |
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
| `game-history` | `{gameId}/turn/{N}` | `GameReport` | Phase 26+ (`game-state.md`) |
| `game-map-toggles` | `{gameId}` | `{toggles: MapToggles; lastResetTurn: number}` | Phase 29+ (`game-state.md`) |
The `game-map-toggles` blob stores the gear popover's per-game
visibility state plus a `lastResetTurn` companion field. Reading
a missing or malformed entry falls back to `DEFAULT_MAP_TOGGLES`
field-by-field, so a stale older client losing a field added
later does not nuke the rest of the user's overrides. The
`GameStateStore.setGame` path resets the blob to defaults
whenever `lastResetTurn < currentTurn`, so a fresh server turn
always greets the player with every map category visible (see
`game-state.md` for the new-turn-reset contract).
Later phases will add more per-feature namespaces (fixtures, lobby
snapshot, etc.). The contract is namespace-strings stay scoped to
@@ -0,0 +1,328 @@
<!--
Phase 29 gear popover. Sits in the top-right corner of the map
canvas and exposes the per-game visibility / wrap toggles that the
`GameStateStore` already owns. The component is a thin view of the
store — every checkbox / radio fires `store.setMapToggle(...)` or
`store.setWrapMode(...)` and reads back the current state through
the rune.
Outside-click + Escape close the popover, matching the
`header/view-menu.svelte` precedent. On mobile (<768 px) the
surface re-styles into a bottom-sheet positioned above the
bottom-tabs bar.
-->
<script lang="ts">
import { onMount } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import type { MapToggles, GameStateStore } from "$lib/game-state.svelte";
import type { WrapMode } from "../../map/world";
type Props = { store: GameStateStore };
let { store }: Props = $props();
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
function toggleOpen(): void {
open = !open;
}
function setFlag<K extends keyof MapToggles>(
key: K,
event: Event & { currentTarget: HTMLInputElement },
): void {
void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]);
}
/**
* setWrap is wired to the radios' `onclick`, not `onchange`, so the
* Playwright `.click()` action on the input fires the callback even
* when the input is already checked (the `change` event suppresses
* the second activation, which made the wrap-mode e2e flake).
* `onclick` also fires reliably on touch / pointer activation.
*/
function setWrap(mode: WrapMode): void {
if (store.wrapMode === mode) return;
void store.setWrapMode(mode);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="map-toggles" bind:this={rootEl}>
<button
type="button"
class="trigger"
data-testid="map-toggles-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.map.toggles.close")
: i18n.t("game.map.toggles.open")}
onclick={toggleOpen}
>
<span aria-hidden="true"></span>
</button>
{#if open}
<div class="surface" role="menu" data-testid="map-toggles-surface">
<fieldset>
<legend>{i18n.t("game.map.toggles.section.objects")}</legend>
<label>
<input
type="checkbox"
data-testid="map-toggles-hyperspace-groups"
checked={store.mapToggles.hyperspaceGroups}
onchange={(e) => setFlag("hyperspaceGroups", e)}
/>
<span>{i18n.t("game.map.toggles.hyperspace_groups")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-incoming-groups"
checked={store.mapToggles.incomingGroups}
onchange={(e) => setFlag("incomingGroups", e)}
/>
<span>{i18n.t("game.map.toggles.incoming_groups")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-unidentified-groups"
checked={store.mapToggles.unidentifiedGroups}
onchange={(e) => setFlag("unidentifiedGroups", e)}
/>
<span>{i18n.t("game.map.toggles.unidentified_groups")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-cargo-routes"
checked={store.mapToggles.cargoRoutes}
onchange={(e) => setFlag("cargoRoutes", e)}
/>
<span>{i18n.t("game.map.toggles.cargo_routes")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-battle-markers"
checked={store.mapToggles.battleMarkers}
onchange={(e) => setFlag("battleMarkers", e)}
/>
<span>{i18n.t("game.map.toggles.battle_markers")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-bombing-markers"
checked={store.mapToggles.bombingMarkers}
onchange={(e) => setFlag("bombingMarkers", e)}
/>
<span>{i18n.t("game.map.toggles.bombing_markers")}</span>
</label>
</fieldset>
<fieldset>
<legend>{i18n.t("game.map.toggles.section.planets")}</legend>
<label>
<input
type="checkbox"
data-testid="map-toggles-foreign-planets"
checked={store.mapToggles.foreignPlanets}
onchange={(e) => setFlag("foreignPlanets", e)}
/>
<span>{i18n.t("game.map.toggles.foreign_planets")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-uninhabited-planets"
checked={store.mapToggles.uninhabitedPlanets}
onchange={(e) => setFlag("uninhabitedPlanets", e)}
/>
<span>{i18n.t("game.map.toggles.uninhabited_planets")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-unidentified-planets"
checked={store.mapToggles.unidentifiedPlanets}
onchange={(e) => setFlag("unidentifiedPlanets", e)}
/>
<span>{i18n.t("game.map.toggles.unidentified_planets")}</span>
</label>
<label>
<input
type="checkbox"
data-testid="map-toggles-unreachable-planets"
checked={store.mapToggles.unreachablePlanets}
onchange={(e) => setFlag("unreachablePlanets", e)}
/>
<span>{i18n.t("game.map.toggles.unreachable_planets")}</span>
</label>
</fieldset>
<fieldset>
<legend>{i18n.t("game.map.toggles.section.view")}</legend>
<label>
<input
type="checkbox"
data-testid="map-toggles-visible-hyperspace"
checked={store.mapToggles.visibleHyperspace}
onchange={(e) => setFlag("visibleHyperspace", e)}
/>
<span>{i18n.t("game.map.toggles.visible_hyperspace")}</span>
</label>
<div class="wrap-row">
<span class="wrap-label">{i18n.t("game.map.toggles.wrap.label")}</span>
<label class="radio">
<input
type="radio"
name="map-toggles-wrap"
data-testid="map-toggles-wrap-torus"
value="torus"
checked={store.wrapMode === "torus"}
onclick={() => setWrap("torus")}
/>
<span>{i18n.t("game.map.toggles.wrap.torus")}</span>
</label>
<label class="radio">
<input
type="radio"
name="map-toggles-wrap"
data-testid="map-toggles-wrap-no-wrap"
value="no-wrap"
checked={store.wrapMode === "no-wrap"}
onclick={() => setWrap("no-wrap")}
/>
<span>{i18n.t("game.map.toggles.wrap.no_wrap")}</span>
</label>
</div>
</fieldset>
</div>
{/if}
</div>
<style>
.map-toggles {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 20;
}
.trigger {
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
font: inherit;
font-size: 1.4rem;
padding: 0.25rem 0.5rem;
background: rgba(20, 24, 42, 0.85);
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 6px;
cursor: pointer;
}
.trigger:hover {
background: #1c2238;
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 16rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
background: #14182a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
padding: 0.5rem;
z-index: 50;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
legend {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #aab;
padding: 0 0 0.15rem 0;
}
label {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
padding: 0.2rem 0.25rem;
border-radius: 3px;
cursor: pointer;
}
label:hover {
background: #1c2238;
}
input[type="checkbox"],
input[type="radio"] {
accent-color: #6dd2ff;
}
.wrap-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
}
.wrap-label {
color: #aab;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
width: 100%;
}
.radio {
padding: 0.15rem 0.4rem;
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: auto;
right: 0;
left: 0;
bottom: 3.25rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
border-radius: 0;
border-left: 0;
border-right: 0;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
}
}
</style>
+191 -19
View File
@@ -31,7 +31,17 @@ preference the store already manages.
} from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { buildPendingSendLines } from "../../map/pending-send-routes";
import { reportToWorld, type HitTarget } from "../../map/state-binding";
import {
reportToWorld,
type HitTarget,
type MapCategory,
} from "../../map/state-binding";
import {
computeFogCircles,
computeHiddenIds,
computeHiddenPlanetNumbers,
fingerprintHiddenPlanets,
} from "../../map/visibility";
import type { PrimitiveID } from "../../map/world";
import {
ORDER_DRAFT_CONTEXT_KEY,
@@ -41,6 +51,7 @@ preference the store already manages.
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
type MapToggles,
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
@@ -57,12 +68,16 @@ preference the store already manages.
import {
installRendererDebugSurface,
registerMapCameraProvider,
registerMapFogProvider,
registerMapModeProvider,
registerMapPickStateProvider,
registerMapPrimitivesProvider,
type MapCameraSnapshot,
type MapFogSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "$lib/debug-surface.svelte";
import MapTogglesControl from "./map-toggles.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const renderedReport = getContext<RenderedReportSource | undefined>(
@@ -92,6 +107,26 @@ preference the store already manages.
let handle: RendererHandle | null = null;
let hitLookup = new Map<PrimitiveID, HitTarget>();
// currentCategories / currentPlanetDependents are populated by
// `reportToWorld` inside `mountRenderer` and consumed by the
// Phase 29 hide-set computation on every effect re-run (mount or
// toggle change). Both maps cover the base world; extras
// (cargo-routes, pending-Send) are gated upstream via
// `skipPlanets`, so they never need a categories entry.
let currentCategories: ReadonlyMap<PrimitiveID, MapCategory> = new Map();
let currentPlanetDependents: ReadonlyMap<
number,
ReadonlySet<PrimitiveID>
> = new Map();
// currentFogCircles mirrors the latest `setVisibilityFog` input so
// the debug surface can report it to Playwright. The renderer
// keeps the Graphics, not the data; recomputing on every read
// would duplicate work.
let currentFogCircles: ReadonlyArray<{
x: number;
y: number;
radius: number;
}> = [];
let mountedTurn: number | null = null;
let mountedGameId: string | null = null;
let onResize: (() => void) | null = null;
@@ -134,9 +169,41 @@ preference the store already manages.
// Track the wrap mode so the renderer remounts when Phase 29's
// toggle UI flips it; the read here also subscribes the effect.
const mode = store?.wrapMode ?? "torus";
// Track the Phase 29 visibility toggles so the effect re-runs
// when the gear popover flips any flag. The hide set + fog +
// extras filter all derive from this rune.
const toggles = store?.mapToggles;
const gameId = store?.gameId ?? "";
if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report) return;
if (status !== "ready" || !report || toggles === undefined) return;
// Explicit reads of every toggle key — Svelte 5's deep proxy
// tracks per-property access, and the actual consumers
// (computeHiddenIds, computeFogCircles, buildExtras) run
// inside `untrack` blocks or async continuations where the
// tracking would otherwise be lost. Touching every key here
// synchronously guarantees a flip triggers the effect.
void toggles.hyperspaceGroups;
void toggles.incomingGroups;
void toggles.unidentifiedGroups;
void toggles.foreignPlanets;
void toggles.uninhabitedPlanets;
void toggles.unidentifiedPlanets;
void toggles.unreachablePlanets;
void toggles.cargoRoutes;
void toggles.battleMarkers;
void toggles.bombingMarkers;
void toggles.visibleHyperspace;
// Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the
// cascade-filtering happens here so the extras list shrinks
// when a destination planet hides. The hide set + fog are
// applied after mount / on every toggle change without a
// remount.
const hiddenPlanetNumbers = computeHiddenPlanetNumbers(report, toggles);
const hiddenPlanetFingerprint =
fingerprintHiddenPlanets(hiddenPlanetNumbers);
// Cargo-route arrows and pending-Send tracks are pushed onto
// the live renderer via `setExtraPrimitives` so the overlay
@@ -146,10 +213,13 @@ preference the store already manages.
// rebuilds when the overlay computation re-runs but the
// routes / pending-Send content is unchanged (e.g. status
// transitions valid → submitting → applied for the same
// command).
// command). The Phase 29 cascade + cargoRoutes toggle are
// folded into the fingerprint so a toggle flip that changes
// the visible set reliably triggers a push.
const draftCommands = orderDraft?.commands ?? [];
const draftStatuses = orderDraft?.statuses ?? {};
const extrasFingerprint =
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
computeRoutesFingerprint(report.routes) +
"|" +
computePendingSendFingerprint(draftCommands, draftStatuses);
@@ -157,15 +227,36 @@ preference the store already manages.
const sameSnapshot =
mountedTurn === report.turn &&
mountedGameId === gameId &&
handle !== null &&
handle.getMode() === mode;
handle !== null;
if (sameSnapshot) {
// Apply wrap-mode flips in-place via the renderer's own
// `setMode` — a full re-mount is unnecessary (the world,
// primitives, and camera are unchanged) and Pixi 8 does
// not reliably re-init on the same canvas (the symptom is
// a crashed tab when the wrap-mode radio fires).
if (handle !== null && handle.getMode() !== mode) {
untrack(() => {
handle?.setMode(mode);
});
}
// Always re-apply hide set + fog on a same-snapshot pass:
// toggle flips bypass the extras fingerprint when they
// only change which baked-world primitives are hidden,
// and a no-op `setHiddenPrimitiveIds` is cheap.
untrack(() => {
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
});
if (lastExtrasFingerprint !== extrasFingerprint) {
untrack(() => {
handle?.setExtraPrimitives([
...buildCargoRouteLines(report),
...buildPendingSendLines(report, draftCommands, draftStatuses),
]);
handle?.setExtraPrimitives(
buildExtras(
report,
draftCommands,
draftStatuses,
toggles,
hiddenPlanetNumbers,
),
);
});
lastExtrasFingerprint = extrasFingerprint;
}
@@ -179,18 +270,80 @@ preference the store already manages.
void pendingMountSignal;
if (mountInProgress) return;
untrack(() => {
void runSerializedMount(report, mode, extrasFingerprint);
void runSerializedMount(
report,
mode,
toggles,
hiddenPlanetNumbers,
extrasFingerprint,
draftCommands,
draftStatuses,
);
});
});
function buildExtras(
report: NonNullable<GameStateStore["report"]>,
draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
): import("../../map/world").Primitive[] {
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
const cargo = toggles.cargoRoutes
? buildCargoRouteLines(report, skip ? { skipPlanets: skip } : undefined)
: [];
const pending = buildPendingSendLines(
report,
draftCommands,
draftStatuses,
skip ? { skipPlanets: skip } : undefined,
);
return [...cargo, ...pending];
}
function applyVisibilityState(
report: NonNullable<GameStateStore["report"]>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
): void {
if (handle === null) return;
const hiddenIds = computeHiddenIds(
currentCategories,
currentPlanetDependents,
hiddenPlanetNumbers,
toggles,
);
handle.setHiddenPrimitiveIds(hiddenIds);
const fogCircles = computeFogCircles(report, toggles);
currentFogCircles = fogCircles;
handle.setVisibilityFog(fogCircles);
}
async function runSerializedMount(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
routesFingerprint: string,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
extrasFingerprint: string,
draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>,
): Promise<void> {
mountInProgress = true;
try {
await mountRenderer(report, mode, routesFingerprint);
await mountRenderer(report, mode);
if (handle === null) return;
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
handle.setExtraPrimitives(
buildExtras(
report,
draftCommands,
draftStatuses,
toggles,
hiddenPlanetNumbers,
),
);
lastExtrasFingerprint = extrasFingerprint;
} finally {
mountInProgress = false;
// Bump the reactive signal so any dep change observed
@@ -230,7 +383,6 @@ preference the store already manages.
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
@@ -262,8 +414,15 @@ preference the store already manages.
handle = null;
}
try {
const { world, hitLookup: nextHitLookup } = reportToWorld(report);
const {
world,
hitLookup: nextHitLookup,
categories,
planetDependents,
} = reportToWorld(report);
hitLookup = nextHitLookup;
currentCategories = categories;
currentPlanetDependents = planetDependents;
handle = await createRenderer({
canvas: canvasEl,
world,
@@ -328,6 +487,7 @@ preference the store already manages.
strokeColor: p.style.strokeColor ?? null,
x: p.kind === "point" ? p.x : null,
y: p.kind === "point" ? p.y : null,
visible: !h.isPrimitiveHidden(p.id),
}));
});
const detachPick = registerMapPickStateProvider(() => {
@@ -370,20 +530,29 @@ preference the store already manages.
},
} satisfies MapCameraSnapshot;
});
const detachFog = registerMapFogProvider(() => ({
circles: currentFogCircles.map((c) => ({ ...c })),
}) satisfies MapFogSnapshot);
const detachMode = registerMapModeProvider(() =>
handle === null ? null : handle.getMode(),
);
detachDebugProviders = (): void => {
detachPrim();
detachPick();
detachCamera();
detachFog();
detachMode();
};
mountedTurn = report.turn;
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.
// runSerializedMount immediately pushes the visibility
// state + extras after this resolves; clearing the
// fingerprint here is defensive in case the post-mount
// path is ever bypassed (e.g. mount-then-throw before the
// extras push). The hide set / fog are applied by the
// caller too, so we do not call them here.
lastExtrasFingerprint = null;
mountError = null;
void routesFingerprint;
} catch (err) {
mountError = err instanceof Error ? err.message : String(err);
}
@@ -503,6 +672,9 @@ preference the store already manages.
bind:this={containerEl}
>
<canvas bind:this={canvasEl}></canvas>
{#if store !== undefined && store.status === "ready"}
<MapTogglesControl {store} />
{/if}
</div>
</section>
+76 -1
View File
@@ -10,7 +10,7 @@
// 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";
import type { Primitive, PrimitiveID, WrapMode } from "../map/world";
/** Snapshot returned by `getMapPrimitives()`. The renderer applies
* pick-mode dimming via the underlying `Graphics.alpha`, so the
@@ -29,6 +29,25 @@ export interface MapPrimitiveSnapshot {
readonly strokeColor: number | null;
readonly x: number | null;
readonly y: number | null;
/**
* visible mirrors the renderer's per-id visibility flag — `false`
* iff `RendererHandle.setHiddenPrimitiveIds` has put the id into
* the hide set. Phase 29 e2e specs use this to assert toggle and
* planet-cascade behaviour without poking at Pixi internals.
*/
readonly visible: boolean;
}
/** Snapshot returned by `getMapFog()` — the current Phase 29
* visibility-fog input as the renderer last received it. The array
* is empty when the fog toggle is off (or when `localPlayerDrive`
* is zero so the radius would be zero). */
export interface MapFogSnapshot {
readonly circles: ReadonlyArray<{
readonly x: number;
readonly y: number;
readonly radius: number;
}>;
}
/** Snapshot returned by `getMapCamera()`. Mirrors the renderer's
@@ -53,10 +72,14 @@ export interface MapPickStateSnapshot {
type PrimitivesProvider = () => readonly MapPrimitiveSnapshot[];
type PickStateProvider = () => MapPickStateSnapshot;
type CameraProvider = () => MapCameraSnapshot | null;
type FogProvider = () => MapFogSnapshot;
type ModeProvider = () => WrapMode | null;
let primitivesProvider: PrimitivesProvider | null = null;
let pickStateProvider: PickStateProvider | null = null;
let cameraProvider: CameraProvider | null = null;
let fogProvider: FogProvider | null = null;
let modeProvider: ModeProvider | null = null;
/**
* registerMapPrimitivesProvider attaches a provider that yields the
@@ -101,6 +124,34 @@ export function registerMapCameraProvider(
};
}
/**
* registerMapFogProvider attaches a provider that yields the current
* Phase 29 fog input as last seen by the renderer. Same idempotent
* semantics as the other providers.
*/
export function registerMapFogProvider(provider: FogProvider): () => void {
fogProvider = provider;
return () => {
if (fogProvider === provider) fogProvider = null;
};
}
/**
* registerMapModeProvider attaches a provider that yields the
* renderer's current `WrapMode` ('torus' or 'no-wrap'). Used by
* Phase 29 e2e specs to await the renderer remount after a
* wrap-mode flip — `getMapCamera()` alone is not a reliable signal
* because the same camera survives across a remount, so the spec
* watches the mode flip instead. Same idempotent semantics as the
* other providers.
*/
export function registerMapModeProvider(provider: ModeProvider): () => void {
modeProvider = provider;
return () => {
if (modeProvider === provider) modeProvider = null;
};
}
const EMPTY_PICK_STATE: MapPickStateSnapshot = {
active: false,
sourcePlanetNumber: null,
@@ -126,11 +177,27 @@ export function getMapCamera(): MapCameraSnapshot | null {
return cameraProvider?.() ?? null;
}
/** Pulls the current visibility-fog snapshot. Returns an empty
* snapshot when no provider is registered (e.g. map view not
* mounted). */
export function getMapFog(): MapFogSnapshot {
return fogProvider?.() ?? { circles: [] };
}
/** Pulls the renderer's current `WrapMode`. Returns `null` when no
* map view is mounted (the surface is queried during navigation or
* before the first render). */
export function getMapMode(): WrapMode | null {
return modeProvider?.() ?? null;
}
interface RendererDebugWindow {
__galaxyDebug?: {
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
getMapPickState?: () => MapPickStateSnapshot;
getMapCamera?: () => MapCameraSnapshot | null;
getMapFog?: () => MapFogSnapshot;
getMapMode?: () => WrapMode | null;
[key: string]: unknown;
};
}
@@ -153,6 +220,8 @@ export function installRendererDebugSurface(): () => void {
getMapPrimitives,
getMapPickState,
getMapCamera,
getMapFog,
getMapMode,
};
win.__galaxyDebug = surface;
return (): void => {
@@ -170,5 +239,11 @@ export function installRendererDebugSurface(): () => void {
if (current.getMapCamera === getMapCamera) {
delete current.getMapCamera;
}
if (current.getMapFog === getMapFog) {
delete current.getMapFog;
}
if (current.getMapMode === getMapMode) {
delete current.getMapMode;
}
};
}
+205
View File
@@ -31,6 +31,61 @@ const HISTORY_NAMESPACE = "game-history";
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
`${gameId}/turn/${turn}`;
const MAP_TOGGLES_NAMESPACE = "game-map-toggles";
/**
* MapToggles is the per-game visibility state exposed by the Phase 29
* gear popover. Every flip persists into `Cache` under
* `MAP_TOGGLES_NAMESPACE/<gameId>` so the next visit to the game keeps
* the user's choices; a new server-side turn force-resets the blob to
* `DEFAULT_MAP_TOGGLES` so a hidden category never makes the player
* miss what changed (see `GameStateStore.setGame` and
* `advanceToPending`).
*
* Categories with no per-toggle entry are always visible: `local`
* planets, in-orbit / on-planet ship groups (rendered by the planet
* inspector, never on the map), and the pending-Send overlay.
*/
export interface MapToggles {
hyperspaceGroups: boolean;
incomingGroups: boolean;
unidentifiedGroups: boolean;
foreignPlanets: boolean;
uninhabitedPlanets: boolean;
unidentifiedPlanets: boolean;
unreachablePlanets: boolean;
cargoRoutes: boolean;
battleMarkers: boolean;
bombingMarkers: boolean;
/**
* visibleHyperspace toggles the foggy overlay that darkens the
* world OUTSIDE the union of `VisibilityDistance` circles around
* LOCAL planets. The visible part of the map — the player's
* intelligence/scan coverage — stays in the regular background
* colour; everything else looks "foggy". Default ON.
*/
visibleHyperspace: boolean;
}
export const DEFAULT_MAP_TOGGLES: MapToggles = {
hyperspaceGroups: true,
incomingGroups: true,
unidentifiedGroups: true,
foreignPlanets: true,
uninhabitedPlanets: true,
unidentifiedPlanets: true,
unreachablePlanets: true,
cargoRoutes: true,
battleMarkers: true,
bombingMarkers: true,
visibleHyperspace: true,
};
interface PersistedMapToggles {
readonly toggles: MapToggles;
readonly lastResetTurn: number;
}
/**
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `GameStateStore` instance to descendants.
@@ -53,6 +108,15 @@ export class GameStateStore {
status: Status = $state("idle");
report: GameReport | null = $state(null);
wrapMode: WrapMode = $state("torus");
/**
* mapToggles is the per-game visibility state surfaced by the
* Phase 29 gear popover. Every value defaults to `true` except for
* the negative `unreachablePlanets` flag (which is also `true` so
* the default view shows every reachable planet). The map view
* resolves the flags into a hide-by-id set on every effect run via
* `RendererHandle.setHiddenPrimitiveIds`.
*/
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
error: string | null = $state(null);
/**
* currentTurn mirrors the engine's turn number for the running
@@ -109,6 +173,13 @@ export class GameStateStore {
private cache: Cache | null = null;
private destroyed = false;
private visibilityListener: (() => void) | null = null;
/**
* lastResetTurn is the turn at which `mapToggles` was last reset to
* defaults. Persisted alongside the toggle blob so the new-turn
* reset path can compare against `currentTurn` after a cross-
* session gap (browser closed at turn N, reopened at turn N + k).
*/
private lastResetTurn = 0;
/**
* init kicks off the per-game lifecycle. The call is idempotent on
@@ -151,6 +222,7 @@ export class GameStateStore {
this.wrapMode = await readWrapMode(this.cache, gameId);
const lastViewed = await readLastViewedTurn(this.cache, gameId);
const persistedToggles = await readMapToggles(this.cache, gameId);
try {
const summary = await this.findGame(gameId);
@@ -161,6 +233,26 @@ export class GameStateStore {
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
// New-turn reset: if the persisted blob is older than the
// server-side `currentTurn`, drop user overrides and write
// the fresh `{defaults, currentTurn}` back to cache so a
// subsequent reload sees the same baseline. The cross-
// session gap counts here too — a player who closed the
// tab at turn N and returns at turn N + k still gets the
// defaults on first map mount.
if (persistedToggles.lastResetTurn < summary.currentTurn) {
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
this.lastResetTurn = summary.currentTurn;
await writeMapToggles(
this.cache,
gameId,
this.mapToggles,
this.lastResetTurn,
);
} else {
this.mapToggles = { ...persistedToggles.toggles };
this.lastResetTurn = persistedToggles.lastResetTurn;
}
// If the persisted last-viewed turn is older than the
// server-side current turn, open the user on their last-seen
// snapshot and surface the gap through `pendingTurn` so the
@@ -225,6 +317,13 @@ export class GameStateStore {
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn, { isCurrent: true });
this.pendingTurn = null;
// Phase 29: a successful jump onto the new server turn
// drops user-set map-visibility overrides so the next
// frame surfaces every category. `viewTurn` is the
// history-mode path and intentionally leaves toggles
// alone — the single shared state stays put across
// in-game time-travel.
await this.resetMapTogglesForTurn(summary.currentTurn);
} catch (err) {
if (this.destroyed) return;
this.status = "error";
@@ -298,6 +397,40 @@ export class GameStateStore {
}
}
/**
* setMapToggle flips one entry of the `mapToggles` rune and
* persists the whole blob (alongside the unchanged
* `lastResetTurn`). Mutating the rune in place keeps subscribers
* reactive without requiring object identity changes.
*/
async setMapToggle<K extends keyof MapToggles>(
key: K,
value: MapToggles[K],
): Promise<void> {
this.mapToggles[key] = value;
if (this.cache !== null) {
await writeMapToggles(
this.cache,
this.gameId,
this.mapToggles,
this.lastResetTurn,
);
}
}
private async resetMapTogglesForTurn(turn: number): Promise<void> {
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
this.lastResetTurn = turn;
if (this.cache !== null) {
await writeMapToggles(
this.cache,
this.gameId,
this.mapToggles,
this.lastResetTurn,
);
}
}
/**
* failBootstrap is used by the layout to surface errors that
* happen *before* `init` could be reached (missing keypair, missing
@@ -329,6 +462,25 @@ export class GameStateStore {
this.gameName = "Synthetic";
this.error = null;
this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
// Synthetic sessions skip the lobby query, so the new-turn
// reset check uses the report's own turn as the reference. A
// reload on the same synthetic id restores user overrides;
// switching to a synthetic report with a higher turn resets
// them.
const persistedToggles = await readMapToggles(opts.cache, opts.gameId);
if (persistedToggles.lastResetTurn < opts.report.turn) {
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
this.lastResetTurn = opts.report.turn;
await writeMapToggles(
opts.cache,
opts.gameId,
this.mapToggles,
this.lastResetTurn,
);
} else {
this.mapToggles = { ...persistedToggles.toggles };
this.lastResetTurn = persistedToggles.lastResetTurn;
}
this.report = opts.report;
this.currentTurn = opts.report.turn;
this.viewedTurn = opts.report.turn;
@@ -422,6 +574,59 @@ async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
return "torus";
}
/**
* readMapToggles loads the persisted `{toggles, lastResetTurn}` blob.
* Missing entries (cleared site data, fresh game) return the defaults
* with `lastResetTurn === -1`, guaranteeing the `setGame` reset path
* runs on the very first visit. Per-field fallback to defaults keeps
* forward-compat with future toggle additions: an older blob
* persisted before a new flag landed loses nothing but the missing
* flag, which gets the default value.
*/
async function readMapToggles(
cache: Cache,
gameId: string,
): Promise<PersistedMapToggles> {
const stored = await cache.get<Partial<PersistedMapToggles>>(
MAP_TOGGLES_NAMESPACE,
gameId,
);
if (stored === undefined || stored === null || typeof stored !== "object") {
return { toggles: { ...DEFAULT_MAP_TOGGLES }, lastResetTurn: -1 };
}
const partial =
stored.toggles !== undefined &&
stored.toggles !== null &&
typeof stored.toggles === "object"
? stored.toggles
: {};
const toggles: MapToggles = { ...DEFAULT_MAP_TOGGLES };
for (const k of Object.keys(DEFAULT_MAP_TOGGLES) as (keyof MapToggles)[]) {
const candidate = (partial as Partial<MapToggles>)[k];
if (typeof candidate === "boolean") {
toggles[k] = candidate;
}
}
const turn =
typeof stored.lastResetTurn === "number" &&
Number.isFinite(stored.lastResetTurn)
? stored.lastResetTurn
: -1;
return { toggles, lastResetTurn: turn };
}
async function writeMapToggles(
cache: Cache,
gameId: string,
toggles: MapToggles,
lastResetTurn: number,
): Promise<void> {
await cache.put<PersistedMapToggles>(MAP_TOGGLES_NAMESPACE, gameId, {
toggles: { ...toggles },
lastResetTurn,
});
}
async function readLastViewedTurn(
cache: Cache,
gameId: string,
+19
View File
@@ -113,6 +113,25 @@ const en = {
"game.shell.history.return_to_current": "Return to current turn",
"game.shell.history.current_badge": "current",
"game.view.map": "map",
"game.map.toggles.open": "open map visibility menu",
"game.map.toggles.close": "close map visibility menu",
"game.map.toggles.section.objects": "Objects",
"game.map.toggles.section.planets": "Planets",
"game.map.toggles.section.view": "View",
"game.map.toggles.hyperspace_groups": "hyperspace groups",
"game.map.toggles.incoming_groups": "incoming groups",
"game.map.toggles.unidentified_groups": "unidentified groups",
"game.map.toggles.cargo_routes": "cargo routes",
"game.map.toggles.battle_markers": "battle markers",
"game.map.toggles.bombing_markers": "bombing markers",
"game.map.toggles.foreign_planets": "foreign planets",
"game.map.toggles.uninhabited_planets": "uninhabited planets",
"game.map.toggles.unidentified_planets": "unidentified planets",
"game.map.toggles.unreachable_planets": "show unreachable planets",
"game.map.toggles.visible_hyperspace": "visible hyperspace",
"game.map.toggles.wrap.label": "wrap scrolling",
"game.map.toggles.wrap.torus": "torus",
"game.map.toggles.wrap.no_wrap": "no-wrap",
"game.view.table": "table",
"game.view.table.planets": "planets",
"game.view.table.ship_classes": "ship classes",
+19
View File
@@ -114,6 +114,25 @@ const ru: Record<keyof typeof en, string> = {
"game.shell.history.return_to_current": "Вернуться к текущему ходу",
"game.shell.history.current_badge": "текущий",
"game.view.map": "карта",
"game.map.toggles.open": "открыть меню видимости карты",
"game.map.toggles.close": "закрыть меню видимости карты",
"game.map.toggles.section.objects": "Объекты",
"game.map.toggles.section.planets": "Планеты",
"game.map.toggles.section.view": "Вид",
"game.map.toggles.hyperspace_groups": "группы в гиперпространстве",
"game.map.toggles.incoming_groups": "входящие группы",
"game.map.toggles.unidentified_groups": "неопознанные группы",
"game.map.toggles.cargo_routes": "грузовые маршруты",
"game.map.toggles.battle_markers": "метки сражений",
"game.map.toggles.bombing_markers": "метки бомбардировок",
"game.map.toggles.foreign_planets": "чужие планеты",
"game.map.toggles.uninhabited_planets": "необитаемые планеты",
"game.map.toggles.unidentified_planets": "неопознанные планеты",
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
"game.map.toggles.visible_hyperspace": "видимое гиперпространство",
"game.map.toggles.wrap.label": "перенос карты",
"game.map.toggles.wrap.torus": "тор",
"game.map.toggles.wrap.no_wrap": "без переноса",
"game.view.table": "таблица",
"game.view.table.planets": "планеты",
"game.view.table.ship_classes": "классы кораблей",
+34 -1
View File
@@ -60,9 +60,26 @@ export interface BombingMarkerTarget {
export type MarkerTarget = BattleMarkerTarget | BombingMarkerTarget;
/**
* MarkerCategory tags every emitted primitive with the toggleable
* surface it belongs to so the Phase 29 hide-set machinery can flip
* each independently. Battles and bombings have their own toggles —
* a player can hide the bombing rings while keeping the battle
* crosses visible.
*/
export type MarkerCategory = "battleMarker" | "bombingMarker";
export interface BuildMarkersResult {
primitives: Primitive[];
lookup: Map<PrimitiveID, MarkerTarget>;
categories: Map<PrimitiveID, MarkerCategory>;
/**
* planetDependents maps the anchor planet number to the ids of
* markers drawn on it; the Phase 29 cascade hides the markers
* together with the planet when the planet itself is filtered out
* (kind toggle off or unreachable filter on).
*/
planetDependents: Map<number, Set<PrimitiveID>>;
}
/**
@@ -93,6 +110,16 @@ export function buildBattleAndBombingMarkers(
const primitives: Primitive[] = [];
const lookup = new Map<PrimitiveID, MarkerTarget>();
const categories = new Map<PrimitiveID, MarkerCategory>();
const planetDependents = new Map<number, Set<PrimitiveID>>();
const addDependent = (planetNumber: number, id: PrimitiveID): void => {
let set = planetDependents.get(planetNumber);
if (set === undefined) {
set = new Set();
planetDependents.set(planetNumber, set);
}
set.add(id);
};
for (let i = 0; i < report.battles.length; i++) {
const battle = report.battles[i];
@@ -135,6 +162,10 @@ export function buildBattleAndBombingMarkers(
primitives.push(lineA, lineB);
lookup.set(lineA.id, target);
lookup.set(lineB.id, target);
categories.set(lineA.id, "battleMarker");
categories.set(lineB.id, "battleMarker");
addDependent(battle.planet, lineA.id);
addDependent(battle.planet, lineB.id);
}
for (let i = 0; i < report.bombings.length; i++) {
@@ -162,7 +193,9 @@ export function buildBattleAndBombingMarkers(
};
primitives.push(ring);
lookup.set(id, { kind: "bombing", planet: bombing.planetNumber });
categories.set(id, "bombingMarker");
addDependent(bombing.planetNumber, id);
}
return { primitives, lookup };
return { primitives, lookup, categories, planetDependents };
}
+14 -1
View File
@@ -86,18 +86,31 @@ const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
* 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.
*
* `opts.skipPlanets` (Phase 29) is an optional set of planet numbers
* whose routes — outgoing or incoming — should be filtered out so the
* arrows do not point at hidden glyphs. Empty / undefined means no
* extra filtering, preserving the pre-Phase-29 contract.
*/
export function buildCargoRouteLines(report: GameReport): LinePrim[] {
export function buildCargoRouteLines(
report: GameReport,
opts?: { skipPlanets?: ReadonlySet<number> },
): LinePrim[] {
if (report.routes.length === 0) return [];
const skip = opts?.skipPlanets;
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) {
if (skip !== undefined && skip.has(route.sourcePlanetNumber)) continue;
const source = planetById.get(route.sourcePlanetNumber);
if (source === undefined) continue;
for (const entry of route.entries) {
if (skip !== undefined && skip.has(entry.destinationPlanetNumber)) {
continue;
}
const dest = planetById.get(entry.destinationPlanetNumber);
if (dest === undefined) continue;
const dx = torusShortestDelta(source.x, dest.x, report.mapWidth);
+9
View File
@@ -21,6 +21,7 @@ import {
type LinePrim,
type PointPrim,
type Primitive,
type PrimitiveID,
type Viewport,
type World,
type WrapMode,
@@ -33,17 +34,25 @@ export interface Hit {
// hitTest returns the best-matching primitive under the cursor, or
// null if no primitive matches within its hit slop.
//
// `hiddenIds` (optional) is consulted before every primitive — ids in
// the set are skipped entirely, so a click on the area they used to
// cover falls through to the next visible primitive. The renderer's
// Phase 29 hide-by-id facility threads its current set in here so
// the click / hover paths stay in lock-step with the visible scene.
export function hitTest(
world: World,
camera: Camera,
viewport: Viewport,
cursorPx: { x: number; y: number },
mode: WrapMode,
hiddenIds?: ReadonlySet<PrimitiveID>,
): Hit | null {
const cursor = screenToWorld(cursorPx, camera, viewport);
const candidates: Hit[] = [];
for (const p of world.primitives) {
if (hiddenIds !== undefined && hiddenIds.has(p.id)) continue;
const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind];
const slopWorld = slopPx / camera.scale;
let result: number | null;
+21
View File
@@ -33,6 +33,27 @@ export function torusShortestDelta(a: number, b: number, size: number): number {
return d + 0;
}
// torusShortestDistance returns the wrap-aware Euclidean distance
// between (ax, ay) and (bx, by) on a torus of size width × height.
// Built on top of `torusShortestDelta` so the two axes share the
// "shortest signed delta" semantics. Used by the Phase 29 reach
// filter (hide planets beyond `FlightDistance` of every LOCAL
// planet); both modes (torus / no-wrap) consume the same metric — in
// no-wrap mode the wrapped distance is never shorter than the
// straight-line one because the player cannot fly across the seam.
export function torusShortestDistance(
ax: number,
ay: number,
bx: number,
by: number,
width: number,
height: number,
): number {
const dx = torusShortestDelta(ax, bx, width);
const dy = torusShortestDelta(ay, by, height);
return Math.hypot(dx, dy);
}
// distSqPointToSegment returns the squared distance from point (px,py)
// to the segment (ax,ay)(bx,by). For zero-length segments it falls
// back to point-to-point distance.
@@ -55,8 +55,10 @@ export function buildPendingSendLines(
report: GameReport,
commands: readonly OrderCommand[],
statuses: Readonly<Record<string, string>>,
opts?: { skipPlanets?: ReadonlySet<number> },
): LinePrim[] {
if (commands.length === 0) return [];
const skip = opts?.skipPlanets;
const planetById = new Map<number, ReportPlanet>();
for (const planet of report.planets) {
planetById.set(planet.number, planet);
@@ -79,6 +81,8 @@ export function buildPendingSendLines(
// origin / range to live coordinates and the in-space track
// renders instead.
if (group.origin !== null || group.range !== null) continue;
if (skip !== undefined && skip.has(group.destination)) continue;
if (skip !== undefined && skip.has(cmd.destinationPlanetNumber)) continue;
const source = planetById.get(group.destination);
const destination = planetById.get(cmd.destinationPlanetNumber);
if (source === undefined || destination === undefined) continue;
+218
View File
@@ -155,6 +155,36 @@ export interface RendererHandle {
* for unknown ids.
*/
getPrimitiveAlpha(id: PrimitiveID): number;
/**
* setHiddenPrimitiveIds replaces the set of primitives the
* renderer should hide. Hidden primitives have their per-copy
* `Graphics.visible` flipped to `false` and are skipped by
* `hitAt`, so a click on the area they used to cover falls
* through to the next primitive. Empty input clears the hide
* set. Called every effect run by the Phase 29 map view to
* materialise the `MapToggles` flags + planet-cascade rule
* without a Pixi remount.
*/
setHiddenPrimitiveIds(ids: ReadonlySet<PrimitiveID>): void;
/**
* isPrimitiveHidden reports whether the supplied primitive id is
* in the current hide set. Used by the debug surface so e2e
* specs can assert toggle behaviour without poking at Pixi
* internals.
*/
isPrimitiveHidden(id: PrimitiveID): boolean;
/**
* setVisibilityFog draws (or removes) the Phase 29 visibility
* fog overlay. Each entry describes a circle around a LOCAL
* planet that the player has scanner / visibility coverage on;
* the overlay fills the world rectangle with a slightly lighter
* fog colour and "punches" each circle out, leaving the
* intelligence-covered area in the regular background. Empty
* input destroys the existing fog Graphics.
*/
setVisibilityFog(
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
): void;
resize(widthPx: number, heightPx: number): void;
dispose(): void;
}
@@ -173,6 +203,111 @@ const TORUS_OFFSETS: ReadonlyArray<readonly [number, number]> = [
const ORIGIN_COPY_INDEX = 4; // (0, 0) entry of TORUS_OFFSETS
// EMPTY_HIDDEN_IDS is the default state of the Phase 29 hide set
// (no primitive is hidden). Shared by every renderer instance so a
// frequent `setHiddenPrimitiveIds(EMPTY_HIDDEN_IDS)` call from the
// debug surface stays allocation-free.
const EMPTY_HIDDEN_IDS: ReadonlySet<PrimitiveID> = new Set();
// FOG_COLOR is the Phase 29 visibility-fog colour. Two shades
// lighter than the dark theme background (`0x0a0e1a`) so it reads
// as a faint fog without contrasting against the rest of the map.
// The colour is tunable in Phase 35 polish.
export const FOG_COLOR = 0x12162a;
/**
* FogPaintOp is one item in the ordered draw sequence produced by
* `fogPaintOps`. The renderer dispatches each op directly onto a
* Pixi `Graphics`; the indirection exists so the Phase 29 layered
* overpaint (fog rect then background-coloured circles on top) can
* be unit-tested without a Pixi context.
*/
export type FogPaintOp =
| {
readonly kind: "fillRect";
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly color: number;
readonly alpha: number;
}
| {
readonly kind: "fillCircle";
readonly x: number;
readonly y: number;
readonly radius: number;
readonly color: number;
readonly alpha: number;
};
/**
* fogPaintOps returns the ordered sequence of paint operations that
* draw the Phase 29 visible-hyperspace overlay. The renderer
* dispatches each op onto its own Pixi `Graphics` inside a single
* `fogLayer` that sits below every primitive copy, so the natural
* rendering order paints fog underneath the world.
*
* Coordinates are in world space (the `fogLayer` has no transform),
* which means the wrap offsets are baked directly into the
* positions — there is no per-tile dispatch on the renderer side.
*
* `mode` controls the torus-wrap behaviour:
*
* - `"torus"`: every fog rect AND every visibility circle is
* emitted at the nine offsets (`(dx, dy) ∈ {-1, 0, 1}²`), so
* the fog covers all nine torus tiles and a planet near a seam
* keeps a continuous visibility hole across it.
* - `"no-wrap"`: only the central tile is emitted. The user can
* never pan past the boundary in no-wrap mode, so the
* additional wraps would just be wasted paint — worse, a
* wrapped circle from a planet near an edge would leak into
* the visible world rectangle as an unwanted hole.
*
* Empty `circles` returns an empty list — the caller skips fog
* rendering entirely. Width/height ≤ 0 also returns empty so a
* degenerate world cannot produce a non-empty op set.
*/
export function fogPaintOps(
world: { width: number; height: number },
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
fogColor: number,
bgColor: number,
mode: WrapMode,
): FogPaintOp[] {
if (circles.length === 0) return [];
if (world.width <= 0 || world.height <= 0) return [];
const offsets: ReadonlyArray<readonly [number, number]> =
mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET;
const ops: FogPaintOp[] = [];
for (const [dx, dy] of offsets) {
ops.push({
kind: "fillRect",
x: dx * world.width,
y: dy * world.height,
width: world.width,
height: world.height,
color: fogColor,
alpha: 1,
});
}
for (const c of circles) {
for (const [dx, dy] of offsets) {
ops.push({
kind: "fillCircle",
x: c.x + dx * world.width,
y: c.y + dy * world.height,
radius: c.radius,
color: bgColor,
alpha: 1,
});
}
}
return ops;
}
const ORIGIN_ONLY_OFFSET: ReadonlyArray<readonly [number, number]> = [[0, 0]];
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
const theme = opts.theme ?? DARK_THEME;
const preference = opts.preference ?? ["webgpu", "webgl"];
@@ -206,6 +341,17 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
app.stage.addChild(viewport);
// Phase 29 fog layer: a single Container sharing the viewport's
// coordinate space, populated by `setVisibilityFog`. Added to
// the viewport BEFORE the nine torus copies so the layered
// repaint (fog rectangles + background-coloured circles) always
// renders underneath every primitive. An earlier per-copy
// approach with `copy.addChildAt(fog, 0)` ended up with fog on
// top in practice — moving the fog to a sibling of the copies
// avoids any reorder ambiguity.
const fogLayer = new Container();
viewport.addChild(fogLayer);
// Create nine torus copies, each holding its own primitive
// graphics. Origin copy is always visible; the other eight
// follow the active wrap mode.
@@ -225,6 +371,20 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const allPrimitiveIds: PrimitiveID[] = [];
const extraPrimitiveIds = new Set<PrimitiveID>();
let currentWorld: World = opts.world;
// hiddenIds is the Phase 29 hide-by-id snapshot. Empty by default;
// every map-view effect run replaces it with the current
// MapToggles-derived set via `setHiddenPrimitiveIds`. Both
// renderer-internal hit-test sites (pointer-move, clicked) and the
// external `handle.hitAt` thread it through `hitTest`.
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
// `fogLayer` (declared above) is repopulated every time
// `setVisibilityFog` runs. We track the dispatched ops only
// implicitly via the layer's children; on every flip we drop
// the previous children and rebuild from the new op list.
const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
const visible = !hiddenIds.has(id);
for (const g of list) g.visible = visible;
};
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
for (const c of copies) {
const g = buildGraphics(prim, theme);
@@ -239,6 +399,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
allPrimitiveIds.push(prim.id);
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
if (isExtra) extraPrimitiveIds.add(prim.id);
// Fresh primitives honour the current hide set so cargo-route
// or pending-Send extras pushed after `setHiddenPrimitiveIds`
// inherit the right visibility.
const list = primitiveGraphics.get(prim.id);
if (list !== undefined) applyHiddenStateTo(prim.id, list);
};
for (const p of opts.world.primitives) {
populatePrimitives(p, false);
@@ -347,6 +512,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
handle.getViewport(),
cursorPx,
mode,
hiddenIds,
);
const hoveredId = hit?.primitive.id ?? null;
if (hoveredId === lastHoveredId) return;
@@ -552,6 +718,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
handle.getViewport(),
cursorPx,
mode,
hiddenIds,
),
setExtraPrimitives: (prims) => {
// Drop the previous extras layer.
@@ -629,6 +796,48 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// torus tile), so the central-tile entry is representative.
return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha;
},
setHiddenPrimitiveIds: (ids) => {
// Snapshot the input so a later mutation by the caller does
// not silently un-hide primitives on the next hit-test.
hiddenIds = new Set(ids);
for (const [id, list] of primitiveGraphics) {
applyHiddenStateTo(id, list);
}
},
isPrimitiveHidden: (id) => hiddenIds.has(id),
setVisibilityFog: (circles) => {
// Drop the previous fog children — every flip rebuilds
// from scratch instead of mutating in place. Pixi v8's
// `Container.removeChildren()` returns the detached
// children so we can destroy each one explicitly.
for (const old of fogLayer.removeChildren()) {
old.destroy({ children: true });
}
const ops = fogPaintOps(
opts.world,
circles,
FOG_COLOR,
theme.background,
mode,
);
if (ops.length === 0) return;
// Each op gets its own Graphics so any multi-shape Pixi
// quirks cannot drop a layer (an earlier all-in-one
// implementation surfaced exactly that symptom in DEV —
// only the last planet's glyph stayed visible inside the
// bg holes). The ops carry world-space positions; the
// `fogLayer` has no transform.
for (const op of ops) {
const g = new Graphics();
if (op.kind === "fillRect") {
g.rect(op.x, op.y, op.width, op.height);
} else {
g.circle(op.x, op.y, op.radius);
}
g.fill({ color: op.color, alpha: op.alpha });
fogLayer.addChild(g);
}
},
resize: (w, h) => {
app.renderer.resize(w, h);
viewport.resize(w, h, opts.world.width, opts.world.height);
@@ -651,6 +860,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
teardownPickMode();
previous?.onPick(null);
}
// `app.destroy({...children: true})` below recursively
// destroys every container in the scene graph, fogLayer
// included. The explicit removeChildren()/destroy here
// drops the fog children eagerly so a future caller
// querying the renderer mid-dispose does not see stale
// fog instances still parented under the layer.
for (const old of fogLayer.removeChildren()) {
old.destroy({ children: true });
}
viewport.off("moved", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera);
viewport.off("clicked", handleViewportClicked);
+51 -3
View File
@@ -31,7 +31,6 @@
import type {
GameReport,
ReportIncomingShipGroup,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
@@ -107,14 +106,51 @@ const PRIORITY_INCOMING_POINT = 6;
const PRIORITY_INCOMING_LINE = 0;
const PRIORITY_UNIDENTIFIED = 4;
/**
* ShipGroupCategory tags every emitted primitive with the toggleable
* surface it belongs to. The Phase 29 hide-set machinery in
* `lib/active-view/map.svelte` looks these up via `categories` to
* decide whether to hide the primitive when the matching `MapToggles`
* flag is `false`.
*/
export type ShipGroupCategory =
| "hyperspaceGroup"
| "incomingGroup"
| "unidentifiedGroup";
export interface ShipGroupPrimitives {
primitives: (PointPrim | LinePrim)[];
lookup: Map<PrimitiveID, ShipGroupRef>;
categories: Map<PrimitiveID, ShipGroupCategory>;
/**
* planetDependents maps a planet number to the set of primitive
* ids that should hide together with that planet. In Phase 29 the
* hide-by-id machinery cascades planet visibility onto in-space
* and incoming groups flying *to* the planet (their points + the
* trajectory / track lines). Unidentified groups have no planet
* anchor and therefore contribute nothing here.
*/
planetDependents: Map<number, Set<PrimitiveID>>;
}
function addDependent(
planetDependents: Map<number, Set<PrimitiveID>>,
planetNumber: number,
primitiveId: PrimitiveID,
): void {
let set = planetDependents.get(planetNumber);
if (set === undefined) {
set = new Set();
planetDependents.set(planetNumber, set);
}
set.add(primitiveId);
}
export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives {
const primitives: (PointPrim | LinePrim)[] = [];
const lookup = new Map<PrimitiveID, ShipGroupRef>();
const categories = new Map<PrimitiveID, ShipGroupCategory>();
const planetDependents = new Map<number, Set<PrimitiveID>>();
const planetIndex = new Map<number, ReportPlanet>();
for (const planet of report.planets) {
planetIndex.set(planet.number, planet);
@@ -129,6 +165,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
const id = SHIP_GROUP_ID_OFFSETS.local + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP));
lookup.set(id, { variant: "local", id: group.id });
categories.set(id, "hyperspaceGroup");
addDependent(planetDependents, group.destination, id);
// Yellow dashed track from the origin planet to the destination
// planet. The colour matches the in-space group point so the
// player can read both as one entity at a glance. Wrap-aware
@@ -140,9 +178,10 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
if (origin !== undefined && destination !== undefined) {
const dx = torusShortestDelta(origin.x, destination.x, w);
const dy = torusShortestDelta(origin.y, destination.y, h);
const lineId = SHIP_GROUP_ID_OFFSETS.localLine + i;
primitives.push({
kind: "line",
id: SHIP_GROUP_ID_OFFSETS.localLine + i,
id: lineId,
priority: PRIORITY_LOCAL_LINE,
style: STYLE_LOCAL_INSPACE_LINE,
hitSlopPx: 0,
@@ -151,6 +190,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
x2: origin.x + dx,
y2: origin.y + dy,
});
categories.set(lineId, "hyperspaceGroup");
addDependent(planetDependents, group.destination, lineId);
}
}
@@ -161,6 +202,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
const id = SHIP_GROUP_ID_OFFSETS.other + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, STYLE_OTHER_GROUP));
lookup.set(id, { variant: "other", index: i });
categories.set(id, "hyperspaceGroup");
addDependent(planetDependents, group.destination, id);
}
for (let i = 0; i < report.incomingShipGroups.length; i++) {
@@ -189,6 +232,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
x2: destX,
y2: destY,
});
categories.set(lineId, "incomingGroup");
addDependent(planetDependents, group.destination, lineId);
const pos = interpolateAlongLine(destX, destY, origin.x, origin.y, group.distance);
const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i;
primitives.push(
@@ -202,6 +247,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
),
);
lookup.set(pointId, { variant: "incoming", index: i });
categories.set(pointId, "incomingGroup");
addDependent(planetDependents, group.destination, pointId);
}
for (let i = 0; i < report.unidentifiedShipGroups.length; i++) {
@@ -218,9 +265,10 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
),
);
lookup.set(id, { variant: "unidentified", index: i });
categories.set(id, "unidentifiedGroup");
}
return { primitives, lookup };
return { primitives, lookup, categories, planetDependents };
}
/**
+95 -3
View File
@@ -15,8 +15,14 @@
import type { GameReport, ReportPlanet } from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte";
import { buildBattleAndBombingMarkers } from "./battle-markers";
import { shipGroupsToPrimitives } from "./ship-groups";
import {
buildBattleAndBombingMarkers,
type MarkerCategory,
} from "./battle-markers";
import {
shipGroupsToPrimitives,
type ShipGroupCategory,
} from "./ship-groups";
import { World, type Primitive, type PrimitiveID, type Style } from "./world";
const STYLE_LOCAL: Style = {
@@ -88,9 +94,45 @@ export type HitTarget =
| { kind: "battle"; battleId: string; planet: number }
| { kind: "bombing"; planet: number };
/**
* PlanetCategory is the per-`ReportPlanet.kind` flavour exposed to the
* Phase 29 visibility layer so the gear popover can toggle foreign /
* uninhabited / unidentified planets independently of one another.
* LOCAL planets stay always-on and therefore have no category-driven
* hide path — they are simply excluded from the toggle table.
*/
export type PlanetCategory =
| "planet-local"
| "planet-foreign"
| "planet-uninhabited"
| "planet-unidentified";
/**
* MapCategory unions every toggleable surface the gear popover can
* hide. The map view in `lib/active-view/map.svelte` walks the
* `categories` map produced by `reportToWorld`, looks the matching
* `MapToggles` flag up, and feeds the union of hidden ids into
* `RendererHandle.setHiddenPrimitiveIds`.
*/
export type MapCategory = PlanetCategory | ShipGroupCategory | MarkerCategory;
export interface ReportToWorldResult {
world: World;
hitLookup: Map<PrimitiveID, HitTarget>;
/**
* categories maps every emitted primitive id to the toggleable
* surface it belongs to. Phase 29 uses this to resolve `MapToggles`
* flags into a hide-by-id set.
*/
categories: Map<PrimitiveID, MapCategory>;
/**
* planetDependents maps a planet number to the set of primitive
* ids whose visibility cascades on that planet. The set always
* contains the planet's own primitive id (planet number itself);
* it grows with battle / bombing markers anchored on the planet
* and with in-space / incoming groups flying *to* it.
*/
planetDependents: Map<number, Set<PrimitiveID>>;
}
/**
@@ -108,6 +150,8 @@ export interface ReportToWorldResult {
export function reportToWorld(report: GameReport): ReportToWorldResult {
const primitives: Primitive[] = [];
const hitLookup = new Map<PrimitiveID, HitTarget>();
const categories = new Map<PrimitiveID, MapCategory>();
const planetDependents = new Map<number, Set<PrimitiveID>>();
for (const planet of report.planets) {
primitives.push({
@@ -120,6 +164,14 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
y: planet.y,
});
hitLookup.set(planet.number, { kind: "planet", number: planet.number });
categories.set(planet.number, categoryForPlanet(planet.kind));
// Seed the planet's own dependents set with the planet
// primitive itself so the cascade iterator does not need a
// special "planet-self" case — hiding planet N becomes
// "hide everything in planetDependents[N]".
const own = new Set<PrimitiveID>();
own.add(planet.number);
planetDependents.set(planet.number, own);
}
const groups = shipGroupsToPrimitives(report);
@@ -129,6 +181,10 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
for (const [primId, ref] of groups.lookup) {
hitLookup.set(primId, { kind: "shipGroup", ref });
}
for (const [primId, category] of groups.categories) {
categories.set(primId, category);
}
mergeDependents(planetDependents, groups.planetDependents);
const markers = buildBattleAndBombingMarkers(report);
for (const prim of markers.primitives) {
@@ -137,8 +193,44 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
for (const [primId, target] of markers.lookup) {
hitLookup.set(primId, target);
}
for (const [primId, category] of markers.categories) {
categories.set(primId, category);
}
mergeDependents(planetDependents, markers.planetDependents);
const width = report.mapWidth > 0 ? report.mapWidth : 1;
const height = report.mapHeight > 0 ? report.mapHeight : 1;
return { world: new World(width, height, primitives), hitLookup };
return {
world: new World(width, height, primitives),
hitLookup,
categories,
planetDependents,
};
}
function categoryForPlanet(kind: ReportPlanet["kind"]): PlanetCategory {
switch (kind) {
case "local":
return "planet-local";
case "other":
return "planet-foreign";
case "uninhabited":
return "planet-uninhabited";
case "unidentified":
return "planet-unidentified";
}
}
function mergeDependents(
into: Map<number, Set<PrimitiveID>>,
from: Map<number, Set<PrimitiveID>>,
): void {
for (const [planetNumber, ids] of from) {
let set = into.get(planetNumber);
if (set === undefined) {
set = new Set();
into.set(planetNumber, set);
}
for (const id of ids) set.add(id);
}
}
+211
View File
@@ -0,0 +1,211 @@
// Pure helpers for the Phase 29 visibility layer. The map view
// (`lib/active-view/map.svelte`) reads `GameStateStore.mapToggles`
// every effect run and feeds the result through these functions to
// produce the renderer inputs:
//
// 1. `computeHiddenPlanetNumbers` resolves the per-kind toggles and
// the optional `unreachablePlanets` filter into a set of planet
// numbers to hide. LOCAL planets are always exempt.
// 2. `computeHiddenIds` cascades that set onto every primitive id
// tracked in `planetDependents` (planet, marker, in-space and
// incoming group, trajectory line), then unions in the
// category-toggled-off primitives walked from `categories`.
// 3. `computeFogCircles` produces the visibility-fog input —
// empty when the toggle is off, otherwise one circle per LOCAL
// planet at `VisibilityDistance(localPlayerDrive)`.
//
// The constants `FLIGHT_DISTANCE_PER_DRIVE` and
// `VISIBILITY_DISTANCE_PER_DRIVE` mirror `pkg/calc/race.go`:
//
// FlightDistance(driveTech) = driveTech * 40
// VisibilityDistance(driveTech) = driveTech * 30
//
// A WASM bridge for the race-level calc helpers does not exist yet
// (Phase 18 wired ship-level math only); the constants are
// duplicated in TS following the same precedent as
// `lib/inspectors/ship-group/actions.svelte` (`40 * localPlayerDrive`)
// and `sync/order-types.ts:298`.
import type { GameReport } from "../api/game-state";
import type { MapToggles } from "../lib/game-state.svelte";
import { torusShortestDistance } from "./math";
import type { MapCategory } from "./state-binding";
import type { PrimitiveID } from "./world";
export const FLIGHT_DISTANCE_PER_DRIVE = 40;
export const VISIBILITY_DISTANCE_PER_DRIVE = 30;
/**
* isCategoryVisible reports whether the supplied `MapCategory` is
* currently visible per the toggle state. LOCAL planets are not
* controlled by a toggle; the function returns `true` for them
* unconditionally. The map view combines this with the planet
* cascade so a kind toggle (e.g. `foreignPlanets = false`) hides
* the planet itself AND every dependent primitive (markers, in-
* space groups flying to it).
*/
export function isCategoryVisible(
category: MapCategory,
toggles: MapToggles,
): boolean {
switch (category) {
case "planet-local":
return true;
case "planet-foreign":
return toggles.foreignPlanets;
case "planet-uninhabited":
return toggles.uninhabitedPlanets;
case "planet-unidentified":
return toggles.unidentifiedPlanets;
case "hyperspaceGroup":
return toggles.hyperspaceGroups;
case "incomingGroup":
return toggles.incomingGroups;
case "unidentifiedGroup":
return toggles.unidentifiedGroups;
case "battleMarker":
return toggles.battleMarkers;
case "bombingMarker":
return toggles.bombingMarkers;
}
}
/**
* computeHiddenPlanetNumbers returns every non-LOCAL planet whose
* kind toggle is off or — when `unreachablePlanets` is off — which
* sits beyond `FlightDistance(localPlayerDrive)` of every LOCAL
* planet. LOCAL planets themselves are never returned.
*
* `localPlayerDrive === 0` (zero drive tech) collapses the reach
* threshold to zero, so when `unreachablePlanets` is off the
* function returns every non-LOCAL planet — matching the engine's
* "no fleet can move" baseline.
*/
export function computeHiddenPlanetNumbers(
report: GameReport,
toggles: MapToggles,
): Set<number> {
const hidden = new Set<number>();
if (report.planets.length === 0) return hidden;
const localPlanets: { x: number; y: number }[] = [];
for (const p of report.planets) {
if (p.kind === "local") localPlanets.push({ x: p.x, y: p.y });
}
const reachThreshold =
toggles.unreachablePlanets || localPlanets.length === 0
? Infinity
: report.localPlayerDrive * FLIGHT_DISTANCE_PER_DRIVE;
for (const p of report.planets) {
if (p.kind === "local") continue;
let kindVisible: boolean;
switch (p.kind) {
case "other":
kindVisible = toggles.foreignPlanets;
break;
case "uninhabited":
kindVisible = toggles.uninhabitedPlanets;
break;
case "unidentified":
kindVisible = toggles.unidentifiedPlanets;
break;
}
if (!kindVisible) {
hidden.add(p.number);
continue;
}
if (reachThreshold === Infinity) continue;
let reachable = false;
for (const lp of localPlanets) {
const d = torusShortestDistance(
p.x,
p.y,
lp.x,
lp.y,
report.mapWidth > 0 ? report.mapWidth : 1,
report.mapHeight > 0 ? report.mapHeight : 1,
);
if (d <= reachThreshold) {
reachable = true;
break;
}
}
if (!reachable) hidden.add(p.number);
}
return hidden;
}
/**
* computeHiddenIds resolves the toggle state into the final hide-by-
* id set fed to `RendererHandle.setHiddenPrimitiveIds`. Inputs:
*
* - `categories`: every primitive's toggleable surface, as
* produced by `reportToWorld`.
* - `planetDependents`: for each planet number, the primitive ids
* whose visibility cascades on that planet (planet itself, the
* markers anchored on it, in-space / incoming groups flying to
* it, their lines). Produced by `reportToWorld`.
* - `hiddenPlanetNumbers`: the kind / reach-filtered set from
* `computeHiddenPlanetNumbers`.
* - `toggles`: the per-category toggle state.
*
* Returns the union of (a) every primitive id whose category toggle
* is off and (b) every dependent of a hidden planet number.
*/
export function computeHiddenIds(
categories: ReadonlyMap<PrimitiveID, MapCategory>,
planetDependents: ReadonlyMap<number, ReadonlySet<PrimitiveID>>,
hiddenPlanetNumbers: ReadonlySet<number>,
toggles: MapToggles,
): Set<PrimitiveID> {
const hidden = new Set<PrimitiveID>();
for (const [id, category] of categories) {
if (!isCategoryVisible(category, toggles)) hidden.add(id);
}
for (const planetNumber of hiddenPlanetNumbers) {
const deps = planetDependents.get(planetNumber);
if (deps === undefined) continue;
for (const id of deps) hidden.add(id);
}
return hidden;
}
/**
* computeFogCircles produces the visibility-fog input — empty when
* the `visibleHyperspace` toggle is off, otherwise one circle per
* LOCAL planet at `VisibilityDistance(localPlayerDrive)`. When the
* drive tech is zero the function returns an empty list as well:
* a zero-radius fog cutout would leave the entire world fogged,
* which is more confusing than helpful in tutorial / debug
* scenarios. The renderer-side fog Graphics is destroyed on an
* empty list.
*/
export function computeFogCircles(
report: GameReport,
toggles: MapToggles,
): { x: number; y: number; radius: number }[] {
if (!toggles.visibleHyperspace) return [];
const radius = report.localPlayerDrive * VISIBILITY_DISTANCE_PER_DRIVE;
if (radius <= 0) return [];
const circles: { x: number; y: number; radius: number }[] = [];
for (const p of report.planets) {
if (p.kind !== "local") continue;
circles.push({ x: p.x, y: p.y, radius });
}
return circles;
}
/**
* fingerprintHiddenPlanets returns a stable string identifying the
* supplied hidden-planet set. The map view threads it into the
* extras fingerprint so a toggle flip that changes the planet set
* — and therefore changes which routes / pending-Send lines must be
* filtered out — reliably triggers an `setExtraPrimitives` push.
*/
export function fingerprintHiddenPlanets(
hiddenPlanetNumbers: ReadonlySet<number>,
): string {
if (hiddenPlanetNumbers.size === 0) return "";
return Array.from(hiddenPlanetNumbers)
.sort((a, b) => a - b)
.join(",");
}
@@ -9,12 +9,16 @@
import type { OrderCommand } from "../../../sync/order-types";
import {
getMapCamera,
getMapFog,
getMapMode,
getMapPickState,
getMapPrimitives,
type MapCameraSnapshot,
type MapFogSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "../../../lib/debug-surface.svelte";
import type { WrapMode } from "../../../map/world";
interface DebugSnapshot {
publicKey: number[];
@@ -39,6 +43,8 @@
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null;
getMapFog(): MapFogSnapshot;
getMapMode(): WrapMode | null;
}
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
@@ -136,6 +142,12 @@
getMapCamera() {
return getMapCamera();
},
getMapFog() {
return getMapFog();
},
getMapMode() {
return getMapMode();
},
};
(window as DebugWindow).__galaxyDebug = surface;
ready = true;
+402
View File
@@ -0,0 +1,402 @@
// Phase 29 end-to-end coverage for the gear popover. The spec mocks
// the gateway with a mixed-kind report (local + foreign + uninhabited
// + unidentified planets, a battle, a bombing, a cargo route, a
// non-zero drive tech for fog math), then walks the popover through
// the toggles and asserts the renderer state via the
// `__galaxyDebug` accessors:
//
// * `getMapPrimitives()` — every primitive carries a `visible`
// flag mirroring the renderer's hide set. The spec counts the
// visible-foreign-planet primitives, etc.
// * `getMapFog()` — the current visibility-fog circle list.
// * `getMapCamera()` — the wrap-mode test reads the centre before
// and after the flip to confirm camera preservation.
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 { 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";
const SESSION_ID = "phase-29-map-toggles-session";
const GAME_ID = "29292929-2929-2929-2929-292929292929";
const RACE = "Earthlings";
// FlightDistance = driveTech * 40; pick drive=10 → reach 400.
// VisibilityDistance = driveTech * 30 → fog radius 300.
const DRIVE_TECH = 10;
interface MockOpts {
currentTurn: number;
}
async function mockGateway(page: Page, opts: MockOpts): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 29 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: opts.currentTurn,
};
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: opts.currentTurn,
mapWidth: 4000,
mapHeight: 4000,
race: RACE,
players: [{ name: RACE, drive: DRIVE_TECH }],
// Two LOCAL planets near the centre so the reach +
// fog math has anchors. The foreign planet at
// (1500, 1000) is 500 units from the closest LOCAL
// — outside reach (400), so the unreachable filter
// toggles flag this one when enabled.
localPlanets: [
{
number: 1,
name: "Earth",
x: 1000,
y: 1000,
size: 1000,
resources: 10,
},
{
number: 2,
name: "Mars",
x: 1200,
y: 1000,
size: 1000,
resources: 10,
},
],
otherPlanets: [
{
number: 3,
name: "Frontier",
x: 1500,
y: 1000,
owner: "Federation",
size: 800,
resources: 5,
},
],
uninhabitedPlanets: [
{
number: 4,
name: "Rock",
x: 1100,
y: 1100,
size: 500,
resources: 1,
},
],
unidentifiedPlanets: [
{ number: 5, x: 2500, y: 1000 },
],
battles: [
{ id: "8c0c1f64-b0f8-4e7d-8c2c-3e1d0a0b0001", planet: 3, shots: 4 },
],
bombings: [
{
planetNumber: 3,
planet: "Frontier",
owner: "Federation",
attacker: RACE,
production: "",
industry: 100,
population: 200,
colonists: 50,
attackPower: 5,
wiped: false,
},
],
routes: [
{
sourcePlanetNumber: 1,
entries: [
{ loadType: "COL", destinationPlanetNumber: 2 },
],
},
],
});
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,
});
},
);
// Keep the push stream open so the revocation watcher does not
// sign the session out mid-test (same convention as
// `game-shell-map.spec.ts`).
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
async () => {
await new Promise<void>(() => {});
},
);
}
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,
);
}
async function openGame(page: Page): Promise<void> {
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
// Wait for the renderer's debug accessor to register so the
// `getMapPrimitives` call below picks up real data instead of an
// empty stub. The renderer registers it inside
// `runSerializedMount`, which awaits Pixi init.
await page.waitForFunction(() => {
const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
return prims.length > 0;
});
}
interface PrimitiveLite {
id: number;
visible: boolean;
}
async function visiblePlanets(page: Page): Promise<number[]> {
return await page.evaluate(() => {
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
// Planet primitive ids are the engine planet numbers — small
// positive integers ≤ planetCount. Other categories use either
// signed-negative high-bit-prefix ids (cargo route 0x80…, battle
// 0xa0…, bombing 0xc0…) or large positive offsets (ship groups
// at 1e8+). The `0 < id < 1e7` window covers the planet range
// and excludes both.
return prims
.filter((p) => p.visible && p.id > 0 && p.id < 10_000_000)
.map((p) => p.id)
.sort((a, b) => a - b);
});
}
async function visibleHighBitCount(
page: Page,
prefix: number,
): Promise<number> {
// JS bitwise `&` always returns a signed int32. Convert both
// sides to uint32 via `>>> 0` AFTER the mask so the comparison
// is well-defined for high-bit-prefix ids that arrive as
// negative Numbers (cargo route 0x80…, battle 0xa0…, bombing
// 0xc0…) as well as for the positive `prefix` literal passed in.
return await page.evaluate((p: number) => {
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
const expected = p >>> 0;
return prims.filter(
(prim) =>
prim.visible && ((prim.id & 0xf0000000) >>> 0) === expected,
).length;
}, prefix);
}
test("gear popover toggles a planet kind off and cascades onto its markers", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Baseline — every planet shows up, plus the battle X-cross (2
// LinePrim) and the bombing ring on the foreign planet.
expect(await visiblePlanets(page)).toEqual([1, 2, 3, 4, 5]);
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(2);
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(1);
await page.getByTestId("map-toggles-trigger").click();
await expect(page.getByTestId("map-toggles-surface")).toBeVisible();
await page.getByTestId("map-toggles-foreign-planets").click();
// The cascade applies asynchronously through the Svelte effect;
// wait for the foreign planet to drop out of the visible set
// before asserting on the markers — both updates happen in the
// same effect tick so once the planet is gone the markers are
// too.
await page.waitForFunction(() => {
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly {
id: number;
visible: boolean;
}[];
const planet3 = prims.find((p) => p.id === 3);
return planet3 !== undefined && planet3.visible === false;
});
expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]);
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
});
test("visibility fog toggles between the LOCAL-planet circle list and an empty overlay", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Defaults: fog on; one circle per LOCAL planet, radius
// `30 * driveTech = 300`.
const initialFog = await page.evaluate(
() => window.__galaxyDebug!.getMapFog!().circles,
);
expect(initialFog.length).toBe(2);
expect(initialFog[0].radius).toBe(300);
expect(initialFog[1].radius).toBe(300);
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-visible-hyperspace").click();
// The effect re-run is async; wait for the fog payload to clear
// instead of reading it on the next tick.
await page.waitForFunction(
() => window.__galaxyDebug!.getMapFog!().circles.length === 0,
);
// Toggling back on rebuilds the fog circles for the same planets.
await page.getByTestId("map-toggles-visible-hyperspace").click();
await page.waitForFunction(
() => window.__galaxyDebug!.getMapFog!().circles.length === 2,
);
});
test("wrap mode radios flip the renderer and the camera centre survives", async ({
page,
}) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
// Confirm the renderer starts in torus mode.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "torus",
);
const initial = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(initial).not.toBeNull();
const startCentre = initial!.camera;
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-wrap-no-wrap").click();
// `setWrapMode` triggers a full Pixi remount; wait for the
// renderer to settle into the new mode and the debug surface to
// re-register before reading the camera. The mode provider is
// re-bound inside `runSerializedMount` after `createRenderer`
// resolves, so observing `getMapMode() === "no-wrap"` is the
// canonical "remount complete" signal.
await page.waitForFunction(
() => window.__galaxyDebug?.getMapMode?.() === "no-wrap",
);
const after = await page.evaluate(() =>
window.__galaxyDebug!.getMapCamera!(),
);
expect(after).not.toBeNull();
expect(
Math.abs(after!.camera.centerX - startCentre.centerX),
).toBeLessThanOrEqual(1);
expect(
Math.abs(after!.camera.centerY - startCentre.centerY),
).toBeLessThanOrEqual(1);
});
test("toggle state persists across a page reload", async ({ page }) => {
await mockGateway(page, { currentTurn: 1 });
await bootSession(page);
await openGame(page);
await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-battle-markers").click();
await page.getByTestId("map-toggles-bombing-markers").click();
// Independent flips: turning battle off must not touch bombing.
expect(
await page.getByTestId("map-toggles-battle-markers").isChecked(),
).toBe(false);
expect(
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false);
await page.reload();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await page.waitForFunction(() => {
const prims = window.__galaxyDebug?.getMapPrimitives?.() ?? [];
return prims.length > 0;
});
await page.getByTestId("map-toggles-trigger").click();
expect(
await page.getByTestId("map-toggles-battle-markers").isChecked(),
).toBe(false);
expect(
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false);
// Battle X-cross and bombing ring are hidden in the renderer.
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
});
@@ -15,9 +15,11 @@ interface DebugSnapshot {
import type {
MapCameraSnapshot,
MapFogSnapshot,
MapPickStateSnapshot,
MapPrimitiveSnapshot,
} from "../../src/lib/debug-surface.svelte";
import type { WrapMode } from "../../src/map/world";
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
@@ -46,6 +48,8 @@ interface DebugSurface {
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null;
getMapFog(): MapFogSnapshot;
getMapMode(): WrapMode | null;
}
declare global {
+197
View File
@@ -0,0 +1,197 @@
// Phase 29 unit coverage for the visible-hyperspace overlay's
// layered overpaint logic. `fogPaintOps` lives in `src/map/render.ts`
// next to its sole consumer (`RendererHandle.setVisibilityFog`) —
// the renderer dispatches each op onto its own Pixi `Graphics`
// inside a `fogLayer` container that sits below every primitive
// copy. The natural rendering order paints fog underneath the
// world, replacing the earlier `cut()` implementation that
// produced disconnected arc segments.
//
// Coordinates returned by `fogPaintOps` are in world space because
// `fogLayer` has no transform — wraps for torus mode are baked
// into the ops directly.
import { describe, expect, test } from "vitest";
import { FOG_COLOR, fogPaintOps } from "../src/map/render";
const BG_COLOR = 0x0a0e1a;
const WORLD = { width: 1000, height: 800 };
describe("fogPaintOps — no-wrap mode", () => {
test("empty input returns no ops", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
});
test("single circle emits a single fog rect + one bg circle", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
);
expect(ops).toEqual([
{
kind: "fillRect",
x: 0,
y: 0,
width: 1000,
height: 800,
color: FOG_COLOR,
alpha: 1,
},
{
kind: "fillCircle",
x: 100,
y: 200,
radius: 50,
color: BG_COLOR,
alpha: 1,
},
]);
});
test("multiple circles produce one fog rect followed by N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 300, y: 200, radius: 80 },
{ x: 500, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"no-wrap",
);
expect(ops.length).toBe(4);
expect(ops[0].kind).toBe("fillRect");
for (let i = 1; i < ops.length; i++) {
expect(ops[i].kind).toBe("fillCircle");
const op = ops[i];
if (op.kind === "fillCircle") {
expect(op.color).toBe(BG_COLOR);
expect(op.alpha).toBe(1);
}
}
});
test("zero or negative world dimensions return no ops", () => {
expect(
fogPaintOps(
{ width: 0, height: 800 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
),
).toEqual([]);
expect(
fogPaintOps(
{ width: 1000, height: -1 },
[{ x: 0, y: 0, radius: 10 }],
FOG_COLOR,
BG_COLOR,
"no-wrap",
),
).toEqual([]);
});
});
describe("fogPaintOps — torus mode", () => {
test("single circle expands to 9 fog rects + 9 bg circles in world space", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 9 fog rects + 9 wrapped circles.
expect(ops.length).toBe(18);
// The first 9 ops are fog rects, one per neighbour tile.
const rectPositions = ops
.slice(0, 9)
.map((op) =>
op.kind === "fillRect" ? `${op.x},${op.y}` : "non-rect",
)
.sort();
const expectedRectPositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedRectPositions.push(`${dx * 1000},${dy * 800}`);
}
}
expectedRectPositions.sort();
expect(rectPositions).toEqual(expectedRectPositions);
// The next 9 ops are bg circles at every wrapped planet position.
const circlePositions = ops
.slice(9)
.map((op) =>
op.kind === "fillCircle" ? `${op.x},${op.y}` : "non-circle",
)
.sort();
const expectedCirclePositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedCirclePositions.push(
`${100 + dx * 1000},${200 + dy * 800}`,
);
}
}
expectedCirclePositions.sort();
expect(circlePositions).toEqual(expectedCirclePositions);
});
test("multiple circles produce 9 fog rects + 9N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
{ x: 100, y: 100, radius: 50 },
{ x: 700, y: 600, radius: 30 },
],
FOG_COLOR,
BG_COLOR,
"torus",
);
// 9 fog rects + (9 wraps × 2 circles) = 27 ops.
expect(ops.length).toBe(27);
expect(
ops.slice(0, 9).every((op) => op.kind === "fillRect"),
).toBe(true);
expect(
ops.slice(9).every((op) => op.kind === "fillCircle"),
).toBe(true);
const radii = ops
.slice(9)
.map((op) => (op.kind === "fillCircle" ? op.radius : 0));
expect(radii.filter((r) => r === 50).length).toBe(9);
expect(radii.filter((r) => r === 30).length).toBe(9);
});
test("a circle near the right edge produces a wrapped copy past the seam", () => {
// Planet at (950, 400) with radius 300 — the painted area
// extends to x = 1250 in the central tile. In torus mode the
// renderer also draws wrapped circles at (-50, 400) and
// (1950, 400) so the circle stays continuous across the seam
// instead of appearing as a sector clipped by the neighbour
// tile's fog rectangle.
const ops = fogPaintOps(
WORLD,
[{ x: 950, y: 400, radius: 300 }],
FOG_COLOR,
BG_COLOR,
"torus",
);
const circleXs = ops
.filter((op) => op.kind === "fillCircle")
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
expect(circleXs).toContain(-50);
expect(circleXs).toContain(950);
expect(circleXs).toContain(1950);
});
test("empty input still returns no ops in torus mode", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]);
});
});
+53
View File
@@ -280,3 +280,56 @@ describe("hitTest — empty results and scale", () => {
expect(ids(wFar, "torus", cam05, cursorOver(500, 500, cam05))).toBe(null);
});
});
describe("hitTest — Phase 29 hiddenIds parameter", () => {
const cam = camAt(500, 500);
test("a hidden primitive is skipped entirely", () => {
const w = new World(1000, 1000, [
point(1, 500, 500),
point(2, 500, 500, { priority: -1 }),
]);
// Without filtering, primitive 1 wins (higher priority).
expect(hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus")?.primitive.id)
.toBe(1);
// With 1 hidden, the cursor falls through to primitive 2.
expect(
hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set([1]),
)?.primitive.id,
).toBe(2);
});
test("hiding every match returns null", () => {
const w = new World(1000, 1000, [point(1, 500, 500)]);
expect(
hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set([1]),
),
).toBeNull();
});
test("an empty hidden set is equivalent to omitting the parameter", () => {
const w = new World(1000, 1000, [point(1, 500, 500)]);
const a = hitTest(w, cam, VP, cursorOver(500, 500, cam), "torus");
const b = hitTest(
w,
cam,
VP,
cursorOver(500, 500, cam),
"torus",
new Set(),
);
expect(a?.primitive.id).toBe(1);
expect(b?.primitive.id).toBe(1);
});
});
+20
View File
@@ -12,6 +12,7 @@ import {
distSqPointToSegment,
screenToWorld,
torusShortestDelta,
torusShortestDistance,
worldToScreen,
} from "../src/map/math";
@@ -104,3 +105,22 @@ describe("screenToWorld and worldToScreen", () => {
expect(w1.x - w0.x).toBeCloseTo(1, 12);
});
});
describe("torusShortestDistance", () => {
test("returns plain Euclidean distance when no wrap is shorter", () => {
const d = torusShortestDistance(0, 0, 3, 4, 1000, 1000);
expect(d).toBeCloseTo(5, 12);
});
test("respects torus wrap on both axes", () => {
// Wrap on x: 50 → 950 across the seam is 100 units.
// Wrap on y: 100 → 900 across the seam is 200 units.
// Hypot(100, 200) ≈ 223.606.
const d = torusShortestDistance(50, 100, 950, 900, 1000, 1000);
expect(d).toBeCloseTo(Math.hypot(100, 200), 9);
});
test("zero when both points coincide", () => {
expect(torusShortestDistance(123, 456, 123, 456, 1000, 1000)).toBe(0);
});
});
@@ -0,0 +1,123 @@
// Phase 29 component coverage for `lib/active-view/map-toggles.svelte`.
// The popover is a thin view of the `GameStateStore` runes —
// every control fires `setMapToggle` / `setWrapMode` on the store
// and reads the current state through `store.mapToggles` /
// `store.wrapMode`. The tests assert the wiring, the default
// rendering, and the popover lifecycle (open / Escape close).
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { i18n } from "../src/lib/i18n/index.svelte";
import MapTogglesControl from "../src/lib/active-view/map-toggles.svelte";
import {
DEFAULT_MAP_TOGGLES,
GameStateStore,
} from "../src/lib/game-state.svelte";
function buildStore(): GameStateStore {
const store = new GameStateStore();
store.status = "ready";
store.wrapMode = "torus";
store.mapToggles = { ...DEFAULT_MAP_TOGGLES };
return store;
}
beforeEach(() => {
i18n.resetForTests("en");
});
describe("MapTogglesControl", () => {
test("trigger renders and the popover is closed by default", () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
const trigger = ui.getByTestId("map-toggles-trigger");
expect(trigger).toBeInTheDocument();
expect(trigger).toHaveAttribute("aria-expanded", "false");
// The 44+ px touch-target is enforced through CSS; jsdom does
// not parse scoped Svelte styles for `getComputedStyle`, so the
// dimension is verified in the Playwright e2e where real
// browsers compute the rule.
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
});
test("clicking the trigger opens the popover with defaults applied", async () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
const surface = ui.getByTestId("map-toggles-surface");
expect(surface).toBeInTheDocument();
expect(ui.getByTestId("map-toggles-hyperspace-groups")).toBeChecked();
expect(ui.getByTestId("map-toggles-incoming-groups")).toBeChecked();
expect(ui.getByTestId("map-toggles-unidentified-groups")).toBeChecked();
expect(ui.getByTestId("map-toggles-cargo-routes")).toBeChecked();
expect(ui.getByTestId("map-toggles-battle-markers")).toBeChecked();
expect(ui.getByTestId("map-toggles-bombing-markers")).toBeChecked();
expect(ui.getByTestId("map-toggles-foreign-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked();
});
test("flipping a checkbox calls setMapToggle with the new value", async () => {
const store = buildStore();
const setMapToggle = vi
.spyOn(store, "setMapToggle")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-battle-markers"));
expect(setMapToggle).toHaveBeenCalledWith("battleMarkers", false);
await fireEvent.click(ui.getByTestId("map-toggles-foreign-planets"));
expect(setMapToggle).toHaveBeenCalledWith("foreignPlanets", false);
});
test("battle and bombing toggles are independent", async () => {
const store = buildStore();
const setMapToggle = vi
.spyOn(store, "setMapToggle")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-battle-markers"));
expect(setMapToggle).toHaveBeenCalledTimes(1);
expect(setMapToggle).toHaveBeenCalledWith("battleMarkers", false);
// No spillover into bombingMarkers.
expect(setMapToggle).not.toHaveBeenCalledWith("bombingMarkers", false);
});
test("selecting the no-wrap radio calls setWrapMode", async () => {
const store = buildStore();
const setWrapMode = vi
.spyOn(store, "setWrapMode")
.mockResolvedValue(undefined);
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
await fireEvent.click(ui.getByTestId("map-toggles-wrap-no-wrap"));
expect(setWrapMode).toHaveBeenCalledWith("no-wrap");
});
test("Escape closes the popover", async () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
expect(ui.getByTestId("map-toggles-surface")).toBeInTheDocument();
await fireEvent.keyDown(document, { key: "Escape" });
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
});
test("clicking outside the popover closes it", async () => {
const store = buildStore();
const ui = render(MapTogglesControl, { props: { store } });
await fireEvent.click(ui.getByTestId("map-toggles-trigger"));
expect(ui.getByTestId("map-toggles-surface")).toBeInTheDocument();
// Synthetic outside click — fire on document with the trigger
// removed from the click target chain.
await fireEvent.click(document.body);
expect(ui.queryByTestId("map-toggles-surface")).toBeNull();
});
});
+213
View File
@@ -0,0 +1,213 @@
// Phase 29 persistence + new-turn reset coverage for the
// `GameStateStore.mapToggles` rune. The tests drive the store
// against a real `fake-indexeddb`-backed Cache (the same harness
// `game-state.test.ts` uses) so the JSON-blob round-trip and the
// stale-`lastResetTurn` branch are exercised end-to-end.
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
import {
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { Builder } from "flatbuffers";
import {
DEFAULT_MAP_TOGGLES,
GameStateStore,
type MapToggles,
} from "../src/lib/game-state.svelte";
import type { GalaxyClient } from "../src/api/galaxy-client";
import type { Cache } from "../src/platform/store/index";
import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { IDBPDatabase } from "idb";
import { Report } from "../src/proto/galaxy/fbs/report";
const listMyGamesSpy = vi.fn();
vi.mock("../src/api/lobby", async () => {
const actual = await vi.importActual<typeof import("../src/api/lobby")>(
"../src/api/lobby",
);
return {
...actual,
listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
};
});
let db: IDBPDatabase<GalaxyDB>;
let dbName: string;
let cache: Cache;
beforeEach(async () => {
dbName = `galaxy-map-toggles-test-${crypto.randomUUID()}`;
db = await openGalaxyDB(dbName);
cache = new IDBCache(db);
listMyGamesSpy.mockReset();
});
afterEach(async () => {
db.close();
await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
});
});
const GAME_ID = "11111111-2222-3333-4444-555555555555";
function makeGameSummary(currentTurn: number) {
return {
gameId: GAME_ID,
gameName: "Test Game",
gameType: "private",
status: "running",
ownerUserId: "owner-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
currentTurn,
};
}
function buildReportPayload(turn: number): Uint8Array {
const builder = new Builder(64);
Report.startReport(builder);
Report.addTurn(builder, BigInt(turn));
Report.addWidth(builder, 4000);
Report.addHeight(builder, 4000);
Report.addPlanetCount(builder, 0);
builder.finish(Report.endReport(builder));
return builder.asUint8Array();
}
function makeFakeClient(turn: number): GalaxyClient {
return {
executeCommand: async () => ({
resultCode: "ok",
payloadBytes: buildReportPayload(turn),
}),
} as unknown as GalaxyClient;
}
describe("GameStateStore.mapToggles persistence", () => {
test("defaults apply when no blob is persisted", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
const store = new GameStateStore();
await store.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES);
store.dispose();
});
test("setMapToggle round-trips through Cache across instances", async () => {
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
const a = new GameStateStore();
await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
await a.setMapToggle("hyperspaceGroups", false);
await a.setMapToggle("battleMarkers", false);
await a.setMapToggle("visibleHyperspace", false);
a.dispose();
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
const b = new GameStateStore();
await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
expect(b.mapToggles.hyperspaceGroups).toBe(false);
expect(b.mapToggles.battleMarkers).toBe(false);
expect(b.mapToggles.visibleHyperspace).toBe(false);
// Untouched flags retain defaults.
expect(b.mapToggles.bombingMarkers).toBe(true);
b.dispose();
});
test("missing fields in a persisted blob fall back to defaults", async () => {
// Simulate an older client persisting a partial blob — only
// `hyperspaceGroups` is set, every other field must inherit
// the current default.
await cache.put("game-map-toggles", GAME_ID, {
toggles: { hyperspaceGroups: false } as Partial<MapToggles>,
lastResetTurn: 5,
});
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
const store = new GameStateStore();
await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID });
expect(store.mapToggles.hyperspaceGroups).toBe(false);
expect(store.mapToggles.battleMarkers).toBe(true);
expect(store.mapToggles.bombingMarkers).toBe(true);
expect(store.mapToggles.visibleHyperspace).toBe(true);
store.dispose();
});
});
describe("GameStateStore.mapToggles new-turn reset", () => {
test("a server turn newer than lastResetTurn resets every flag", async () => {
await cache.put("game-map-toggles", GAME_ID, {
toggles: {
...DEFAULT_MAP_TOGGLES,
hyperspaceGroups: false,
battleMarkers: false,
visibleHyperspace: false,
},
lastResetTurn: 4,
});
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
const store = new GameStateStore();
await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID });
expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES);
// The reset write back to cache so a subsequent reload sees the
// fresh state.
const persisted = await cache.get<{
toggles: MapToggles;
lastResetTurn: number;
}>("game-map-toggles", GAME_ID);
expect(persisted?.toggles).toEqual(DEFAULT_MAP_TOGGLES);
expect(persisted?.lastResetTurn).toBe(5);
store.dispose();
});
test("matching lastResetTurn restores persisted overrides", async () => {
await cache.put("game-map-toggles", GAME_ID, {
toggles: { ...DEFAULT_MAP_TOGGLES, hyperspaceGroups: false },
lastResetTurn: 5,
});
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
const store = new GameStateStore();
await store.init({ client: makeFakeClient(5), cache, gameId: GAME_ID });
expect(store.mapToggles.hyperspaceGroups).toBe(false);
store.dispose();
});
test("advanceToPending resets toggles after jumping onto the new turn", async () => {
await cache.put("game-prefs", `${GAME_ID}/last-viewed-turn`, 4);
await cache.put("game-map-toggles", GAME_ID, {
toggles: DEFAULT_MAP_TOGGLES,
lastResetTurn: 4,
});
// First setGame opens the user on turn 4 with currentTurn=5
// (last-viewed-turn bookmark < currentTurn). The new-turn
// reset path fires immediately because lastResetTurn=4 < 5.
listMyGamesSpy.mockResolvedValue([makeGameSummary(5)]);
const store = new GameStateStore();
await store.init({ client: makeFakeClient(4), cache, gameId: GAME_ID });
// Drift the toggles after the setGame reset so we can verify
// that advanceToPending resets them again on the user's
// explicit jump onto turn 5.
await store.setMapToggle("battleMarkers", false);
expect(store.mapToggles.battleMarkers).toBe(false);
expect(store.pendingTurn).toBe(5);
// User clicks "Return to current turn" — the store fetches the
// turn-5 report and resets toggles.
await store.advanceToPending();
expect(store.mapToggles).toEqual(DEFAULT_MAP_TOGGLES);
expect(store.pendingTurn).toBeNull();
store.dispose();
});
});
@@ -0,0 +1,311 @@
// Phase 29 coverage for the categories + planetDependents maps that
// `reportToWorld` now returns. The map view consumes both to feed
// `RendererHandle.setHiddenPrimitiveIds`: categories drive the
// per-category toggle, planetDependents drive the cascade (planet
// hidden → markers + in-space + incoming groups flying to it hide
// together).
import { describe, expect, test } from "vitest";
import type {
GameReport,
ReportBattle,
ReportBombing,
ReportIncomingShipGroup,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportUnidentifiedShipGroup,
} from "../src/api/game-state";
import { BATTLE_MARKER_ID_PREFIX, BOMBING_MARKER_ID_PREFIX } from "../src/map/battle-markers";
import { SHIP_GROUP_ID_OFFSETS } from "../src/map/ship-groups";
import { reportToWorld } from "../src/map/state-binding";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
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 makeLocalShipGroup(
overrides: Partial<ReportLocalShipGroup>,
): ReportLocalShipGroup {
return {
id: "00000000-0000-0000-0000-000000000000",
count: 1,
class: "Scout",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 0 },
cargo: "NONE",
load: 0,
destination: 0,
origin: null,
range: null,
speed: 1,
mass: 0,
state: "InOrbit",
fleet: null,
...overrides,
};
}
function makeOtherShipGroup(
overrides: Partial<ReportOtherShipGroup>,
): ReportOtherShipGroup {
return {
count: 1,
class: "Cruiser",
tech: { drive: 1, weapons: 0, shields: 0, cargo: 0 },
cargo: "NONE",
load: 0,
destination: 0,
origin: null,
range: null,
speed: 1,
mass: 0,
...overrides,
};
}
function makeIncoming(
overrides: Partial<ReportIncomingShipGroup>,
): ReportIncomingShipGroup {
return {
origin: 0,
destination: 0,
distance: 0,
speed: 1,
mass: 1,
...overrides,
};
}
function makeUnidentified(
overrides: Partial<ReportUnidentifiedShipGroup>,
): ReportUnidentifiedShipGroup {
return { x: 0, y: 0, ...overrides };
}
function makeBattle(overrides: Partial<ReportBattle>): ReportBattle {
return {
id: "battle",
planet: 0,
shots: 1,
...overrides,
};
}
function makeBombing(overrides: Partial<ReportBombing>): ReportBombing {
return {
planetNumber: 0,
planet: "",
owner: "",
attacker: "",
production: "",
industry: 0,
population: 0,
colonists: 0,
industryStockpile: 0,
materialsStockpile: 0,
attackPower: 0,
wiped: false,
...overrides,
};
}
describe("reportToWorld — categories", () => {
test("planet primitives carry their kind-flavoured category", () => {
const { categories } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
makePlanet({ number: 3, kind: "uninhabited", x: 300, y: 300 }),
makePlanet({ number: 4, kind: "unidentified", x: 400, y: 400 }),
],
}),
);
expect(categories.get(1)).toBe("planet-local");
expect(categories.get(2)).toBe("planet-foreign");
expect(categories.get(3)).toBe("planet-uninhabited");
expect(categories.get(4)).toBe("planet-unidentified");
});
test("ship-group sub-builder tags every primitive id", () => {
const localGroupPrimId = SHIP_GROUP_ID_OFFSETS.local + 0;
const localLineId = SHIP_GROUP_ID_OFFSETS.localLine + 0;
const otherId = SHIP_GROUP_ID_OFFSETS.other + 0;
const incomingPointId = SHIP_GROUP_ID_OFFSETS.incoming + 0;
const incomingLineId = SHIP_GROUP_ID_OFFSETS.incomingLine + 0;
const unidentifiedId = SHIP_GROUP_ID_OFFSETS.unidentified + 0;
const { categories } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
localShipGroups: [
makeLocalShipGroup({ origin: 1, range: 10, destination: 2 }),
],
otherShipGroups: [
makeOtherShipGroup({ origin: 1, range: 10, destination: 2 }),
],
incomingShipGroups: [
makeIncoming({ origin: 1, destination: 2, distance: 5 }),
],
unidentifiedShipGroups: [makeUnidentified({ x: 500, y: 500 })],
}),
);
expect(categories.get(localGroupPrimId)).toBe("hyperspaceGroup");
expect(categories.get(localLineId)).toBe("hyperspaceGroup");
expect(categories.get(otherId)).toBe("hyperspaceGroup");
expect(categories.get(incomingPointId)).toBe("incomingGroup");
expect(categories.get(incomingLineId)).toBe("incomingGroup");
expect(categories.get(unidentifiedId)).toBe("unidentifiedGroup");
});
test("battle markers and bombing markers each carry their own category", () => {
const { categories } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
battles: [makeBattle({ id: "b1", planet: 2 })],
bombings: [makeBombing({ planetNumber: 2 })],
}),
);
// Battle marker emits two LinePrims at `BATTLE_MARKER_ID_PREFIX | (i << 4) | (A|B)`.
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
expect(categories.get(battleA)).toBe("battleMarker");
expect(categories.get(battleB)).toBe("battleMarker");
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
expect(categories.get(bombingId)).toBe("bombingMarker");
});
});
describe("reportToWorld — planetDependents", () => {
test("every planet seeds its own dependents entry with its own id", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 7, kind: "other", x: 200, y: 200 }),
],
}),
);
expect(planetDependents.get(1)?.has(1)).toBe(true);
expect(planetDependents.get(7)?.has(7)).toBe(true);
});
test("battle / bombing markers cascade onto their anchor planet", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
battles: [makeBattle({ planet: 2 })],
bombings: [makeBombing({ planetNumber: 2 })],
}),
);
const battleA = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 0;
const battleB = BATTLE_MARKER_ID_PREFIX | (0 << 4) | 1;
const bombingId = BOMBING_MARKER_ID_PREFIX | 0;
const deps = planetDependents.get(2) ?? new Set();
expect(deps.has(2)).toBe(true);
expect(deps.has(battleA)).toBe(true);
expect(deps.has(battleB)).toBe(true);
expect(deps.has(bombingId)).toBe(true);
});
test("in-space groups cascade onto their destination planet", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
localShipGroups: [
makeLocalShipGroup({ origin: 1, range: 10, destination: 2 }),
],
otherShipGroups: [
makeOtherShipGroup({ origin: 1, range: 10, destination: 2 }),
],
}),
);
const localPointId = SHIP_GROUP_ID_OFFSETS.local + 0;
const localLineId = SHIP_GROUP_ID_OFFSETS.localLine + 0;
const otherId = SHIP_GROUP_ID_OFFSETS.other + 0;
const deps = planetDependents.get(2) ?? new Set();
expect(deps.has(localPointId)).toBe(true);
expect(deps.has(localLineId)).toBe(true);
expect(deps.has(otherId)).toBe(true);
});
test("incoming groups cascade onto their destination planet", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 200 }),
],
incomingShipGroups: [
makeIncoming({ origin: 1, destination: 2, distance: 5 }),
],
}),
);
const incomingPointId = SHIP_GROUP_ID_OFFSETS.incoming + 0;
const incomingLineId = SHIP_GROUP_ID_OFFSETS.incomingLine + 0;
const deps = planetDependents.get(2) ?? new Set();
expect(deps.has(incomingPointId)).toBe(true);
expect(deps.has(incomingLineId)).toBe(true);
});
test("unidentified groups do not contribute to any planet's dependents", () => {
const { planetDependents } = reportToWorld(
makeReport({
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
unidentifiedShipGroups: [makeUnidentified({ x: 500, y: 500 })],
}),
);
// Only the local planet seeds its own entry; no other entries.
expect(planetDependents.size).toBe(1);
expect(planetDependents.get(1)?.size).toBe(1);
});
});
@@ -0,0 +1,314 @@
// Phase 29 pure helpers in `src/map/visibility.ts`. The tests exercise
// `computeHiddenPlanetNumbers`, `computeHiddenIds`, `computeFogCircles`,
// and `isCategoryVisible` directly so the map view can stay a thin
// wiring layer.
import { describe, expect, test } from "vitest";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { DEFAULT_MAP_TOGGLES, type MapToggles } from "../src/lib/game-state.svelte";
import type { MapCategory } from "../src/map/state-binding";
import {
FLIGHT_DISTANCE_PER_DRIVE,
VISIBILITY_DISTANCE_PER_DRIVE,
computeFogCircles,
computeHiddenIds,
computeHiddenPlanetNumbers,
fingerprintHiddenPlanets,
isCategoryVisible,
} from "../src/map/visibility";
import type { PrimitiveID } from "../src/map/world";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
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 toggles(overrides: Partial<MapToggles> = {}): MapToggles {
return { ...DEFAULT_MAP_TOGGLES, ...overrides };
}
describe("isCategoryVisible", () => {
test("local planets are always visible regardless of toggles", () => {
expect(
isCategoryVisible("planet-local", toggles({ foreignPlanets: false })),
).toBe(true);
});
test("each kind toggle controls its planet category", () => {
const t = toggles({
foreignPlanets: false,
uninhabitedPlanets: false,
unidentifiedPlanets: false,
});
expect(isCategoryVisible("planet-foreign", t)).toBe(false);
expect(isCategoryVisible("planet-uninhabited", t)).toBe(false);
expect(isCategoryVisible("planet-unidentified", t)).toBe(false);
});
test("battle and bombing markers have independent toggles", () => {
const t = toggles({ battleMarkers: false, bombingMarkers: true });
expect(isCategoryVisible("battleMarker", t)).toBe(false);
expect(isCategoryVisible("bombingMarker", t)).toBe(true);
});
});
describe("computeHiddenPlanetNumbers", () => {
test("returns an empty set when defaults are in effect", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
],
});
expect(computeHiddenPlanetNumbers(report, toggles())).toEqual(new Set());
});
test("kind-toggle off hides every planet of that kind", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
makePlanet({ number: 3, kind: "uninhabited", x: 300, y: 100 }),
makePlanet({ number: 4, kind: "unidentified", x: 400, y: 100 }),
],
});
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ foreignPlanets: false, unidentifiedPlanets: false }),
);
expect(hidden).toEqual(new Set([2, 4]));
});
test("unreachablePlanets=off hides planets beyond FlightDistance", () => {
const report = makeReport({
localPlayerDrive: 10,
mapWidth: 4000,
mapHeight: 4000,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
// Foreign within reach: distance ≈ 100 < 400.
makePlanet({ number: 2, kind: "other", x: 200, y: 100 }),
// Foreign beyond reach: distance ≈ 500 > 400.
makePlanet({ number: 3, kind: "other", x: 600, y: 100 }),
],
});
const reachLimit = 10 * FLIGHT_DISTANCE_PER_DRIVE;
expect(reachLimit).toBe(400);
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ unreachablePlanets: false }),
);
expect(hidden).toEqual(new Set([3]));
});
test("torus wrap shortens reach distance across the seam", () => {
const report = makeReport({
localPlayerDrive: 10,
mapWidth: 1000,
mapHeight: 1000,
planets: [
makePlanet({ number: 1, kind: "local", x: 50, y: 500 }),
// Wrap distance is 100 (50 → 950 via the left seam), well
// inside the 400-unit reach. Without the torus metric this
// would resolve to 900 and the planet would hide.
makePlanet({ number: 2, kind: "other", x: 950, y: 500 }),
],
});
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ unreachablePlanets: false }),
);
expect(hidden).toEqual(new Set());
});
test("localPlayerDrive=0 hides every non-local planet when reach filter is on", () => {
const report = makeReport({
localPlayerDrive: 0,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "other", x: 101, y: 100 }),
],
});
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ unreachablePlanets: false }),
);
expect(hidden).toEqual(new Set([2]));
});
test("a report with no LOCAL planets keeps everything visible (no reach anchor)", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [makePlanet({ number: 9, kind: "other", x: 9000, y: 9000 })],
});
const hidden = computeHiddenPlanetNumbers(
report,
toggles({ unreachablePlanets: false }),
);
expect(hidden).toEqual(new Set());
});
test("LOCAL planets are never hidden", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [makePlanet({ number: 1, kind: "local", x: 1, y: 1 })],
});
expect(
computeHiddenPlanetNumbers(
report,
toggles({ foreignPlanets: false, unreachablePlanets: false }),
),
).toEqual(new Set());
});
});
describe("computeHiddenIds", () => {
const categories: Map<PrimitiveID, MapCategory> = new Map<
PrimitiveID,
MapCategory
>([
[1, "planet-local"],
[2, "planet-foreign"],
[100, "hyperspaceGroup"],
[150, "hyperspaceGroup"],
[200, "incomingGroup"],
[300, "battleMarker"],
[400, "bombingMarker"],
]);
const planetDependents = new Map<number, ReadonlySet<PrimitiveID>>([
[1, new Set([1])],
[2, new Set([2, 100, 150, 200, 300, 400])],
]);
test("category-toggle off hides every primitive in that category", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
new Set(),
toggles({ hyperspaceGroups: false }),
);
expect(hidden.has(100)).toBe(true);
expect(hidden.has(150)).toBe(true);
expect(hidden.has(200)).toBe(false);
expect(hidden.has(2)).toBe(false);
});
test("hiding a planet cascades onto its dependent primitives", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
new Set([2]),
toggles(),
);
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
});
test("battle / bombing markers have independent toggles", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
new Set(),
toggles({ battleMarkers: false }),
);
expect(hidden.has(300)).toBe(true);
expect(hidden.has(400)).toBe(false);
});
test("planet cascade and category toggle compose without duplicates", () => {
const hidden = computeHiddenIds(
categories,
planetDependents,
new Set([2]),
toggles({ battleMarkers: false }),
);
// 300 is already present from the cascade; the category toggle
// re-adds it but Set semantics dedupe.
expect(hidden).toEqual(new Set([2, 100, 150, 200, 300, 400]));
});
});
describe("computeFogCircles", () => {
test("disabled toggle returns an empty list", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
});
expect(
computeFogCircles(report, toggles({ visibleHyperspace: false })),
).toEqual([]);
});
test("zero drive returns an empty list (radius would be zero)", () => {
const report = makeReport({
localPlayerDrive: 0,
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
});
expect(computeFogCircles(report, toggles())).toEqual([]);
});
test("emits one circle per LOCAL planet at VisibilityDistance", () => {
const report = makeReport({
localPlayerDrive: 10,
planets: [
makePlanet({ number: 1, kind: "local", x: 100, y: 100 }),
makePlanet({ number: 2, kind: "local", x: 300, y: 200 }),
makePlanet({ number: 3, kind: "other", x: 500, y: 500 }),
],
});
const radius = 10 * VISIBILITY_DISTANCE_PER_DRIVE;
expect(radius).toBe(300);
expect(computeFogCircles(report, toggles())).toEqual([
{ x: 100, y: 100, radius },
{ x: 300, y: 200, radius },
]);
});
});
describe("fingerprintHiddenPlanets", () => {
test("sorts numerically for deterministic fingerprint", () => {
expect(fingerprintHiddenPlanets(new Set([3, 1, 2]))).toBe("1,2,3");
});
test("empty set returns an empty string", () => {
expect(fingerprintHiddenPlanets(new Set())).toBe("");
});
});