diff --git a/AI-Car-Racer/archive/hash.js b/AI-Car-Racer/archive/hash.js index 045a21a..a1861bb 100644 --- a/AI-Car-Racer/archive/hash.js +++ b/AI-Car-Racer/archive/hash.js @@ -79,3 +79,9 @@ export function hashBrain(flat, seed = 0) { const h = xxHash32Bytes(bytes, seed); return h.toString(16).padStart(8, '0'); } + +// Symmetric alias: the same xxHash32-over-Float32Array function is used to +// content-address brains (Phase 1D) AND tracks (Phase A fastlap-track-aware). +// "hashBrain" reads naturally for the F5 dedup site; "hashVec" reads naturally +// at the track-fastlap site. Same code, two domain-honest names. +export const hashVec = hashBrain; diff --git a/AI-Car-Racer/buttonResponse.js b/AI-Car-Racer/buttonResponse.js index a9251d1..dc1c5a3 100644 --- a/AI-Car-Racer/buttonResponse.js +++ b/AI-Car-Racer/buttonResponse.js @@ -87,13 +87,218 @@ function restoreOldBrain(){ localStorage.setItem("bestBrain", localStorage.getItem("oldBestBrain")); restartBatch(); } + +// === Phase A — named brain saves (multi-slot localStorage) ================= +// +// Mirrors the single-slot save()/restoreOldBrain() pair above but with +// arbitrary user-named slots, keyed under `vv_brainsave_`. Each slot +// stores the same shape as serializeBrain(bestCar.brain) plus light meta +// (fitness, savedAt, optional trackId/generation) for the dropdown label. +// +// Load reuses the existing seeding pathway: write to localStorage.bestBrain +// + restartBatch(), exactly like restoreOldBrain. Start-fresh wipes every +// piece of trained state — IDB archive via bridge._debugReset(), the +// localStorage prior, the lap timer — and reloads. +const BRAIN_SAVE_PREFIX = "vv_brainsave_"; + +function _brainSavesList(){ + var out = []; + for (var i = 0; i < localStorage.length; i++){ + var k = localStorage.key(i); + if (k && k.indexOf(BRAIN_SAVE_PREFIX) === 0){ + out.push(k.slice(BRAIN_SAVE_PREFIX.length)); + } + } + out.sort(); + return out; +} +function refreshBrainSavesDropdown(selectedName){ + var sel = document.getElementById("brainSavesSelect"); + if (!sel) return; + var names = _brainSavesList(); + sel.innerHTML = ""; + if (names.length === 0){ + var opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "(no saves yet)"; + opt.disabled = true; + opt.selected = true; + sel.appendChild(opt); + return; + } + for (var i = 0; i < names.length; i++){ + var n = names[i]; + var opt2 = document.createElement("option"); + opt2.value = n; + // Decorate the label with fitness if we can read it cheaply. + var label = n; + try { + var raw = localStorage.getItem(BRAIN_SAVE_PREFIX + n); + if (raw){ + var slot = JSON.parse(raw); + if (slot && typeof slot.fitness === "number"){ + label += " (fit " + slot.fitness.toFixed(1) + ")"; + } + } + } catch (_) {} + opt2.textContent = label; + if (selectedName && n === selectedName) opt2.selected = true; + sel.appendChild(opt2); + } +} +function brainSaveAs(){ + if (typeof bestCar === "undefined" || !bestCar || !bestCar.brain){ + window.alert("No best brain to save yet — train at least one generation first."); + return; + } + // Default name: timestamp-fitness, e.g. "2026-04-24-1830-fit42". The user + // can rename freely; only the prompt response is used as the key. + var fit = Number(window.__rvSessionBestFitness) || 0; + var ts = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 16); + var defaultName = ts + "-fit" + fit.toFixed(0); + var name = (window.prompt("Name this saved brain:", defaultName) || "").trim(); + if (!name) return; + if (localStorage.getItem(BRAIN_SAVE_PREFIX + name)){ + if (!window.confirm('"' + name + '" already exists. Overwrite?')) return; + } + var slot = { + name: name, + savedAt: new Date().toISOString(), + fitness: fit, + // Optional context — used only for dropdown labels and inspection. + // Best-effort; missing fields don't break anything. + trackId: (window.lastEmbeddedTrackId || null), + // serializeBrain is a global from main.js; its output is the shape + // localStorage.bestBrain expects, so Load is a one-line copy. + brain: serializeBrain(bestCar.brain), + }; + localStorage.setItem(BRAIN_SAVE_PREFIX + name, JSON.stringify(slot)); + refreshBrainSavesDropdown(name); +} +function brainSaveLoad(){ + var sel = document.getElementById("brainSavesSelect"); + var name = sel && sel.value; + if (!name){ window.alert("Pick a saved brain from the dropdown first."); return; } + var raw = localStorage.getItem(BRAIN_SAVE_PREFIX + name); + if (!raw){ window.alert('Saved brain "' + name + '" not found.'); refreshBrainSavesDropdown(); return; } + var slot; + try { slot = JSON.parse(raw); } catch (_) { + window.alert('Saved brain "' + name + '" is corrupted.'); + return; + } + if (!slot || !slot.brain){ + window.alert('Saved brain "' + name + '" is empty.'); + return; + } + var ok = window.confirm( + 'Load "' + name + '"?\n\n' + + 'This will REPLACE the current best brain with the saved one and ' + + 'restart the batch.\n\nProceed?' + ); + if (!ok) return; + // Mirror restoreOldBrain: write to bestBrain, restart. The seeding + // loop in main.js reads localStorage.bestBrain when the batch begins. + localStorage.setItem("bestBrain", JSON.stringify(slot.brain)); + if (typeof restartBatch === "function") restartBatch(); +} +function brainSaveDelete(){ + var sel = document.getElementById("brainSavesSelect"); + var name = sel && sel.value; + if (!name) return; + if (!window.confirm('Delete saved brain "' + name + '"? This cannot be undone.')) return; + localStorage.removeItem(BRAIN_SAVE_PREFIX + name); + refreshBrainSavesDropdown(); +} +async function brainStartFresh(){ + var ok = window.confirm( + "Start with an empty brain?\n\n" + + "This will WIPE everything trained so far:\n" + + " • the live archive (all archived brains, tracks, dynamics)\n" + + " • the saved best brain + fast lap\n" + + " • the lineage DAG\n" + + "\n" + + "Your named brain saves are NOT deleted (use Delete for those).\n" + + "The page will reload. Proceed?" + ); + if (!ok) return; + // Wipe IDB + bridge in-memory state via bridge._debugReset(). The + // bridge surfaces this on window for the verifier console; we use the + // same hook here. + try { + if (window.__rvBridge && typeof window.__rvBridge._debugReset === "function"){ + await window.__rvBridge._debugReset(); + } + } catch (e) { console.warn("[brain-saves] _debugReset failed", e); } + // Wipe legacy localStorage trained state. Named saves + // (vv_brainsave_*) are deliberately preserved so a fresh-start + // doesn't lose the user's curated slots. + var legacyKeys = ["bestBrain", "oldBestBrain", "fastLap", "progress", "rvAnnotations"]; + for (var i = 0; i < legacyKeys.length; i++){ + try { localStorage.removeItem(legacyKeys[i]); } catch (_) {} + } + location.reload(); +} +// ========================================================================= function resetFastLap(){ + // Phase A: scope the reset to the CURRENT track only. The legacy + // global `localStorage.fastLap` key was retired at boot; the new + // per-track keys are vv_fastlap_. Use the bridge helper + // exposed by main.js to look up the current track's key. + try { + if (window.__vvFastLap && typeof window.__vvFastLap.trackKey === 'function') { + const k = window.__vvFastLap.trackKey(); + if (k) localStorage.removeItem(k); + // Re-sync the global cache so the UI reflects the cleared state. + if (typeof window.__vvFastLap.syncFromStore === 'function') { + window.__vvFastLap.syncFromStore(); + return; + } + } + } catch (_) {} + // Fallback for the case where the bridge helper isn't loaded yet + // (e.g., during very early boot). Match the pre-Phase-A behaviour + // of clearing the in-memory cache. fastLap = '--'; + if (typeof lastLap !== 'undefined') lastLap = null; } function destroyBrain(){ localStorage.removeItem("bestBrain"); + // Phase A: legacy fastLap key is already retired at boot; this + // removal is a no-op now but kept so any pre-Phase-A revert leaves + // a clean slate. resetFastLap() handles the per-track keys. localStorage.removeItem("fastLap"); - fastLap="--"; + resetFastLap(); +} + +// Phase A: bulk clear of every per-track fastLap. Wired from the +// 🧠 Brain saves disclosure (utils.js) for the destructive bulk option, +// distinct from resetFastLap() which only clears the current track. +function clearAllFastLaps(){ + var keys = []; + try { + for (var i = 0; i < localStorage.length; i++){ + var k = localStorage.key(i); + if (k && window.__vvFastLap && k.indexOf(window.__vvFastLap.prefix) === 0){ + keys.push(k); + } + } + } catch (_) {} + if (keys.length === 0){ + window.alert('No fast-lap records to clear.'); + return; + } + if (!window.confirm('Clear ALL ' + keys.length + ' track fast-lap record' + + (keys.length === 1 ? '' : 's') + + '? This cannot be undone.\n\nNamed brain saves are NOT affected.')){ + return; + } + for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]); + // Resync display. + try { + if (window.__vvFastLap && typeof window.__vvFastLap.syncFromStore === 'function'){ + window.__vvFastLap.syncFromStore(); + } + } catch (_) {} } function submitTrack(){ road.getTrack(); diff --git a/AI-Car-Racer/main.js b/AI-Car-Racer/main.js index 527dfe9..a102774 100644 --- a/AI-Car-Racer/main.js +++ b/AI-Car-Racer/main.js @@ -93,7 +93,140 @@ var invincible=false; var traction=0.5; var frameCount = 0; // mirrors worker's frameCount via snapshots + +// === Phase A — track-aware fast lap ===================================== +// Three pieces of state, all globals because main.js is a classic script +// and other classic-script files (buttonResponse.js, brainExport.js) read +// `fastLap` directly. We KEEP `fastLap` as a global cache of the *current +// track's* best lap so existing callers continue to work; we just resync +// it whenever the active track changes (see _syncFastLapForCurrentTrack). +// +// Storage shape: localStorage.vv_fastlap_ = JSON of +// { timeS, recordedAt, generation }. trackHash is xxHash32 of the 512-d +// CNN track embedding (window.currentTrackVec) — same Phase 0 hash util +// that content-addresses brains; Phase A re-uses it under the alias +// hashVec for naming honesty. +// +// `lastLap` is in-memory only: it tracks the most recent *completed* lap +// in this session, regardless of whether it was a record. Persisting +// "last" would conflate it with "fastest"; the value of last-lap is +// real-time signal during a run, not historical. +// +// `allTimeBest` is a derived cache: min over every vv_fastlap_* entry. +// Recomputed on track change and on new-record. Cheap (small N). var fastLap = '--'; +var lastLap = null; +var allTimeBest = null; +const FASTLAP_PREFIX = 'vv_fastlap_'; + +// Async-load the hash util once. main.js is a classic script; archive/hash.js +// is an ES module — dynamic import() bridges them. Until it resolves, +// _trackHash() returns null and the fast-lap logic falls back to '--' (no +// regression vs. pre-Phase-A: the legacy global was '--' on first load too). +let __hashVec = null; +import('./archive/hash.js').then((m) => { __hashVec = m.hashVec || m.hashBrain; }) + .catch((e) => { console.warn('[fastlap] hash util load failed', e); }); + +// Track-hash cache so we don't re-xxHash the 512-dim vec on every render. +let __trackHashCache = { vec: null, hash: null }; +function _trackHash() { + const v = window.currentTrackVec; + if (!v || !v.length || !__hashVec) return null; + if (__trackHashCache.vec === v) return __trackHashCache.hash; + try { + const h = __hashVec(v); + __trackHashCache = { vec: v, hash: h }; + return h; + } catch (e) { + console.warn('[fastlap] hash failed', e); + return null; + } +} +function _trackKey() { + const h = _trackHash(); + return h ? (FASTLAP_PREFIX + h) : null; +} +function _readFastLapForCurrentTrack() { + const key = _trackKey(); + if (!key) return '--'; + try { + const raw = localStorage.getItem(key); + if (!raw) return '--'; + const obj = JSON.parse(raw); + return (typeof obj.timeS === 'number') ? obj.timeS : '--'; + } catch (_) { return '--'; } +} +function _writeFastLapForCurrentTrack(timeS, generation) { + const key = _trackKey(); + if (!key) return false; + try { + localStorage.setItem(key, JSON.stringify({ + timeS: timeS, + recordedAt: new Date().toISOString(), + generation: (generation | 0), + })); + return true; + } catch (e) { + console.warn('[fastlap] write failed', e); + return false; + } +} +function _computeAllTimeBest() { + let best = Infinity; + try { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (!k || k.indexOf(FASTLAP_PREFIX) !== 0) continue; + try { + const obj = JSON.parse(localStorage.getItem(k)); + if (obj && typeof obj.timeS === 'number' && obj.timeS < best) best = obj.timeS; + } catch (_) {} + } + } catch (_) {} + return Number.isFinite(best) ? best : null; +} +// Re-sync the global cache from localStorage. Called on boot (after the +// hash util loads), on track change, on reset, and after a new-record write. +function _syncFastLapForCurrentTrack() { + fastLap = _readFastLapForCurrentTrack(); + allTimeBest = _computeAllTimeBest(); +} +// Exposed on window so buttonResponse.js (classic script, separate file) +// can call them without a module bridge. Kept namespaced under __vvFastLap +// so we don't pollute the global namespace beyond `fastLap` itself. +window.__vvFastLap = { + syncFromStore: _syncFastLapForCurrentTrack, + write: _writeFastLapForCurrentTrack, + read: _readFastLapForCurrentTrack, + trackKey: _trackKey, + allTimeBest: _computeAllTimeBest, + setLastLap: function (t) { lastLap = (typeof t === 'number') ? t : null; }, + getLastLap: function () { return lastLap; }, + prefix: FASTLAP_PREFIX, +}; + +// Legacy retirement: silently drop the pre-Phase-A track-confused +// localStorage.fastLap key. The old value was attributed to no specific +// track, so migrating it would be misleading; clean cut is honest. +try { localStorage.removeItem('fastLap'); } catch (_) {} + +// Polling boot-sync: when the hash util eventually loads AND +// currentTrackVec eventually becomes available, sync the cache so the +// timer renders the right number on the very first paint that has both. +// Bounded retries; gives up silently if neither materializes (display +// stays '--' which is the correct "no record on this track yet" state). +(function _bootSyncFastLap() { + let tries = 0; + const id = setInterval(() => { + if (__hashVec && window.currentTrackVec) { + _syncFastLapForCurrentTrack(); + clearInterval(id); + } else if (++tries > 60) { + clearInterval(id); + } + }, 250); +})(); +// ========================================================================= // Sim-speed multiplier. Worker owns the AI-car accumulator; main owns a // parallel accumulator for the 2 player cars only. They drift slightly under @@ -145,9 +278,10 @@ if (localStorage.getItem("traction")){ if (localStorage.getItem("maxSpeed")){ maxSpeed=JSON.parse(localStorage.getItem("maxSpeed")); } -if (localStorage.getItem("fastLap")){ - fastLap = JSON.parse(localStorage.getItem("fastLap")); -} +// Phase A: legacy `fastLap` localStorage key retired in the boot block +// above; per-track values now live under vv_fastlap_. The +// initial hydration from those keys happens in _bootSyncFastLap() once +// both the hash util and currentTrackVec are available. if (localStorage.getItem("conservativeInit")){ const v = parseFloat(localStorage.getItem("conservativeInit")); if (Number.isFinite(v)) conservativeInit = Math.max(0, Math.min(1, v)); @@ -1126,9 +1260,21 @@ function performNextBatch(genData){ _times.save = performance.now() - _tSave; if (genData.laps > 0 && genData.lapTimes && genData.lapTimes.length){ const minLap = Math.min.apply(null, genData.lapTimes); + // Phase A: lastLap is "the most recent completed lap", not the + // batch's fastest. Multi-lap batches still update lastLap to the + // last entry for an honest "what just happened?" signal. + const lastEntry = genData.lapTimes[genData.lapTimes.length - 1]; + if (typeof lastEntry === 'number' && Number.isFinite(lastEntry)) { + lastLap = lastEntry; + } if (fastLap === '--' || minLap < fastLap){ + // Per-track write. _writeFastLapForCurrentTrack is a no-op if + // currentTrackVec/hash aren't ready yet; the in-memory + // `fastLap` cache still updates so the UI reflects the new + // record even if the persist is deferred to the next sync. fastLap = minLap; - localStorage.setItem('fastLap', JSON.stringify(fastLap)); + _writeFastLapForCurrentTrack(minLap, generation); + allTimeBest = _computeAllTimeBest(); } } @@ -1233,7 +1379,25 @@ function animate(){ const wallSecs = ((performance.now() - wallStart)/1000).toFixed(2); timer.innerHTML = "

Sim Time: " + simSecs + "s " + "(wall " + wallSecs + "s · " + simSpeed + "×)

"; - timer.innerHTML += "

Fast Lap: " + (typeof fastLap === 'number' ? fastLap.toFixed(2) : fastLap) + "

"; + // 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(); if(!pause){ diff --git a/AI-Car-Racer/style.css b/AI-Car-Racer/style.css index 4022b89..9e249a6 100644 --- a/AI-Car-Racer/style.css +++ b/AI-Car-Racer/style.css @@ -2117,3 +2117,168 @@ label { 0% { background: #62f0b8; box-shadow: 0 0 10px rgba(98, 240, 184, 0.65); } 100% { background: rgba(98, 240, 184, 0.14); box-shadow: 0 0 0 rgba(0, 0, 0, 0); } } + +/* === Phase A — Experiments disclosure panel === */ +/* Consolidates RuLake-inspired feature toggles. Native
+ + * for the disclosure — no JS animation, no custom widget. */ +.rv-experiments { + margin-top: 14px; + padding: 8px 10px; + background: rgba(20, 28, 48, 0.55); + border: 1px solid rgba(120, 140, 200, 0.18); + border-radius: 6px; +} +.rv-experiments[open] { + background: rgba(20, 28, 48, 0.75); +} +.rv-experiments-summary { + cursor: pointer; + font-weight: 600; + color: #cbd8ff; + user-select: none; + list-style: none; + padding: 2px 0; +} +.rv-experiments-summary::-webkit-details-marker { display: none; } +.rv-experiments-summary::before { + content: '▸'; + display: inline-block; + margin-right: 6px; + transition: transform 120ms ease-out; +} +.rv-experiments[open] .rv-experiments-summary::before { + transform: rotate(90deg); +} +.rv-experiments-hint { + font-weight: 400; + color: #8a98be; + font-size: 0.85em; + margin-left: 6px; +} +.rv-experiments-body { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.rv-experiments-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 4px 6px; + border-radius: 4px; +} +.rv-experiments-row + .rv-experiments-row { + border-top: 1px solid rgba(120, 140, 200, 0.08); + padding-top: 8px; +} +.rv-experiments-row > [data-rv]:not(input):not(label) { + /* migrated rows (consistency, federation, crosstab) keep their own + * layouts — strip the row's wrap so they read clean inside */ + flex: 1 1 100%; +} +.rv-experiments-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + color: #e6ebfa; +} +.rv-experiments-toggle input[type="checkbox"] { cursor: pointer; } +.rv-experiments-emoji { font-size: 1.05em; } +.rv-experiments-label { font-weight: 500; } +.rv-experiments-hint-inline { + color: #8a98be; + font-size: 0.85em; + font-style: italic; +} +.rv-experiments-subbody { + flex: 1 1 100%; + margin-top: 4px; + padding: 6px 8px; + background: rgba(10, 16, 32, 0.45); + border-radius: 4px; +} +.rv-experiments-subbody[hidden] { display: none; } + +/* Disabled (library-only) row styling */ +.rv-experiments-row-disabled { opacity: 0.55; } +.rv-experiments-toggle-disabled { cursor: not-allowed; } +.rv-experiments-toggle-disabled input { cursor: not-allowed; } +.rv-experiments-badge { + display: inline-block; + padding: 1px 6px; + margin-left: 4px; + border-radius: 3px; + background: rgba(180, 140, 90, 0.22); + color: #d8c39a; + font-size: 0.75em; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* === Phase A — Brain saves (named-slot persistence) === */ +/* Mounted directly below the legacy Save Best + Restart actions, so the + * single-slot legacy buttons and the multi-slot named saves are + * visually adjacent. Native
/ for the disclosure. */ +.brain-saves { + margin-top: 10px; + padding: 8px 10px; + background: rgba(20, 28, 48, 0.55); + border: 1px solid rgba(120, 140, 200, 0.18); + border-radius: 6px; +} +.brain-saves > summary { + cursor: pointer; + font-weight: 600; + color: #cbd8ff; + user-select: none; + list-style: none; + padding: 2px 0; +} +.brain-saves > summary::-webkit-details-marker { display: none; } +.brain-saves > summary::before { + content: '▸'; + display: inline-block; + margin-right: 6px; + transition: transform 120ms ease-out; +} +.brain-saves[open] > summary::before { transform: rotate(90deg); } +.brain-saves-hint { + font-weight: 400; + color: #8a98be; + font-size: 0.85em; + margin-left: 6px; +} +.brain-saves-body { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.brain-saves-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: stretch; +} +.brain-saves-select { + flex: 1 1 auto; + min-width: 0; + padding: 4px 8px; + background: rgba(10, 16, 32, 0.85); + color: #e6ebfa; + border: 1px solid rgba(120, 140, 200, 0.32); + border-radius: 4px; + font: inherit; +} +.brain-saves-fresh { + /* Make Start Fresh visually distinct so users don't confuse it with + * the cheaper Reset Brain. Soft amber tint = "destructive but + * not catastrophic." Confirm dialog still gates execution. */ + background: rgba(180, 110, 60, 0.20) !important; + border-color: rgba(200, 130, 75, 0.45) !important; + color: #f0d6b6 !important; +} +.brain-saves-fresh:hover { background: rgba(180, 110, 60, 0.32) !important; } diff --git a/AI-Car-Racer/uiPanels.js b/AI-Car-Racer/uiPanels.js index 3620896..7e6db88 100644 --- a/AI-Car-Racer/uiPanels.js +++ b/AI-Car-Racer/uiPanels.js @@ -241,17 +241,24 @@ '', ].join(''); - // Phase 1A (F3). Warm-restart archive Export/Import row — rendered ONLY - // when the URL has ?snapshots=1 so the default experience is unchanged - // while the feature is baking. The row is a plain DOM append (not baked - // into the innerHTML above) so the gate lives in one readable place. - let _snapshotsFlagOn = false; - try { - if (typeof URLSearchParams === 'function') { - _snapshotsFlagOn = new URLSearchParams(window.location.search || '').get('snapshots') === '1'; - } - } catch (_) { _snapshotsFlagOn = false; } - if (_snapshotsFlagOn) { + // Phase 1A (F3). Warm-restart archive Export/Import row. + // + // Phase A (UI discoverability pass): the row is now ALWAYS created. The + // ?snapshots=1 URL flag is preserved, but it presets the "Save & share + // archives" toggle inside the 🧪 Experiments disclosure rather than gating + // whether this DOM ever exists. The Experiments wrapper at the bottom of + // this file moves this row into the disclosure body and hides it until the + // user (or the URL flag) opts in. + const _snapshotsFlagOn = (function () { + try { + if (typeof URLSearchParams === 'function') { + return new URLSearchParams(window.location.search || '').get('snapshots') === '1'; + } + } catch (_) {} + return false; + })(); + let __rvSnapshotsRow = null; + { const row = document.createElement('div'); row.className = 'rv-snapshots'; row.setAttribute('data-rv', 'snapshots'); @@ -309,7 +316,23 @@ } }); - btnImport.addEventListener('click', function () { fileInput.click(); }); + btnImport.addEventListener('click', function () { + // Phase A guardrail — Import REPLACES the live archive. With the + // ?snapshots=1 URL flag retired as a friction layer, the confirm() + // is what stops an accidental click from wiping a long training run. + const b = window.__rvBridge; + const liveCount = (function () { + try { return (b && b.info && b.info().brains) | 0; } catch (_) { return 0; } + })(); + const ok = window.confirm( + 'Import archive bundle?\n\n' + + 'This will REPLACE your current archive (' + liveCount + ' brain' + + (liveCount === 1 ? '' : 's') + ') with the contents of the imported file.\n\n' + + 'Proceed?' + ); + if (!ok) return; + fileInput.click(); + }); fileInput.addEventListener('change', async function () { const file = fileInput.files && fileInput.files[0]; if (!file) return; @@ -335,6 +358,7 @@ fileInput.value = ''; } }); + __rvSnapshotsRow = row; } const el = { @@ -503,19 +527,14 @@ if (on) await ensureFederationViewer(); }); } - // Phase 2B (F6) — cross-tab pill. Visible only when ?crosstab=1 URL flag is - // present (feature is baking behind a flag). Subscribes to the bridge's - // setCrosstabListeners so we get a callback on every received remote brain - // (for the green pulse) and on peer-count changes (for the "N peer(s)" - // readout). The subscription attempt is best-effort and retries a few - // times because the bridge sidecar can land slightly after the panel. - let _crosstabFlagOn = false; - try { - if (typeof URLSearchParams === 'function') { - _crosstabFlagOn = new URLSearchParams(window.location.search || '').get('crosstab') === '1'; - } - } catch (_) { _crosstabFlagOn = false; } - if (el.crosstab && _crosstabFlagOn) el.crosstab.hidden = false; + // Phase 2B (F6) — cross-tab pill. Phase A (UI discoverability pass): + // visibility now belongs to the Experiments disclosure's "Cross-tab live + // training" toggle, which also flips bridge.setCrosstabEnabled. The pill + // element itself (`el.crosstab`) is moved into the disclosure subbody + // later in this file; we always wire listeners here so the pulse + peer + // count work the moment the user flips the checkbox. + // The legacy `?crosstab=1` URL flag is preserved as a preset (it pre- + // checks the experiments toggle); see buildExperimentsPanel(). function renderCrosstabPeers(n) { if (!el.crosstabPeers) return; const count = Math.max(0, n | 0); @@ -555,7 +574,9 @@ } } } - if (_crosstabFlagOn) ensureCrosstabWiring(); + // Always wire listeners; the experiments toggle gates whether the bridge + // actually broadcasts/receives. The pill stays accurate either way. + ensureCrosstabWiring(); function renderFederation() { const b = window.__rvBridge; @@ -1432,8 +1453,10 @@ }); // === Phase 3C share panel === - // Rendered ONLY when `?snapshots=1` is present (same gate as the Phase - // 1A Export/Import row above). Three capabilities: + // Phase A (UI discoverability pass): always created; visibility is + // controlled by the "Save & share archives" toggle inside the Experiments + // disclosure. The ?snapshots=1 URL flag is preserved as a preset for that + // toggle. Three capabilities (unchanged): // 1. "📋 Copy shareable link" — prompts for a URL the user already // hosts the bundle at, copies `?snapshots=1&archive=` to the // clipboard. We host nothing. @@ -1444,13 +1467,8 @@ // (see the external-scope note in gallery.js). // The anchor comment above is load-bearing: future phases should mount // above/below it, not replace it. - let _sharePanelGateOn = false; - try { - if (typeof URLSearchParams === 'function') { - _sharePanelGateOn = new URLSearchParams(window.location.search || '').get('snapshots') === '1'; - } - } catch (_) { _sharePanelGateOn = false; } - if (_sharePanelGateOn) { + let __rvShareRow = null; + { const shareRow = document.createElement('div'); shareRow.className = 'rv-share'; shareRow.setAttribute('data-rv', 'share'); @@ -1536,16 +1554,209 @@ }); btnImport.addEventListener('click', function () { - __shareImportFromUrl((urlInput.value || '').trim()); + const url = (urlInput.value || '').trim(); + if (!url) { setShareStatus('paste a URL above first', 'error'); return; } + // Phase A guardrail — same logic as the file-import confirm above. + const b = window.__rvBridge; + const liveCount = (function () { + try { return (b && b.info && b.info().brains) | 0; } catch (_) { return 0; } + })(); + const ok = window.confirm( + 'Import archive from URL?\n\n' + + url + '\n\n' + + 'This will REPLACE your current archive (' + liveCount + ' brain' + + (liveCount === 1 ? '' : 's') + ').\n\nProceed?' + ); + if (!ok) return; + __shareImportFromUrl(url); }); // Mount the community gallery. Each entry's button routes back // through the same fetch+import flow as the "📎 Import from URL" - // button so the UX is consistent. + // button so the UX is consistent. The gallery handler also goes + // through confirm() because the placeholder will eventually become + // real third-party URLs. import('./share/gallery.js').then(({ mountGalleryPanel }) => { - mountGalleryPanel(galleryMount, (url) => __shareImportFromUrl(url)); + mountGalleryPanel(galleryMount, (url) => { + const b = window.__rvBridge; + const liveCount = (function () { + try { return (b && b.info && b.info().brains) | 0; } catch (_) { return 0; } + })(); + const ok = window.confirm( + 'Import this community archive?\n\n' + url + '\n\n' + + 'This will REPLACE your current archive (' + liveCount + ' brain' + + (liveCount === 1 ? '' : 's') + ').\n\nProceed?' + ); + if (!ok) return; + __shareImportFromUrl(url); + }); }).catch((e) => { console.warn('[rv-panel] share gallery mount failed', e); }); + __rvShareRow = shareRow; } + + // === Phase A — UI discoverability pass: 🧪 Experiments disclosure === + // + // Consolidates the RuLake-inspired feature toggles into one collapsible + // section. The previously-flag-gated rows (snapshots, share, crosstab) + // now live here and are gated by checkboxes inside the disclosure. URL + // flags are preserved as PRESETS that pre-toggle the corresponding + // checkbox at boot, but they no longer gate whether the UI exists. + // + // We MOVE existing DOM nodes (consistency, federation, crosstab, + // snapshots, share) into the disclosure body via appendChild, which + // preserves every existing event listener and querySelector reference. + // Refactoring all that wiring would be a much bigger change; the move- + // not-rebuild approach is the smallest possible diff that achieves the + // discoverability goal. + // + // Default state: see the "Default state per feature" table in + // docs/plan/ui-discoverability-pass.md. + (function buildExperimentsPanel() { + const usp = (function () { + try { return new URLSearchParams(window.location.search || ''); } catch (_) { return null; } + })(); + const flag = (k) => usp && usp.get(k) !== null; + const flagEq = (k, v) => usp && usp.get(k) === v; + + const details = document.createElement('details'); + details.className = 'rv-experiments'; + details.setAttribute('data-rv', 'experiments'); + details.innerHTML = [ + '🧪 Experiments (RuLake-inspired toggles)', + '
', + '
', + ' ', + ' flame-graph-lite for every generation', + ' ', + '
', + '
', + ' ', + ' export, import, share via URL', + ' ', + ' ', + '
', + '
', + ' ', + ' two tabs share an archive via BroadcastChannel', + ' ', + ' ', + '
', + '
', + '
', + '
', + ' ', + ' module ships, integration is a future slice', + ' ', + '
', + '
', + ].join(''); + root.appendChild(details); + + const expBody = details.querySelector('[data-rv="experiments-body"]'); + const expSnapshotsRow = details.querySelector('[data-rv="exp-snapshots-row"]'); + const expSnapshotsCb = details.querySelector('[data-rv="exp-snapshots"]'); + const expSnapshotsSub = details.querySelector('[data-rv="exp-snapshots-subbody"]'); + const expCrosstabRow = details.querySelector('[data-rv="exp-crosstab-row"]'); + const expCrosstabCb = details.querySelector('[data-rv="exp-crosstab"]'); + const expCrosstabSub = details.querySelector('[data-rv="exp-crosstab-subbody"]'); + const expFedRow = details.querySelector('[data-rv="exp-federation-row"]'); + const expConsRow = details.querySelector('[data-rv="exp-consistency-row"]'); + const expObsCb = details.querySelector('[data-rv="exp-observability"]'); + + // Move existing DOM nodes into the disclosure. Listeners attached + // earlier survive the appendChild move — that's the whole reason we + // refactored as "wrap, don't rebuild." + const consistencyEl = root.querySelector('[data-rv="consistency"]'); + const federationEl = root.querySelector('[data-rv="federation"]'); + const crosstabEl = root.querySelector('[data-rv="crosstab"]'); + if (federationEl && expFedRow) { + // Unset the federation toggle's prior placement; re-parent under exp. + expFedRow.appendChild(federationEl); + } + if (consistencyEl && expConsRow) { + expConsRow.appendChild(consistencyEl); + } + if (crosstabEl && expCrosstabSub) { + // The pill itself moves into the snapshots-style subbody, hidden + // until the experiments checkbox flips it on. + crosstabEl.hidden = false; // we control via subbody.hidden now + expCrosstabSub.appendChild(crosstabEl); + } + if (__rvSnapshotsRow && expSnapshotsSub) { + expSnapshotsSub.appendChild(__rvSnapshotsRow); + } + if (__rvShareRow && expSnapshotsSub) { + expSnapshotsSub.appendChild(__rvShareRow); + } + + // Snapshots toggle — show/hide the controls (which include both the + // file-based Export/Import row and the URL share row). + function applySnapshotsState(on) { + if (!expSnapshotsSub) return; + expSnapshotsSub.hidden = !on; + } + expSnapshotsCb.addEventListener('change', () => applySnapshotsState(expSnapshotsCb.checked)); + if (_snapshotsFlagOn) { + expSnapshotsCb.checked = true; + applySnapshotsState(true); + } + + // Crosstab toggle — flip both the bridge state AND the pill visibility. + function applyCrosstabState(on) { + if (expCrosstabSub) expCrosstabSub.hidden = !on; + try { + const b = window.__rvBridge; + if (b && typeof b.setCrosstabEnabled === 'function') b.setCrosstabEnabled(!!on); + } catch (e) { console.warn('[rv-experiments] setCrosstabEnabled failed', e); } + } + expCrosstabCb.addEventListener('change', () => applyCrosstabState(expCrosstabCb.checked)); + // ?crosstab=1 preset — but the bridge may not be ready yet. The + // existing __applyUrlCrosstabFlag in main.js polls for bridge + // readiness; here we just sync the checkbox state. The bridge's + // setCrosstabEnabled will then be called once it's ready. + if (flagEq('crosstab', '1')) { + expCrosstabCb.checked = true; + // Apply with a short delay to give the bridge time to load. If the + // bridge isn't ready, applyCrosstabState's try/catch swallows it + // and main.js's poll will pick up the slack. + setTimeout(() => applyCrosstabState(true), 100); + } + + // Observability toggle — show/hide the obs panel. The panel itself is + // mounted by the existing 3A code below; we just toggle its CSS. + function applyObsState(on) { + const obs = root.querySelector('.rv-obs-panel'); + if (obs) obs.hidden = !on; + } + expObsCb.addEventListener('change', () => applyObsState(expObsCb.checked)); + // Apply after a tick so the obs panel is mounted by then. + setTimeout(() => applyObsState(expObsCb.checked), 250); + + // Default-collapsed: leave details closed unless any feature is + // pre-toggled by a URL flag, in which case open it so the user can + // see what their share-link enabled. + if (flag('snapshots') || flag('crosstab') || flag('federation') || + flag('consistency') || flag('archive')) { + details.open = true; + } + })(); })(); diff --git a/AI-Car-Racer/utils.js b/AI-Car-Racer/utils.js index 85b0d5b..06282ed 100644 --- a/AI-Car-Racer/utils.js +++ b/AI-Car-Racer/utils.js @@ -172,6 +172,27 @@ function phaseToLayout(phase){
+
+ 🧠 Brain saves (named slots) +
+
+ +
+
+ + + +
+
+ +
+
+ +
+
+
`; bottomText.innerHTML = `

Train your model!

@@ -189,13 +210,23 @@ function phaseToLayout(phase){ try { const brainShareEl = document.getElementById('brainShareSection'); const moreActionsEl = document.getElementById('moreActions'); + const brainSavesEl = document.getElementById('brainSaves'); const rvPanelEl = document.getElementById('rv-panel'); if (rvPanelEl && rvPanelEl.parentNode) { if (brainShareEl) rvPanelEl.parentNode.insertBefore(brainShareEl, rvPanelEl.nextSibling); if (moreActionsEl && brainShareEl) brainShareEl.parentNode.insertBefore(moreActionsEl, brainShareEl.nextSibling); else if (moreActionsEl) rvPanelEl.parentNode.insertBefore(moreActionsEl, rvPanelEl.nextSibling); + // Brain saves sits directly under moreActions so the + // legacy single-slot Save Best+Restart button and the + // new named-slot row are visually adjacent. + if (brainSavesEl && moreActionsEl) moreActionsEl.parentNode.insertBefore(brainSavesEl, moreActionsEl.nextSibling); } } catch (_) {} + // Populate the brain-saves dropdown from localStorage now that + // the + + + + + + + diff --git a/tests/experiments-panel-smoke.html b/tests/experiments-panel-smoke.html new file mode 100644 index 0000000..8842563 --- /dev/null +++ b/tests/experiments-panel-smoke.html @@ -0,0 +1,150 @@ + + + + + Experiments panel smoke (Phase A) + + + +

Experiments panel smoke (Phase A)

+

+ Loads ../AI-Car-Racer/index.html in a hidden iframe and + asserts UI claims. The plain URL load tests default state; a second + iframe loads with ?snapshots=1 to verify the URL flag + presets the corresponding toggle. +

+ +
running…
+ +

Claims

+ + + +
#claimverdictdetail
+ + + + + + + diff --git a/tests/fastlap-track-aware-smoke.html b/tests/fastlap-track-aware-smoke.html new file mode 100644 index 0000000..adf1ee3 --- /dev/null +++ b/tests/fastlap-track-aware-smoke.html @@ -0,0 +1,165 @@ + + + + + Fast-lap track-aware smoke (Phase A) + + + +

Fast-lap track-aware smoke (Phase A)

+

+ Exercises the per-track fast-lap storage primitives. Imports + archive/hash.js directly to verify hash determinism, + then drives localStorage with the + vv_fastlap_<trackHash> shape used by + main.js. Also confirms the legacy fastLap + key retirement leaves the per-track keys alone. +

+ +
running…
+ +

Claims

+ + + +
#claimverdictdetail
+ + + +