// `MapPickService` is the Svelte-side adapter the inspector uses to // drive a map-driven destination pick. The service owns the // promise-shaped contract (`pick()` returns the picked planet // number or `null` on cancel) and a reactive `active` flag for any // surface that wants to disable other UI while a session is open. // // The actual renderer plumbing — dim outside `reachableIds`, anchor // ring, cursor line, hover outline, click + Escape resolution — // lives in `ui/frontend/src/map/render.ts.setPickMode`. The map // active view (`lib/active-view/map.svelte`) is the only producer: // it constructs the service, sets it on the layout context with // `MAP_PICK_CONTEXT_KEY`, and binds a resolver that translates the // service-level request into a `PickModeOptions` payload for the // current renderer handle. export const MAP_PICK_CONTEXT_KEY = Symbol("map-pick"); /** High-level pick request the inspector composes. The renderer * resolver (registered by the map view) is responsible for turning * `sourcePlanetNumber` into the underlying `PickModeOptions`. */ export interface MapPickRequest { readonly sourcePlanetNumber: number; readonly reachableIds: ReadonlySet; } /** A renderer-side resolver registered by the map view. Returns an * imperative cancel hook the service uses for `cancel()`, or `null` * when the renderer cannot open a session right now (e.g. the * source planet is missing from the world). When `null` is * returned, the service resolves the pending promise with `null` * straight away. */ export type MapPickResolver = (input: { sourcePlanetNumber: number; reachableIds: ReadonlySet; onResolve: (id: number | null) => void; }) => { cancel(): void } | null; /** * MapPickService coordinates pick-mode sessions between the Svelte * inspector and the renderer. Lives for the lifetime of the * in-game shell layout; renderer handles come and go through * `bindResolver` as the map remounts. */ export class MapPickService { /** Reactive flag — true while a pick session is open. The * inspector reads this to render its "pick prompt" status line * and to keep the slot button disabled until resolution. */ active = $state(false); private resolver: MapPickResolver | null = null; private currentHandle: { cancel(): void } | null = null; private currentResolve: ((id: number | null) => void) | null = null; /** * bindResolver attaches a renderer-side handler that opens * pick-mode sessions. Pass `null` to detach (the map view does * this on dispose); a detach with a session in progress * resolves the pending promise with `null` so callers do not * deadlock waiting for a renderer that no longer exists. */ bindResolver(resolver: MapPickResolver | null): void { if (resolver === null && this.currentResolve !== null) { const r = this.currentResolve; this.currentResolve = null; this.currentHandle = null; this.active = false; r(null); } this.resolver = resolver; } /** * pick opens a pick session. Resolves to the picked planet * number on a successful pick, or `null` when the player * cancels via Escape, the inspector calls `cancel()`, or the * renderer detaches mid-session. * * Calling `pick` while a session is already active cancels the * old one first (its promise resolves to `null`). The * inspector should normally guard against this via the * reactive `active` flag, but the service stays defensive. */ pick(request: MapPickRequest): Promise { return new Promise((resolve) => { if (this.resolver === null) { resolve(null); return; } if (this.currentHandle !== null) { const previousHandle = this.currentHandle; this.currentHandle = null; previousHandle.cancel(); } this.currentResolve = resolve; this.active = true; const handle = this.resolver({ sourcePlanetNumber: request.sourcePlanetNumber, reachableIds: request.reachableIds, onResolve: (id) => { // Guard against late notifications from a stale // session (e.g. resolver swapped while a pick was // in flight). if (this.currentResolve !== resolve) return; this.currentResolve = null; this.currentHandle = null; this.active = false; resolve(id); }, }); if (handle === null) { if (this.currentResolve === resolve) { this.currentResolve = null; this.active = false; resolve(null); } return; } this.currentHandle = handle; }); } /** * cancel terminates the active session, if any. Safe to call * when no session is open — it is a no-op then. The pending * promise resolves with `null`. */ cancel(): void { if (this.currentHandle === null) return; const handle = this.currentHandle; this.currentHandle = null; handle.cancel(); } }