feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s

Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-19 21:33:53 +02:00
parent 65c0fbb87d
commit 2bd1b54936
32 changed files with 3046 additions and 63 deletions
@@ -0,0 +1,320 @@
<!--
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]);
}
function setWrap(mode: WrapMode): void {
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-visibility-fog"
checked={store.mapToggles.visibilityFog}
onchange={(e) => setFlag("visibilityFog", e)}
/>
<span>{i18n.t("game.map.toggles.visibility_fog")}</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"}
onchange={() => 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"}
onchange={() => 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>
+157 -17
View File
@@ -31,7 +31,17 @@ preference the store already manages.
} from "../../map/index";
import { buildCargoRouteLines } from "../../map/cargo-routes";
import { buildPendingSendLines } from "../../map/pending-send-routes";
import { reportToWorld, type HitTarget } from "../../map/state-binding";
import {
reportToWorld,
type HitTarget,
type MapCategory,
} from "../../map/state-binding";
import {
computeFogCircles,
computeHiddenIds,
computeHiddenPlanetNumbers,
fingerprintHiddenPlanets,
} from "../../map/visibility";
import type { PrimitiveID } from "../../map/world";
import {
ORDER_DRAFT_CONTEXT_KEY,
@@ -41,6 +51,7 @@ preference the store already manages.
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
type MapToggles,
} from "$lib/game-state.svelte";
import {
SELECTION_CONTEXT_KEY,
@@ -57,12 +68,15 @@ preference the store already manages.
import {
installRendererDebugSurface,
registerMapCameraProvider,
registerMapFogProvider,
registerMapPickStateProvider,
registerMapPrimitivesProvider,
type MapCameraSnapshot,
type MapFogSnapshot,
type MapPickStateSnapshot,
type MapPrimitiveSnapshot,
} from "$lib/debug-surface.svelte";
import MapTogglesControl from "./map-toggles.svelte";
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const renderedReport = getContext<RenderedReportSource | undefined>(
@@ -92,6 +106,26 @@ preference the store already manages.
let handle: RendererHandle | null = null;
let hitLookup = new Map<PrimitiveID, HitTarget>();
// currentCategories / currentPlanetDependents are populated by
// `reportToWorld` inside `mountRenderer` and consumed by the
// Phase 29 hide-set computation on every effect re-run (mount or
// toggle change). Both maps cover the base world; extras
// (cargo-routes, pending-Send) are gated upstream via
// `skipPlanets`, so they never need a categories entry.
let currentCategories: ReadonlyMap<PrimitiveID, MapCategory> = new Map();
let currentPlanetDependents: ReadonlyMap<
number,
ReadonlySet<PrimitiveID>
> = new Map();
// currentFogCircles mirrors the latest `setVisibilityFog` input so
// the debug surface can report it to Playwright. The renderer
// keeps the Graphics, not the data; recomputing on every read
// would duplicate work.
let currentFogCircles: ReadonlyArray<{
x: number;
y: number;
radius: number;
}> = [];
let mountedTurn: number | null = null;
let mountedGameId: string | null = null;
let onResize: (() => void) | null = null;
@@ -134,9 +168,23 @@ preference the store already manages.
// Track the wrap mode so the renderer remounts when Phase 29's
// toggle UI flips it; the read here also subscribes the effect.
const mode = store?.wrapMode ?? "torus";
// Track the Phase 29 visibility toggles so the effect re-runs
// when the gear popover flips any flag. The hide set + fog +
// extras filter all derive from this rune.
const toggles = store?.mapToggles;
const gameId = store?.gameId ?? "";
if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report) return;
if (status !== "ready" || !report || toggles === undefined) return;
// Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the
// cascade-filtering happens here so the extras list shrinks
// when a destination planet hides. The hide set + fog are
// applied after mount / on every toggle change without a
// remount.
const hiddenPlanetNumbers = computeHiddenPlanetNumbers(report, toggles);
const hiddenPlanetFingerprint =
fingerprintHiddenPlanets(hiddenPlanetNumbers);
// Cargo-route arrows and pending-Send tracks are pushed onto
// the live renderer via `setExtraPrimitives` so the overlay
@@ -146,10 +194,13 @@ preference the store already manages.
// rebuilds when the overlay computation re-runs but the
// routes / pending-Send content is unchanged (e.g. status
// transitions valid → submitting → applied for the same
// command).
// command). The Phase 29 cascade + cargoRoutes toggle are
// folded into the fingerprint so a toggle flip that changes
// the visible set reliably triggers a push.
const draftCommands = orderDraft?.commands ?? [];
const draftStatuses = orderDraft?.statuses ?? {};
const extrasFingerprint =
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
computeRoutesFingerprint(report.routes) +
"|" +
computePendingSendFingerprint(draftCommands, draftStatuses);
@@ -160,12 +211,24 @@ preference the store already manages.
handle !== null &&
handle.getMode() === mode;
if (sameSnapshot) {
// Always re-apply hide set + fog on a same-snapshot pass:
// toggle flips bypass the extras fingerprint when they
// only change which baked-world primitives are hidden,
// and a no-op `setHiddenPrimitiveIds` is cheap.
untrack(() => {
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
});
if (lastExtrasFingerprint !== extrasFingerprint) {
untrack(() => {
handle?.setExtraPrimitives([
...buildCargoRouteLines(report),
...buildPendingSendLines(report, draftCommands, draftStatuses),
]);
handle?.setExtraPrimitives(
buildExtras(
report,
draftCommands,
draftStatuses,
toggles,
hiddenPlanetNumbers,
),
);
});
lastExtrasFingerprint = extrasFingerprint;
}
@@ -179,18 +242,80 @@ preference the store already manages.
void pendingMountSignal;
if (mountInProgress) return;
untrack(() => {
void runSerializedMount(report, mode, extrasFingerprint);
void runSerializedMount(
report,
mode,
toggles,
hiddenPlanetNumbers,
extrasFingerprint,
draftCommands,
draftStatuses,
);
});
});
function buildExtras(
report: NonNullable<GameStateStore["report"]>,
draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
): import("../../map/world").Primitive[] {
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
const cargo = toggles.cargoRoutes
? buildCargoRouteLines(report, skip ? { skipPlanets: skip } : undefined)
: [];
const pending = buildPendingSendLines(
report,
draftCommands,
draftStatuses,
skip ? { skipPlanets: skip } : undefined,
);
return [...cargo, ...pending];
}
function applyVisibilityState(
report: NonNullable<GameStateStore["report"]>,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
): void {
if (handle === null) return;
const hiddenIds = computeHiddenIds(
currentCategories,
currentPlanetDependents,
hiddenPlanetNumbers,
toggles,
);
handle.setHiddenPrimitiveIds(hiddenIds);
const fogCircles = computeFogCircles(report, toggles);
currentFogCircles = fogCircles;
handle.setVisibilityFog(fogCircles);
}
async function runSerializedMount(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
routesFingerprint: string,
toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>,
extrasFingerprint: string,
draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>,
): Promise<void> {
mountInProgress = true;
try {
await mountRenderer(report, mode, routesFingerprint);
await mountRenderer(report, mode);
if (handle === null) return;
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
handle.setExtraPrimitives(
buildExtras(
report,
draftCommands,
draftStatuses,
toggles,
hiddenPlanetNumbers,
),
);
lastExtrasFingerprint = extrasFingerprint;
} finally {
mountInProgress = false;
// Bump the reactive signal so any dep change observed
@@ -230,7 +355,6 @@ preference the store already manages.
async function mountRenderer(
report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
routesFingerprint: string,
): Promise<void> {
if (canvasEl === null || containerEl === null) return;
// Capture camera state before disposing so a remount inside
@@ -262,8 +386,15 @@ preference the store already manages.
handle = null;
}
try {
const { world, hitLookup: nextHitLookup } = reportToWorld(report);
const {
world,
hitLookup: nextHitLookup,
categories,
planetDependents,
} = reportToWorld(report);
hitLookup = nextHitLookup;
currentCategories = categories;
currentPlanetDependents = planetDependents;
handle = await createRenderer({
canvas: canvasEl,
world,
@@ -328,6 +459,7 @@ preference the store already manages.
strokeColor: p.style.strokeColor ?? null,
x: p.kind === "point" ? p.x : null,
y: p.kind === "point" ? p.y : null,
visible: !h.isPrimitiveHidden(p.id),
}));
});
const detachPick = registerMapPickStateProvider(() => {
@@ -370,20 +502,25 @@ preference the store already manages.
},
} satisfies MapCameraSnapshot;
});
const detachFog = registerMapFogProvider(() => ({
circles: currentFogCircles.map((c) => ({ ...c })),
}) satisfies MapFogSnapshot);
detachDebugProviders = (): void => {
detachPrim();
detachPick();
detachCamera();
detachFog();
};
mountedTurn = report.turn;
mountedGameId = targetGameId;
// Initial mount carries no extras yet; the post-mount
// effect run pushes the current cargo-route lines via
// `setExtraPrimitives` once `lastExtrasFingerprint`
// disagrees with the freshly computed fingerprint.
// runSerializedMount immediately pushes the visibility
// state + extras after this resolves; clearing the
// fingerprint here is defensive in case the post-mount
// path is ever bypassed (e.g. mount-then-throw before the
// extras push). The hide set / fog are applied by the
// caller too, so we do not call them here.
lastExtrasFingerprint = null;
mountError = null;
void routesFingerprint;
} catch (err) {
mountError = err instanceof Error ? err.message : String(err);
}
@@ -503,6 +640,9 @@ preference the store already manages.
bind:this={containerEl}
>
<canvas bind:this={canvasEl}></canvas>
{#if store !== undefined && store.status === "ready"}
<MapTogglesControl {store} />
{/if}
</div>
</section>
@@ -29,6 +29,25 @@ export interface MapPrimitiveSnapshot {
readonly strokeColor: number | null;
readonly x: number | null;
readonly y: number | null;
/**
* visible mirrors the renderer's per-id visibility flag — `false`
* iff `RendererHandle.setHiddenPrimitiveIds` has put the id into
* the hide set. Phase 29 e2e specs use this to assert toggle and
* planet-cascade behaviour without poking at Pixi internals.
*/
readonly visible: boolean;
}
/** Snapshot returned by `getMapFog()` — the current Phase 29
* visibility-fog input as the renderer last received it. The array
* is empty when the fog toggle is off (or when `localPlayerDrive`
* is zero so the radius would be zero). */
export interface MapFogSnapshot {
readonly circles: ReadonlyArray<{
readonly x: number;
readonly y: number;
readonly radius: number;
}>;
}
/** Snapshot returned by `getMapCamera()`. Mirrors the renderer's
@@ -53,10 +72,12 @@ export interface MapPickStateSnapshot {
type PrimitivesProvider = () => readonly MapPrimitiveSnapshot[];
type PickStateProvider = () => MapPickStateSnapshot;
type CameraProvider = () => MapCameraSnapshot | null;
type FogProvider = () => MapFogSnapshot;
let primitivesProvider: PrimitivesProvider | null = null;
let pickStateProvider: PickStateProvider | null = null;
let cameraProvider: CameraProvider | null = null;
let fogProvider: FogProvider | null = null;
/**
* registerMapPrimitivesProvider attaches a provider that yields the
@@ -101,6 +122,18 @@ export function registerMapCameraProvider(
};
}
/**
* registerMapFogProvider attaches a provider that yields the current
* Phase 29 fog input as last seen by the renderer. Same idempotent
* semantics as the other providers.
*/
export function registerMapFogProvider(provider: FogProvider): () => void {
fogProvider = provider;
return () => {
if (fogProvider === provider) fogProvider = null;
};
}
const EMPTY_PICK_STATE: MapPickStateSnapshot = {
active: false,
sourcePlanetNumber: null,
@@ -126,11 +159,19 @@ export function getMapCamera(): MapCameraSnapshot | null {
return cameraProvider?.() ?? null;
}
/** Pulls the current visibility-fog snapshot. Returns an empty
* snapshot when no provider is registered (e.g. map view not
* mounted). */
export function getMapFog(): MapFogSnapshot {
return fogProvider?.() ?? { circles: [] };
}
interface RendererDebugWindow {
__galaxyDebug?: {
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
getMapPickState?: () => MapPickStateSnapshot;
getMapCamera?: () => MapCameraSnapshot | null;
getMapFog?: () => MapFogSnapshot;
[key: string]: unknown;
};
}
@@ -153,6 +194,7 @@ export function installRendererDebugSurface(): () => void {
getMapPrimitives,
getMapPickState,
getMapCamera,
getMapFog,
};
win.__galaxyDebug = surface;
return (): void => {
@@ -170,5 +212,8 @@ export function installRendererDebugSurface(): () => void {
if (current.getMapCamera === getMapCamera) {
delete current.getMapCamera;
}
if (current.getMapFog === getMapFog) {
delete current.getMapFog;
}
};
}
+198
View File
@@ -31,6 +31,54 @@ const HISTORY_NAMESPACE = "game-history";
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
`${gameId}/turn/${turn}`;
const MAP_TOGGLES_NAMESPACE = "game-map-toggles";
/**
* MapToggles is the per-game visibility state exposed by the Phase 29
* gear popover. Every flip persists into `Cache` under
* `MAP_TOGGLES_NAMESPACE/<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;
visibilityFog: boolean;
}
export const DEFAULT_MAP_TOGGLES: MapToggles = {
hyperspaceGroups: true,
incomingGroups: true,
unidentifiedGroups: true,
foreignPlanets: true,
uninhabitedPlanets: true,
unidentifiedPlanets: true,
unreachablePlanets: true,
cargoRoutes: true,
battleMarkers: true,
bombingMarkers: true,
visibilityFog: true,
};
interface PersistedMapToggles {
readonly toggles: MapToggles;
readonly lastResetTurn: number;
}
/**
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `GameStateStore` instance to descendants.
@@ -53,6 +101,15 @@ export class GameStateStore {
status: Status = $state("idle");
report: GameReport | null = $state(null);
wrapMode: WrapMode = $state("torus");
/**
* mapToggles is the per-game visibility state surfaced by the
* Phase 29 gear popover. Every value defaults to `true` except for
* the negative `unreachablePlanets` flag (which is also `true` so
* the default view shows every reachable planet). The map view
* resolves the flags into a hide-by-id set on every effect run via
* `RendererHandle.setHiddenPrimitiveIds`.
*/
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
error: string | null = $state(null);
/**
* currentTurn mirrors the engine's turn number for the running
@@ -109,6 +166,13 @@ export class GameStateStore {
private cache: Cache | null = null;
private destroyed = false;
private visibilityListener: (() => void) | null = null;
/**
* lastResetTurn is the turn at which `mapToggles` was last reset to
* defaults. Persisted alongside the toggle blob so the new-turn
* reset path can compare against `currentTurn` after a cross-
* session gap (browser closed at turn N, reopened at turn N + k).
*/
private lastResetTurn = 0;
/**
* init kicks off the per-game lifecycle. The call is idempotent on
@@ -151,6 +215,7 @@ export class GameStateStore {
this.wrapMode = await readWrapMode(this.cache, gameId);
const lastViewed = await readLastViewedTurn(this.cache, gameId);
const persistedToggles = await readMapToggles(this.cache, gameId);
try {
const summary = await this.findGame(gameId);
@@ -161,6 +226,26 @@ export class GameStateStore {
}
this.gameName = summary.gameName;
this.currentTurn = summary.currentTurn;
// New-turn reset: if the persisted blob is older than the
// server-side `currentTurn`, drop user overrides and write
// the fresh `{defaults, currentTurn}` back to cache so a
// subsequent reload sees the same baseline. The cross-
// session gap counts here too — a player who closed the
// tab at turn N and returns at turn N + k still gets the
// defaults on first map mount.
if (persistedToggles.lastResetTurn < summary.currentTurn) {
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
this.lastResetTurn = summary.currentTurn;
await writeMapToggles(
this.cache,
gameId,
this.mapToggles,
this.lastResetTurn,
);
} else {
this.mapToggles = { ...persistedToggles.toggles };
this.lastResetTurn = persistedToggles.lastResetTurn;
}
// If the persisted last-viewed turn is older than the
// server-side current turn, open the user on their last-seen
// snapshot and surface the gap through `pendingTurn` so the
@@ -225,6 +310,13 @@ export class GameStateStore {
this.currentTurn = summary.currentTurn;
await this.loadTurn(summary.currentTurn, { isCurrent: true });
this.pendingTurn = null;
// Phase 29: a successful jump onto the new server turn
// drops user-set map-visibility overrides so the next
// frame surfaces every category. `viewTurn` is the
// history-mode path and intentionally leaves toggles
// alone — the single shared state stays put across
// in-game time-travel.
await this.resetMapTogglesForTurn(summary.currentTurn);
} catch (err) {
if (this.destroyed) return;
this.status = "error";
@@ -298,6 +390,40 @@ export class GameStateStore {
}
}
/**
* setMapToggle flips one entry of the `mapToggles` rune and
* persists the whole blob (alongside the unchanged
* `lastResetTurn`). Mutating the rune in place keeps subscribers
* reactive without requiring object identity changes.
*/
async setMapToggle<K extends keyof MapToggles>(
key: K,
value: MapToggles[K],
): Promise<void> {
this.mapToggles[key] = value;
if (this.cache !== null) {
await writeMapToggles(
this.cache,
this.gameId,
this.mapToggles,
this.lastResetTurn,
);
}
}
private async resetMapTogglesForTurn(turn: number): Promise<void> {
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
this.lastResetTurn = turn;
if (this.cache !== null) {
await writeMapToggles(
this.cache,
this.gameId,
this.mapToggles,
this.lastResetTurn,
);
}
}
/**
* failBootstrap is used by the layout to surface errors that
* happen *before* `init` could be reached (missing keypair, missing
@@ -329,6 +455,25 @@ export class GameStateStore {
this.gameName = "Synthetic";
this.error = null;
this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
// Synthetic sessions skip the lobby query, so the new-turn
// reset check uses the report's own turn as the reference. A
// reload on the same synthetic id restores user overrides;
// switching to a synthetic report with a higher turn resets
// them.
const persistedToggles = await readMapToggles(opts.cache, opts.gameId);
if (persistedToggles.lastResetTurn < opts.report.turn) {
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
this.lastResetTurn = opts.report.turn;
await writeMapToggles(
opts.cache,
opts.gameId,
this.mapToggles,
this.lastResetTurn,
);
} else {
this.mapToggles = { ...persistedToggles.toggles };
this.lastResetTurn = persistedToggles.lastResetTurn;
}
this.report = opts.report;
this.currentTurn = opts.report.turn;
this.viewedTurn = opts.report.turn;
@@ -422,6 +567,59 @@ async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
return "torus";
}
/**
* readMapToggles loads the persisted `{toggles, lastResetTurn}` blob.
* Missing entries (cleared site data, fresh game) return the defaults
* with `lastResetTurn === -1`, guaranteeing the `setGame` reset path
* runs on the very first visit. Per-field fallback to defaults keeps
* forward-compat with future toggle additions: an older blob
* persisted before a new flag landed loses nothing but the missing
* flag, which gets the default value.
*/
async function readMapToggles(
cache: Cache,
gameId: string,
): Promise<PersistedMapToggles> {
const stored = await cache.get<Partial<PersistedMapToggles>>(
MAP_TOGGLES_NAMESPACE,
gameId,
);
if (stored === undefined || stored === null || typeof stored !== "object") {
return { toggles: { ...DEFAULT_MAP_TOGGLES }, lastResetTurn: -1 };
}
const partial =
stored.toggles !== undefined &&
stored.toggles !== null &&
typeof stored.toggles === "object"
? stored.toggles
: {};
const toggles: MapToggles = { ...DEFAULT_MAP_TOGGLES };
for (const k of Object.keys(DEFAULT_MAP_TOGGLES) as (keyof MapToggles)[]) {
const candidate = (partial as Partial<MapToggles>)[k];
if (typeof candidate === "boolean") {
toggles[k] = candidate;
}
}
const turn =
typeof stored.lastResetTurn === "number" &&
Number.isFinite(stored.lastResetTurn)
? stored.lastResetTurn
: -1;
return { toggles, lastResetTurn: turn };
}
async function writeMapToggles(
cache: Cache,
gameId: string,
toggles: MapToggles,
lastResetTurn: number,
): Promise<void> {
await cache.put<PersistedMapToggles>(MAP_TOGGLES_NAMESPACE, gameId, {
toggles: { ...toggles },
lastResetTurn,
});
}
async function readLastViewedTurn(
cache: Cache,
gameId: string,
+19
View File
@@ -113,6 +113,25 @@ const en = {
"game.shell.history.return_to_current": "Return to current turn",
"game.shell.history.current_badge": "current",
"game.view.map": "map",
"game.map.toggles.open": "open map visibility menu",
"game.map.toggles.close": "close map visibility menu",
"game.map.toggles.section.objects": "Objects",
"game.map.toggles.section.planets": "Planets",
"game.map.toggles.section.view": "View",
"game.map.toggles.hyperspace_groups": "hyperspace groups",
"game.map.toggles.incoming_groups": "incoming groups",
"game.map.toggles.unidentified_groups": "unidentified groups",
"game.map.toggles.cargo_routes": "cargo routes",
"game.map.toggles.battle_markers": "battle markers",
"game.map.toggles.bombing_markers": "bombing markers",
"game.map.toggles.foreign_planets": "foreign planets",
"game.map.toggles.uninhabited_planets": "uninhabited planets",
"game.map.toggles.unidentified_planets": "unidentified planets",
"game.map.toggles.unreachable_planets": "show unreachable planets",
"game.map.toggles.visibility_fog": "visibility fog",
"game.map.toggles.wrap.label": "wrap scrolling",
"game.map.toggles.wrap.torus": "torus",
"game.map.toggles.wrap.no_wrap": "no-wrap",
"game.view.table": "table",
"game.view.table.planets": "planets",
"game.view.table.ship_classes": "ship classes",
+19
View File
@@ -114,6 +114,25 @@ const ru: Record<keyof typeof en, string> = {
"game.shell.history.return_to_current": "Вернуться к текущему ходу",
"game.shell.history.current_badge": "текущий",
"game.view.map": "карта",
"game.map.toggles.open": "открыть меню видимости карты",
"game.map.toggles.close": "закрыть меню видимости карты",
"game.map.toggles.section.objects": "Объекты",
"game.map.toggles.section.planets": "Планеты",
"game.map.toggles.section.view": "Вид",
"game.map.toggles.hyperspace_groups": "группы в гиперпространстве",
"game.map.toggles.incoming_groups": "входящие группы",
"game.map.toggles.unidentified_groups": "неопознанные группы",
"game.map.toggles.cargo_routes": "грузовые маршруты",
"game.map.toggles.battle_markers": "метки сражений",
"game.map.toggles.bombing_markers": "метки бомбардировок",
"game.map.toggles.foreign_planets": "чужие планеты",
"game.map.toggles.uninhabited_planets": "необитаемые планеты",
"game.map.toggles.unidentified_planets": "неопознанные планеты",
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
"game.map.toggles.visibility_fog": "туман видимости",
"game.map.toggles.wrap.label": "перенос карты",
"game.map.toggles.wrap.torus": "тор",
"game.map.toggles.wrap.no_wrap": "без переноса",
"game.view.table": "таблица",
"game.view.table.planets": "планеты",
"game.view.table.ship_classes": "классы кораблей",