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