Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/js/analysis/data-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function mountDataAnalysis(container, ctx) {
]);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
/** @type {any} */ (window).__remitFault?.(`provisioning: ${msg}`);
window.__remitFault?.(`provisioning: ${msg}`);
}
refresh();
}
Expand Down
26 changes: 26 additions & 0 deletions app/js/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Ambient declarations for the app's debug/test global handles and the shared
// cross-window context (DEC-61). Typing them here — at their origin — is what
// lets the use sites drop their `/** @type {any} */ (window)` casts.

type RemitContext = typeof import('./shell/context.js').context;

declare global {
interface Window {
/** The shared app context (DEC-61); the Overview lap attaches `.state`. Debug/test handle. */
__remit: RemitContext & { state?: unknown };
/** Surface a failure in the banner — nothing fails silently. Seeded by the Overview lap. */
__remitFault?: (msg: string) => void;
/** Minimal API a popped-out child calls to pop itself back in on close. */
__remitShell?: { popIn: (id: string, fromClose: boolean) => void };
/** Author-time terrain-sampling flag (views/map.js + tools/sample-terrain.mjs). */
__REMIT_SAMPLE?: boolean;
/** The live MapLibre map, exposed only while sampling terrain. */
__map?: unknown;
}

// context.js seeds globalThis.__remit so it exists regardless of which tab boots first.
// eslint-disable-next-line no-var
var __remit: RemitContext & { state?: unknown };
}

export {};
36 changes: 21 additions & 15 deletions app/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { getDraft, setDraft, subscribeDraft, reconcileOwnForce } from './orbat/o
// other role surface (tab) so they all project the same objects (DEC-61).
import { objects, logs, seam, world, playhead } from './shell/context.js';

/** @typedef {import('../../schema/gen/remit').Requirement} Requirement */
/** @typedef {import('../../schema/gen/remit').Plan} Plan */
/** @typedef {import('../../schema/gen/remit').Constraint} Constraint */

const MISSION_ID = 'M-001';
const STRATEGY_SEED = 1337;

Expand All @@ -43,26 +47,26 @@ const state = {
stage: 'world',
unlocked: new Set(['world']),
done: new Set(),
requirement: /** @type {any} */ (null),
requirement: /** @type {Requirement | null} */ (null),
ids: { requirement: '', baseline: '', profile: '', configCore: '', stamp: '', rationale: '' },
configCoreHash: '',
bandUnit: bandUnitFor(world.baseline.channels[0]),
appetites: { tempo: 'balanced', exposure: 'balanced' },
steering: /** @type {any[]} */ ([]), // operator no-go constraints (Plan)
handful: /** @type {any[]} */ ([]),
selectedPlan: /** @type {any} */ (null),
previewPlan: /** @type {any} */ (null), // COA highlighted (radio) in Compare, before commit
execPlan: /** @type {any} */ (null), // live clone played back (and re-routed) in Execute
execSummary: /** @type {any} */ (null),
steering: /** @type {Constraint[]} */ ([]), // operator no-go constraints (Plan)
handful: /** @type {Plan[]} */ ([]),
selectedPlan: /** @type {Plan | null} */ (null),
previewPlan: /** @type {Plan | null} */ (null), // COA highlighted (radio) in Compare, before commit
execPlan: /** @type {Plan | null} */ (null), // live clone played back (and re-routed) in Execute
execSummary: /** @type {unknown} */ (null),
horizonMin: 180,
lastPlanRequest: /** @type {any} */ (null),
lastPlanRequest: /** @type {unknown} */ (null),
nextHint: /** @type {string|null} */ (null),
};

// Debug/test handle (read-only use; not part of any contract). The context
// module seeds window.__remit = {objects, logs, seam, world, playhead}; attach
// the Overview's mission state so existing tooling can read window.__remit.state.
/** @type {any} */ (window).__remit.state = state;
window.__remit.state = state;

// --- projection surface (map + timeline + playhead) ------------------------
const mapEl = /** @type {HTMLElement} */ (document.getElementById('map'));
Expand Down Expand Up @@ -260,7 +264,7 @@ function showStage(/** @type {string} */ key) {

/** Surface a failure in the banner — nothing fails silently. */
function showFault(/** @type {string} */ msg) {
/** @type {any} */ (window).__remitFault?.(msg);
window.__remitFault?.(msg);
}

/**
Expand Down Expand Up @@ -308,23 +312,25 @@ function mountStage(/** @type {string} */ key) {
if (key === 'capture') mountCaptureStage();
if (key === 'plan') mountPlan();
if (key === 'compare') {
if (!state.requirement) return;
mountCompare(panel('compare'), {
seam, handful: state.handful, commitments: state.requirement.commitments,
onPreview(planId) {
// Highlighting a COA (radio) previews it everywhere — map + Sync Matrix
// own-force tracks — before the rationale is committed.
state.previewPlan = state.handful.find((p) => p.id === planId);
state.previewPlan = state.handful.find((p) => p.id === planId) ?? null;
renderProjection();
},
onSelected(planId, _rationale, rationaleId) {
state.selectedPlan = state.handful.find((p) => p.id === planId);
state.selectedPlan = state.handful.find((p) => p.id === planId) ?? null;
state.ids.rationale = rationaleId;
renderProjection();
advance('compare');
},
});
}
if (key === 'execute') {
if (!state.requirement) return;
// The wingman plays back (and may re-route) a live clone, so the committed
// plan stays immutable. Reset the shared playhead to H+0 (the removed Views
// interstitial used to do this on the way through).
Expand Down Expand Up @@ -448,7 +454,7 @@ function mountCaptureStage() {
// `constraints` payload is the schema's Constraint shape (DEC-24); the delta
// envelope (scope + attribution) is the DEC-61 stamped-delta scaffolding that
// becomes a first-class Delta type in the writes phase.
let steeringShareTimer = 0;
/** @type {ReturnType<typeof setTimeout> | undefined} */ let steeringShareTimer;
let lastSharedNogoKey = '';
function shareSteering() {
// Hex world: no-go cells are H3 ids (the kernel reads `cell.h3`), not square x/y.
Expand All @@ -472,7 +478,7 @@ function shareSteering() {
}
function scheduleShareSteering() {
clearTimeout(steeringShareTimer);
steeringShareTimer = /** @type {any} */ (setTimeout(shareSteering, 450));
steeringShareTimer = setTimeout(shareSteering, 450);
}

function mountPlan() {
Expand Down Expand Up @@ -553,7 +559,7 @@ function mountPlan() {
<h4 style="color:${STRAT_COLORS[/** @type {keyof typeof STRAT_COLORS} */ (p.strategy.key)]}">${p.strategy.label}</h4>
<div class="muted">${p.strategy.blurb}</div>
${p.tide_decision ? `<div class="tide-note" data-testid="tide-${p.strategy.key}">≋ ${p.tide_decision.narrative}</div>` : ''}
<div>observe <span class="band band-${obs.margin_band}">${obs.margin_band} ${obs.margin_min}m</span></div>
<div>observe <span class="band band-${obs?.margin_band ?? 'crossed'}">${obs?.margin_band ?? 'n/a'}${obs ? ` ${obs.margin_min}m` : ''}</span></div>
<div>exfil ${rv ? `H+${rv.end_min} ` : ''}<span class="band band-${exf?.margin_band ?? 'crossed'}">${exf?.margin_band ?? 'n/a'}${exf ? ` ${exf.margin_min}m` : ''}</span>
<span class="band band-${p.scores.cost_band}">cost ${p.scores.cost_band}</span></div>
<div class="muted">id <code class="hash">${shortId(p.id)}</code> = hash(stamp ⊕ strategy)</div>
Expand Down
67 changes: 38 additions & 29 deletions app/js/orbat/orbat.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { canonicalJSON, contentId } from '../shapes/canonical.js';
/** @typedef {import('../../../schema/gen/remit').BlueParams} BlueParams */
/** @typedef {import('../../../schema/gen/remit').RedParams} RedParams */
/** @typedef {import('../../../schema/gen/remit').GreenParams} GreenParams */
/** @typedef {import('../../../schema/gen/remit').HexCell} HexCell */
/** @typedef {import('../../../schema/gen/remit').TimeWindow} TimeWindow */
/** @typedef {import('../entities/entities.js').Entity} Entity */

/** localStorage key for the working draft (per mission). */
Expand Down Expand Up @@ -85,7 +87,7 @@ const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v));
const clampInt = (v, bounds) => Math.round(clamp(v, bounds));

/** Trim a free-text value; empty ⇒ undefined (so empties are dropped, not stored blank, FR-014). */
const cleanStr = (/** @type {any} */ v) => { const s = String(v ?? '').trim(); return s || undefined; };
const cleanStr = (/** @type {unknown} */ v) => { const s = String(v ?? '').trim(); return s || undefined; };

/** Set `obj[key]` to a free-text `value` when non-empty, else delete the key — so cleared
* fields are dropped, never stored blank (FR-014). @param {any} obj @param {string} key @param {any} value */
Expand All @@ -101,6 +103,10 @@ function setVocab(obj, key, value, vocab) {
if (vocab.includes(value)) obj[key] = value; // else: ignore
}

/** Vocabulary membership that accepts any string — avoids the readonly-tuple
* `.includes()` literal-narrowing without a cast. @param {readonly string[]} vocab @param {string} v */
const inVocab = (vocab, v) => vocab.some((x) => x === v);

/** Default per-allegiance parameter group.
* @param {'blue'|'red'|'green'} allegiance */
function defaultParams(allegiance) {
Expand Down Expand Up @@ -170,8 +176,8 @@ function replaceAsset(orbat, id, fn) {
* Add a default asset of an allegiance at `position`, with a fresh unique id.
* Rejects an out-of-AO position when an `inAO` predicate is supplied (FR-001/003).
* @param {Orbat} orbat
* @param {{ allegiance: 'blue'|'red'|'green', position?: any, label?: string }} seed
* @param {{ inAO?: (pos: any) => boolean }} [opts]
* @param {{ allegiance: 'blue'|'red'|'green', position?: HexCell, label?: string }} seed
* @param {{ inAO?: (pos: HexCell) => boolean }} [opts]
* @returns {{ orbat: Orbat, id: string }}
*/
export function addAsset(orbat, { allegiance, position, label }, opts = {}) {
Expand Down Expand Up @@ -207,7 +213,7 @@ export function duplicateAsset(orbat, id) {
* Apply `patch` to ONLY the targeted asset, clamping/validating its bounds (FR-004).
* Other assets stay byte-identical (SC-003). Returns a new draft.
* @param {Orbat} orbat @param {string} id @param {Partial<Asset>} patch
* @param {{ inAO?: (pos: any) => boolean }} [opts]
* @param {{ inAO?: (pos: HexCell) => boolean }} [opts]
* @returns {Orbat}
*/
export function tuneAsset(orbat, id, patch, opts = {}) {
Expand All @@ -221,14 +227,14 @@ export function tuneAsset(orbat, id, patch, opts = {}) {
if (patch.extent_m !== undefined) next.extent_m = clamp(Number(patch.extent_m), BOUNDS.extent_m);
// Enrichment (spec 005): kind/symbol/confidence + descriptive strength/notes. Vocab-checked
// where applicable; free-text is trimmed and empties are dropped (FR-014), never stored blank.
if ('kind' in patch) setVocab(next, 'kind', /** @type {any} */ (patch).kind, PLATFORM_KINDS);
if ('symbol' in patch) setOrDrop(next, 'symbol', cleanStr(/** @type {any} */ (patch).symbol));
if ('confidence' in patch) setVocab(next, 'confidence', /** @type {any} */ (patch).confidence, CONFIDENCE_LEVELS);
if ('strength' in patch) setOrDrop(next, 'strength', cleanStr(/** @type {any} */ (patch).strength));
if ('notes' in patch) setOrDrop(next, 'notes', cleanStr(/** @type {any} */ (patch).notes));
if ('kind' in patch) setVocab(next, 'kind', patch.kind, PLATFORM_KINDS);
if ('symbol' in patch) setOrDrop(next, 'symbol', cleanStr(patch.symbol));
if ('confidence' in patch) setVocab(next, 'confidence', patch.confidence, CONFIDENCE_LEVELS);
if ('strength' in patch) setOrDrop(next, 'strength', cleanStr(patch.strength));
if ('notes' in patch) setOrDrop(next, 'notes', cleanStr(patch.notes));
if (patch.red && next.allegiance === 'red') {
next.red = { ...next.red };
const pr = /** @type {any} */ (patch.red);
const pr = patch.red;
if (pr.severity !== undefined) next.red.severity = clampInt(Number(pr.severity), BOUNDS.severity);
if (pr.active_windows !== undefined) next.red.active_windows = sanitizeWindows(pr.active_windows);
if (pr.detection_range_m !== undefined) next.red.detection_range_m = clamp(Number(pr.detection_range_m), BOUNDS.extent_m);
Expand All @@ -240,24 +246,24 @@ export function tuneAsset(orbat, id, patch, opts = {}) {
}
if (patch.green && next.allegiance === 'green') {
next.green = { ...next.green };
const pg = /** @type {any} */ (patch.green);
const pg = patch.green;
if (pg.sensitivity !== undefined) next.green.sensitivity = clampInt(Number(pg.sensitivity), BOUNDS.sensitivity);
if (pg.protection !== undefined && PROTECTIONS.includes(pg.protection)) next.green.protection = pg.protection;
if (pg.protection !== undefined && inVocab(PROTECTIONS, pg.protection)) next.green.protection = pg.protection;
if ('category' in pg) setVocab(next.green, 'category', pg.category, GREEN_CATEGORIES);
}
if (patch.blue && next.allegiance === 'blue') {
next.blue = { ...next.blue };
if (patch.blue.availability !== undefined) next.blue.availability = String(patch.blue.availability);
if ('role' in patch.blue) setOrDrop(next.blue, 'role', cleanStr(/** @type {any} */ (patch.blue).role));
if ('role' in patch.blue) setOrDrop(next.blue, 'role', cleanStr(patch.blue.role));
if (patch.blue.capabilities !== undefined)
next.blue.capabilities = (patch.blue.capabilities ?? []).map(String).filter(Boolean);
if ('availability_window' in patch.blue) {
const w = /** @type {any} */ (patch.blue).availability_window;
const w = patch.blue.availability_window;
if (w && w.start_min != null && w.end_min != null) {
const s = Math.round(Number(w.start_min)), e = Math.round(Number(w.end_min));
/** @type {any} */ (next.blue).availability_window = { start_min: Math.min(s, e), end_min: Math.max(s, e) };
next.blue.availability_window = { start_min: Math.min(s, e), end_min: Math.max(s, e) };
} else {
delete (/** @type {any} */ (next.blue).availability_window);
delete next.blue.availability_window;
}
}
}
Expand All @@ -266,7 +272,7 @@ export function tuneAsset(orbat, id, patch, opts = {}) {
}

/** Clamp a list of time windows to start ≤ end, dropping malformed entries (FR-004).
* @param {any[]} windows */
* @param {TimeWindow[]} windows */
function sanitizeWindows(windows) {
return (windows ?? [])
.map((w) => ({ start_min: Math.round(Number(w.start_min)), end_min: Math.round(Number(w.end_min)) }))
Expand All @@ -291,15 +297,15 @@ export function removeAsset(orbat, id) {
* `canonical_own_force = true` (FR-012). The asset is reconciled, never duplicated — the
* plan keeps driving from the pre-existing machinery.
* @param {Orbat} orbat
* @param {{ label?: string, position?: any }} self the planned own-force entity/place
* @param {{ label?: string, position?: HexCell }} self the planned own-force entity/place
* @returns {Orbat}
*/
export function reconcileOwnForce(orbat, self) {
const assets = (orbat.assets ?? []).map((a) => {
// Strip any stray canonical flag from non-canonical rows (exactly one survives).
if (a.canonical_own_force && a.id !== OWN_FORCE_ID) {
const { canonical_own_force, ...rest } = a;
return /** @type {Asset} */ (rest);
return rest;
}
return a;
});
Expand All @@ -324,15 +330,18 @@ export function reconcileOwnForce(orbat, self) {
/**
* Validate one asset: allegiance ∈ {blue,red,green}, the matching param group present,
* bounds in range, window start ≤ end, and (when `inAO` is supplied) position in the AO.
* @param {Asset} asset @param {{ inAO?: (pos: any) => boolean }} [opts]
* @param {Asset} asset @param {{ inAO?: (pos: HexCell) => boolean }} [opts]
* @returns {{ ok: boolean, issues: string[] }}
*/
export function validate(asset, opts = {}) {
const issues = [];
if (!ALLEGIANCES.includes(/** @type {any} */ (asset.allegiance)))
if (!inVocab(ALLEGIANCES, asset.allegiance))
issues.push(`allegiance must be one of ${ALLEGIANCES.join('/')}`);
else if (!asset[/** @type {'blue'|'red'|'green'} */ (asset.allegiance)])
issues.push(`${asset.allegiance} asset is missing its ${asset.allegiance} parameter group`);
else {
const group = asset.allegiance === 'blue' ? asset.blue
: asset.allegiance === 'red' ? asset.red : asset.green;
if (!group) issues.push(`${asset.allegiance} asset is missing its ${asset.allegiance} parameter group`);
}
if (asset.extent_m !== undefined && (asset.extent_m < BOUNDS.extent_m[0] || asset.extent_m > BOUNDS.extent_m[1]))
issues.push(`extent_m out of bounds ${BOUNDS.extent_m.join('..')}`);
if (asset.red?.severity !== undefined && (asset.red.severity < BOUNDS.severity[0] || asset.red.severity > BOUNDS.severity[1]))
Expand All @@ -342,9 +351,9 @@ export function validate(asset, opts = {}) {
for (const w of asset.red?.active_windows ?? [])
if (Number(w.start_min) > Number(w.end_min)) issues.push('active window start_min must be ≤ end_min');
// Enrichment (spec 005): vocab + red dual-range bounds + engagement ≤ detection.
if (asset.kind !== undefined && !PLATFORM_KINDS.includes(/** @type {any} */ (asset.kind))) issues.push('unknown platform kind');
if (asset.confidence !== undefined && !CONFIDENCE_LEVELS.includes(/** @type {any} */ (asset.confidence))) issues.push('unknown confidence level');
if (asset.green?.category !== undefined && !GREEN_CATEGORIES.includes(/** @type {any} */ (asset.green.category))) issues.push('unknown green category');
if (asset.kind !== undefined && !inVocab(PLATFORM_KINDS, asset.kind)) issues.push('unknown platform kind');
if (asset.confidence !== undefined && !inVocab(CONFIDENCE_LEVELS, asset.confidence)) issues.push('unknown confidence level');
if (asset.green?.category !== undefined && !inVocab(GREEN_CATEGORIES, asset.green.category)) issues.push('unknown green category');
const dr = asset.red?.detection_range_m, er = asset.red?.engagement_range_m;
if (dr !== undefined && (dr < BOUNDS.extent_m[0] || dr > BOUNDS.extent_m[1])) issues.push(`detection_range_m out of bounds ${BOUNDS.extent_m.join('..')}`);
if (er !== undefined && (er < BOUNDS.extent_m[0] || er > BOUNDS.extent_m[1])) issues.push(`engagement_range_m out of bounds ${BOUNDS.extent_m.join('..')}`);
Expand Down Expand Up @@ -457,7 +466,7 @@ export function subscribeDraft(fn) {
* Any asset with a time-varying aspect (red `active_windows`, a blue availability window)
* exposes a `window`-type aspect (reusing the satellite-pass render path) so it appears as
* a Sync-Matrix track. Contains NO kernel reference and alters no plan (NF9).
* @param {Asset} asset @returns {Entity & { allegiance: string, position?: any, asset: Asset }}
* @param {Asset} asset @returns {Entity & { allegiance: string, position?: HexCell, asset: Asset }}
*/
export function assetToEntity(asset) {
const windows = activeWindowsOf(asset);
Expand All @@ -466,7 +475,7 @@ export function assetToEntity(asset) {
if (windows.length) {
aspects.active = {
type: 'window',
at: (/** @type {any} */ _plan, /** @type {number} */ t) => windows.some((w) => t >= w.start && t <= w.end),
at: (/** @type {unknown} */ _plan, /** @type {number} */ t) => windows.some((w) => t >= w.start && t <= w.end),
windows: (/** @type {number} */ h) => windows
.filter((w) => w.end >= 0 && w.start <= h)
.map((w) => ({ start: Math.max(0, w.start), end: Math.min(h, w.end), center: (w.start + w.end) / 2 })),
Expand All @@ -489,7 +498,7 @@ function activeWindowsOf(asset) {
if (asset.allegiance === 'red')
return (asset.red?.active_windows ?? []).map((w) => ({ start: Number(w.start_min), end: Number(w.end_min) }));
if (asset.allegiance === 'blue') {
const w = /** @type {any} */ (asset.blue)?.availability_window;
const w = asset.blue?.availability_window;
if (w && w.start_min != null && w.end_min != null) return [{ start: Number(w.start_min), end: Number(w.end_min) }];
}
return [];
Expand Down
2 changes: 1 addition & 1 deletion app/js/shell/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ export const context = { objects, logs, seam, world, playhead };

// Debug/test handle (read-only use; not part of any contract). Seeded here so
// it exists regardless of which tab boots first; main.js attaches `.state`.
/** @type {any} */ (globalThis).__remit = context;
globalThis.__remit = context;
Loading
Loading