174 lines
8.1 KiB
JavaScript
174 lines
8.1 KiB
JavaScript
// src/contactsDetectionPolicy.js — Contacts-scoped detection policy.
|
|
//
|
|
// Owner playtest 2026-08-18: "when you click on Contacts, detections should just
|
|
// turn on, and they should stay on in Cockpit or in third-person tracking inside
|
|
// Contacts or inside Cockpit, both… when I leave the Cockpit, detections go off"
|
|
// — that last part being the bug.
|
|
//
|
|
// The scope is CONTACTS, not Cockpit. Three rules follow:
|
|
//
|
|
// 1. ACTIVATING Contacts turns detection on at the tactical preset the military
|
|
// styles use. The session-wide `_detectionUserOverridden` flag gates the map
|
|
// STYLE presets only (an explicit Sparse/Off must survive a style switch)
|
|
// and is deliberately NOT consulted here — Contacts is a mode you enter, not
|
|
// a preset that fires behind your back.
|
|
// 2. Detection then STAYS on for the whole Contacts session. Cockpit enter and
|
|
// exit, and third-person tracking, do not touch it at all — they are moves
|
|
// WITHIN Contacts, and an earlier build that captured/restored around
|
|
// cockpit is exactly what turned detections off on cockpit exit.
|
|
// 3. Whatever the operator does next INSIDE the session stands. Detection is
|
|
// never re-forced while Contacts stays active, so a manual DETECT-off holds
|
|
// until the next activation.
|
|
//
|
|
// Deactivating Contacts restores the pre-Contacts state, mirroring the
|
|
// established `_cockpitVisionRestore` pattern in ui.js: a mode's look is scoped
|
|
// to that mode and never leaks out.
|
|
|
|
/**
|
|
* Normalize a detection state snapshot.
|
|
*
|
|
* The snapshot must cover EVERYTHING activation mutates, not just the mode.
|
|
* Activation applies the tactical preset, which writes density as well — so a
|
|
* mode-only snapshot restored OFF @ 25% as OFF @ 75%, and the operator's next
|
|
* manual enable came back Dense instead of the Sparse they had been using.
|
|
* @param {?{mode?: string, densityPct?: number}} state
|
|
* @returns {{mode: string, densityPct: ?number}}
|
|
*/
|
|
function normalizeDetectionState(state) {
|
|
const densityPct = Number(state?.densityPct);
|
|
return {
|
|
mode: String(state?.mode || 'OFF').toUpperCase(),
|
|
densityPct: Number.isFinite(densityPct) ? densityPct : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Plan the detection change for a Contacts ACTIVATION.
|
|
* @param {?{mode: string, densityPct: ?number}} current Detection state right now.
|
|
* @param {?{mode: string, densityPct: ?number}} restore Saved pre-Contacts state,
|
|
* if the session already owns it.
|
|
* @returns {?{restore: {mode: string, densityPct: ?number}, turnOn: boolean}} Null
|
|
* while Contacts is already active (re-entrancy: every context-mode sync
|
|
* re-runs this hook).
|
|
*/
|
|
export function contactsDetectionEnterPlan(current, restore) {
|
|
if (restore) return null;
|
|
const state = normalizeDetectionState(current);
|
|
return { restore: state, turnOn: state.mode === 'OFF' };
|
|
}
|
|
|
|
/**
|
|
* Plan the detection change for a Contacts DEACTIVATION.
|
|
*
|
|
* The entry snapshot is NOT replayed blindly. A map STYLE chosen during the
|
|
* session carries its own detection preset for the world outside Contacts (the
|
|
* military styles auto-enable Panoptic), and that rule is younger than the
|
|
* snapshot: replaying an entry-time OFF over it would defeat the style the
|
|
* operator just picked. The snapshot only wins when no style auto-enable rule
|
|
* applies on the way out.
|
|
*
|
|
* @param {?{mode: string, densityPct: ?number}} restore Saved pre-Contacts state.
|
|
* @param {?{mode: string, densityPct: ?number}} current Detection state right now.
|
|
* @param {boolean} [styleOwnsDetection] Whether the CURRENT map style's
|
|
* detection preset would apply outside Contacts (i.e. the style has one and
|
|
* the operator has not overridden detection this session).
|
|
* @returns {?{mode: string, densityPct: ?number}} State to replay, or null when
|
|
* there is nothing to do.
|
|
*/
|
|
export function contactsDetectionExitPlan(restore, current, styleOwnsDetection = false) {
|
|
if (!restore || styleOwnsDetection) return null;
|
|
const saved = normalizeDetectionState(restore);
|
|
const now = normalizeDetectionState(current);
|
|
if (saved.mode === now.mode && saved.densityPct === now.densityPct) return null;
|
|
return saved;
|
|
}
|
|
|
|
/**
|
|
* Run one Contacts activation/deactivation transition against a detection engine.
|
|
*
|
|
* The engine is injected so this is the SAME code the UI runs and the tests
|
|
* exercise, driven against the real `src/data/detection.js` in both. Turning on
|
|
* applies the TACTICAL PRESET the military styles use — owner playtest: "I want
|
|
* that as the default. It should just happen" — rather than restoring whatever
|
|
* profile the operator last left detection at.
|
|
*
|
|
* @param {object} input
|
|
* @param {boolean} input.active Whether Contacts is now active.
|
|
* @param {?{mode: string, densityPct: ?number}} input.restore Saved pre-Contacts state.
|
|
* @param {() => {mode: string, densityPct: ?number}} input.getState Reads the
|
|
* engine's full restorable detection state.
|
|
* @param {() => void} input.applyPreset Applies the tactical detection preset.
|
|
* @param {(state: {mode: string, densityPct: ?number}) => void} input.restoreState
|
|
* Replays a saved state.
|
|
* @param {boolean} [input.styleOwnsDetection] See contactsDetectionExitPlan.
|
|
* @returns {{restore: ?{mode: string, densityPct: ?number}, changed: boolean}} New
|
|
* saved state, and whether the engine was touched (so the caller can skip
|
|
* redundant UI syncs).
|
|
*/
|
|
export function applyContactsDetection({
|
|
active,
|
|
restore,
|
|
getState,
|
|
applyPreset,
|
|
restoreState,
|
|
styleOwnsDetection = false,
|
|
}) {
|
|
if (active) {
|
|
const plan = contactsDetectionEnterPlan(getState(), restore);
|
|
if (!plan) return { restore, changed: false };
|
|
if (plan.turnOn) applyPreset();
|
|
return { restore: plan.restore, changed: plan.turnOn };
|
|
}
|
|
const plan = contactsDetectionExitPlan(restore, getState(), styleOwnsDetection);
|
|
if (plan) restoreState(plan);
|
|
return { restore: null, changed: Boolean(plan) };
|
|
}
|
|
|
|
/**
|
|
* Detection as a DURABLE preference, for serialization into a share link.
|
|
*
|
|
* While Contacts is active it OWNS detection and forces the tactical preset
|
|
* (Dense @ 75%). That is a session-scoped override, not an operator choice: it
|
|
* is undone verbatim on deactivation. Serializing the FORCED values shipped a
|
|
* link that pinned Dense @ 75% on the recipient as a durable preference, with
|
|
* no Contacts mode present to explain or undo it, even when the author's own
|
|
* setting was OFF @ 50%. Publish what deactivation would restore instead.
|
|
*
|
|
* @param {object} input
|
|
* @param {?{mode: string, densityPct: ?number}} input.owned Saved pre-Contacts
|
|
* state; null whenever Contacts does not own detection.
|
|
* @param {string} input.liveMode Current engine detection mode.
|
|
* @param {number} input.liveDensityPct Current density percent.
|
|
* @returns {{mode: string, densityPct: number}} Values safe to serialize.
|
|
*/
|
|
export function shareableDetectionState({ owned, liveMode, liveDensityPct }) {
|
|
if (!owned) return { mode: liveMode, densityPct: liveDensityPct };
|
|
return {
|
|
mode: owned.mode ?? liveMode,
|
|
densityPct: Number.isFinite(owned.densityPct) ? owned.densityPct : liveDensityPct,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether the cached share state must be re-published after a Contacts
|
|
* transition.
|
|
*
|
|
* Serialization is ownership-dependent: while Contacts owns detection the link
|
|
* carries the SAVED snapshot, and once ownership is released it carries live
|
|
* state. So the cache goes stale on any ownership transition, whether or not
|
|
* the detection engine itself moved — and it does not always move. Exiting
|
|
* while a military style owns detection produces `changed: false` (that style's
|
|
* preset already matches what is on screen), which previously returned early
|
|
* and left a copied link advertising the operator's pre-Contacts values while
|
|
* the map showed Dense @ 75%.
|
|
*
|
|
* @param {object} input
|
|
* @param {boolean} input.changed Whether the detection engine was touched.
|
|
* @param {boolean} input.hadOwnership Contacts owned detection before.
|
|
* @param {boolean} input.hasOwnership Contacts owns detection now.
|
|
* @returns {boolean} True when the share cache must be re-synced.
|
|
*/
|
|
export function shareCacheNeedsHeal({ changed, hadOwnership, hasOwnership }) {
|
|
return Boolean(changed) || Boolean(hadOwnership) !== Boolean(hasOwnership);
|
|
}
|