From 2bd1b54936ef7ec47669c5f00ea779f5074ef45b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 21:33:53 +0200 Subject: [PATCH 1/8] feat(ui): Phase 29 map visibility toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the gear-icon popover on the map view with per-game persistence of every category toggle plus the wrap-mode radio. Hide-by-id and visibility-fog facilities land on the renderer so every flip applies within one frame without a Pixi remount; the wrap-mode toggle keeps its existing remount + camera-preserve path. A new server-side turn force-resets every flag to defaults so a hidden category never makes the player miss the next turn's news. Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go (plus the single Go caller); the TS side keeps duplicating the formula until a race-level WASM bridge lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/FUNCTIONAL.md | 44 +- docs/FUNCTIONAL_ru.md | 46 +- game/internal/model/game/race.go | 2 +- pkg/calc/race.go | 2 +- ui/PLAN.md | 105 ++++- ui/docs/game-state.md | 38 ++ ui/docs/renderer.md | 66 ++- ui/docs/storage.md | 25 +- .../src/lib/active-view/map-toggles.svelte | 320 ++++++++++++++ ui/frontend/src/lib/active-view/map.svelte | 174 +++++++- ui/frontend/src/lib/debug-surface.svelte.ts | 45 ++ ui/frontend/src/lib/game-state.svelte.ts | 198 +++++++++ ui/frontend/src/lib/i18n/locales/en.ts | 19 + ui/frontend/src/lib/i18n/locales/ru.ts | 19 + ui/frontend/src/map/battle-markers.ts | 35 +- ui/frontend/src/map/cargo-routes.ts | 15 +- ui/frontend/src/map/hit-test.ts | 9 + ui/frontend/src/map/math.ts | 21 + ui/frontend/src/map/pending-send-routes.ts | 4 + ui/frontend/src/map/render.ts | 117 +++++ ui/frontend/src/map/ship-groups.ts | 54 ++- ui/frontend/src/map/state-binding.ts | 98 ++++- ui/frontend/src/map/visibility.ts | 210 +++++++++ .../src/routes/__debug/store/+page.svelte | 6 + ui/frontend/tests/e2e/map-toggles.spec.ts | 401 ++++++++++++++++++ .../e2e/storage-keypair-persistence.spec.ts | 2 + ui/frontend/tests/map-hit-test.test.ts | 53 +++ ui/frontend/tests/map-math.test.ts | 20 + .../tests/map-toggles-component.test.ts | 123 ++++++ ui/frontend/tests/map-toggles-state.test.ts | 213 ++++++++++ .../tests/state-binding-cascade.test.ts | 311 ++++++++++++++ ui/frontend/tests/visibility-helpers.test.ts | 314 ++++++++++++++ 32 files changed, 3046 insertions(+), 63 deletions(-) create mode 100644 ui/frontend/src/lib/active-view/map-toggles.svelte create mode 100644 ui/frontend/src/map/visibility.ts create mode 100644 ui/frontend/tests/e2e/map-toggles.spec.ts create mode 100644 ui/frontend/tests/map-toggles-component.test.ts create mode 100644 ui/frontend/tests/map-toggles-state.test.ts create mode 100644 ui/frontend/tests/state-binding-cascade.test.ts create mode 100644 ui/frontend/tests/visibility-helpers.test.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 4ef3e42..370fae0 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -786,7 +786,49 @@ 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** — visibility fog toggle (slightly lighter overlay + outside the union of `VisibilityDistance(localPlayerDrive)` + circles around LOCAL planets; LOCAL planets are always + exempt) 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..30b40cb 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -806,7 +806,51 @@ 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..81ca4f3 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -3167,28 +3167,44 @@ 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** — visibility-fog checkbox + torus / no-wrap radios. +- `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 +3221,58 @@ 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. **Visibility fog overlay**. A separate `visibilityFog` 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 (Pixi `Graphics.cut()`), 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..decb78c 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 `visibilityFog` (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..ef62950 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -269,25 +269,79 @@ 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. + +## Visibility fog + +`RendererHandle.setVisibilityFog(circles)` draws (or removes) the +Phase 29 fog overlay. 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 fills the world rectangle with `FOG_COLOR` (two shades + lighter than the dark theme background) and "cuts" every + circle out of it via Pixi v8's `Graphics.cut()` path operator, + so overlapping circles compose into a union hole (no + even-odd-fill quirks). 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 +fog 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 visibility-fog input + (the list of circles last passed to `setVisibilityFog`). + Empty when the fog 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..67158e6 --- /dev/null +++ b/ui/frontend/src/lib/active-view/map-toggles.svelte @@ -0,0 +1,320 @@ + + + +
+ + {#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..801daed 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,15 @@ preference the store already manages. import { installRendererDebugSurface, registerMapCameraProvider, + registerMapFogProvider, 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 +106,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 +168,23 @@ 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; + + // 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 +194,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); @@ -160,12 +211,24 @@ preference the store already manages. handle !== null && handle.getMode() === mode; if (sameSnapshot) { + // 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 +242,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 +355,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 +386,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 +459,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 +502,25 @@ preference the store already manages. }, } satisfies MapCameraSnapshot; }); + const detachFog = registerMapFogProvider(() => ({ + circles: currentFogCircles.map((c) => ({ ...c })), + }) satisfies MapFogSnapshot); detachDebugProviders = (): void => { detachPrim(); detachPick(); detachCamera(); + detachFog(); }; 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 +640,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..5cc9b23 100644 --- a/ui/frontend/src/lib/debug-surface.svelte.ts +++ b/ui/frontend/src/lib/debug-surface.svelte.ts @@ -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,12 @@ export interface MapPickStateSnapshot { type PrimitivesProvider = () => readonly MapPrimitiveSnapshot[]; type PickStateProvider = () => MapPickStateSnapshot; type CameraProvider = () => MapCameraSnapshot | null; +type FogProvider = () => MapFogSnapshot; let primitivesProvider: PrimitivesProvider | null = null; let pickStateProvider: PickStateProvider | null = null; let cameraProvider: CameraProvider | null = null; +let fogProvider: FogProvider | null = null; /** * registerMapPrimitivesProvider attaches a provider that yields the @@ -101,6 +122,18 @@ 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; + }; +} + const EMPTY_PICK_STATE: MapPickStateSnapshot = { active: false, sourcePlanetNumber: null, @@ -126,11 +159,19 @@ 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: [] }; +} + interface RendererDebugWindow { __galaxyDebug?: { getMapPrimitives?: () => readonly MapPrimitiveSnapshot[]; getMapPickState?: () => MapPickStateSnapshot; getMapCamera?: () => MapCameraSnapshot | null; + getMapFog?: () => MapFogSnapshot; [key: string]: unknown; }; } @@ -153,6 +194,7 @@ export function installRendererDebugSurface(): () => void { getMapPrimitives, getMapPickState, getMapCamera, + getMapFog, }; win.__galaxyDebug = surface; return (): void => { @@ -170,5 +212,8 @@ export function installRendererDebugSurface(): () => void { if (current.getMapCamera === getMapCamera) { delete current.getMapCamera; } + if (current.getMapFog === getMapFog) { + delete current.getMapFog; + } }; } diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index ecf3b62..5c12eb1 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -31,6 +31,54 @@ 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; + visibilityFog: 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, + visibilityFog: 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 +101,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 +166,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 +215,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 +226,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 +310,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 +390,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 +455,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 +567,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..8e4e78a 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.visibility_fog": "visibility fog", + "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..4ea6d5c 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.visibility_fog": "туман видимости", + "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..3a6b4d0 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,18 @@ 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. +const FOG_COLOR = 0x12162a; + export async function createRenderer(opts: RendererOptions): Promise { const theme = opts.theme ?? DARK_THEME; const preference = opts.preference ?? ["webgpu", "webgl"]; @@ -225,6 +267,22 @@ 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; + // Per-copy fog Graphics for the Phase 29 visibility fog overlay. + // Created lazily when `setVisibilityFog` first receives a + // non-empty list; cleared (and destroyed) when the list goes + // empty again. Each fog Graphics is inserted at index 0 of its + // torus copy so primitives paint on top. + let fogGraphics: Graphics[] = []; + 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 +297,11 @@ export async function createRenderer(opts: RendererOptions): Promise { // Drop the previous extras layer. @@ -629,6 +694,49 @@ 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) => { + if (circles.length === 0) { + for (const g of fogGraphics) { + g.parent?.removeChild(g); + g.destroy(); + } + fogGraphics = []; + return; + } + // Recreate the fog Graphics on every call. Pixi v8's + // `Graphics.clear()` exists but reusing the same instance + // with multiple `.cut()` operations across calls can + // accumulate stale path state in our experience; a fresh + // Graphics keeps the contract simple. + for (const g of fogGraphics) { + g.parent?.removeChild(g); + g.destroy(); + } + fogGraphics = []; + for (const copy of copies) { + const g = new Graphics(); + g.rect(0, 0, opts.world.width, opts.world.height); + g.fill({ color: FOG_COLOR, alpha: 1 }); + for (const c of circles) { + g.circle(c.x, c.y, c.radius); + g.cut(); + } + // Fog sits below every primitive on the same copy so + // planet glyphs paint on top. `addChildAt(g, 0)` keeps + // the rest of the children's order intact. + copy.addChildAt(g, 0); + fogGraphics.push(g); + } + }, resize: (w, h) => { app.renderer.resize(w, h); viewport.resize(w, h, opts.world.width, opts.world.height); @@ -651,6 +759,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..b03cf7f --- /dev/null +++ b/ui/frontend/src/map/visibility.ts @@ -0,0 +1,210 @@ +// 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 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.visibilityFog) 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..0534432 100644 --- a/ui/frontend/src/routes/__debug/store/+page.svelte +++ b/ui/frontend/src/routes/__debug/store/+page.svelte @@ -9,9 +9,11 @@ import type { OrderCommand } from "../../../sync/order-types"; import { getMapCamera, + getMapFog, getMapPickState, getMapPrimitives, type MapCameraSnapshot, + type MapFogSnapshot, type MapPickStateSnapshot, type MapPrimitiveSnapshot, } from "../../../lib/debug-surface.svelte"; @@ -39,6 +41,7 @@ getMapPrimitives(): readonly MapPrimitiveSnapshot[]; getMapPickState(): MapPickStateSnapshot; getMapCamera(): MapCameraSnapshot | null; + getMapFog(): MapFogSnapshot; } type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface }; @@ -136,6 +139,9 @@ getMapCamera() { return getMapCamera(); }, + getMapFog() { + return getMapFog(); + }, }; (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..e851a9e --- /dev/null +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -0,0 +1,401 @@ +// 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); + // every other category uses a high-bit prefix. + return prims + .filter((p) => p.visible && p.id < 1_000_000) + .map((p) => p.id) + .sort((a, b) => a - b); + }); +} + +async function visibleCount( + page: Page, + predicate: (id: number) => boolean, +): Promise { + return await page.evaluate((pred: string) => { + const fn = new Function("id", `return (${pred})(id);`) as ( + id: number, + ) => boolean; + const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; + return prims.filter((p) => p.visible && fn(p.id)).length; + }, predicate.toString()); +} + +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]); + // Two battle marker line primitives (high-bit prefix 0xa0000000). + expect( + await visibleCount( + page, + (id) => (id & 0xf0000000) === 0xa0000000, + ), + ).toBe(2); + // One bombing ring (prefix 0xc0000000). + expect( + await visibleCount( + page, + (id) => (id & 0xf0000000) === 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 foreign planet (id 3) is gone — and its battle / bombing + // markers cascaded with it. + expect(await visiblePlanets(page)).toEqual([1, 2, 4, 5]); + expect( + await visibleCount( + page, + (id) => (id & 0xf0000000) === 0xa0000000, + ), + ).toBe(0); + expect( + await visibleCount( + page, + (id) => (id & 0xf0000000) === 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-visibility-fog").click(); + + const offFog = await page.evaluate( + () => window.__galaxyDebug!.getMapFog!().circles, + ); + expect(offFog).toEqual([]); + + // Toggling back on rebuilds the fog circles for the same planets. + await page.getByTestId("map-toggles-visibility-fog").click(); + const onAgain = await page.evaluate( + () => window.__galaxyDebug!.getMapFog!().circles, + ); + expect(onAgain.length).toBe(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); + + 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(); + + // Mount path is async (Pixi re-init takes a frame). Wait for the + // camera reading to settle into the new mount and assert the + // centre is within 1 px of the pre-toggle value. + await page.waitForFunction(() => { + const c = window.__galaxyDebug?.getMapCamera?.(); + return c !== null && c !== undefined && c.camera.centerX !== undefined; + }); + 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 visibleCount( + page, + (id) => (id & 0xf0000000) === 0xa0000000, + ), + ).toBe(0); + expect( + await visibleCount( + page, + (id) => (id & 0xf0000000) === 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..938afca 100644 --- a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts +++ b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts @@ -15,6 +15,7 @@ interface DebugSnapshot { import type { MapCameraSnapshot, + MapFogSnapshot, MapPickStateSnapshot, MapPrimitiveSnapshot, } from "../../src/lib/debug-surface.svelte"; @@ -46,6 +47,7 @@ interface DebugSurface { getMapPrimitives(): readonly MapPrimitiveSnapshot[]; getMapPickState(): MapPickStateSnapshot; getMapCamera(): MapCameraSnapshot | null; + getMapFog(): MapFogSnapshot; } declare global { 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..0cf06f9 --- /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-visibility-fog")).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..541a656 --- /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("visibilityFog", 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.visibilityFog).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.visibilityFog).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, + visibilityFog: 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..1c80fc1 --- /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({ visibilityFog: 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(""); + }); +}); From 2528d63b51df7b8f2118d331a393763c9d8fdcb6 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 22:02:15 +0200 Subject: [PATCH 2/8] fix(ui-e2e): Phase 29 map-toggles spec passes across all four projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent bugs in `tests/e2e/map-toggles.spec.ts` made the fresh-Phase-29 suite red on CI #216: 1. `visiblePlanets` filtered on `p.id < 1_000_000`, which JS interprets in signed space — high-bit-prefix primitives (cargo route 0x80…, battle 0xa0…, bombing 0xc0…) are stored as negative Numbers and leaked into the planet list. Filter switched to a `0 < id < 1e7` window that matches the engine planet-number range exactly. 2. The `visibleHighBitCount` helper now ToUint32-converts the id before masking so the bitmask comparison works regardless of whether the id is stored as positive or negative. 3. The fog and wrap-mode tests read the renderer state synchronously after the click — the Svelte effect re-runs asynchronously, so the tests saw stale state. Both now `waitForFunction` on the canonical "settled" signal: empty fog circles for the fog flip, and a new `getMapMode()` debug accessor for the wrap-mode remount. Renderer side: registers a `MapModeProvider` next to the existing camera / fog providers and exposes `getMapMode()` through the debug surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/lib/active-view/map.svelte | 5 + ui/frontend/src/lib/debug-surface.svelte.ts | 32 ++++- .../src/routes/__debug/store/+page.svelte | 6 + ui/frontend/tests/e2e/map-toggles.spec.ts | 130 +++++++++--------- .../e2e/storage-keypair-persistence.spec.ts | 2 + 5 files changed, 108 insertions(+), 67 deletions(-) diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 801daed..9e211b4 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -69,6 +69,7 @@ preference the store already manages. installRendererDebugSurface, registerMapCameraProvider, registerMapFogProvider, + registerMapModeProvider, registerMapPickStateProvider, registerMapPrimitivesProvider, type MapCameraSnapshot, @@ -505,11 +506,15 @@ preference the store already manages. 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; diff --git a/ui/frontend/src/lib/debug-surface.svelte.ts b/ui/frontend/src/lib/debug-surface.svelte.ts index 5cc9b23..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 @@ -73,11 +73,13 @@ 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 @@ -134,6 +136,22 @@ export function registerMapFogProvider(provider: FogProvider): () => void { }; } +/** + * 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, @@ -166,12 +184,20 @@ 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; }; } @@ -195,6 +221,7 @@ export function installRendererDebugSurface(): () => void { getMapPickState, getMapCamera, getMapFog, + getMapMode, }; win.__galaxyDebug = surface; return (): void => { @@ -215,5 +242,8 @@ export function installRendererDebugSurface(): () => void { if (current.getMapFog === getMapFog) { delete current.getMapFog; } + if (current.getMapMode === getMapMode) { + delete current.getMapMode; + } }; } diff --git a/ui/frontend/src/routes/__debug/store/+page.svelte b/ui/frontend/src/routes/__debug/store/+page.svelte index 0534432..5cc2262 100644 --- a/ui/frontend/src/routes/__debug/store/+page.svelte +++ b/ui/frontend/src/routes/__debug/store/+page.svelte @@ -10,6 +10,7 @@ import { getMapCamera, getMapFog, + getMapMode, getMapPickState, getMapPrimitives, type MapCameraSnapshot, @@ -17,6 +18,7 @@ type MapPickStateSnapshot, type MapPrimitiveSnapshot, } from "../../../lib/debug-surface.svelte"; + import type { WrapMode } from "../../../map/world"; interface DebugSnapshot { publicKey: number[]; @@ -42,6 +44,7 @@ getMapPickState(): MapPickStateSnapshot; getMapCamera(): MapCameraSnapshot | null; getMapFog(): MapFogSnapshot; + getMapMode(): WrapMode | null; } type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface }; @@ -142,6 +145,9 @@ 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 index e851a9e..857ec6a 100644 --- a/ui/frontend/tests/e2e/map-toggles.spec.ts +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -221,26 +221,33 @@ interface PrimitiveLite { 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); - // every other category uses a high-bit prefix. + // 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 < 1_000_000) + .filter((p) => p.visible && p.id > 0 && p.id < 10_000_000) .map((p) => p.id) .sort((a, b) => a - b); }); } -async function visibleCount( +async function visibleHighBitCount( page: Page, - predicate: (id: number) => boolean, + prefix: number, ): Promise { - return await page.evaluate((pred: string) => { - const fn = new Function("id", `return (${pred})(id);`) as ( - id: number, - ) => boolean; + // Convert ids to uint32 before masking so the comparison works + // for ids stored as signed-negative numbers (JS bitwise ops force + // ToInt32). `prefix >>> 0` keeps the literal in uint32 space too. + return await page.evaluate((p: number) => { const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[]; - return prims.filter((p) => p.visible && fn(p.id)).length; - }, predicate.toString()); + return prims.filter( + (prim) => + prim.visible && ((prim.id >>> 0) & 0xf0000000) === (p >>> 0), + ).length; + }, prefix); } test("gear popover toggles a planet kind off and cascades onto its markers", async ({ @@ -253,40 +260,30 @@ test("gear popover toggles a planet kind off and cascades onto its markers", asy // 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]); - // Two battle marker line primitives (high-bit prefix 0xa0000000). - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xa0000000, - ), - ).toBe(2); - // One bombing ring (prefix 0xc0000000). - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xc0000000, - ), - ).toBe(1); + 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 foreign planet (id 3) is gone — and its battle / bombing - // markers cascaded with it. + // 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 visibleCount( - page, - (id) => (id & 0xf0000000) === 0xa0000000, - ), - ).toBe(0); - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xc0000000, - ), - ).toBe(0); + 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 ({ @@ -308,17 +305,17 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-visibility-fog").click(); - const offFog = await page.evaluate( - () => window.__galaxyDebug!.getMapFog!().circles, + // 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, ); - expect(offFog).toEqual([]); // Toggling back on rebuilds the fog circles for the same planets. await page.getByTestId("map-toggles-visibility-fog").click(); - const onAgain = await page.evaluate( - () => window.__galaxyDebug!.getMapFog!().circles, + await page.waitForFunction( + () => window.__galaxyDebug!.getMapFog!().circles.length === 2, ); - expect(onAgain.length).toBe(2); }); test("wrap mode radios flip the renderer and the camera centre survives", async ({ @@ -328,6 +325,10 @@ test("wrap mode radios flip the renderer and the camera centre survives", async 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!(), ); @@ -337,19 +338,26 @@ test("wrap mode radios flip the renderer and the camera centre survives", async await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-wrap-no-wrap").click(); - // Mount path is async (Pixi re-init takes a frame). Wait for the - // camera reading to settle into the new mount and assert the - // centre is within 1 px of the pre-toggle value. - await page.waitForFunction(() => { - const c = window.__galaxyDebug?.getMapCamera?.(); - return c !== null && c !== undefined && c.camera.centerX !== undefined; - }); + // `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); + 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 }) => { @@ -386,16 +394,6 @@ test("toggle state persists across a page reload", async ({ page }) => { await page.getByTestId("map-toggles-bombing-markers").isChecked(), ).toBe(false); // Battle X-cross and bombing ring are hidden in the renderer. - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xa0000000, - ), - ).toBe(0); - expect( - await visibleCount( - page, - (id) => (id & 0xf0000000) === 0xc0000000, - ), - ).toBe(0); + 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 938afca..e7fce43 100644 --- a/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts +++ b/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts @@ -19,6 +19,7 @@ import type { 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`, @@ -48,6 +49,7 @@ interface DebugSurface { getMapPickState(): MapPickStateSnapshot; getMapCamera(): MapCameraSnapshot | null; getMapFog(): MapFogSnapshot; + getMapMode(): WrapMode | null; } declare global { From 7c46aa4becee79c8e0a3b7a053c3647cddc5bcbb Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 22:23:15 +0200 Subject: [PATCH 3/8] fix(ui-e2e): tighten Phase 29 effect tracking + radio wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run #217 surfaced three independent bugs that survived the first fixup pass: 1. `visibleHighBitCount` masked the id with `(prim.id >>> 0) & 0xf…`, but JS bitwise AND always returns a signed int32 — the mask had to be re-converted with `>>> 0` AFTER the AND, not before. Result was always 0 on the previous run, masking the next two bugs by making the persistence test's high-bit-count assertions a tautology. 2. `applyVisibilityState` was wrapped in `untrack`, so the `toggles.X` reads inside `computeHiddenIds` / `computeFogCircles` never landed in the effect's dependency set — toggling fog or any marker / group / kind flag did not re-run the effect, so the renderer never received the new hide / fog input. Explicit `void toggles.X` reads now live at the top of the effect so every key is tracked synchronously. 3. The wrap-mode radios fired on `onchange`, which Svelte 5 suppresses on a re-activation of an already-checked input — the Playwright `.click()` flake on the second wrap test reflected the missed event. Switched to `onclick` and short-circuited when the target mode is already active. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/active-view/map-toggles.svelte | 12 ++++++++++-- ui/frontend/src/lib/active-view/map.svelte | 18 ++++++++++++++++++ ui/frontend/tests/e2e/map-toggles.spec.ts | 11 +++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/ui/frontend/src/lib/active-view/map-toggles.svelte b/ui/frontend/src/lib/active-view/map-toggles.svelte index 67158e6..3898d0a 100644 --- a/ui/frontend/src/lib/active-view/map-toggles.svelte +++ b/ui/frontend/src/lib/active-view/map-toggles.svelte @@ -34,7 +34,15 @@ bottom-tabs bar. void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]); } + /** + * setWrap is wired to the radios' `onclick`, not `onchange`, so the + * Playwright `.click()` action on the input fires the callback even + * when the input is already checked (the `change` event suppresses + * the second activation, which made the wrap-mode e2e flake). + * `onclick` also fires reliably on touch / pointer activation. + */ function setWrap(mode: WrapMode): void { + if (store.wrapMode === mode) return; void store.setWrapMode(mode); } @@ -192,7 +200,7 @@ bottom-tabs bar. data-testid="map-toggles-wrap-torus" value="torus" checked={store.wrapMode === "torus"} - onchange={() => setWrap("torus")} + onclick={() => setWrap("torus")} /> {i18n.t("game.map.toggles.wrap.torus")} @@ -203,7 +211,7 @@ bottom-tabs bar. data-testid="map-toggles-wrap-no-wrap" value="no-wrap" checked={store.wrapMode === "no-wrap"} - onchange={() => setWrap("no-wrap")} + onclick={() => setWrap("no-wrap")} /> {i18n.t("game.map.toggles.wrap.no_wrap")} diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 9e211b4..e0de5b2 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -177,6 +177,24 @@ preference the store already manages. if (!mounted || canvasEl === null || containerEl === null) 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.visibilityFog; + // 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 diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts index 857ec6a..090125b 100644 --- a/ui/frontend/tests/e2e/map-toggles.spec.ts +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -238,14 +238,17 @@ async function visibleHighBitCount( page: Page, prefix: number, ): Promise { - // Convert ids to uint32 before masking so the comparison works - // for ids stored as signed-negative numbers (JS bitwise ops force - // ToInt32). `prefix >>> 0` keeps the literal in uint32 space too. + // 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 >>> 0) & 0xf0000000) === (p >>> 0), + prim.visible && ((prim.id & 0xf0000000) >>> 0) === expected, ).length; }, prefix); } From 2f4dc01d543766b5dd6c8733437de4d9550a2007 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 22:33:38 +0200 Subject: [PATCH 4/8] fix(ui-map): apply wrap-mode flips in place instead of remounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous logic re-mounted the renderer whenever `store.wrapMode` flipped, because the `sameSnapshot` gate included `handle.getMode() === mode`. Pixi 8 does not reliably re-initialise an `Application` on the same canvas — the symptom showed up as the chromium tab silently closing during the Phase 29 wrap-mode e2e ("Target page, context or browser has been closed"). The renderer already exposes an in-place `setMode` that swaps the wrap-clamp / torus-copy visibility synchronously while preserving the camera; the playground-map.spec.ts wrap toggle has been driving it for several phases without issue. Drop mode from the snapshot gate and route the change through `handle.setMode(mode)` instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/lib/active-view/map.svelte | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index e0de5b2..316864f 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -227,9 +227,18 @@ 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, From 37580b7699f4906c93eb9c9d82c42b53b39640f1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 23:39:39 +0200 Subject: [PATCH 5/8] fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 29 fog overlay rendered as a handful of random arc segments instead of a clean union of holes around LOCAL planets — Pixi v8's `Graphics.cut()` does not reliably subtract multiple overlapping circles from a base path. Replaced the cut-based approach with a layered overpaint: a fog-tinted rectangle fills the world, then opaque background- coloured circles are painted on top for every visibility circle. The natural rendering order unions overlapping circles for free — no geometry, no `cut()` quirks, one extra fill per circle. Renamed the toggle from `visibilityFog` to `visibleHyperspace` across the store, i18n strings, popover, tests, and docs. The overlay still implements the visual "fog" effect at the renderer level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is named after the player-facing concept it controls — the portion of the map that is visible (intelligence/scan coverage) — rather than the obscured part. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/FUNCTIONAL.md | 12 ++++---- docs/FUNCTIONAL_ru.md | 7 +++-- ui/PLAN.md | 17 +++++++---- ui/docs/game-state.md | 2 +- ui/docs/renderer.md | 29 +++++++++++-------- .../src/lib/active-view/map-toggles.svelte | 8 ++--- ui/frontend/src/lib/active-view/map.svelte | 2 +- ui/frontend/src/lib/game-state.svelte.ts | 11 +++++-- ui/frontend/src/lib/i18n/locales/en.ts | 2 +- ui/frontend/src/lib/i18n/locales/ru.ts | 2 +- ui/frontend/src/map/render.ts | 28 +++++++++--------- ui/frontend/src/map/visibility.ts | 15 +++++----- ui/frontend/tests/e2e/map-toggles.spec.ts | 4 +-- .../tests/map-toggles-component.test.ts | 2 +- ui/frontend/tests/map-toggles-state.test.ts | 8 ++--- ui/frontend/tests/visibility-helpers.test.ts | 2 +- 16 files changed, 86 insertions(+), 65 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 370fae0..116c0fc 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -802,11 +802,13 @@ every change applies within one frame (no Pixi remount): off, hides every non-LOCAL planet that sits beyond `FlightDistance(localPlayerDrive)` of every LOCAL planet (torus-aware metric). -- **View** — visibility fog toggle (slightly lighter overlay - outside the union of `VisibilityDistance(localPlayerDrive)` - circles around LOCAL planets; LOCAL planets are always - exempt) plus the torus / no-wrap radio that switches the - renderer mode while preserving the camera centre. +- **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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 30b40cb..6b2d9ab 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -823,10 +823,11 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий- не-LOCAL планету, отстоящую дальше `FlightDistance(localPlayerDrive)` от любой LOCAL-планеты (метрика учитывает торическую развёртку). -- **Вид** — переключатель тумана видимости (чуть более светлая - заливка вне объединения окружностей +- **Вид** — переключатель «видимое гиперпространство» (чуть + более светлая заливка вне объединения окружностей `VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет; - LOCAL-планеты всегда вне фильтра) плюс радиогруппа + LOCAL-планеты всегда вне фильтра — тоггл назван по видимой + области карты, а не по затемнённой) плюс радиогруппа «торус / без переноса», переключающая режим рендерера с сохранением центра камеры. diff --git a/ui/PLAN.md b/ui/PLAN.md index 81ca4f3..a42f28a 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -3189,7 +3189,10 @@ Artifacts: toggles plus a `unreachablePlanets` switch that, when off, hides planets beyond `FlightDistance(localPlayerDrive)` of every LOCAL planet (torus-aware). - - **View** — visibility-fog checkbox + torus / no-wrap radios. + - **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 @@ -3245,11 +3248,13 @@ Decisions: 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. **Visibility fog overlay**. A separate `visibilityFog` 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 (Pixi `Graphics.cut()`), not a - primitive — it never participates in hit-test. +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 diff --git a/ui/docs/game-state.md b/ui/docs/game-state.md index decb78c..5a1cf93 100644 --- a/ui/docs/game-state.md +++ b/ui/docs/game-state.md @@ -121,7 +121,7 @@ with the new mode. 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 `visibilityFog` (the fog overlay on by default). The +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. diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md index ef62950..fca2463 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -292,20 +292,25 @@ 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. -## Visibility fog +## Visible-hyperspace overlay (the "fog") `RendererHandle.setVisibilityFog(circles)` draws (or removes) the -Phase 29 fog overlay. Each entry describes a circle around a -LOCAL planet where the player has scanner / visibility coverage: +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 fills the world rectangle with `FOG_COLOR` (two shades - lighter than the dark theme background) and "cuts" every - circle out of it via Pixi v8's `Graphics.cut()` path operator, - so overlapping circles compose into a union hole (no - even-odd-fill quirks). The fog is inserted at the bottom of - each copy's z-order so primitives paint on top. + 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 @@ -313,7 +318,7 @@ LOCAL planet where the player has scanner / visibility coverage: behaviour because the fog Graphics is a child of each copy. The map view recomputes the fog input only when the report or the -fog toggle changes — per-frame cost stays at zero. +`visibleHyperspace` toggle changes — per-frame cost stays at zero. ## Debug surface @@ -334,9 +339,9 @@ state without scraping pixels: - `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 visibility-fog input +- `getMapFog()` returns the most recent fog input (the list of circles last passed to `setVisibilityFog`). - Empty when the fog toggle is off. + Empty when the `visibleHyperspace` toggle is off. The active map view registers providers on mount via `registerMapPrimitivesProvider` / `registerMapPickStateProvider` diff --git a/ui/frontend/src/lib/active-view/map-toggles.svelte b/ui/frontend/src/lib/active-view/map-toggles.svelte index 3898d0a..f56d098 100644 --- a/ui/frontend/src/lib/active-view/map-toggles.svelte +++ b/ui/frontend/src/lib/active-view/map-toggles.svelte @@ -185,11 +185,11 @@ bottom-tabs bar.
{i18n.t("game.map.toggles.wrap.label")} diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 316864f..914415d 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -193,7 +193,7 @@ preference the store already manages. void toggles.cargoRoutes; void toggles.battleMarkers; void toggles.bombingMarkers; - void toggles.visibilityFog; + void toggles.visibleHyperspace; // Phase 29 visibility derivation. Cargo routes and pending- // Send overlay are extras (no Pixi remount on flip); the diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index 5c12eb1..3a7b4ae 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -57,7 +57,14 @@ export interface MapToggles { cargoRoutes: boolean; battleMarkers: boolean; bombingMarkers: boolean; - visibilityFog: 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 = { @@ -71,7 +78,7 @@ export const DEFAULT_MAP_TOGGLES: MapToggles = { cargoRoutes: true, battleMarkers: true, bombingMarkers: true, - visibilityFog: true, + visibleHyperspace: true, }; interface PersistedMapToggles { diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 8e4e78a..0934719 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -128,7 +128,7 @@ const en = { "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.visibility_fog": "visibility fog", + "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", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index 4ea6d5c..20be513 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -129,7 +129,7 @@ const ru: Record = { "game.map.toggles.uninhabited_planets": "необитаемые планеты", "game.map.toggles.unidentified_planets": "неопознанные планеты", "game.map.toggles.unreachable_planets": "показывать недостижимые планеты", - "game.map.toggles.visibility_fog": "туман видимости", + "game.map.toggles.visible_hyperspace": "видимое гиперпространство", "game.map.toggles.wrap.label": "перенос карты", "game.map.toggles.wrap.torus": "тор", "game.map.toggles.wrap.no_wrap": "без переноса", diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 3a6b4d0..e65e601 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -704,31 +704,31 @@ export async function createRenderer(opts: RendererOptions): Promise hiddenIds.has(id), setVisibilityFog: (circles) => { - if (circles.length === 0) { - for (const g of fogGraphics) { - g.parent?.removeChild(g); - g.destroy(); - } - fogGraphics = []; - return; - } - // Recreate the fog Graphics on every call. Pixi v8's - // `Graphics.clear()` exists but reusing the same instance - // with multiple `.cut()` operations across calls can - // accumulate stale path state in our experience; a fresh - // Graphics keeps the contract simple. + // Drop the old fog Graphics first — every flip rebuilds + // from scratch instead of mutating in place, so the + // implementation stays simple and Pixi-v8-residue-free. for (const g of fogGraphics) { g.parent?.removeChild(g); g.destroy(); } fogGraphics = []; + if (circles.length === 0) return; + // Layered overpaint: a fog-tinted rectangle covers the + // world, then opaque background-coloured circles drawn on + // top reveal the visible-hyperspace area. The natural + // rendering order handles overlapping circles correctly — + // Pixi v8's `Graphics.cut()` produces inconsistent + // results for unions of holes (the previous Phase 29 + // implementation hit this), and the overpaint approach + // avoids the geometry calculation entirely. + const bg = theme.background; for (const copy of copies) { const g = new Graphics(); g.rect(0, 0, opts.world.width, opts.world.height); g.fill({ color: FOG_COLOR, alpha: 1 }); for (const c of circles) { g.circle(c.x, c.y, c.radius); - g.cut(); + g.fill({ color: bg, alpha: 1 }); } // Fog sits below every primitive on the same copy so // planet glyphs paint on top. `addChildAt(g, 0)` keeps diff --git a/ui/frontend/src/map/visibility.ts b/ui/frontend/src/map/visibility.ts index b03cf7f..d1fb0aa 100644 --- a/ui/frontend/src/map/visibility.ts +++ b/ui/frontend/src/map/visibility.ts @@ -171,18 +171,19 @@ export function computeHiddenIds( /** * computeFogCircles produces the visibility-fog input — empty when - * the 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. + * 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.visibilityFog) return []; + if (!toggles.visibleHyperspace) return []; const radius = report.localPlayerDrive * VISIBILITY_DISTANCE_PER_DRIVE; if (radius <= 0) return []; const circles: { x: number; y: number; radius: number }[] = []; diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts index 090125b..c4ea0f3 100644 --- a/ui/frontend/tests/e2e/map-toggles.spec.ts +++ b/ui/frontend/tests/e2e/map-toggles.spec.ts @@ -306,7 +306,7 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o expect(initialFog[1].radius).toBe(300); await page.getByTestId("map-toggles-trigger").click(); - await page.getByTestId("map-toggles-visibility-fog").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. @@ -315,7 +315,7 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o ); // Toggling back on rebuilds the fog circles for the same planets. - await page.getByTestId("map-toggles-visibility-fog").click(); + await page.getByTestId("map-toggles-visible-hyperspace").click(); await page.waitForFunction( () => window.__galaxyDebug!.getMapFog!().circles.length === 2, ); diff --git a/ui/frontend/tests/map-toggles-component.test.ts b/ui/frontend/tests/map-toggles-component.test.ts index 0cf06f9..600dd97 100644 --- a/ui/frontend/tests/map-toggles-component.test.ts +++ b/ui/frontend/tests/map-toggles-component.test.ts @@ -58,7 +58,7 @@ describe("MapTogglesControl", () => { 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-visibility-fog")).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(); }); diff --git a/ui/frontend/tests/map-toggles-state.test.ts b/ui/frontend/tests/map-toggles-state.test.ts index 541a656..b5b21d9 100644 --- a/ui/frontend/tests/map-toggles-state.test.ts +++ b/ui/frontend/tests/map-toggles-state.test.ts @@ -113,7 +113,7 @@ describe("GameStateStore.mapToggles persistence", () => { await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID }); await a.setMapToggle("hyperspaceGroups", false); await a.setMapToggle("battleMarkers", false); - await a.setMapToggle("visibilityFog", false); + await a.setMapToggle("visibleHyperspace", false); a.dispose(); listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]); @@ -121,7 +121,7 @@ describe("GameStateStore.mapToggles persistence", () => { 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.visibilityFog).toBe(false); + expect(b.mapToggles.visibleHyperspace).toBe(false); // Untouched flags retain defaults. expect(b.mapToggles.bombingMarkers).toBe(true); b.dispose(); @@ -141,7 +141,7 @@ describe("GameStateStore.mapToggles persistence", () => { expect(store.mapToggles.hyperspaceGroups).toBe(false); expect(store.mapToggles.battleMarkers).toBe(true); expect(store.mapToggles.bombingMarkers).toBe(true); - expect(store.mapToggles.visibilityFog).toBe(true); + expect(store.mapToggles.visibleHyperspace).toBe(true); store.dispose(); }); }); @@ -153,7 +153,7 @@ describe("GameStateStore.mapToggles new-turn reset", () => { ...DEFAULT_MAP_TOGGLES, hyperspaceGroups: false, battleMarkers: false, - visibilityFog: false, + visibleHyperspace: false, }, lastResetTurn: 4, }); diff --git a/ui/frontend/tests/visibility-helpers.test.ts b/ui/frontend/tests/visibility-helpers.test.ts index 1c80fc1..322327a 100644 --- a/ui/frontend/tests/visibility-helpers.test.ts +++ b/ui/frontend/tests/visibility-helpers.test.ts @@ -273,7 +273,7 @@ describe("computeFogCircles", () => { planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })], }); expect( - computeFogCircles(report, toggles({ visibilityFog: false })), + computeFogCircles(report, toggles({ visibleHyperspace: false })), ).toEqual([]); }); From 7ade838df88a743c3adb74b8663807d4a0e9a965 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 23:42:39 +0200 Subject: [PATCH 6/8] test(ui-map): unit-cover the fog overlay's layered-overpaint contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifted the Phase 29 fog draw sequence out of `setVisibilityFog` into a pure `fogPaintOps` helper that returns an ordered list of fill operations (one fog rect, then one background-coloured circle per visibility entry). The renderer now dispatches each op straight onto a Pixi `Graphics`; the indirection lets the layered- overpaint contract be tested without booting Pixi. `tests/fog-paint-ops.test.ts` covers: empty input → no ops; single circle → fog rect + bg circle in that order; multiple circles → N bg circles after the fog rect; overlapping circles emitted independently (the rendering order unions them); zero / negative world dimensions → no ops. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/map/render.ts | 105 ++++++++++++++++--- ui/frontend/tests/fog-paint-ops.test.ts | 130 ++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 ui/frontend/tests/fog-paint-ops.test.ts diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index e65e601..3c6461b 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -213,7 +213,81 @@ const EMPTY_HIDDEN_IDS: ReadonlySet = new Set(); // 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. -const FOG_COLOR = 0x12162a; +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 on a single torus + * copy. The first op is the fog-coloured rectangle covering the + * full world; subsequent ops are background-coloured circles, one + * per visibility circle, painted on top of the fog rectangle. The + * natural rendering order unions overlapping circles for free — + * earlier iterations relied on Pixi v8's `Graphics.cut()` to + * subtract holes, but `cut()` produced incorrect unions for + * overlapping circles (the symptom was a handful of disconnected + * arc segments instead of a clean union). + * + * 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-emit op set. + */ +export function fogPaintOps( + world: { width: number; height: number }, + circles: ReadonlyArray<{ x: number; y: number; radius: number }>, + fogColor: number, + bgColor: number, +): FogPaintOp[] { + if (circles.length === 0) return []; + if (world.width <= 0 || world.height <= 0) return []; + const ops: FogPaintOp[] = [ + { + kind: "fillRect", + x: 0, + y: 0, + width: world.width, + height: world.height, + color: fogColor, + alpha: 1, + }, + ]; + for (const c of circles) { + ops.push({ + kind: "fillCircle", + x: c.x, + y: c.y, + radius: c.radius, + color: bgColor, + alpha: 1, + }); + } + return ops; +} export async function createRenderer(opts: RendererOptions): Promise { const theme = opts.theme ?? DARK_THEME; @@ -712,23 +786,22 @@ export async function createRenderer(opts: RendererOptions): Promise { + test("empty input returns no ops", () => { + expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR)).toEqual([]); + }); + + test("single circle emits fog rect + one bg circle in that order", () => { + const ops = fogPaintOps( + WORLD, + [{ x: 100, y: 200, radius: 50 }], + FOG_COLOR, + BG_COLOR, + ); + 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, + ); + 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"); + // Background-coloured circles paint on top of the fog rect. + const op = ops[i]; + if (op.kind === "fillCircle") { + expect(op.color).toBe(BG_COLOR); + expect(op.alpha).toBe(1); + } + } + }); + + test("overlapping circles are emitted independently — the rendering order unions them", () => { + // Two overlapping circles around adjacent LOCAL planets — the + // op list keeps both circles. The renderer relies on the + // overpaint to merge them visually; `cut()` (the previous + // implementation) miscomputed the union. + const ops = fogPaintOps( + WORLD, + [ + { x: 200, y: 200, radius: 100 }, + { x: 250, y: 200, radius: 100 }, + ], + FOG_COLOR, + BG_COLOR, + ); + expect(ops.length).toBe(3); + expect(ops[1]).toMatchObject({ x: 200, y: 200, radius: 100 }); + expect(ops[2]).toMatchObject({ x: 250, y: 200, radius: 100 }); + }); + + test("the fog rect always covers the full world rectangle", () => { + const ops = fogPaintOps( + { width: 3200, height: 1600 }, + [{ x: 0, y: 0, radius: 10 }], + FOG_COLOR, + BG_COLOR, + ); + expect(ops[0]).toEqual({ + kind: "fillRect", + x: 0, + y: 0, + width: 3200, + height: 1600, + color: FOG_COLOR, + alpha: 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, + ), + ).toEqual([]); + expect( + fogPaintOps( + { width: 1000, height: -1 }, + [{ x: 0, y: 0, radius: 10 }], + FOG_COLOR, + BG_COLOR, + ), + ).toEqual([]); + }); +}); From 00e84579cae01433e4a1aaa70a624247438e9133 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 20 May 2026 00:04:35 +0200 Subject: [PATCH 7/8] fix(ui-map): split fog overlay into per-shape Graphics + torus-wrap circles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two visible regressions in the in-game map's fog overlay surfaced on dev-deploy: 1. With three LOCAL planets close together, only the last planet glyph stayed visible inside the bg holes — the other two were obscured. The previous implementation stacked the fog rectangle plus every bg circle onto a single `Graphics` via repeated `g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's multi-shape Graphics is supported in theory, but in practice only the last shape's fill seems to land, dropping the earlier bg holes (and the planet glyphs on top look like they vanished along with their hole). Splitting each op onto its own `Graphics` inside a per-copy `Container` removes the ambiguity — one shape, one fill, one render pass. 2. A planet near the right world edge produced a "sector" — the bg circle painted into the area past the seam, but the neighbouring tile's fog rectangle then overpainted that bleed, leaving a quarter-circle hole. In torus mode each visibility circle is now drawn at the nine wrapped positions (`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the neighbour-tile-aligned positions keep the hole continuous across the seam. No-wrap mode keeps a single emission per circle, because wrapped circles would leak into the visible world rectangle as unwanted holes. The `fogPaintOps` helper now takes the wrap mode as a parameter; `tests/fog-paint-ops.test.ts` covers the torus expansion (nine-wrap product per circle, the seam-fix case at x = 950) and re-asserts the no-wrap path. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/map/render.ts | 89 +++++++++++----- ui/frontend/tests/fog-paint-ops.test.ts | 133 ++++++++++++++++-------- 2 files changed, 152 insertions(+), 70 deletions(-) diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 3c6461b..9664faa 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -253,18 +253,35 @@ export type FogPaintOp = * overlapping circles (the symptom was a handful of disconnected * arc segments instead of a clean union). * + * `mode` controls the torus-wrap behaviour: + * + * - `"torus"`: every visibility circle is also drawn at the eight + * wrapped positions (±width, ±height) so the circle remains + * visually continuous when its painted area extends past the + * world rectangle into a neighbouring tile — without the wraps + * the next tile's fog rectangle overpaints the bleed, producing + * a "sector" artifact at the seam. + * - `"no-wrap"`: only the planet's own position is drawn. The + * wrapped positions would create extra holes inside the world + * rectangle when a planet sits near an edge (the user can never + * pan past the boundary in no-wrap mode, but the wrapped circle + * could still leak into the visible area). + * * 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-emit op set. + * 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[] = [ { kind: "fillRect", @@ -277,18 +294,22 @@ export function fogPaintOps( }, ]; for (const c of circles) { - ops.push({ - kind: "fillCircle", - x: c.x, - y: c.y, - radius: c.radius, - color: bgColor, - alpha: 1, - }); + 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"]; @@ -347,12 +368,17 @@ export async function createRenderer(opts: RendererOptions): Promise = EMPTY_HIDDEN_IDS; - // Per-copy fog Graphics for the Phase 29 visibility fog overlay. - // Created lazily when `setVisibilityFog` first receives a - // non-empty list; cleared (and destroyed) when the list goes - // empty again. Each fog Graphics is inserted at index 0 of its - // torus copy so primitives paint on top. - let fogGraphics: Graphics[] = []; + // Per-copy fog Containers for the Phase 29 visibility fog + // overlay. Each container holds one `Graphics` per + // `FogPaintOp` (fog rect + one bg-coloured circle per + // visibility circle × wrap position), inserted at index 0 of + // the torus copy so primitives paint on top. Created lazily + // when `setVisibilityFog` first receives a non-empty list and + // destroyed wholesale on every subsequent call — Pixi v8's + // multi-shape Graphics is supported in theory, but stacking + // each fill on its own Graphics removes any risk of an + // internal-state regression dropping a layer. + let fogGraphics: Container[] = []; const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => { const visible = !hiddenIds.has(id); for (const g of list) g.visible = visible; @@ -778,12 +804,14 @@ export async function createRenderer(opts: RendererOptions): Promise hiddenIds.has(id), setVisibilityFog: (circles) => { - // Drop the old fog Graphics first — every flip rebuilds + // Drop the old fog Containers first — every flip rebuilds // from scratch instead of mutating in place, so the // implementation stays simple and Pixi-v8-residue-free. - for (const g of fogGraphics) { - g.parent?.removeChild(g); - g.destroy(); + // `destroy({children: true})` propagates to every owned + // Graphics inside the Container. + for (const c of fogGraphics) { + c.parent?.removeChild(c); + c.destroy({ children: true }); } fogGraphics = []; const ops = fogPaintOps( @@ -791,23 +819,32 @@ export async function createRenderer(opts: RendererOptions): Promise { @@ -833,12 +870,12 @@ export async function createRenderer(opts: RendererOptions): Promise { +describe("fogPaintOps — no-wrap mode", () => { test("empty input returns no ops", () => { - expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR)).toEqual([]); + expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]); }); test("single circle emits fog rect + one bg circle in that order", () => { @@ -26,6 +28,7 @@ describe("fogPaintOps", () => { [{ x: 100, y: 200, radius: 50 }], FOG_COLOR, BG_COLOR, + "no-wrap", ); expect(ops).toEqual([ { @@ -58,12 +61,12 @@ describe("fogPaintOps", () => { ], 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"); - // Background-coloured circles paint on top of the fog rect. const op = ops[i]; if (op.kind === "fillCircle") { expect(op.color).toBe(BG_COLOR); @@ -72,43 +75,6 @@ describe("fogPaintOps", () => { } }); - test("overlapping circles are emitted independently — the rendering order unions them", () => { - // Two overlapping circles around adjacent LOCAL planets — the - // op list keeps both circles. The renderer relies on the - // overpaint to merge them visually; `cut()` (the previous - // implementation) miscomputed the union. - const ops = fogPaintOps( - WORLD, - [ - { x: 200, y: 200, radius: 100 }, - { x: 250, y: 200, radius: 100 }, - ], - FOG_COLOR, - BG_COLOR, - ); - expect(ops.length).toBe(3); - expect(ops[1]).toMatchObject({ x: 200, y: 200, radius: 100 }); - expect(ops[2]).toMatchObject({ x: 250, y: 200, radius: 100 }); - }); - - test("the fog rect always covers the full world rectangle", () => { - const ops = fogPaintOps( - { width: 3200, height: 1600 }, - [{ x: 0, y: 0, radius: 10 }], - FOG_COLOR, - BG_COLOR, - ); - expect(ops[0]).toEqual({ - kind: "fillRect", - x: 0, - y: 0, - width: 3200, - height: 1600, - color: FOG_COLOR, - alpha: 1, - }); - }); - test("zero or negative world dimensions return no ops", () => { expect( fogPaintOps( @@ -116,6 +82,7 @@ describe("fogPaintOps", () => { [{ x: 0, y: 0, radius: 10 }], FOG_COLOR, BG_COLOR, + "no-wrap", ), ).toEqual([]); expect( @@ -124,7 +91,85 @@ describe("fogPaintOps", () => { [{ x: 0, y: 0, radius: 10 }], FOG_COLOR, BG_COLOR, + "no-wrap", ), ).toEqual([]); }); }); + +describe("fogPaintOps — torus mode", () => { + test("each circle is emitted at nine wrapped positions", () => { + const ops = fogPaintOps( + WORLD, + [{ x: 100, y: 200, radius: 50 }], + FOG_COLOR, + BG_COLOR, + "torus", + ); + // 1 fog rect + 9 wrapped circles. + expect(ops.length).toBe(10); + expect(ops[0].kind).toBe("fillRect"); + const positions = ops + .slice(1) + .map((op) => (op.kind === "fillCircle" ? `${op.x},${op.y}` : "")) + .sort(); + // Every neighbour offset is emitted with width=1000 / height=800. + const expected: string[] = []; + for (const dx of [-1, 0, 1]) { + for (const dy of [-1, 0, 1]) { + expected.push(`${100 + dx * 1000},${200 + dy * 800}`); + } + } + expected.sort(); + expect(positions).toEqual(expected); + }); + + test("multiple circles produce 9 × N wrapped circles after the fog rect", () => { + const ops = fogPaintOps( + WORLD, + [ + { x: 100, y: 100, radius: 50 }, + { x: 700, y: 600, radius: 30 }, + ], + FOG_COLOR, + BG_COLOR, + "torus", + ); + // 1 fog rect + (9 wraps × 2 circles) = 19 ops. + expect(ops.length).toBe(19); + expect(ops[0].kind).toBe("fillRect"); + // Each circle keeps its own radius across every wrap. + const radii = ops + .slice(1) + .map((op) => (op.kind === "fillCircle" ? op.radius : 0)) + .filter((r) => r > 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 a wrapped circle at (950 - 1000, 400) = + // (-50, 400) so the next tile (with its own fog rect) keeps a + // matching unfogged hole at the seam — this is the fix for + // the "sector" artifact at the wrap boundary. + const ops = fogPaintOps( + WORLD, + [{ x: 950, y: 400, radius: 300 }], + FOG_COLOR, + BG_COLOR, + "torus", + ); + const xs = ops + .slice(1) + .map((op) => (op.kind === "fillCircle" ? op.x : 0)); + expect(xs).toContain(-50); + expect(xs).toContain(950); + expect(xs).toContain(1950); + }); + + test("empty input still returns no ops in torus mode", () => { + expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]); + }); +}); From 53b892ae005cdb45de47d5e401a54cd5e8f3700a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 20 May 2026 00:26:06 +0200 Subject: [PATCH 8/8] fix(ui-map): move fog overlay to a viewport-level layer below the copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions surfaced once visible-hyperspace toggled on a real dev-deploy map: 1. On the zero-turn map the bg holes painted ON TOP of the planet glyphs — every LOCAL planet looked like a hollow circle of background colour instead of the planet pixel inside an unfogged area. 2. On a legacy report with a drive tech that pushes the visibility radius well past the world dimensions the bg circles overlapped to cover the entire viewport. Combined with the wrong z-order the result was a uniformly black canvas with every primitive hidden. The per-copy implementation added the fog container via `copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the container at the start of the copy's children. Whether by a Pixi quirk or by some interaction with how `populatePrimitives` orders its `c.addChild(g)` calls, the fog ended up rendering after every primitive in practice — the symptoms above are a perfect match for that ordering. Restructured the fog rendering so the z-order is structural rather than relying on `addChildAt`: - A single `fogLayer: Container` is added to the viewport BEFORE the nine torus copies. Pixi renders viewport children in order, so the layer is guaranteed to paint first; every copy renders on top. - `fogPaintOps` now emits world-space coordinates with wrap offsets baked in (9 fog rects + 9 bg circles per visibility entry in torus mode, 1 + N in no-wrap mode). The renderer populates `fogLayer` with one `Graphics` per op — no per-copy iteration on the fog side. - The previous `fogGraphics: Container[]` closure state is gone. Each `setVisibilityFog` flip drops every child of `fogLayer` and rebuilds it. The dispose path drops the children eagerly before `app.destroy({children: true})` walks the tree. The fog-paint-ops test exercises the new contract: the no-wrap path keeps one rect + N circles, the torus path expands to nine rects + nine wrapped circles per entry (including the seam-fix case at x = 950). Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/src/map/render.ts | 143 +++++++++++------------- ui/frontend/tests/fog-paint-ops.test.ts | 104 ++++++++++------- 2 files changed, 130 insertions(+), 117 deletions(-) diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 9664faa..5320ad8 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -243,29 +243,26 @@ export type FogPaintOp = /** * fogPaintOps returns the ordered sequence of paint operations that - * draw the Phase 29 visible-hyperspace overlay on a single torus - * copy. The first op is the fog-coloured rectangle covering the - * full world; subsequent ops are background-coloured circles, one - * per visibility circle, painted on top of the fog rectangle. The - * natural rendering order unions overlapping circles for free — - * earlier iterations relied on Pixi v8's `Graphics.cut()` to - * subtract holes, but `cut()` produced incorrect unions for - * overlapping circles (the symptom was a handful of disconnected - * arc segments instead of a clean union). + * 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 visibility circle is also drawn at the eight - * wrapped positions (±width, ±height) so the circle remains - * visually continuous when its painted area extends past the - * world rectangle into a neighbouring tile — without the wraps - * the next tile's fog rectangle overpaints the bleed, producing - * a "sector" artifact at the seam. - * - `"no-wrap"`: only the planet's own position is drawn. The - * wrapped positions would create extra holes inside the world - * rectangle when a planet sits near an edge (the user can never - * pan past the boundary in no-wrap mode, but the wrapped circle - * could still leak into the visible area). + * - `"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 @@ -282,17 +279,18 @@ export function fogPaintOps( if (world.width <= 0 || world.height <= 0) return []; const offsets: ReadonlyArray = mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET; - const ops: FogPaintOp[] = [ - { + const ops: FogPaintOp[] = []; + for (const [dx, dy] of offsets) { + ops.push({ kind: "fillRect", - x: 0, - y: 0, + 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({ @@ -343,6 +341,17 @@ export async function createRenderer(opts: RendererOptions): Promise = EMPTY_HIDDEN_IDS; - // Per-copy fog Containers for the Phase 29 visibility fog - // overlay. Each container holds one `Graphics` per - // `FogPaintOp` (fog rect + one bg-coloured circle per - // visibility circle × wrap position), inserted at index 0 of - // the torus copy so primitives paint on top. Created lazily - // when `setVisibilityFog` first receives a non-empty list and - // destroyed wholesale on every subsequent call — Pixi v8's - // multi-shape Graphics is supported in theory, but stacking - // each fill on its own Graphics removes any risk of an - // internal-state regression dropping a layer. - let fogGraphics: Container[] = []; + // `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; @@ -804,16 +806,13 @@ export async function createRenderer(opts: RendererOptions): Promise hiddenIds.has(id), setVisibilityFog: (circles) => { - // Drop the old fog Containers first — every flip rebuilds - // from scratch instead of mutating in place, so the - // implementation stays simple and Pixi-v8-residue-free. - // `destroy({children: true})` propagates to every owned - // Graphics inside the Container. - for (const c of fogGraphics) { - c.parent?.removeChild(c); - c.destroy({ children: true }); + // 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 }); } - fogGraphics = []; const ops = fogPaintOps( opts.world, circles, @@ -822,29 +821,21 @@ export async function createRenderer(opts: RendererOptions): Promise { @@ -869,15 +860,15 @@ export async function createRenderer(opts: RendererOptions): Promise { expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]); }); - test("single circle emits fog rect + one bg circle in that order", () => { + test("single circle emits a single fog rect + one bg circle", () => { const ops = fogPaintOps( WORLD, [{ x: 100, y: 200, radius: 50 }], @@ -98,7 +99,7 @@ describe("fogPaintOps — no-wrap mode", () => { }); describe("fogPaintOps — torus mode", () => { - test("each circle is emitted at nine wrapped positions", () => { + test("single circle expands to 9 fog rects + 9 bg circles in world space", () => { const ops = fogPaintOps( WORLD, [{ x: 100, y: 200, radius: 50 }], @@ -106,25 +107,43 @@ describe("fogPaintOps — torus mode", () => { BG_COLOR, "torus", ); - // 1 fog rect + 9 wrapped circles. - expect(ops.length).toBe(10); - expect(ops[0].kind).toBe("fillRect"); - const positions = ops - .slice(1) - .map((op) => (op.kind === "fillCircle" ? `${op.x},${op.y}` : "")) + // 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(); - // Every neighbour offset is emitted with width=1000 / height=800. - const expected: string[] = []; + const expectedRectPositions: string[] = []; for (const dx of [-1, 0, 1]) { for (const dy of [-1, 0, 1]) { - expected.push(`${100 + dx * 1000},${200 + dy * 800}`); + expectedRectPositions.push(`${dx * 1000},${dy * 800}`); } } - expected.sort(); - expect(positions).toEqual(expected); + 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 × N wrapped circles after the fog rect", () => { + test("multiple circles produce 9 fog rects + 9N bg circles", () => { const ops = fogPaintOps( WORLD, [ @@ -135,14 +154,17 @@ describe("fogPaintOps — torus mode", () => { BG_COLOR, "torus", ); - // 1 fog rect + (9 wraps × 2 circles) = 19 ops. - expect(ops.length).toBe(19); - expect(ops[0].kind).toBe("fillRect"); - // Each circle keeps its own radius across every wrap. + // 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(1) - .map((op) => (op.kind === "fillCircle" ? op.radius : 0)) - .filter((r) => r > 0); + .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); }); @@ -150,10 +172,10 @@ describe("fogPaintOps — torus mode", () => { 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 a wrapped circle at (950 - 1000, 400) = - // (-50, 400) so the next tile (with its own fog rect) keeps a - // matching unfogged hole at the seam — this is the fix for - // the "sector" artifact at the wrap boundary. + // 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 }], @@ -161,12 +183,12 @@ describe("fogPaintOps — torus mode", () => { BG_COLOR, "torus", ); - const xs = ops - .slice(1) + const circleXs = ops + .filter((op) => op.kind === "fillCircle") .map((op) => (op.kind === "fillCircle" ? op.x : 0)); - expect(xs).toContain(-50); - expect(xs).toContain(950); - expect(xs).toContain(1950); + expect(circleXs).toContain(-50); + expect(circleXs).toContain(950); + expect(circleXs).toContain(1950); }); test("empty input still returns no ops in torus mode", () => {