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
+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,