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