// Per-game selection state: which on-map object the user is // currently inspecting. Phase 13 modelled planets only; Phase 19 // widened the union to ship groups (own / foreign / incoming / // unidentified). // // The store is in-memory only: lifetime matches the in-game shell // layout instance, which itself is preserved across active-view // switches inside `/games/:id/*`. Persisting selection across // reloads is intentionally out of scope — the Phase 13 acceptance // criterion calls out "across view switches", and survival across a // reload would be a surprising contrast with the empty-state copy // users see on first load. // // Like `GameStateStore` and `OrderDraftStore`, the store is // instantiated by the layout and shared with descendants through // Svelte context. The map view pushes selection events into it; the // inspector tab and the mobile bottom-sheet read from it. // // The store deliberately carries no Svelte component imports so it // can be tested directly without rendering any UI. /** * ShipGroupRef identifies a ship group inside the current report. * `local` groups carry a stable engine UUID (passed through * `report.localGroup.id` and used by the upcoming Phase 20 order * envelopes). The remaining variants do not — they are addressed by * their position in the matching report array, which is fine for * the read-only inspector: a new report load reseeds the store and * any stale index resolves to a missing entry on lookup, collapsing * the inspector cleanly. */ export type ShipGroupRef = | { variant: "local"; id: string } | { variant: "other"; index: number } | { variant: "incoming"; index: number } | { variant: "unidentified"; index: number }; /** * Selected describes the currently selected map object. The * discriminated union is closed: every map-clickable surface maps * to one of these variants. Future phases (e.g. fleet selection) * extend by adding a new branch — extension is purely additive. */ export type Selected = | { kind: "planet"; id: number } | { kind: "shipGroup"; ref: ShipGroupRef }; /** * SELECTION_CONTEXT_KEY is the Svelte context key the in-game shell * layout uses to expose its `SelectionStore` instance to descendants. * Map view, inspector tab, and the mobile bottom-sheet resolve the * store via `getContext(SELECTION_CONTEXT_KEY)`. */ export const SELECTION_CONTEXT_KEY = Symbol("selection"); export class SelectionStore { selected: Selected | null = $state(null); // pendingFocus is a transient one-shot request to centre the map // camera on a selection. F8-10 tables raise it together with the // view switch to map; map.svelte consumes it once on mount (see // `consumePendingFocus`) and clears it. Held in memory only — not // persisted, so an F5 after a click-through does not re-centre. #pendingFocus: Selected | null = $state(null); // pendingCenter is the coord-only sibling of pendingFocus: it // asks map.svelte to centre on a world point without touching // the selection. F8-10 uses it for in-space fleet rows (the // `Selected` union has no "fleet" variant, but the user still // expects the camera to find them). Also one-shot and transient. #pendingCenter: { x: number; y: number } | null = $state(null); private destroyed = false; /** * selectPlanet sets the active selection to the planet identified * by its engine `number`. A no-op once the store has been disposed. */ selectPlanet(id: number): void { if (this.destroyed) return; this.selected = { kind: "planet", id }; } /** * selectShipGroup sets the active selection to a ship group. The * `ref` discriminator carries the variant + the right id shape for * lookup against the current report. */ selectShipGroup(ref: ShipGroupRef): void { if (this.destroyed) return; this.selected = { kind: "shipGroup", ref }; } /** * focus sets the active selection to `target` and queues a one-shot * camera-centre request for whichever next mount of the map view * picks it up via `consumePendingFocus`. Used by the F8-10 tables to * navigate from a row click to the map. A no-op once the store has * been disposed. */ focus(target: Selected): void { if (this.destroyed) return; this.selected = target; this.#pendingFocus = target; } /** * consumePendingFocus returns the queued focus target (if any) and * clears it in the same call. Designed for `map.svelte` to invoke * once after the renderer mounts. */ consumePendingFocus(): Selected | null { const target = this.#pendingFocus; this.#pendingFocus = null; return target; } /** * focusPoint queues a one-shot camera-centre on a free-form world * coordinate, without altering selection. Used by the fleets table * when the user clicks an in-space fleet (there is no `"fleet"` * variant in `Selected`, but the camera should still find it). * A no-op once the store has been disposed. */ focusPoint(x: number, y: number): void { if (this.destroyed) return; this.#pendingCenter = { x, y }; } /** * consumePendingCenter returns the queued centre point (if any) * and clears it. */ consumePendingCenter(): { x: number; y: number } | null { const point = this.#pendingCenter; this.#pendingCenter = null; return point; } /** * clear drops the current selection. The mobile sheet's close * button calls this; otherwise selection persists across active- * view switches. Does not affect any queued pending focus — that * is a transient delivery channel, not selection state. */ clear(): void { if (this.destroyed) return; this.selected = null; } dispose(): void { this.destroyed = true; this.selected = null; this.#pendingFocus = null; this.#pendingCenter = null; } }