Files
gods-eye-view/src/contextModePolicy.js
T
German KhokhlovandClaude Opus 5.5 20f4cf3763 i18n: fix the LGTM review findings and tidy the branch texts
- i18n scanner: lastError sink, no false sinks on defaults and ternaries
- shared src/i18n/marker.js for modules that only mark strings
- wind field messages move to wind/fields.js; CITY_POIS imports from cityPois.js
- drop review IDs, change history and stale references from comments and docs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 21:00:28 +04:00

469 lines
17 KiB
JavaScript

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<string>} 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<string>, userRemoved?: Set<string>}|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<string>} currentEnabledLayerIds Manager state at capture time.
* @param {Iterable<string>|null} restoringLayerIds Pending restore target, if any.
* @param {Iterable<string>} excludeLayerIds Entry-layer intents that were
* already published synchronously but did not exist before this session.
* @returns {Set<string>} 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<string>, userAdded?: Iterable<string>, userRemoved?: Iterable<string>}} snapshot Session snapshot.
* @returns {Set<string>} 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;
}