diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 4ef3e42..116c0fc 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -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).
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index 220ba37..6b2d9ab 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -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).
diff --git a/game/internal/model/game/race.go b/game/internal/model/game/race.go
index 49fd88d..d0cacc8 100644
--- a/game/internal/model/game/race.go
+++ b/game/internal/model/game/race.go
@@ -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 {
diff --git a/pkg/calc/race.go b/pkg/calc/race.go
index 8d515e1..b4df131 100644
--- a/pkg/calc/race.go
+++ b/pkg/calc/race.go
@@ -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
}
diff --git a/ui/PLAN.md b/ui/PLAN.md
index d9ffb60..a42f28a 100644
--- a/ui/PLAN.md
+++ b/ui/PLAN.md
@@ -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
diff --git a/ui/docs/game-state.md b/ui/docs/game-state.md
index 5cb6f53..5a1cf93 100644
--- a/ui/docs/game-state.md
+++ b/ui/docs/game-state.md
@@ -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
diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md
index e042f28..fca2463 100644
--- a/ui/docs/renderer.md
+++ b/ui/docs/renderer.md
@@ -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
diff --git a/ui/docs/storage.md b/ui/docs/storage.md
index 334c0a7..89396a9 100644
--- a/ui/docs/storage.md
+++ b/ui/docs/storage.md
@@ -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
diff --git a/ui/frontend/src/lib/active-view/map-toggles.svelte b/ui/frontend/src/lib/active-view/map-toggles.svelte
new file mode 100644
index 0000000..f56d098
--- /dev/null
+++ b/ui/frontend/src/lib/active-view/map-toggles.svelte
@@ -0,0 +1,328 @@
+
+
+
+
+
+ {#if open}
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte
index 1ed4ece..914415d 100644
--- a/ui/frontend/src/lib/active-view/map.svelte
+++ b/ui/frontend/src/lib/active-view/map.svelte
@@ -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(GAME_STATE_CONTEXT_KEY);
const renderedReport = getContext(
@@ -92,6 +107,26 @@ preference the store already manages.
let handle: RendererHandle | null = null;
let hitLookup = new Map();
+ // 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 = new Map();
+ let currentPlanetDependents: ReadonlyMap<
+ number,
+ ReadonlySet
+ > = 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,
+ draftCommands: readonly OrderCommand[],
+ draftStatuses: Readonly>,
+ toggles: MapToggles,
+ hiddenPlanetNumbers: ReadonlySet,
+ ): 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,
+ toggles: MapToggles,
+ hiddenPlanetNumbers: ReadonlySet,
+ ): 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,
mode: "torus" | "no-wrap",
- routesFingerprint: string,
+ toggles: MapToggles,
+ hiddenPlanetNumbers: ReadonlySet,
+ extrasFingerprint: string,
+ draftCommands: readonly OrderCommand[],
+ draftStatuses: Readonly>,
): Promise {
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,
mode: "torus" | "no-wrap",
- routesFingerprint: string,
): Promise {
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}
>
+ {#if store !== undefined && store.status === "ready"}
+
+ {/if}
diff --git a/ui/frontend/src/lib/debug-surface.svelte.ts b/ui/frontend/src/lib/debug-surface.svelte.ts
index 29e3b25..41b4afb 100644
--- a/ui/frontend/src/lib/debug-surface.svelte.ts
+++ b/ui/frontend/src/lib/debug-surface.svelte.ts
@@ -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;
+ }
};
}
diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts
index ecf3b62..3a7b4ae 100644
--- a/ui/frontend/src/lib/game-state.svelte.ts
+++ b/ui/frontend/src/lib/game-state.svelte.ts
@@ -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/` 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(
+ key: K,
+ value: MapToggles[K],
+ ): Promise {
+ this.mapToggles[key] = value;
+ if (this.cache !== null) {
+ await writeMapToggles(
+ this.cache,
+ this.gameId,
+ this.mapToggles,
+ this.lastResetTurn,
+ );
+ }
+ }
+
+ private async resetMapTogglesForTurn(turn: number): Promise {
+ 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 {
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 {
+ const stored = await cache.get>(
+ 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)[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 {
+ await cache.put(MAP_TOGGLES_NAMESPACE, gameId, {
+ toggles: { ...toggles },
+ lastResetTurn,
+ });
+}
+
async function readLastViewedTurn(
cache: Cache,
gameId: string,
diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts
index be0bca3..0934719 100644
--- a/ui/frontend/src/lib/i18n/locales/en.ts
+++ b/ui/frontend/src/lib/i18n/locales/en.ts
@@ -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",
diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts
index 25dee81..20be513 100644
--- a/ui/frontend/src/lib/i18n/locales/ru.ts
+++ b/ui/frontend/src/lib/i18n/locales/ru.ts
@@ -114,6 +114,25 @@ const ru: Record = {
"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": "классы кораблей",
diff --git a/ui/frontend/src/map/battle-markers.ts b/ui/frontend/src/map/battle-markers.ts
index 49ec6f3..cff2432 100644
--- a/ui/frontend/src/map/battle-markers.ts
+++ b/ui/frontend/src/map/battle-markers.ts
@@ -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;
+ categories: Map;
+ /**
+ * 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>;
}
/**
@@ -93,6 +110,16 @@ export function buildBattleAndBombingMarkers(
const primitives: Primitive[] = [];
const lookup = new Map();
+ const categories = new Map();
+ const planetDependents = new Map>();
+ 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 };
}
diff --git a/ui/frontend/src/map/cargo-routes.ts b/ui/frontend/src/map/cargo-routes.ts
index ded3c48..d12e51b 100644
--- a/ui/frontend/src/map/cargo-routes.ts
+++ b/ui/frontend/src/map/cargo-routes.ts
@@ -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 },
+): LinePrim[] {
if (report.routes.length === 0) return [];
+ const skip = opts?.skipPlanets;
const planetById = new Map();
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);
diff --git a/ui/frontend/src/map/hit-test.ts b/ui/frontend/src/map/hit-test.ts
index a49e239..67e5105 100644
--- a/ui/frontend/src/map/hit-test.ts
+++ b/ui/frontend/src/map/hit-test.ts
@@ -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,
): 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;
diff --git a/ui/frontend/src/map/math.ts b/ui/frontend/src/map/math.ts
index 5fa1102..c675df9 100644
--- a/ui/frontend/src/map/math.ts
+++ b/ui/frontend/src/map/math.ts
@@ -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.
diff --git a/ui/frontend/src/map/pending-send-routes.ts b/ui/frontend/src/map/pending-send-routes.ts
index 1c77c79..b1f3fa1 100644
--- a/ui/frontend/src/map/pending-send-routes.ts
+++ b/ui/frontend/src/map/pending-send-routes.ts
@@ -55,8 +55,10 @@ export function buildPendingSendLines(
report: GameReport,
commands: readonly OrderCommand[],
statuses: Readonly>,
+ opts?: { skipPlanets?: ReadonlySet },
): LinePrim[] {
if (commands.length === 0) return [];
+ const skip = opts?.skipPlanets;
const planetById = new Map();
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;
diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts
index 056b687..5320ad8 100644
--- a/ui/frontend/src/map/render.ts
+++ b/ui/frontend/src/map/render.ts
@@ -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): 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 = [
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 = 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 =
+ 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 = [[0, 0]];
+
export async function createRenderer(opts: RendererOptions): Promise {
const theme = opts.theme ?? DARK_THEME;
const preference = opts.preference ?? ["webgpu", "webgl"];
@@ -206,6 +341,17 @@ export async function createRenderer(opts: RendererOptions): Promise();
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 = 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 {
// Drop the previous extras layer.
@@ -629,6 +796,48 @@ export async function createRenderer(opts: RendererOptions): Promise {
+ // 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;
+ categories: Map;
+ /**
+ * 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>;
+}
+
+function addDependent(
+ planetDependents: Map>,
+ 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();
+ const categories = new Map();
+ const planetDependents = new Map>();
const planetIndex = new Map();
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 };
}
/**
diff --git a/ui/frontend/src/map/state-binding.ts b/ui/frontend/src/map/state-binding.ts
index 730869f..5a9e90e 100644
--- a/ui/frontend/src/map/state-binding.ts
+++ b/ui/frontend/src/map/state-binding.ts
@@ -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;
+ /**
+ * 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;
+ /**
+ * 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>;
}
/**
@@ -108,6 +150,8 @@ export interface ReportToWorldResult {
export function reportToWorld(report: GameReport): ReportToWorldResult {
const primitives: Primitive[] = [];
const hitLookup = new Map();
+ const categories = new Map();
+ const planetDependents = new Map>();
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();
+ 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>,
+ from: Map>,
+): 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);
+ }
}
diff --git a/ui/frontend/src/map/visibility.ts b/ui/frontend/src/map/visibility.ts
new file mode 100644
index 0000000..d1fb0aa
--- /dev/null
+++ b/ui/frontend/src/map/visibility.ts
@@ -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 {
+ const hidden = new Set();
+ 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,
+ planetDependents: ReadonlyMap>,
+ hiddenPlanetNumbers: ReadonlySet,
+ toggles: MapToggles,
+): Set {
+ const hidden = new Set();
+ 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,
+): string {
+ if (hiddenPlanetNumbers.size === 0) return "";
+ return Array.from(hiddenPlanetNumbers)
+ .sort((a, b) => a - b)
+ .join(",");
+}
diff --git a/ui/frontend/src/routes/__debug/store/+page.svelte b/ui/frontend/src/routes/__debug/store/+page.svelte
index e9fe678..5cc2262 100644
--- a/ui/frontend/src/routes/__debug/store/+page.svelte
+++ b/ui/frontend/src/routes/__debug/store/+page.svelte
@@ -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;
diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts
new file mode 100644
index 0000000..c4ea0f3
--- /dev/null
+++ b/ui/frontend/tests/e2e/map-toggles.spec.ts
@@ -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 {
+ 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(() => {});
+ },
+ );
+}
+
+async function bootSession(page: Page): Promise {
+ 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 {
+ 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 {
+ 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 {
+ // 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);
+});
diff --git a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts
index 7175d9d..e7fce43 100644
--- a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts
+++ b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts
@@ -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 {
diff --git a/ui/frontend/tests/fog-paint-ops.test.ts b/ui/frontend/tests/fog-paint-ops.test.ts
new file mode 100644
index 0000000..3363f70
--- /dev/null
+++ b/ui/frontend/tests/fog-paint-ops.test.ts
@@ -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([]);
+ });
+});
diff --git a/ui/frontend/tests/map-hit-test.test.ts b/ui/frontend/tests/map-hit-test.test.ts
index 46770aa..e106d57 100644
--- a/ui/frontend/tests/map-hit-test.test.ts
+++ b/ui/frontend/tests/map-hit-test.test.ts
@@ -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);
+ });
+});
diff --git a/ui/frontend/tests/map-math.test.ts b/ui/frontend/tests/map-math.test.ts
index 620239d..31b200f 100644
--- a/ui/frontend/tests/map-math.test.ts
+++ b/ui/frontend/tests/map-math.test.ts
@@ -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);
+ });
+});
diff --git a/ui/frontend/tests/map-toggles-component.test.ts b/ui/frontend/tests/map-toggles-component.test.ts
new file mode 100644
index 0000000..600dd97
--- /dev/null
+++ b/ui/frontend/tests/map-toggles-component.test.ts
@@ -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();
+ });
+});
diff --git a/ui/frontend/tests/map-toggles-state.test.ts b/ui/frontend/tests/map-toggles-state.test.ts
new file mode 100644
index 0000000..b5b21d9
--- /dev/null
+++ b/ui/frontend/tests/map-toggles-state.test.ts
@@ -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(
+ "../src/api/lobby",
+ );
+ return {
+ ...actual,
+ listMyGames: (...args: unknown[]) => listMyGamesSpy(...args),
+ };
+});
+
+let db: IDBPDatabase;
+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((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,
+ 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();
+ });
+});
diff --git a/ui/frontend/tests/state-binding-cascade.test.ts b/ui/frontend/tests/state-binding-cascade.test.ts
new file mode 100644
index 0000000..f7d6859
--- /dev/null
+++ b/ui/frontend/tests/state-binding-cascade.test.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ return {
+ origin: 0,
+ destination: 0,
+ distance: 0,
+ speed: 1,
+ mass: 1,
+ ...overrides,
+ };
+}
+
+function makeUnidentified(
+ overrides: Partial,
+): ReportUnidentifiedShipGroup {
+ return { x: 0, y: 0, ...overrides };
+}
+
+function makeBattle(overrides: Partial): ReportBattle {
+ return {
+ id: "battle",
+ planet: 0,
+ shots: 1,
+ ...overrides,
+ };
+}
+
+function makeBombing(overrides: Partial): 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);
+ });
+});
diff --git a/ui/frontend/tests/visibility-helpers.test.ts b/ui/frontend/tests/visibility-helpers.test.ts
new file mode 100644
index 0000000..322327a
--- /dev/null
+++ b/ui/frontend/tests/visibility-helpers.test.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 = new Map<
+ PrimitiveID,
+ MapCategory
+ >([
+ [1, "planet-local"],
+ [2, "planet-foreign"],
+ [100, "hyperspaceGroup"],
+ [150, "hyperspaceGroup"],
+ [200, "incomingGroup"],
+ [300, "battleMarker"],
+ [400, "bombingMarker"],
+ ]);
+ const planetDependents = new Map>([
+ [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("");
+ });
+});