import { msg } from './i18n/marker.js'; const CONTEXT_DEPENDENCIES = Object.freeze({ flights: new Set([ 'military-awareness', 'flights', 'military', 'ais-live-vessels', 'military-installations', ]), 'space-missions': new Set(['rocket-launches', 'satellites']), }); const CONTEXT_COMPANIONS = new Set(['radio']); /** The Space Missions refusal, with the blocked layer's name as {layer}. */ export const SPACE_MISSIONS_LAYER_BLOCK_REASON = msg( 'Space Missions isolates replay data. Exit the mode to enable {layer}.', ); const [BLOCK_REASON_PREFIX, BLOCK_REASON_SUFFIX] = SPACE_MISSIONS_LAYER_BLOCK_REASON.split('{layer}'); /** Return whether an origin represents a direct user choice on this route. */ export function isExplicitUserIntentOrigin(origin, layerId = null) { return origin === 'user' || origin === 'voice'; } /** Preserve explicit layer intent that completes while a stale restore is queued. */ export function recordContextRestoreExplicitChange({ restoreState, change }) { if ( !restoreState?.enabledLayerIds || change?.type !== 'visibility' || !isExplicitUserIntentOrigin(change.origin, change.layerId) ) return false; if (!restoreState.explicitLayerStates) restoreState.explicitLayerStates = new Map(); restoreState.explicitLayerStates.set(change.layerId, Boolean(change.enabled)); if (change.enabled) restoreState.enabledLayerIds.add(change.layerId); else restoreState.enabledLayerIds.delete(change.layerId); return true; } /** Settle every explicit Context-restore replay without losing semantic failures. */ export async function settleContextIntentReplay({ restoreState, setEnabled, notificationToken = null, }) { if (restoreState?.cancelled) return null; const entries = [...(restoreState?.explicitLayerStates || [])]; const results = await Promise.allSettled( entries.map(([layerId, enabled]) => setEnabled(layerId, enabled, { origin: 'context-intent-replay', ...(notificationToken ? { notificationToken } : {}), }), ), ); const failedIndexes = results.flatMap((result, index) => result.status === 'rejected' || result.value === false ? [index] : [], ); if (failedIndexes.length === 0) return null; const failedLayerIds = failedIndexes.map((index) => entries[index][0]); const rejected = failedIndexes .map((index) => results[index]) .find((result) => result.status === 'rejected'); const error = rejected?.reason instanceof Error ? rejected.reason : new Error( `Context intent replay failed for ${failedLayerIds.map((layerId) => `"${layerId}"`).join(', ')}`, ); error.failedLayerIds = [ ...new Set([ ...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []), ...failedLayerIds, ]), ]; return error; } /** Preserve the primary Context failure while aggregating every failed layer. */ export function mergeContextTransitionErrors(primaryError, secondaryError) { if (!primaryError) return secondaryError || null; if (!secondaryError) return primaryError; primaryError.failedLayerIds = [ ...new Set([ ...(Array.isArray(primaryError.failedLayerIds) ? primaryError.failedLayerIds : []), ...(Array.isArray(secondaryError.failedLayerIds) ? secondaryError.failedLayerIds : []), ]), ]; return primaryError; } /** Settle a user-facing Context action and convert every failure form to false. */ export async function settleUserFacingContextAction({ operation, onFailure, falseIsFailure = true, }) { try { const result = await operation(); if (falseIsFailure && result === false) throw new Error('Context transition did not complete'); return result; } catch (error) { try { onFailure?.(error); } catch { // A broken error surface must not recreate the unhandled rejection. } return false; } } /** * Layers whose user-owned enable IS a Context entry. They own the session, * so they are never bookkept as layers the user added *during* it — filing * one into `userAdded` makes exit restoration re-enable the mode layer the * user just turned off. */ export const CONTEXT_ENTRY_LAYER_IDS = Object.freeze([ 'military-awareness', 'rocket-launches', ]); /** * Leave a Context transaction: publish the settled coordination flag, then * re-run the owner's context funnel so every SETTLE-GATED consumer sees the * final state. * * That second half is load-bearing. Consumers gated on `!_contextModeChanging` * — the Contacts detection override among them — deliberately no-op while a * transaction is in flight, so a path that mutates `_contextMode` mid-flight and * then merely drops the flag leaves them holding the pre-transaction state * forever. (Field case: destroying a Contacts dependency layer exited the * session but left detection forced on.) * * A nested scope handing control back to a still-running outer transaction has * settled nothing, so the funnel only runs on a real settle. * * @param {{_contextModeChanging?: boolean, _syncContextModeButtons?: () => void}} owner * @param {boolean} [changing=false] Flag state to publish. * @returns {void} */ export function settleContextModeChange(owner, changing = false) { if (!owner) return; const wasChanging = Boolean(owner._contextModeChanging); owner._contextModeChanging = Boolean(changing); if (wasChanging && !owner._contextModeChanging) owner._syncContextModeButtons?.(); } /** * Run an asynchronous Context transition while preserving the caller's prior * coordination state. This matters during teardown: restoration notifications * must remain suppressed only for the duration of the restore, not forever. * * @param {{_contextModeChanging?: boolean}} owner Context coordinator. * @param {() => Promise<*>|*} operation Transition work. * @returns {Promise<*>} The operation result. */ export async function runWithContextModeChanging(owner, operation) { const wasChanging = Boolean(owner?._contextModeChanging); owner._contextModeChanging = true; try { return await operation(); } finally { settleContextModeChange(owner, wasChanging); } } /** * Decide whether a manual Data Layer change should hand control away from the * active Context mode. User-enabled layers are additive and keep the active * mode running. Manually disabling one of the mode's dependencies requests * exit; programmatic dependency changes remain allowed. * * @param {object} input Context and layer-change state. * @param {string|null} input.contextMode Active Context mode. * @param {boolean} input.globalContextEnabled Whether the neutral/Contacts shell is enabled. * @param {object|null} input.change DataLayerManager visibility notification. * @returns {boolean} Whether the active Context bundle should be disabled. */ export function shouldExitContextForLayerChange({ contextMode, globalContextEnabled, change, }) { if ( change?.type !== 'visibility' || !isExplicitUserIntentOrigin(change.origin, change.layerId) || change.layerId === 'military-awareness' || CONTEXT_COMPANIONS.has(change.layerId) ) { return false; } const active = Boolean(globalContextEnabled || contextMode); if (!active) return false; const dependencies = CONTEXT_DEPENDENCIES[contextMode] || new Set(['military-awareness']); if (change.enabled) return false; return Boolean(contextMode && dependencies.has(change.layerId)); } /** * Return the only layers allowed while a Context mode is being activated. * @param {string|null} contextMode Context mode or neutral shell. * @returns {Set} Allowed layer identifiers. */ export function contextAllowedLayerIds(contextMode) { return new Set([ ...(CONTEXT_DEPENDENCIES[contextMode] || ['military-awareness']), ...CONTEXT_COMPANIONS, ]); } /** * Decide whether an accepted intent or pre-transition notification represents a user-owned * Context entry whose pre-entry state must be captured. Internal programmatic, * dependency, and restoration enables must never create or replace a user * restoration snapshot. * @param {object|null} change DataLayerManager visibility notification. * @returns {boolean} Whether the Context session should capture now. */ export function shouldCaptureContextSession(change) { return Boolean( ['visibility-requested', 'visibility-will-change'].includes(change?.type) && isExplicitUserIntentOrigin(change.origin, change.layerId) && change.enabled && CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId), ); } /** * Return whether one explicit mission intent superseded an in-flight Clear All * reservation and therefore needs deferred Context adoption. */ export function shouldDeferContextEntryDuringClear({ change, clearInFlight }) { return Boolean( clearInFlight && change?.type === 'visibility-requested' && change.layerId === 'rocket-launches' && change.enabled === true && isExplicitUserIntentOrigin(change.origin, change.layerId) && Number.isInteger(change.intentEpoch), ); } /** * Bookkeep a user-owned layer change against the active Context session so * exit restoration can honor layers the user added mid-session. Two rules * keep restoration exact: * - Context ENTRY layers are never recorded — they own the session and must * not survive their own exit. * - The allowed set is judged against the EFFECTIVE mode (a mode still being * entered counts), because the entry layer's own enable event lands before * the mode is committed. * @param {object} input Session and change state. * @param {{userAdded: Set, userRemoved?: Set}|null} input.snapshot Active session snapshot. * @param {object|null} input.change DataLayerManager visibility notification. * @param {string|null} [input.effectiveContextMode] Committed or entering mode. * @returns {boolean} Whether the snapshot's userAdded set was modified. */ export function recordContextSessionUserChange({ snapshot, change, effectiveContextMode = null, }) { if ( !snapshot?.userAdded || change?.type !== 'visibility' || !isExplicitUserIntentOrigin(change.origin, change.layerId) ) { return false; } const isCompanion = CONTEXT_COMPANIONS.has(change.layerId); if (change.enabled) { if (CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)) return false; if (change.adoptedFromSelection) { snapshot.userAdded.add(change.layerId); return true; } if ( contextAllowedLayerIds(effectiveContextMode).has(change.layerId) && !isCompanion ) return false; snapshot.userAdded.add(change.layerId); if (isCompanion) snapshot.userRemoved?.delete(change.layerId); return true; } const removedAddition = snapshot.userAdded.delete(change.layerId); if (isCompanion && snapshot.userRemoved) { const wasRemoved = snapshot.userRemoved.has(change.layerId); snapshot.userRemoved.add(change.layerId); return removedAddition || !wasRemoved; } return removedAddition; } /** * Return the authoritative layer set for a new Context snapshot. When a prior * session is still restoring, the settled restore target is authoritative; * reading the manager mid-restore would capture a partial, order-dependent set. * @param {Iterable} currentEnabledLayerIds Manager state at capture time. * @param {Iterable|null} restoringLayerIds Pending restore target, if any. * @param {Iterable} excludeLayerIds Entry-layer intents that were * already published synchronously but did not exist before this session. * @returns {Set} Detached pre-entry layer set. */ export function contextSnapshotLayerIds( currentEnabledLayerIds, restoringLayerIds = null, excludeLayerIds = [], ) { const snapshot = new Set(restoringLayerIds || currentEnabledLayerIds || []); for (const layerId of excludeLayerIds || []) snapshot.delete(layerId); return snapshot; } /** * Block layers that would mix unrelated live/current data into Space Missions. * The manager calls this before lifecycle work so a refused layer never starts. * @param {object} input Current mode and requested visibility transition. * @param {string|null} input.contextMode Active Context mode. * @param {object|null} input.change Requested visibility transition. * @param {string|null} [input.layerName] User-facing layer name. * @returns {string|null} Honest user-facing refusal reason, or null when allowed. */ export function contextLayerEnableBlockReason({ contextMode, change, layerName = null, }) { if ( contextMode !== 'space-missions' || change?.enabled !== true || contextAllowedLayerIds('space-missions').has(change.layerId) ) { return null; } const label = String(layerName || change.layerId || 'that layer'); return SPACE_MISSIONS_LAYER_BLOCK_REASON.replace('{layer}', label); } /** * The layer name from a Space Missions refusal, so the UI can rebuild the * sentence in its language. * @param {string} reason * @returns {{layer: string}|null} The blocked layer's name, or null when the * reason is not a Space Missions refusal. */ export function parseContextLayerBlockReason(reason) { if ( typeof reason !== 'string' || !reason.startsWith(BLOCK_REASON_PREFIX) || !reason.endsWith(BLOCK_REASON_SUFFIX) || reason.length <= BLOCK_REASON_PREFIX.length + BLOCK_REASON_SUFFIX.length ) return null; return { layer: reason.slice( BLOCK_REASON_PREFIX.length, reason.length - BLOCK_REASON_SUFFIX.length, ), }; } /** * Resolve who owns a cancelled direct Space Missions entry. A newer ON keeps * the entry shell reserved for its replacement transaction; every other * cancellation must roll back the isolated pre-entry session. * * @param {object} input Cancellation and current manager intent state. * @param {object|null} input.change Manager visibility notification. * @returns {'ignore'|'replacement'|'restore'} Cancellation disposition. */ export function spaceMissionEntryCancellationDisposition({ change }) { if ( change?.type !== 'visibility-cancelled' || change.layerId !== 'rocket-launches' || change.enabled !== true ) return 'ignore'; if ( change.cancellationReason === 'superseded' && Number.isInteger(change.successorIntentEpoch) && change.successorIntentEpoch > change.intentEpoch && change.successorEnabled === true ) return 'replacement'; return 'restore'; } /** * Merge the pre-entry layer snapshot with layers the user enabled during the * Context session so both survive restoration. * @param {{enabledLayerIds?: Iterable, userAdded?: Iterable, userRemoved?: Iterable}} snapshot Session snapshot. * @returns {Set} Layer identifiers to restore. */ export function contextRestoreLayerIds(snapshot = {}) { const restored = new Set([ ...(snapshot.enabledLayerIds || []), ...(snapshot.userAdded || []), ]); for (const layerId of snapshot.userRemoved || []) restored.delete(layerId); return restored; } /** * Cockpit entry belongs to the operational Contacts context bundle. A tracked * aircraft alone is not sufficient: both observed-flight feeds must still be * active so the cockpit's surrounding-contact picture is not presented as a * complete context view when one source is disabled. * * @param {object} input Current context and dependency visibility. * @param {string|null} input.contextMode Active Context mode. * @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled. * @param {boolean} input.flightsEnabled Whether Live Flights is enabled. * @param {boolean} input.militaryEnabled Whether Military Flights is enabled. * @returns {boolean} Whether cockpit entry may be offered or activated. */ export function cockpitEntryAllowed({ contextMode, contextModeChanging, flightsEnabled, militaryEnabled, }) { return ( contextMode === 'flights' && !contextModeChanging && Boolean(flightsEnabled) && Boolean(militaryEnabled) ); } /** * Internal context-mode id → the word the voice tools accept for it. * * `set_context_mode` takes 'contacts'; the mode's internal id is 'flights'. * The mapping lives here, beside the rest of the context-mode policy, so the * UI's operator-facing text and the voice payload translator cannot drift * apart — one saying "flights" while the other says "contacts" is the bug * this whole seam exists to prevent. */ export const CONTEXT_MODE_VOICE_NAMES = Object.freeze({ flights: 'contacts', 'space-missions': 'space-missions', }); /** * Name a context mode in the vocabulary the tools and the operator share. * @param {string|null|undefined} internalMode Internal mode id. * @param {object} [options] * @param {string|null} [options.emptyAs='off'] What "no mode" is called. 'off' * for a state field that always names a mode; null where an absent value * means "none at all" (nothing is entering, there was no prior mode) and * calling it 'off' would assert something untrue. * @returns {string|null} The shared word for this mode. */ export function contextModeWord(internalMode, { emptyAs = 'off' } = {}) { if (!internalMode) return emptyAs; return CONTEXT_MODE_VOICE_NAMES[internalMode] || internalMode; }