diff --git a/AI-Car-Racer/adaptiveGates.js b/AI-Car-Racer/adaptiveGates.js new file mode 100644 index 0000000..1847e82 --- /dev/null +++ b/AI-Car-Racer/adaptiveGates.js @@ -0,0 +1,839 @@ +// adaptiveGates.js — opt-in curriculum for green checkpoint lines. +// +// When enabled: +// 1. Measure reach rates from popCheckpoints each generation. +// 2. Find the bottleneck gate (largest drop in reach rate). +// 3. Use crash positions (popDeathXY — same signal as Heat mode) to aim +// nudges: pull the hard gate toward the approach side of the death +// cluster so cars get credit / next-CP direction *before* the wall. +// 4. Occasionally ADD a gate (midway before a severe cliff) or REMOVE a +// near-redundant intermediate. +// 5. Remember high-survival layouts + crash centroids per track. +// +// Relation to Heat / ruvector: +// Heat mode — live viz of death deposits. +// Worker popDeathXY — same events, authoritative end-of-gen positions. +// Crash-map HNSW (ruvectorBridge) — encodes the death grid (16×9) into a +// 144-d vector, stores it with the gate layout + survival, retrieves +// similar past crash problems so we can reuse layouts that worked. +// Brains archive — still separate (who drives); crash maps are curriculum. +// +// Safety rails (from prior Triangle curriculum failures): +// - Never reorder walls; never change gate *order*, only insert/delete slots +// - Min 4 gates; max baseline.length + MAX_EXTRA +// - Protect first & last gate from removal (spawn / lap seal) +// - Topology changes at most every TOPO_EVERY adapt cycles +// - Hard caps on nudge distance from baseline when lengths match +// +// Off by default. 🧪 Experiments → "Adaptive green gates" or `?adaptGates=1`. + +(function (global) { + 'use strict'; + + const MAX_STEP = 0.08; + const MAX_DRIFT_PX = 55; + const MAX_STEP_PX = 14; + const MIN_GENS_BETWEEN = 1; + const MIN_GATES = 4; + const MAX_EXTRA = 2; // may grow to baseline.length + MAX_EXTRA + const TOPO_EVERY = 3; // topology change cooldown (adapt cycles) + const ADD_DROP = 0.32; // cliff size that can trigger an insert + const REMOVE_RATIO = 0.97; // rates[k]/rates[k-1] above this → redundant + const REMOVE_MIN_REACH = 0.20; // only prune if enough cars reach the pair + const MEMORY_PREFIX = 'vv_adapt_gates_'; + const MEMORY_RING = 6; + + const state = { + enabled: false, + baseline: null, + lastStatus: 'off', + lastBottleneck: -1, + lastSurvival: null, + lastCrashCentroid: null, // {x,y,n} dominant death cluster this gen + lastCrashHit: null, // best HNSW crash-map retrieval this gen + hnswHits: 0, + hnswApplies: 0, + badStreak: 0, + nudgeCount: 0, + addCount: 0, + removeCount: 0, + genSinceAdapt: 0, + topoCooldown: 0, + trackKey: null, + }; + + const CRASH_SIM_MIN = 0.55; // min cosine sim to trust a retrieved layout + const CRASH_SURV_LIFT = 0.03; // retrieved survival must beat current by this + + try { + if (new URLSearchParams(location.search).get('adaptGates') === '1') { + state.enabled = true; + } + } catch (_) {} + + function cloneCps(cps) { + if (!cps || !cps.length) return []; + return cps.map(function (seg) { + return [ + { x: +seg[0].x, y: +seg[0].y }, + { x: +seg[1].x, y: +seg[1].y }, + ]; + }); + } + + function mid(seg) { + return { + x: (seg[0].x + seg[1].x) * 0.5, + y: (seg[0].y + seg[1].y) * 0.5, + }; + } + + function dist(a, b) { + return Math.hypot(a.x - b.x, a.y - b.y); + } + + function nudgeToward(p, target, t) { + return { + x: p.x + (target.x - p.x) * t, + y: p.y + (target.y - p.y) * t, + }; + } + + function clampStep(from, to, maxPx) { + const d = dist(from, to); + if (d <= maxPx || d < 1e-6) return to; + const s = maxPx / d; + return { x: from.x + (to.x - from.x) * s, y: from.y + (to.y - from.y) * s }; + } + + function clampDrift(p, base, maxPx) { + if (!base) return p; + const d = dist(p, base); + if (d <= maxPx || d < 1e-6) return p; + const s = maxPx / d; + return { x: base.x + (p.x - base.x) * s, y: base.y + (p.y - base.y) * s }; + } + + /** Linear blend of two gate segments (for inserting a midway gate). */ + function lerpGate(a, b, t) { + return [ + { + x: a[0].x + (b[0].x - a[0].x) * t, + y: a[0].y + (b[0].y - a[0].y) * t, + }, + { + x: a[1].x + (b[1].x - a[1].x) * t, + y: a[1].y + (b[1].y - a[1].y) * t, + }, + ]; + } + + function currentCps() { + try { + if (typeof road !== 'undefined' && road && road.roadEditor && + road.roadEditor.checkPointListEditor && + road.roadEditor.checkPointListEditor.length) { + return road.roadEditor.checkPointListEditor; + } + } catch (_) {} + return null; + } + + function maxGates() { + const baseN = (state.baseline && state.baseline.length) || 0; + return Math.max(MIN_GATES, baseN + MAX_EXTRA); + } + + /** + * Stable id for the *walls* (not the adaptive gate list). Used so Triangle + * memory never re-applies onto Rectangle after a preset switch. + */ + function geometrySignature() { + try { + const re = road.roadEditor; + if (!re) return 'g0'; + const parts = []; + const pushPts = function (arr) { + if (!arr) return; + for (let i = 0; i < arr.length; i++) { + parts.push(Math.round(arr[i].x) + ',' + Math.round(arr[i].y)); + } + }; + pushPts(re.points); + parts.push('|'); + pushPts(re.points2); + // Include *baseline* gate count when known; avoid live adaptive gate + // count so adds/removes don't thrash the track key every gen. + if (state.baseline && state.baseline.length) { + parts.push('|b' + state.baseline.length); + } + let h = 2166136261; + const s = parts.join(';'); + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return 'g' + (h >>> 0).toString(16); + } catch (_) { + return 'g0'; + } + } + + function trackKey() { + return MEMORY_PREFIX + geometrySignature(); + } + + /** True when adaptive state still refers to a different wall geometry. */ + function trackStale() { + if (!state.trackKey) return true; + // Compare without the baseline-length suffix drift: recompute from walls only. + try { + const re = road.roadEditor; + if (!re || !re.points || !re.points2) return true; + // If baseline exists, check first wall point still matches baseline context + // by seeing whether live walls match what we hashed into trackKey. + return state.trackKey !== trackKey(); + } catch (_) { + return true; + } + } + + function loadMemory(key) { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw); + } catch (_) { return null; } + } + + function saveMemory(key, mem) { + try { localStorage.setItem(key, JSON.stringify(mem)); } catch (_) {} + } + + function captureBaseline() { + const cps = currentCps(); + if (!cps || !cps.length) return false; + state.baseline = cloneCps(cps); + // Hash walls *after* baseline length is known so trackKey is stable. + state.trackKey = trackKey(); + state.nudgeCount = 0; + state.addCount = 0; + state.removeCount = 0; + state.badStreak = 0; + state.lastBottleneck = -1; + state.lastSurvival = null; + state.lastCrashCentroid = null; + state.lastCrashHit = null; + state.topoCooldown = 0; + state.genSinceAdapt = 0; + return true; + } + + /** + * Called when the user loads a different preset / redraws the track. + * Drops Triangle baselines so Reset / HNSW / bad-streak restore cannot + * paint the old green gates onto the new walls. + */ + function onTrackChange() { + // Drop stale adaptive state first so trackKey() doesn't include the old + // baseline gate count in the geometry signature. + state.baseline = null; + state.trackKey = null; + state.nudgeCount = 0; + state.addCount = 0; + state.removeCount = 0; + state.badStreak = 0; + state.lastBottleneck = -1; + state.lastSurvival = null; + state.lastCrashCentroid = null; + state.lastCrashHit = null; + state.topoCooldown = 0; + state.genSinceAdapt = 0; + state._hnswNote = null; + + const ok = captureBaseline(); + // Do NOT restoreBestIfAny() here — that re-applies an adapted layout and + // is the wrong move right after an explicit preset load (preset CPs win). + state.lastStatus = state.enabled + ? (ok + ? ('on · track changed · baseline = ' + state.baseline.length + ' gates (preset)') + : 'on · track changed · no gates yet') + : (ok ? 'off · track changed · baseline recaptured' : 'off'); + return ok; + } + + function applyCps(cps) { + if (!cps || !cps.length) return false; + try { + if (typeof road === 'undefined' || !road || !road.roadEditor) return false; + road.roadEditor.checkPointListEditor = cloneCps(cps); + road.checkPointList = cloneCps(cps); + if (typeof road.rebuildGrids === 'function') road.rebuildGrids(); + try { localStorage.setItem('checkPointList', JSON.stringify(cps)); } catch (_) {} + if (typeof invalidateWorkerInit === 'function') invalidateWorkerInit(); + if (window.DemoPresentation && window.DemoPresentation.invalidateRoad) { + window.DemoPresentation.invalidateRoad(); + } + try { + if (typeof embedCurrentTrack === 'function') embedCurrentTrack(); + } catch (_) {} + return true; + } catch (e) { + console.warn('[adaptiveGates] apply failed', e); + return false; + } + } + + function reachRates(popCheckpoints, cpLen, N) { + const rates = new Array(cpLen + 1); + rates[0] = 1; + for (let k = 1; k <= cpLen; k++) { + let c = 0; + for (let i = 0; i < N; i++) if ((popCheckpoints[i] | 0) >= k) c++; + rates[k] = c / N; + } + return rates; + } + + function findBottleneck(rates) { + let bestK = 1; + let bestDrop = -1; + for (let k = 1; k < rates.length; k++) { + const drop = rates[k - 1] - rates[k]; + if (drop > bestDrop) { + bestDrop = drop; + bestK = k; + } + } + return { gate: bestK, drop: bestDrop }; + } + + /** + * Mean death position for cars that died after clearing `afterCp` gates + * but not more (failed trying for the next gate). Falls back to global + * death mean. Coordinates come from the worker (same events Heat paints). + */ + function crashCentroid(genData, afterCp) { + const N = genData.popN | 0; + const xy = genData.popDeathXY; + const counts = genData.popCheckpoints; + if (!xy || !N) return null; + + let sx = 0, sy = 0, n = 0; + let gsx = 0, gsy = 0, gn = 0; + for (let i = 0; i < N; i++) { + const x = xy[i * 2], y = xy[i * 2 + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + gsx += x; gsy += y; gn++; + if (counts && (counts[i] | 0) === (afterCp | 0)) { + sx += x; sy += y; n++; + } + } + if (n >= 3) return { x: sx / n, y: sy / n, n: n, scoped: true }; + if (gn >= 5) return { x: gsx / gn, y: gsy / gn, n: gn, scoped: false }; + return null; + } + + /** + * Target for nudging gate b: blend previous-gate mid with crash centroid + * so the gate moves into the approach corridor, not onto the wall. + */ + function crashAwareTarget(next, b, crash) { + let prevMid; + if (b <= 0) { + try { + prevMid = (typeof startInfo !== 'undefined' && startInfo) + ? { x: startInfo.x, y: startInfo.y } + : mid(next[0]); + } catch (_) { + prevMid = mid(next[0]); + } + } else { + prevMid = mid(next[b - 1]); + } + if (!crash) return prevMid; + return { + x: prevMid.x * 0.65 + crash.x * 0.35, + y: prevMid.y * 0.65 + crash.y * 0.35, + }; + } + + /** Index of most redundant intermediate gate (0-based), or -1. */ + function findRedundant(rates, cpLen) { + // Prefer removing the *easiest* intermediate transition (highest pass ratio), + // never first/last. + let bestI = -1; + let bestRatio = 0; + for (let k = 2; k <= cpLen - 1; k++) { + // k is 1-based gate; intermediate if not first/last + if (rates[k - 1] < REMOVE_MIN_REACH) continue; + const ratio = rates[k - 1] > 1e-6 ? rates[k] / rates[k - 1] : 0; + if (ratio >= REMOVE_RATIO && ratio > bestRatio) { + bestRatio = ratio; + bestI = k - 1; // 0-based + } + } + return bestI; + } + + function tryInsert(next, b, rates, drop, crash) { + // b = 0-based bottleneck index. Insert a gate *before* it (between b-1 and b) + // so the cliff is split into two smaller steps. If we have a crash centroid, + // bias the new gate toward the approach-to-crash locus. + if (state.topoCooldown > 0) return null; + if (drop < ADD_DROP) return null; + if (next.length >= maxGates()) return null; + if (b < 1) return null; // need a previous gate to interpolate from + + const prev = next[b - 1]; + const cur = next[b]; + // Avoid stacking inserts when gates are already very close. + if (dist(mid(prev), mid(cur)) < 80) return null; + + let inserted = lerpGate(prev, cur, 0.5); + if (crash) { + // Slide the synthetic gate so its midpoint sits nearer the crash approach. + const m = mid(inserted); + const aim = { + x: m.x * 0.45 + crash.x * 0.55, + y: m.y * 0.45 + crash.y * 0.55, + }; + const dx = aim.x - m.x, dy = aim.y - m.y; + inserted = [ + { x: inserted[0].x + dx, y: inserted[0].y + dy }, + { x: inserted[1].x + dx, y: inserted[1].y + dy }, + ]; + } + next.splice(b, 0, inserted); + state.addCount++; + state.topoCooldown = TOPO_EVERY; + return { + kind: 'add', + msg: 'added gate before #' + (b + 1) + + ' (cliff ' + (drop * 100).toFixed(0) + 'pp' + + (crash ? ', crash-aimed' : '') + + ') · now ' + next.length + ' gates', + }; + } + + function tryRemove(next, rates) { + if (state.topoCooldown > 0) return null; + if (next.length <= MIN_GATES) return null; + // Only prune when the population is doing OK overall — don't strip + // structure while everything is still dying early. + if (rates[1] < 0.15) return null; + + const idx = findRedundant(rates, next.length); + if (idx < 0) return null; + + next.splice(idx, 1); + state.removeCount++; + state.topoCooldown = TOPO_EVERY; + return { + kind: 'remove', + msg: 'removed redundant gate #' + (idx + 1) + + ' · now ' + next.length + ' gates', + }; + } + + function tryNudge(next, b, crash) { + const target = crashAwareTarget(next, b, crash); + const usedCrash = !!(crash && crash.n >= 3); + + const canClamp = state.baseline && state.baseline.length === next.length && b < state.baseline.length; + for (let e = 0; e < 2; e++) { + let p = nudgeToward(next[b][e], target, MAX_STEP); + p = clampStep(next[b][e], p, MAX_STEP_PX); + if (canClamp) p = clampDrift(p, state.baseline[b][e], MAX_DRIFT_PX); + next[b][e] = p; + } + state.nudgeCount++; + return { + kind: 'nudge', + msg: 'nudged gate #' + (b + 1) + + (usedCrash + ? ' toward crash heat (n=' + crash.n + (crash.scoped ? ', scoped' : '') + ')' + : (' toward ' + (b <= 0 ? 'spawn' : ('#' + b)))), + }; + } + + function encodeCrashVec(genData) { + try { + if (window.CrashMapCodec && window.CrashMapCodec.encodeDeathMap) { + return window.CrashMapCodec.encodeDeathMap( + genData.popDeathXY, genData.popN | 0, + (typeof canvas !== 'undefined' && canvas && canvas.width) || 3200, + (typeof canvas !== 'undefined' && canvas && canvas.height) || 1800 + ); + } + const b = window.__rvBridge; + if (b && typeof b.encodeCrashMap === 'function') { + return b.encodeCrashMap(genData.popDeathXY, genData.popN | 0); + } + } catch (_) {} + return null; + } + + function causesOf(genData) { + try { + if (window.CrashMapCodec && window.CrashMapCodec.causeHistogram) { + return window.CrashMapCodec.causeHistogram(genData.popDeathCauses, genData.popN | 0); + } + } catch (_) {} + return null; + } + + /** + * Query HNSW for similar crash maps. If a hit carries a gate layout with + * better survival, apply it (full replace). Returns status string or null. + */ + function tryHnswLayout(crashVec, survival, genData) { + const b = window.__rvBridge; + if (!b || typeof b.recommendCrashLayouts !== 'function') return null; + if (window.rvDisabled) return null; + let hits; + try { hits = b.recommendCrashLayouts(crashVec, 5); } + catch (_) { return null; } + if (!hits || !hits.length) return null; + state.hnswHits++; + state.lastCrashHit = hits[0]; + + // Prefer highest survival among sufficiently similar maps on *this* wall geometry. + const geo = geometrySignature(); + let best = null; + for (let i = 0; i < hits.length; i++) { + const h = hits[i]; + if (!h.cps || !h.cps.length) continue; + // Reject layouts archived on a different track (Triangle ≠ Rectangle). + // Require geometrySig: older untagged maps are never auto-applied + // (avoids painting Triangle gates onto Rectangle after a preset switch). + if (!h.geometrySig || h.geometrySig !== geo) continue; + if ((h.similarity || 0) < CRASH_SIM_MIN) continue; + if ((h.survival || 0) < survival + CRASH_SURV_LIFT) continue; + if (!best || h.survival > best.survival || + (h.survival === best.survival && h.similarity > best.similarity)) { + best = h; + } + } + if (!best) { + return 'hnsw: ' + hits.length + ' similar crash map(s), none beat survival'; + } + if (!applyCps(best.cps)) return 'hnsw: apply failed'; + state.hnswApplies++; + state.topoCooldown = TOPO_EVERY; + return 'hnsw: applied layout from similar crash (sim ' + + (best.similarity * 100).toFixed(0) + '%, surv ' + + (best.survival * 100).toFixed(0) + '%, ' + best.cps.length + ' gates)'; + } + + function archiveCrashToBridge(crashVec, survival, fitness, cps, genData, bottleneck) { + const b = window.__rvBridge; + if (!b || typeof b.archiveCrashMap !== 'function') return; + if (window.rvDisabled) return; + let nDeaths = 0; + const xy = genData.popDeathXY; + const N = genData.popN | 0; + if (xy) { + for (let i = 0; i < N; i++) { + if (Number.isFinite(xy[i * 2]) && Number.isFinite(xy[i * 2 + 1])) nDeaths++; + } + } + try { + b.archiveCrashMap(crashVec, { + survival: survival, + fitness: fitness || 0, + generation: (typeof generation === 'number' ? generation : 0), + nDeaths: nDeaths, + nGates: cps.length, + cps: cloneCps(cps), + causes: causesOf(genData), + bottleneck: bottleneck, + geometrySig: geometrySignature(), + }); + } catch (e) { + console.warn('[adaptiveGates] archiveCrashMap failed', e); + } + } + + function adaptOnce(genData) { + const cps = currentCps(); + if (!cps || cps.length < 2) { + state.lastStatus = 'on · need ≥2 gates'; + return false; + } + // Track switch while adaptive is on: recapture baseline before any nudge/HNSW. + if (!state.baseline || trackStale()) { + onTrackChange(); + } + + const N = genData.popN | 0; + const popCp = genData.popCheckpoints; + if (!N || !popCp || popCp.length < N) { + state.lastStatus = 'on · waiting for pop stats'; + return false; + } + + const cpLen = cps.length; + const rates = reachRates(popCp, cpLen, N); + const survival = (genData.popStillAlive | 0) / N; + const { gate: bot1, drop } = findBottleneck(rates); + state.lastBottleneck = bot1; + + // Crash heat for cars that died with checkPointsCount === bot1-1 + // (failed trying to reach bottleneck gate). Same events Heat mode paints. + const crash = crashCentroid(genData, bot1 - 1); + state.lastCrashCentroid = crash; + + // Encode + HNSW retrieve similar crash problems (ruvector crash-map index). + const crashVec = encodeCrashVec(genData); + if (crashVec) { + const hnswMsg = tryHnswLayout(crashVec, survival, genData); + if (hnswMsg && hnswMsg.indexOf('applied') !== -1) { + state.lastStatus = 'on · ' + hnswMsg; + state.lastSurvival = survival; + const nowCps = currentCps() || cps; + maybeRemember(survival, genData.fitness || 0, nowCps, crash); + archiveCrashToBridge(crashVec, survival, genData.fitness || 0, nowCps, genData, bot1); + return true; + } + // Always archive this gen's crash map + layout for future retrieval. + archiveCrashToBridge(crashVec, survival, genData.fitness || 0, cps, genData, bot1); + if (hnswMsg) { + // Fall through to local nudge/add/remove, but keep hnsw note. + state._hnswNote = hnswMsg; + } else { + state._hnswNote = null; + } + } else { + state._hnswNote = null; + } + + if (state.topoCooldown > 0) state.topoCooldown--; + + // Healthy population clearing the course — prefer pruning fluff over fidgeting. + if (rates[cpLen] > 0.55 && drop < 0.08) { + const next = cloneCps(cps); + const op = tryRemove(next, rates); + if (op && applyCps(next)) { + state.lastStatus = 'on · ' + op.msg; + state.lastSurvival = survival; + maybeRemember(survival, genData.fitness || 0, next, crash); + return true; + } + state.lastStatus = 'on · no bottleneck (population clearing gates)' + + (crash ? ' · heat n=' + crash.n : ''); + state.lastSurvival = survival; + maybeRemember(survival, genData.fitness || 0, cps, crash); + return false; + } + + if (state.lastSurvival != null && survival < state.lastSurvival - 0.04) { + state.badStreak++; + } else { + state.badStreak = 0; + } + state.lastSurvival = survival; + + const next = cloneCps(cps); + const b = bot1 - 1; + let op = null; + + if (state.badStreak >= 2 && state.baseline) { + // Hard reset toward baseline geometry (handles length mismatch too). + const restored = cloneCps(state.baseline); + state.badStreak = 0; + state.topoCooldown = TOPO_EVERY; + if (applyCps(restored)) { + state.lastStatus = 'on · survival dipped — restored baseline gates'; + maybeRemember(survival, genData.fitness || 0, restored, crash); + return true; + } + } + + // Priority: severe cliff → try insert (crash-aimed); else crash-aware nudge. + op = tryInsert(next, b, rates, drop, crash); + if (!op) op = tryNudge(next, Math.min(b, next.length - 1), crash); + // After a nudge on a mild cliff, also consider pruning elsewhere. + if (op && op.kind === 'nudge' && drop < 0.12 && next.length > MIN_GATES) { + const pruned = cloneCps(next); + const rm = tryRemove(pruned, rates); + if (rm) { + op = rm; + for (let i = next.length - 1; i >= 0; i--) next.pop(); + for (let i = 0; i < pruned.length; i++) next.push(pruned[i]); + } + } + + if (!op) { + state.lastStatus = 'on · no action'; + return false; + } + + if (!applyCps(next)) { + state.lastStatus = 'on · apply failed'; + return false; + } + + const pct = (rates[bot1] * 100).toFixed(0); + const prevPct = (rates[bot1 - 1] * 100).toFixed(0); + state.lastStatus = + 'on · bottleneck #' + bot1 + + ' (' + prevPct + '%→' + pct + '%) · ' + op.msg + + ' · n/a/r ' + state.nudgeCount + '/' + state.addCount + '/' + state.removeCount + + (state._hnswNote ? ' · ' + state._hnswNote : '') + + (crashVec ? ' · map archived' : ''); + maybeRemember(survival, genData.fitness || 0, next, crash); + if (crashVec) { + archiveCrashToBridge(crashVec, survival, genData.fitness || 0, next, genData, bot1); + } + return true; + } + + function maybeRemember(survival, fitness, cps, crash) { + const key = state.trackKey || trackKey(); + let mem = loadMemory(key) || { best: null, ring: [] }; + const entry = { + survival: +survival.toFixed(4), + fitness: +(+fitness || 0).toFixed(3), + cps: cloneCps(cps), + nGates: cps.length, + geometrySig: geometrySignature(), + crash: crash ? { x: +crash.x.toFixed(1), y: +crash.y.toFixed(1), n: crash.n | 0 } : null, + t: Date.now(), + }; + mem.ring.push(entry); + if (mem.ring.length > MEMORY_RING) mem.ring.shift(); + if (!mem.best || + entry.survival > mem.best.survival + 0.01 || + (Math.abs(entry.survival - mem.best.survival) < 0.01 && entry.fitness > mem.best.fitness)) { + mem.best = entry; + } + if (state.baseline) mem.baseline = cloneCps(state.baseline); + saveMemory(key, mem); + } + + function restoreBestIfAny() { + const key = trackKey(); + const mem = loadMemory(key); + if (!mem || !mem.best || !mem.best.cps || !mem.best.cps.length) return false; + // Memory is geometry-keyed; still reject if wall sig diverged. + if (mem.best.geometrySig && mem.best.geometrySig !== geometrySignature()) return false; + if (applyCps(mem.best.cps)) { + state.lastStatus = + 'on · restored best layout for this track (' + + mem.best.cps.length + ' gates, survival ' + + (mem.best.survival * 100).toFixed(0) + '%)'; + return true; + } + return false; + } + + function setEnabled(on) { + state.enabled = !!on; + if (!state.enabled) { + state.lastStatus = 'off'; + return state.enabled; + } + // Always align baseline with the *current* walls when enabling. + if (!state.baseline || trackStale()) { + onTrackChange(); + } + // Optional: restore a *same-track* remembered layout (not another preset). + if (!restoreBestIfAny()) { + state.lastStatus = 'on · watching pass rates + crash HNSW (baseline ' + + ((state.baseline && state.baseline.length) || 0) + ' gates)'; + } + return state.enabled; + } + + function resetToBaseline() { + // If the user switched tracks, baseline may still be the old triangle — + // refuse to paint it; recapture from whatever gates the preset installed. + if (!state.baseline || trackStale()) { + const ok = onTrackChange(); + state.lastStatus = state.enabled + ? (ok + ? ('on · baseline recaptured for current track (' + state.baseline.length + ' gates)') + : 'on · cannot reset (no gates)') + : 'off · baseline recaptured'; + return ok; + } + const ok = applyCps(state.baseline); + state.nudgeCount = 0; + state.addCount = 0; + state.removeCount = 0; + state.badStreak = 0; + state.lastBottleneck = -1; + state.topoCooldown = 0; + state.lastStatus = state.enabled + ? 'on · reset to baseline (' + state.baseline.length + ' gates)' + : 'off · baseline restored'; + return ok; + } + + function onGenEnd(genData) { + if (!state.enabled) return; + // Detect track switches that happened without onTrackChange (defensive). + if (trackStale()) onTrackChange(); + state.genSinceAdapt++; + if (state.genSinceAdapt < MIN_GENS_BETWEEN) return; + state.genSinceAdapt = 0; + try { + adaptOnce(genData); + } catch (e) { + console.warn('[adaptiveGates] onGenEnd failed', e); + state.lastStatus = 'on · error (see console)'; + } + } + + function getStatus() { + let crashMaps = 0; + try { + const b = window.__rvBridge; + if (b && b.info) crashMaps = (b.info().crashMaps && b.info().crashMaps.count) | 0; + else if (b && typeof b.crashMapCount === 'function') crashMaps = b.crashMapCount() | 0; + } catch (_) {} + return { + enabled: state.enabled, + status: state.lastStatus, + bottleneck: state.lastBottleneck, + nudgeCount: state.nudgeCount, + addCount: state.addCount, + removeCount: state.removeCount, + hnswHits: state.hnswHits, + hnswApplies: state.hnswApplies, + crashMaps: crashMaps, + survival: state.lastSurvival, + crash: state.lastCrashCentroid, + lastCrashHit: state.lastCrashHit, + hasBaseline: !!state.baseline, + baselineGates: state.baseline ? state.baseline.length : 0, + trackKey: state.trackKey, + }; + } + + global.AdaptiveGates = { + setEnabled: setEnabled, + isEnabled: function () { return !!state.enabled; }, + onGenEnd: onGenEnd, + onTrackChange: onTrackChange, + resetToBaseline: resetToBaseline, + captureBaseline: captureBaseline, + getStatus: getStatus, + geometrySignature: geometrySignature, + _state: state, + }; + + if (state.enabled) { + const boot = function () { + try { + if (currentCps() && currentCps().length) setEnabled(true); + } catch (_) {} + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { setTimeout(boot, 50); }); + } else { + setTimeout(boot, 50); + } + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/AI-Car-Racer/buttonResponse.js b/AI-Car-Racer/buttonResponse.js index dc1c5a3..2be105b 100644 --- a/AI-Car-Racer/buttonResponse.js +++ b/AI-Car-Racer/buttonResponse.js @@ -424,22 +424,16 @@ function setConservativeInit(value){ conservativeInit = Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0; try { localStorage.setItem("conservativeInit", String(conservativeInit)); } catch (_) {} } -// Training-phase presets. Each entry sets the four knobs that dominate -// training feel: N (population size), simSpeed (stride schedule kicks in -// above 2×), seconds (per-generation evaluation window), mutateValue -// (GA variance). Values are step-aligned to the slider granularity so -// the DOM reflects them exactly. -// fresh — random brains: small population, honest 2× (stride=1), high -// variance, short gens. Cheapest path from garbage to "turns". -// grind — elite drives laps consistently: grind raw generation count -// via high simSpeed. Fitness noise from stride=3 is tolerated -// because elite always survives. -// polish — refine a competent brain: wider population, low variance, -// longer gens for multi-lap evaluation, honest 2×. +// Training-phase presets. Each entry sets the knobs that dominate training +// feel: N, simSpeed, seconds, mutateValue, conservativeInit. Values are +// step-aligned to the slider granularity so the DOM reflects them exactly. +// fresh — cold / early: honest 2×, conservative wall-reflex bias, explore +// grind — elite exists: farm generations + grow the ruvector archive +// polish — refine lap times: wider pop, low variance, longer gens const TRAINING_PRESETS = { - fresh: { N: 500, simSpeed: 2, seconds: 10, mutate: 0.30 }, - grind: { N: 500, simSpeed: 20, seconds: 15, mutate: 0.20 }, - polish: { N: 1000, simSpeed: 2, seconds: 25, mutate: 0.05 } + fresh: { N: 500, simSpeed: 2, seconds: 15, mutate: 0.25, conservativeInit: 0.70 }, + grind: { N: 600, simSpeed: 20, seconds: 15, mutate: 0.18, conservativeInit: 0.50 }, + polish: { N: 800, simSpeed: 2, seconds: 25, mutate: 0.05, conservativeInit: 0.30 } }; function applyTrainingPreset(name){ @@ -452,6 +446,9 @@ function applyTrainingPreset(name){ setSeconds(p.seconds); setMutateValue(p.mutate); setSimSpeed(p.simSpeed); + if (typeof p.conservativeInit === 'number' && typeof setConservativeInit === 'function') { + setConservativeInit(p.conservativeInit); + } // Reflect values in the DOM so the user can see what changed. const bs = document.getElementById('batchSizeInput'); if (bs){ bs.value = p.N; document.getElementById('batchSizeOutput').value = 'Batch Size: ' + p.N; } @@ -459,6 +456,12 @@ function applyTrainingPreset(name){ if (se){ se.value = p.seconds; document.getElementById('secondsOutput').value = 'Round Length: ' + p.seconds; } const mv = document.getElementById('mutateValueInput'); if (mv){ mv.value = p.mutate; document.getElementById('mutateValueOutput').value = 'Variance: ' + p.mutate; } + const ci = document.getElementById('conservativeInitInput'); + if (ci && typeof p.conservativeInit === 'number'){ + ci.value = p.conservativeInit; + const co = document.getElementById('conservativeInitOutput'); + if (co) co.value = 'Conservative Init: ' + p.conservativeInit; + } const ss = document.getElementById('simSpeedInput'); if (ss){ ss.value = String(p.simSpeed); } } diff --git a/AI-Car-Racer/crashMapCodec.js b/AI-Car-Racer/crashMapCodec.js new file mode 100644 index 0000000..f1afc21 --- /dev/null +++ b/AI-Car-Racer/crashMapCodec.js @@ -0,0 +1,97 @@ +// crashMapCodec.js — encode gen-end death positions into a fixed grid vector +// for HNSW / cosine search. Classic script + ES-module friendly (no deps). +// +// Grid is 16×9 over the 3200×1800 canvas (CRASH_DIM = 144). Cell values are +// log1p(count) then L2-normalised so cosine distance is well-behaved. +// +// Surface: window.CrashMapCodec (classic) and named exports when imported. + +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (typeof root !== 'undefined') root.CrashMapCodec = api; + // ES module re-export surface when this file is imported as a module via + // dynamic import of a thin wrapper — bridge inlines the same constants + // for no-build static import simplicity; keep this file as the source of + // truth for adaptiveGates (classic). +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const CRASH_GW = 16; + const CRASH_GH = 9; + const CRASH_DIM = CRASH_GW * CRASH_GH; + const DEFAULT_W = 3200; + const DEFAULT_H = 1800; + + /** + * @param {Float32Array|null} popDeathXY length 2N, NaN for alive + * @param {number} N + * @param {number} [canvasW] + * @param {number} [canvasH] + * @returns {Float32Array|null} L2-normalised CRASH_DIM vector, or null if no deaths + */ + function encodeDeathMap(popDeathXY, N, canvasW, canvasH) { + if (!popDeathXY || !N) return null; + const W = canvasW || (typeof canvas !== 'undefined' && canvas && canvas.width) || DEFAULT_W; + const H = canvasH || (typeof canvas !== 'undefined' && canvas && canvas.height) || DEFAULT_H; + const grid = new Float32Array(CRASH_DIM); + let deaths = 0; + for (let i = 0; i < N; i++) { + const x = popDeathXY[i * 2]; + const y = popDeathXY[i * 2 + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + let gx = Math.floor((x / W) * CRASH_GW); + let gy = Math.floor((y / H) * CRASH_GH); + if (gx < 0) gx = 0; else if (gx >= CRASH_GW) gx = CRASH_GW - 1; + if (gy < 0) gy = 0; else if (gy >= CRASH_GH) gy = CRASH_GH - 1; + grid[gy * CRASH_GW + gx] += 1; + deaths++; + } + if (deaths < 3) return null; + // log1p compresses hot cells so one pile-up doesn't dominate the embedding + let sumSq = 0; + for (let i = 0; i < CRASH_DIM; i++) { + const v = Math.log1p(grid[i]); + grid[i] = v; + sumSq += v * v; + } + const norm = Math.sqrt(sumSq); + if (norm < 1e-9) return null; + const inv = 1 / norm; + for (let i = 0; i < CRASH_DIM; i++) grid[i] *= inv; + return grid; + } + + /** Decode a (possibly unnormalised) map back to a dense GH×GW count-ish grid for viz. */ + function decodeToGrid(vec) { + const out = new Float32Array(CRASH_DIM); + if (!vec || vec.length !== CRASH_DIM) return out; + for (let i = 0; i < CRASH_DIM; i++) out[i] = Math.max(0, vec[i]); + return out; + } + + function causeHistogram(popDeathCauses, N) { + const h = { headOn: 0, side: 0, slide: 0, stalled: 0, alive: 0 }; + if (!popDeathCauses || !N) return h; + for (let i = 0; i < N; i++) { + const c = popDeathCauses[i] | 0; + if (c === 0) h.headOn++; + else if (c === 1) h.side++; + else if (c === 2) h.slide++; + else if (c === 3) h.stalled++; + else h.alive++; + } + return h; + } + + return { + CRASH_GW: CRASH_GW, + CRASH_GH: CRASH_GH, + CRASH_DIM: CRASH_DIM, + encodeDeathMap: encodeDeathMap, + decodeToGrid: decodeToGrid, + causeHistogram: causeHistogram, + }; +}); diff --git a/AI-Car-Racer/demoPresentation.js b/AI-Car-Racer/demoPresentation.js new file mode 100644 index 0000000..73b63ba --- /dev/null +++ b/AI-Car-Racer/demoPresentation.js @@ -0,0 +1,1082 @@ +// demoPresentation.js — cinematic presentation layer for VectorVroom. +// Classic script (no modules). Consumes the same Float32Array pose snapshots +// the worker already posts; never touches the sim loop. +// +// Features: +// • Road cache blit (skip full road repaint every rAF during training) +// • Rank-colored swarm + Top-K quads + pack dots +// • Champion outline + sensor rays + motion trails +// • Crash heatmap (where cars die) +// • Follow-best camera +// • Cinematic HUD (gen / alive / fitness / story line) +// • Optional 3D perspective view of the same 2D world +// • Demo mode: auto-start, follow, story beats +// +// URL flags: ?demo=1 ?follow=1 ?view=3d ?trails=0 ?heatmap=0 +// Keyboard: F follow · 3 3D · T trails · H heatmap · D demo · 0 reset cam + +(function (global) { + 'use strict'; + + const params = new URLSearchParams(location.search); + const flag = (k, defTrue) => { + const v = params.get(k); + if (v === null) return !!defTrue; + if (v === '0' || v === 'false') return false; + return true; + }; + + const state = { + ready: false, + // feature toggles + followBest: flag('follow', false) || flag('demo', false), + view3d: params.get('view') === '3d', + trails: flag('trails', true), + heatmap: flag('heatmap', true), + rankColors: true, + // camera + camScale: 1, + camX: 0, + camY: 0, + targetScale: 1, + targetX: 0, + targetY: 0, + followZoom: 2.2, + // road cache + roadCache: null, + roadCacheDirty: true, + roadCacheKey: '', + // trails: ring of {x,y} for champion + trail: [], + trailMax: 48, + // heatmap grid + heatW: 160, + heatH: 90, + heat: null, + heatCanvas: null, + prevDamaged: null, + // HUD + hudEl: null, + storyEl: null, + controlsEl: null, + lastFitness: 0, + lastGen: -1, + peakFitness: 0, + genStartAlive: 0, + story: '', + storyUntil: 0, + // demo mode + demoMode: flag('demo', false), + demoStep: 0, + demoStarted: false, + // 3D orbital camera — high 3/4 angle so the track reads as a flat + // loop with depth, not a thin edge-on strip. + elev: 0.95, // elevation from horizontal (~54°) — mostly top-down + yaw: 0.55, // orbit around vertical (~31°) — see both track axes + baseDist: 2400, // eye distance from look-at at zoom=1 + focal: 1700, // mild perspective (higher = flatter / more isometric) + wallHeight: 48, + carHeight: 20, + view3dFit: 1.08, // slight zoom-in so the loop fills the frame + followZoom3d: 1.55, // keep corridor context — avoid edge-on crop + }; + + // ------------------------------------------------------------------ setup + function init() { + if (state.ready) return; + state.ready = true; + state.heat = new Float32Array(state.heatW * state.heatH); + ensureHud(); + ensureControls(); + bindKeys(); + if (state.demoMode) { + // Defer so phase-4 layout + worker exist. + setTimeout(startDemoMode, 80); + } + syncControlButtons(); + setStory(state.demoMode + ? 'Demo mode — watch a swarm learn to drive.' + : 'Train a neural net to race. Press D for demo mode.', 6000); + } + + function ensureHud() { + if (state.hudEl) return; + const host = document.getElementById('canvasDiv') || document.body; + const el = document.createElement('div'); + el.id = 'cinema-hud'; + el.setAttribute('aria-live', 'polite'); + el.innerHTML = + '
Sim Time: " + simSecs + "s " + - "(wall " + wallSecs + "s · " + simSpeed + "×)
"; - // Phase A: track-aware fast-lap render. Three lines: - // • Fast Lap: 12.34s (this track) - // • Last: 14.10s - // • all-time best: 9.10s - // The "(this track)" tag is load-bearing — without it a returning - // user could assume the displayed value is global. allTimeBest is - // hidden when only one track has ever been raced (best === current). - const _fastStr = (typeof fastLap === 'number' ? fastLap.toFixed(2) + 's' : fastLap); - const _lastStr = (typeof lastLap === 'number' ? lastLap.toFixed(2) + 's' : '—'); - timer.innerHTML += "Fast Lap: " + _fastStr + - " (this track)
"; - timer.innerHTML += "Last: " + _lastStr + "
"; - if (typeof allTimeBest === 'number' && - (typeof fastLap !== 'number' || allTimeBest < fastLap)) { - // Only show the subscript when a different track holds the - // record — saves a row of noise for single-track users. - timer.innerHTML += "" + - "all-time best: " + allTimeBest.toFixed(2) + "s
"; + if (timer){ + const simSecs = (frameCount/60).toFixed(2); + const wallSecs = ((performance.now() - wallStart)/1000).toFixed(2); + timer.innerHTML = "Sim Time: " + simSecs + "s " + + "(wall " + wallSecs + "s · " + simSpeed + "×)
"; + // Phase A: track-aware fast-lap render. Three lines: + // • Fast Lap: 12.34s (this track) + // • Last: 14.10s + // • all-time best: 9.10s + // The "(this track)" tag is load-bearing — without it a returning + // user could assume the displayed value is global. allTimeBest is + // hidden when only one track has ever been raced (best === current). + const _fastStr = (typeof fastLap === 'number' ? fastLap.toFixed(2) + 's' : fastLap); + const _lastStr = (typeof lastLap === 'number' ? lastLap.toFixed(2) + 's' : '—'); + timer.innerHTML += "Fast Lap: " + _fastStr + + " (this track)
"; + timer.innerHTML += "Last: " + _lastStr + "
"; + if (typeof allTimeBest === 'number' && + (typeof fastLap !== 'number' || allTimeBest < fastLap)) { + // Only show the subscript when a different track holds the + // record — saves a row of noise for single-track users. + timer.innerHTML += "" + + "all-time best: " + allTimeBest.toFixed(2) + "s
"; + } } - ctx.save(); + // Still render the last snapshot while paused so the track isn't empty + // after the user hits Pause / before first Start. + const shouldDrawCars = !pause || !!latestSnapshot; if(!pause){ // Local player-car accumulator. Runs in parallel with the worker's; // exact lockstep isn't needed because player cars only matter when @@ -1417,10 +1501,14 @@ function animate(){ playerCar.update(road.borders, road.checkPointList); playerCar2.update(road.borders, road.checkPointList); } + } + if (shouldDrawCars){ const _perfDrawT0 = perfEnabled ? performance.now() : 0; + const usePresSwarm = _pres && _pres.usePresentationSwarm && DP && typeof DP.drawSwarm === 'function'; if (latestSnapshot){ - drawFromSnapshot(latestSnapshot); + if (usePresSwarm) DP.drawSwarm(ctx, latestSnapshot); + else drawFromSnapshot(latestSnapshot); } if (bestCar){ // Pass the whole bestCar proxy — inputVisual still reads @@ -1428,13 +1516,31 @@ function animate(){ // reads .brainInputs / .brainOutputActivations (Task 2.D) to // render the NN decision bars. inputVisual(bestCar); - drawBestCar(bestCar); + if (DP && typeof DP.drawChampion === 'function') DP.drawChampion(ctx, bestCar); + else drawBestCar(bestCar); + } + // Player cars are 2D-world quads — skip in pure 3D projection so + // they don't ghost in flat screen space over the perspective scene. + const skipPlayers = DP && DP.state && DP.state.view3d; + if (!skipPlayers){ + if (playerCar) playerCar.draw(ctx,"#E6194B",true); + if (playerCar2) playerCar2.draw(ctx,"#4FC3F7",true); } - playerCar.draw(ctx,"#E6194B",true); - playerCar2.draw(ctx,"#4FC3F7",true); if (perfEnabled) _perfDraw += performance.now() - _perfDrawT0; } - ctx.restore(); + if (_pres && DP && typeof DP.endFrame === 'function'){ + DP.endFrame(ctx, _pres.camApplied); + } + if (DP && typeof DP.tickHud === 'function'){ + DP.tickHud({ + generation: generation, + bestCar: bestCar, + snap: latestSnapshot, + pause: pause, + simSpeed: simSpeed, + frameCount: frameCount, + }); + } } if (perfEnabled){ try { diff --git a/AI-Car-Racer/roadEditor.js b/AI-Car-Racer/roadEditor.js index 7e407c5..0886670 100644 --- a/AI-Car-Racer/roadEditor.js +++ b/AI-Car-Racer/roadEditor.js @@ -91,14 +91,38 @@ class roadEditor{ // this.ctx=document.getElementById("myCanvas").getContext("2d"); if (this.points.length > 0) { this.ctx.clearRect(0, 0, canvas.width, canvas.height); - if(this.editMode){ - this.drawCircles(); + this.paintTo(this.ctx); + } else { + this.drawStartPos(this.startInfo, this.ctx); + } + // Track edits invalidate the presentation road cache (if present). + try { + if (window.DemoPresentation && window.DemoPresentation.invalidateRoad) { + window.DemoPresentation.invalidateRoad(); } - this.drawLines(); + } catch (_) {} + } + + // Paint walls / checkpoints / start flag to any 2d context. Used by the + // live redraw path and by DemoPresentation's offscreen road cache so + // training frames can blit a static road instead of re-stroking geometry. + paintTo(c) { + if (!c) c = this.ctx; + if (this.editMode) { + // drawCircles uses this.ctx — temporarily rebind. + const prev = this.ctx; + this.ctx = c; + try { this.drawCircles(); } finally { this.ctx = prev; } } - this.drawStartPos(this.startInfo, this.ctx); + this.drawLinesOn(c); + this.drawStartPos(this.startInfo, c); } + drawLines() { + this.drawLinesOn(this.ctx); + } + + drawLinesOn(c) { // Canvas is 3200x1800 but the layout downscales it ~3x in CSS pixels, // so sub-3px strokes vanish after downsample. Use 10–12 canvas px // during gameplay (editMode=false) so walls remain legible to the eye. @@ -106,46 +130,46 @@ class roadEditor{ const closeW = this.editMode ? .75 : 12; const checkW = this.editMode ? 2 : 8; - this.ctx.beginPath(); - this.ctx.moveTo(this.points[0].x, this.points[0].y); - this.ctx.strokeStyle = "white"; - this.ctx.lineWidth = wallW; + c.beginPath(); + c.moveTo(this.points[0].x, this.points[0].y); + c.strokeStyle = "white"; + c.lineWidth = wallW; this.points.forEach((p) => { - this.ctx.lineTo(p.x, p.y); + c.lineTo(p.x, p.y); }) - this.ctx.stroke(); - this.ctx.lineWidth = closeW; - this.ctx.globalAlpha = this.editMode?.2:1; - this.ctx.lineTo(this.points[0].x,this.points[0].y); - this.ctx.stroke(); - this.ctx.globalAlpha = 1; + c.stroke(); + c.lineWidth = closeW; + c.globalAlpha = this.editMode?.2:1; + c.lineTo(this.points[0].x,this.points[0].y); + c.stroke(); + c.globalAlpha = 1; - this.ctx.beginPath(); - this.ctx.moveTo(this.points2[0].x, this.points2[0].y); - this.ctx.strokeStyle = "white"; - this.ctx.lineWidth = wallW; + c.beginPath(); + c.moveTo(this.points2[0].x, this.points2[0].y); + c.strokeStyle = "white"; + c.lineWidth = wallW; this.points2.forEach((p) => { - this.ctx.lineTo(p.x, p.y); + c.lineTo(p.x, p.y); }) - this.ctx.stroke(); - this.ctx.lineWidth = closeW; - this.ctx.globalAlpha = this.editMode?.2:1; - this.ctx.lineTo(this.points2[0].x,this.points2[0].y); - this.ctx.stroke(); - this.ctx.globalAlpha = 1; + c.stroke(); + c.lineWidth = closeW; + c.globalAlpha = this.editMode?.2:1; + c.lineTo(this.points2[0].x,this.points2[0].y); + c.stroke(); + c.globalAlpha = 1; // Checkpoints: named-"green" (#008000) reads only 3.2:1 on the // near-black scene (#15161a) and collapses toward muddy olive under // deuteranopia. #58E05D lime jumps to ~7:1, stays clearly green to // trichromats, and stays distinct from the yellow sensor rays by // luminance even under red-green CVD. - this.ctx.strokeStyle = "#58E05D"; - this.ctx.lineWidth = checkW; + c.strokeStyle = "#58E05D"; + c.lineWidth = checkW; this.checkPointListEditor.forEach((p)=>{ - this.ctx.beginPath(); - this.ctx.moveTo(p[0].x,p[0].y); - this.ctx.lineTo(p[1].x,p[1].y); - this.ctx.stroke(); + c.beginPath(); + c.moveTo(p[0].x,p[0].y); + c.lineTo(p[1].x,p[1].y); + c.stroke(); }) // 1-based index labels: the order (cp[0]→cp[1]→…) determines lap @@ -155,10 +179,10 @@ class roadEditor{ // Labels are tangent-offset so they sit just off the gate line rather // than clipping on top of it. const labelPx = this.editMode ? 40 : 60; - this.ctx.font = `bold ${labelPx}px Tahoma, sans-serif`; - this.ctx.textAlign = 'center'; - this.ctx.textBaseline = 'middle'; - this.ctx.lineWidth = 4; + c.font = `bold ${labelPx}px Tahoma, sans-serif`; + c.textAlign = 'center'; + c.textBaseline = 'middle'; + c.lineWidth = 4; this.checkPointListEditor.forEach((p, i) => { const mx = (p[0].x + p[1].x) / 2; const my = (p[0].y + p[1].y) / 2; @@ -172,10 +196,10 @@ class roadEditor{ const lx = mx + nx * off; const ly = my + ny * off; const label = String(i + 1); - this.ctx.strokeStyle = "#15161a"; // dark outline matches scene bg - this.ctx.strokeText(label, lx, ly); - this.ctx.fillStyle = "#58E05D"; - this.ctx.fillText(label, lx, ly); + c.strokeStyle = "#15161a"; // dark outline matches scene bg + c.strokeText(label, lx, ly); + c.fillStyle = "#58E05D"; + c.fillText(label, lx, ly); }); } deleteLast(){ diff --git a/AI-Car-Racer/ruvectorBridge.js b/AI-Car-Racer/ruvectorBridge.js index d5aeb2c..0e35ddf 100644 --- a/AI-Car-Racer/ruvectorBridge.js +++ b/AI-Car-Racer/ruvectorBridge.js @@ -107,16 +107,24 @@ const IDB_NAME = 'rv_car_learning'; // so old archives continue to hydrate unchanged — they just don't have // dynamicsId set on any brain meta (backwards-compat: missing → skip the // dynamics term in recommendSeeds). -const IDB_VERSION = 3; +// Bumped to 4 for crash-map HNSW store (adaptive-gates curriculum memory). +const IDB_VERSION = 4; const BRAINS_STORE = `brains_${TOPOLOGY.join('_')}`; // topology-scoped per PRD risk #6 const TRACKS_STORE = 'tracks'; const OBS_STORE = 'observations'; const LORA_STORE = 'lora_track'; const LORA_KEY = 'singleton'; // single-row store; this is the only id ever used const DYNAMICS_STORE = 'dynamics'; +const CRASH_STORE = 'crash_maps'; const TRACK_DIM = 512; const DYNAMICS_DIM = 64; // matches dynamicsEmbedder.DYNAMICS_DIM +// Crash heat maps: 16×9 log1p-count grid over the canvas, L2-normalised. +// Same layout as crashMapCodec.js (classic) — keep in sync. +const CRASH_GW = 16; +const CRASH_GH = 9; +const CRASH_DIM = CRASH_GW * CRASH_GH; // 144 +export { CRASH_DIM, CRASH_GW, CRASH_GH }; // VectorDB returns cosine DISTANCE (1 - similarity), range [0, 2]. Dedup when // distance is tiny, i.e. the two track vectors are essentially identical. const TRACK_DEDUPE_MAX_DIST = 0.005; // ≈ 0.9975 cosine similarity @@ -221,7 +229,15 @@ let _brainDB = null; let _brainDB_hyperbolic = null; let _trackDB = null; let _dynamicsDB = null; +let _crashDB = null; +let _crashMirror = new Map(); // id -> { vector, meta } let _cnn = null; +// Crash-map retrieval toggle (default on). Adaptive gates can still call +// archive/search when disabled for diagnostics; recommendCrashLayouts returns +// [] when off so the curriculum path no-ops cleanly. +let _useCrashMaps = true; +export function setUseCrashMaps(on) { _useCrashMaps = !!on; return _useCrashMaps; } +export function isUsingCrashMaps() { return !!_useCrashMaps; } // Phase 2A — F2. Off by default → recommendSeeds is byte-identical to the // pre-2A single-index path. Flipped via setFederationEnabled({on}). @@ -282,11 +298,12 @@ export function setIndexKind(kind) { return true; } function rebuildIndicesFromMirror() { - if (!_brainDB || !_trackDB || !_dynamicsDB) return; + if (!_brainDB || !_trackDB || !_dynamicsDB || !_crashDB) return; const IndexClass = pickIndexClass(_indexKind); _brainDB = new IndexClass(FLAT_LENGTH, 'cosine'); _trackDB = new IndexClass(TRACK_DIM, 'cosine'); _dynamicsDB = new IndexClass(DYNAMICS_DIM, 'cosine'); + _crashDB = new IndexClass(CRASH_DIM, 'cosine'); // Phase 2A — rebuild the shadow hyperbolic brain index too when // available. We don't shadow the track / dynamics DBs — federation only // fans out over the brain index (track / dynamics are joins, not @@ -302,6 +319,9 @@ function rebuildIndicesFromMirror() { for (const [id, { vector, meta }] of _dynamicsMirror) { _dynamicsDB.insert(vector, id, meta || {}); } + for (const [id, { vector, meta }] of _crashMirror) { + _crashDB.insert(vector, id, meta || {}); + } // F3 — rebuild preserves the original insertion order from _insertionOrder // when available; falls back to mirror iteration order otherwise. We do // NOT reset _insertionOrder here since the mirror contents are unchanged. @@ -319,11 +339,12 @@ function rebuildIndicesFromMirror() { } } -// Dynamics retrieval toggle. Default off per P1.C plan: adding the extra -// similarity term shifts seeding behaviour, so we keep it opt-in and let the -// panel checkbox drive it. `_queryDynamicsVec` is set by callers (main.js / -// uiPanels) before recommendSeeds so the bridge stays pure-read. -let _useDynamics = false; +// Dynamics retrieval toggle. Default ON for product UX: once trajectories +// exist, seeding can prefer brains that *drive like* successful ones on this +// track (not only track-shape neighbors). Empty dynamics archive is a no-op +// (term skipped). UI checkbox / A/B strip stay authoritative after first paint. +// `_queryDynamicsVec` is set by callers (main.js / uiPanels) before recommendSeeds. +let _useDynamics = true; let _queryDynamicsVec = null; // Weight of the dynamics-sim term in the final score product. Small enough // that dynamics can tie-break but won't drown out fitness × track-similarity. @@ -417,6 +438,7 @@ export function ready() { _brainDB = new IndexClass(FLAT_LENGTH, 'cosine'); _trackDB = new IndexClass(TRACK_DIM, 'cosine'); _dynamicsDB = new IndexClass(DYNAMICS_DIM, 'cosine'); + _crashDB = new IndexClass(CRASH_DIM, 'cosine'); // Phase 2A — F2. Stand up the hyperbolic shadow brain index so federated // search has something to fan out to, regardless of whether _indexKind is // currently hyperbolic. When the wasm didn't load this stays null and @@ -475,8 +497,9 @@ export function ready() { tracks: _trackMirror.size, obs: _observations.size, dynamics: _dynamicsMirror.size, + crashMaps: _crashMirror.size, }; - console.log(`[ruvector] ready — brains=${_brainMirror.size} tracks=${_trackMirror.size} obs=${_observations.size}`); + console.log(`[ruvector] ready — brains=${_brainMirror.size} tracks=${_trackMirror.size} obs=${_observations.size} crashMaps=${_crashMirror.size}`); // One compact line so the full breakdown survives console truncation and // can be copy-pasted back for diagnosis. console.log('[boot-timings] ' + JSON.stringify(_bootTimings)); @@ -485,11 +508,141 @@ export function ready() { } function requireReady() { - if (!_brainDB || !_trackDB || !_dynamicsDB || !_cnn) { + if (!_brainDB || !_trackDB || !_dynamicsDB || !_crashDB || !_cnn) { throw new Error('ruvectorBridge: call await ready() before using the bridge'); } } +// ─── crash-map HNSW (adaptive-gates curriculum memory) ─────────────────────── + +/** + * Encode death positions into a CRASH_DIM L2-normalised heat vector. + * Mirrors window.CrashMapCodec.encodeDeathMap when that classic script loaded. + */ +export function encodeCrashMap(popDeathXY, N, canvasW, canvasH) { + try { + if (typeof window !== 'undefined' && window.CrashMapCodec && + typeof window.CrashMapCodec.encodeDeathMap === 'function') { + return window.CrashMapCodec.encodeDeathMap(popDeathXY, N, canvasW, canvasH); + } + } catch (_) {} + // Inline fallback (same algorithm as crashMapCodec.js) + if (!popDeathXY || !N) return null; + const W = canvasW || 3200, H = canvasH || 1800; + const grid = new Float32Array(CRASH_DIM); + let deaths = 0; + for (let i = 0; i < N; i++) { + const x = popDeathXY[i * 2], y = popDeathXY[i * 2 + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + let gx = Math.floor((x / W) * CRASH_GW); + let gy = Math.floor((y / H) * CRASH_GH); + if (gx < 0) gx = 0; else if (gx >= CRASH_GW) gx = CRASH_GW - 1; + if (gy < 0) gy = 0; else if (gy >= CRASH_GH) gy = CRASH_GH - 1; + grid[gy * CRASH_GW + gx] += 1; + deaths++; + } + if (deaths < 3) return null; + let sumSq = 0; + for (let i = 0; i < CRASH_DIM; i++) { + const v = Math.log1p(grid[i]); + grid[i] = v; + sumSq += v * v; + } + const norm = Math.sqrt(sumSq); + if (norm < 1e-9) return null; + const inv = 1 / norm; + for (let i = 0; i < CRASH_DIM; i++) grid[i] *= inv; + return grid; +} + +/** + * Archive a crash heat map + optional gate layout / survival meta. + * @returns {string|null} id + */ +export function archiveCrashMap(crashVec, meta = {}) { + if (!_crashDB) return null; + if (!(crashVec instanceof Float32Array) || crashVec.length !== CRASH_DIM) return null; + const m = { + survival: Number(meta.survival) || 0, + fitness: Number(meta.fitness) || 0, + generation: (meta.generation | 0), + nDeaths: (meta.nDeaths | 0), + nGates: (meta.nGates | 0), + timestamp: Date.now(), + }; + if (Array.isArray(meta.cps)) m.cps = meta.cps; + if (meta.causes && typeof meta.causes === 'object') m.causes = meta.causes; + if (meta.trackId) m.trackId = meta.trackId; + if (meta.bottleneck != null) m.bottleneck = meta.bottleneck | 0; + // Wall-geometry signature — adaptive gates refuse to apply a layout whose + // sig doesn't match the live track (stops Triangle gates on Rectangle). + if (meta.geometrySig) m.geometrySig = String(meta.geometrySig); + try { + const id = _crashDB.insert(crashVec, null, m); + _crashMirror.set(id, { vector: crashVec.slice(), meta: m }); + schedulePersist(); + return id; + } catch (e) { + console.warn('[crash-map] insert failed', e); + return null; + } +} + +/** + * Nearest crash maps in HNSW. Returns [] when disabled / empty / not ready. + * Score is cosine similarity in [0,1] (converted from VectorDB distance). + */ +export function recommendCrashLayouts(crashVec, k = 5) { + if (!_useCrashMaps || !_crashDB || _crashMirror.size === 0) return []; + if (!(crashVec instanceof Float32Array) || crashVec.length !== CRASH_DIM) return []; + const kk = Math.max(1, Math.min(k | 0, _crashMirror.size)); + let hits; + try { + hits = _crashDB.search(crashVec, kk); + } catch (e) { + console.warn('[crash-map] search failed', e); + return []; + } + if (!hits || !hits.length) return []; + const out = []; + for (let i = 0; i < hits.length; i++) { + const h = hits[i]; + const id = h.id; + const entry = _crashMirror.get(id); + // VectorDB cosine distance ∈ [0, 2]; sim = 1 - dist/2 roughly, or 1-dist for unit vectors. + // Our vectors are L2-normalised; cosine distance ≈ 1 - cos_sim for some builds. + // Prefer metadata from mirror; fall back to hit.metadata. + const meta = (entry && entry.meta) || h.metadata || {}; + // VectorDB cosine DISTANCE: 0 = identical, 2 = opposite (same as tracks). + const dist = Number(h.score); + const sim = Number.isFinite(dist) + ? Math.max(0, Math.min(1, 1 - dist / 2)) + : 0; + out.push({ + id, + similarity: sim, + distance: dist, + survival: Number(meta.survival) || 0, + fitness: Number(meta.fitness) || 0, + generation: meta.generation | 0, + nGates: meta.nGates | 0, + nDeaths: meta.nDeaths | 0, + cps: Array.isArray(meta.cps) ? meta.cps : null, + causes: meta.causes || null, + bottleneck: meta.bottleneck != null ? (meta.bottleneck | 0) : null, + geometrySig: meta.geometrySig || null, + timestamp: meta.timestamp || 0, + }); + } + // Prefer high similarity, then high survival + out.sort((a, b) => (b.similarity - a.similarity) || (b.survival - a.survival)); + return out; +} + +export function crashMapCount() { + return _crashMirror.size; +} + // P1.C — dynamics retrieval controls. UI owns the toggle; `setUseDynamics` // flips the flag, `setQueryDynamicsVec` stages the current-generation vector // the next recommendSeeds() call will use. Both are no-ops when the bridge @@ -1265,6 +1418,12 @@ export function info() { count: _dynamicsMirror.size, hasQuery: !!_queryDynamicsVec, }, + // Crash-map HNSW — heat-grid curriculum memory for adaptive gates. + crashMaps: { + enabled: !!_useCrashMaps, + count: _crashMirror.size, + dim: CRASH_DIM, + }, // P3.B — lineage DAG stats. `lineageDag.ready` is the canonical flag for // the viewer's "is the graph live?" check. `nodeCount` / `edgeCount` come // straight from the wasm side; `droppedEdges` is >0 only if malformed @@ -1413,6 +1572,8 @@ function openDB() { // dynamics vector, keyed by the _dynamicsDB-assigned id — mirrors the // brains/tracks store shape. if (!db.objectStoreNames.contains(DYNAMICS_STORE)) db.createObjectStore(DYNAMICS_STORE, { keyPath: 'id' }); + // CRASH_STORE added in IDB v4 — gen-end death heat maps + gate layouts. + if (!db.objectStoreNames.contains(CRASH_STORE)) db.createObjectStore(CRASH_STORE, { keyPath: 'id' }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); @@ -1440,8 +1601,10 @@ export async function hydrate() { // readAll is safe. Still, wrap in try/catch to be defensive against // partial upgrades from a crashed earlier session. let dynamicsRows = []; + let crashRows = []; const _tIdb = _tStart(); try { dynamicsRows = await readAll(db, DYNAMICS_STORE); } catch (_) { dynamicsRows = []; } + try { crashRows = await readAll(db, CRASH_STORE); } catch (_) { crashRows = []; } const [brainRows, trackRows, obsRows] = await Promise.all([ readAll(db, BRAINS_STORE), readAll(db, TRACKS_STORE), @@ -1453,6 +1616,7 @@ export async function hydrate() { tracks: trackRows.length, obs: obsRows.length, dynamics: dynamicsRows.length, + crashMaps: crashRows.length, }; // Tracks first, so upsertTrack-dedup can reference them (not strictly needed // since we load by id, but keeps mirror/DB consistent). @@ -1476,6 +1640,16 @@ export async function hydrate() { _dynamicsMirror.set(row.id, { vector: vec, meta: row.meta || {} }); } _tEnd('3c_insert_dynamics', _tDyn); + const _tCrash = _tStart(); + for (const row of crashRows) { + const vec = toFloat32(row.vec); + if (vec.length !== CRASH_DIM) continue; + try { + _crashDB.insert(vec, row.id, row.meta || {}); + _crashMirror.set(row.id, { vector: vec, meta: row.meta || {} }); + } catch (e) { console.warn('[crash-map] hydrate insert failed', e); } + } + _tEnd('3c2_insert_crashMaps', _tCrash); const _tBrains = _tStart(); for (const row of brainRows) { const vec = toFloat32(row.vec); @@ -1571,12 +1745,15 @@ export async function persist() { _persistInFlight = (async () => { const db = await openDB(); try { - const tx = db.transaction([BRAINS_STORE, TRACKS_STORE, OBS_STORE, LORA_STORE, DYNAMICS_STORE], 'readwrite'); + const storeNames = [BRAINS_STORE, TRACKS_STORE, OBS_STORE, LORA_STORE, DYNAMICS_STORE]; + if (db.objectStoreNames.contains(CRASH_STORE)) storeNames.push(CRASH_STORE); + const tx = db.transaction(storeNames, 'readwrite'); const brains = tx.objectStore(BRAINS_STORE); const tracks = tx.objectStore(TRACKS_STORE); const obs = tx.objectStore(OBS_STORE); const lora = tx.objectStore(LORA_STORE); const dynamics = tx.objectStore(DYNAMICS_STORE); + const crashes = db.objectStoreNames.contains(CRASH_STORE) ? tx.objectStore(CRASH_STORE) : null; // Full rewrite keeps the logic simple; archive size stays small (hundreds // of entries, <100KB serialized) so the write cost is negligible. brains.clear(); @@ -1584,6 +1761,7 @@ export async function persist() { obs.clear(); lora.clear(); dynamics.clear(); + if (crashes) crashes.clear(); for (const [id, { vector, meta }] of _brainMirror) { brains.put({ id, vec: Array.from(vector), meta }); } @@ -1593,6 +1771,11 @@ export async function persist() { for (const [id, { vector, meta }] of _dynamicsMirror) { dynamics.put({ id, vec: Array.from(vector), meta }); } + if (crashes) { + for (const [id, { vector, meta }] of _crashMirror) { + crashes.put({ id, vec: Array.from(vector), meta }); + } + } for (const [id, { weight, count }] of _observations) { obs.put({ id, weight, count }); } @@ -1880,6 +2063,7 @@ export async function _debugReset() { _brainMirror.clear(); _trackMirror.clear(); _dynamicsMirror.clear(); + _crashMirror.clear(); _observations.clear(); _insertionOrder = []; _queryDynamicsVec = null; diff --git a/AI-Car-Racer/sim-worker.js b/AI-Car-Racer/sim-worker.js index 37cf4ce..8eeadc6 100644 --- a/AI-Car-Racer/sim-worker.js +++ b/AI-Car-Racer/sim-worker.js @@ -322,6 +322,9 @@ function stepOnce() { if (!prevDamaged && cc.damaged) { cc.deathFrame = self.frameCount; cc.slideAtDeath = prevSlide; + // Crash locus for adaptive-gates / heat analysis on main. + cc.deathX = cc.x; + cc.deathY = cc.y; } } // O(N) bestCar scan — mirror of main.js's pre-worker logic. @@ -504,6 +507,9 @@ function endGen() { // 2=slide-out, 3=stalled, 4=alive. Mutually exclusive — sum across buckets // equals N. Classification is O(N) at endGen (no per-frame cost). const popDeathCauses = new Int8Array(N); + // Crash positions (x,y per car). Alive / never-damaged → NaN so consumers + // can skip. Used by AdaptiveGates crash-heat curriculum on main. + const popDeathXY = new Float32Array(N * 2); let wallBumps = 0, stillAlive = 0; for (let i = 0; i < N; i++) { const c = cars[i]; @@ -511,6 +517,8 @@ function endGen() { if (c.damaged) { wallBumps++; popDeathFrames[i] = (c.deathFrame != null ? c.deathFrame : self.frameCount) | 0; + popDeathXY[i * 2] = (c.deathX != null ? c.deathX : c.x); + popDeathXY[i * 2 + 1] = (c.deathY != null ? c.deathY : c.y); // Forward speed magnitude — `c.speed` is scalar along the car's // heading axis; |speed|/maxSpeed > 0.7 means the car was driving // hard into the wall rather than scraping it laterally. @@ -530,6 +538,8 @@ function endGen() { stillAlive++; popDeathFrames[i] = -1; popDeathCauses[i] = (c.checkPointsCount | 0) >= 1 ? 4 : 3; + popDeathXY[i * 2] = NaN; + popDeathXY[i * 2 + 1] = NaN; } } @@ -543,7 +553,13 @@ function endGen() { if (lev0 && lev0.outputs) bestHiddenActivations = new Float32Array(lev0.outputs); } catch (_) {} - const transfer = [flat.buffer, popCheckpoints.buffer, popDeathFrames.buffer, popDeathCauses.buffer]; + const transfer = [ + flat.buffer, + popCheckpoints.buffer, + popDeathFrames.buffer, + popDeathCauses.buffer, + popDeathXY.buffer, + ]; if (bestHiddenActivations) transfer.push(bestHiddenActivations.buffer); self.postMessage({ @@ -557,7 +573,7 @@ function endGen() { popN: N, popWallBumps: wallBumps, popStillAlive: stillAlive, - popCheckpoints, popDeathFrames, popDeathCauses, + popCheckpoints, popDeathFrames, popDeathCauses, popDeathXY, bestHiddenActivations, genSeconds: seconds }, transfer); diff --git a/AI-Car-Racer/style.css b/AI-Car-Racer/style.css index 9e249a6..c9c432e 100644 --- a/AI-Car-Racer/style.css +++ b/AI-Car-Racer/style.css @@ -2217,6 +2217,18 @@ label { font-weight: 600; letter-spacing: 0.02em; } +.rv-experiments-status { + flex: 1 1 100%; + margin-top: 2px; + font: 11px/1.35 var(--font-mono); + color: rgba(168, 200, 255, 0.9); + opacity: 0.92; +} +.rv-experiments-reset-gates { + margin-left: auto; + padding: 2px 8px !important; + font-size: 0.78em !important; +} /* === Phase A — Brain saves (named-slot persistence) === */ /* Mounted directly below the legacy Save Best + Restart actions, so the @@ -2282,3 +2294,139 @@ label { color: #f0d6b6 !important; } .brain-saves-fresh:hover { background: rgba(180, 110, 60, 0.32) !important; } + +/* ============================================================= + Cinematic presentation layer (demoPresentation.js) + Overlay HUD + view toggles on #canvasDiv. Pointer-events none + on the HUD; controls are clickable chips. + ============================================================= */ +#cinema-hud { + position: absolute; + top: 10px; + left: 10px; + z-index: 8; + max-width: min(92%, 560px); + padding: 8px 12px; + border-radius: 12px; + background: rgba(12, 14, 18, 0.72); + -webkit-backdrop-filter: blur(10px) saturate(130%); + backdrop-filter: blur(10px) saturate(130%); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); + color: #f3efe6; + font-family: var(--font-body); + font-size: 12px; + line-height: 1.35; + pointer-events: none; +} +.cinema-hud-row { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + align-items: baseline; +} +.cinema-stat { + color: rgba(243, 239, 230, 0.78); + letter-spacing: 0.01em; +} +.cinema-stat b { + color: #ffe566; + font-family: var(--font-mono); + font-weight: 500; + font-size: 12.5px; +} +.cinema-muted { + opacity: 0.55; + font-size: 0.92em; +} +.cinema-story { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 236, 180, 0.92); + font-size: 12px; + font-family: var(--font-display); + font-weight: 500; + letter-spacing: -0.01em; +} +.cinema-story[hidden] { display: none; } + +#cinema-controls { + /* Bottom-right of the track cell so we don't collide with the + bottom-left perf HUD or the centered #bottomText banner. */ + position: absolute; + bottom: 12px; + right: 12px; + left: auto; + z-index: 8; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + max-width: min(70%, 480px); + pointer-events: auto; +} +#cinema-controls button { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(12, 14, 18, 0.72); + color: rgba(243, 239, 230, 0.88); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border-radius: 999px; + padding: 5px 11px; + font: 600 11px/1.2 var(--font-body); + letter-spacing: 0.02em; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; +} +#cinema-controls button:hover { + background: rgba(28, 30, 38, 0.88); + border-color: rgba(255, 229, 102, 0.35); + color: #fff; +} +#cinema-controls button:focus-visible { + outline: 2px solid var(--amber-500); + outline-offset: 2px; +} +#cinema-controls button.is-on { + background: rgba(211, 139, 75, 0.28); + border-color: rgba(255, 229, 102, 0.55); + color: #ffe566; +} +#cinema-controls button.cinema-demo.is-on { + background: rgba(122, 101, 204, 0.35); + border-color: rgba(190, 175, 255, 0.55); + color: #e4dcff; +} + +/* Keep overlays clear of the A/B dual stack */ +#canvasDiv.ab-on #cinema-hud { + max-width: min(48%, 360px); +} +#canvasDiv.ab-on #cinema-controls { + bottom: auto; + top: calc(50% + 8px); + right: 8px; +} + +@media (max-width: 720px) { + #cinema-hud { + font-size: 11px; + padding: 6px 9px; + top: 6px; + left: 6px; + } + #cinema-controls { + bottom: 8px; + right: 8px; + } + #cinema-controls button { + padding: 4px 9px; + font-size: 10px; + } +} + +@media (prefers-reduced-motion: reduce) { + #cinema-controls button { transition: none; } +} diff --git a/AI-Car-Racer/trackPresets.js b/AI-Car-Racer/trackPresets.js index 37a92db..f348ea1 100644 --- a/AI-Car-Racer/trackPresets.js +++ b/AI-Car-Racer/trackPresets.js @@ -98,25 +98,46 @@ window.TRACK_PRESETS = [ }, { name: 'Triangle', - description: 'Apex points left; spawn sits in the spacious right lobe.', - // Apex-left triangle. cp[0] sits low on the right edge and cp[1] sits - // high on the same edge (directly above), so the car spawns facing - // screen-up and drives up the right edge before cornering toward the apex. + description: 'Clean apex-left triangle, symmetric about y=900. Spacious right lobe; full-loop gates with apex approach/exit.', + // Apex-left isosceles triangle, mirror-symmetric about the horizontal + // midline (y=900). Blunted inner tip keeps the sharp left turn learnable; + // outer/inner bases share x so the right lobe is a clean rectangle. + // + // Corridor ~200–280px on the long edges, ~580px on the right lobe. + // Gates are outer→inner (or right-lobe horizontal) along the driving line: + // 1 spawn → 2 right-upper → 3 top-right mouth → 4 top-mid + // → 5 top near-apex → 6 apex → 7 bottom near-apex + // → 8 bottom-mid → 9 bottom-right mouth → (1 for lap) points: [ - { x: 500, y: 900 }, // left apex (inner) - { x: 2400, y: 500 }, // top-right - { x: 2400, y: 1300 } // bottom-right + { x: 780, y: 900 }, // left apex (inner, blunted) + { x: 2420, y: 480 }, // top-right + { x: 2420, y: 1320 } // bottom-right ], points2: [ - { x: 150, y: 900 }, // left apex (outer) - { x: 3100, y: 250 }, // top-right - { x: 3100, y: 1550 } // bottom-right + { x: 180, y: 900 }, // left apex (outer) + { x: 3000, y: 80 }, // top-right + { x: 3000, y: 1720 } // bottom-right ], checkPointListEditor: [ - [{ x: 3100, y: 1200 }, { x: 2400, y: 1200 }], // 1: right-low (spawn) - [{ x: 3100, y: 600 }, { x: 2400, y: 600 }], // 2: right-top (above cp[0] → heading up) - [{ x: 1500, y: 550 }, { x: 1500, y: 720 }], // 3: top edge - [{ x: 150, y: 900 }, { x: 500, y: 900 }] // 4: left apex + // 1: right-low spawn / start-finish — horizontal across the right lobe + [{ x: 3000, y: 1220 }, { x: 2420, y: 1220 }], + // 2: right-upper — directly above #1 so spawn heading = screen-up + [{ x: 3000, y: 700 }, { x: 2420, y: 700 }], + // 3: top-right mouth — angled outer→inner at the top base corner + // (shortened 22% from outer so it sits in the driving line) + [{ x: 2872, y: 168 }, { x: 2420, y: 480 }], + // 4: top-mid — outer/inner top edges @ t=0.42 from apex + [{ x: 1364, y: 556 }, { x: 1469, y: 724 }], + // 5: top near-apex approach (hand-tuned — short, tight to tip) + [{ x: 695, y: 707 }, { x: 778, y: 898 }], + // 6: apex — from mid-corridor into the inner tip + [{ x: 461, y: 902 }, { x: 780, y: 900 }], + // 7: bottom near-apex exit (hand-tuned mirror of #5) + [{ x: 674, y: 1071 }, { x: 778, y: 909 }], + // 8: bottom-mid — mirror of #4 + [{ x: 1364, y: 1244 }, { x: 1469, y: 1076 }], + // 9: bottom-right mouth — mirror of #3 + [{ x: 2872, y: 1632 }, { x: 2420, y: 1320 }] ] }, { @@ -520,6 +541,11 @@ window.loadTrackPreset = function(nameOrIdx) { localStorage.removeItem('progress'); localStorage.removeItem('rvAnnotations'); try { if (typeof resetTrainCount === 'function') resetTrainCount(); } catch (_) {} + try { + if (window.DemoPresentation && window.DemoPresentation.invalidateRoad) { + window.DemoPresentation.invalidateRoad(); + } + } catch (_) {} // Swap the in-memory editor state if the game has booted. if (typeof road !== 'undefined' && road.roadEditor) { @@ -578,6 +604,13 @@ window.loadTrackPreset = function(nameOrIdx) { // Force the AI-worker to re-init with the new borders/checkpoints on the // next begin() — otherwise AI cars would also race against stale walls. try { if (typeof invalidateWorkerInit === 'function') invalidateWorkerInit(); } catch (_) {} + // Adaptive gates keep a per-track baseline; without this, Reset / HNSW + // / bad-streak restore would re-paint Triangle green lines on Rectangle. + try { + if (window.AdaptiveGates && typeof window.AdaptiveGates.onTrackChange === 'function') { + window.AdaptiveGates.onTrackChange(); + } + } catch (_) {} try { road.roadEditor.redraw(); } catch (_) { /* redraw only works in phase 1/2 */ } } diff --git a/AI-Car-Racer/uiPanels.js b/AI-Car-Racer/uiPanels.js index 7e6db88..7a18fd2 100644 --- a/AI-Car-Racer/uiPanels.js +++ b/AI-Car-Racer/uiPanels.js @@ -154,7 +154,7 @@ ' dynamics key', '