feat(ui): Phase 29 map visibility toggles
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user