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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AI-Car-Racer/car.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ class Car{
// still run #move() every step so simSpeed scales monotonically
// and motion stays visually smooth — gating the whole update()
// here made 5× per-car motion slower than 2×.
const isPrivileged = (this === bestCar) || (this.controlType !== 'AI');
const isPrivileged = (this === bestCar) || (this.controlType !== 'AI') || this.senseEveryFrame;
const skipPerception = !isPrivileged
&& typeof SENSOR_STRIDE !== 'undefined'
&& SENSOR_STRIDE > 1
Expand Down
143 changes: 143 additions & 0 deletions AI-Car-Racer/cloud/ghost.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// cloud/ghost.js — race the cloud champion (docs/plan/cloud-brain.md, X3).
// In shared mode the best brain the shared pool holds for this track drives
// a ghost car next to yours: simulated here from its weights, as the trial
// worker drives a car (car.js, sensors every frame), from the same start
// pose and with the same physics as your car. It never collides with your
// car, never trains and is never scored. main.js steps it with your cars
// while the AI trains, draws it, and starts it over with them; the 3D view
// places it from `pose()`.

/** Frames a crashed ghost waits before it starts over (as your car does). */
export const RESPAWN_FRAMES = 40;

/**
* The champion among what this page pulled, for the track whose key is
* `track` (the learning context's): the fastest verified first lap on this
* track's leaderboard whose brain the last pull brought (it can be driven
* here), else the pool's best that learned on this track (a pool holds the
* nearest tracks' brains, which may be other tracks'). `board`: wire.js
* parseLeaderboardResponse's answer or null; `pool`: the recall entries
* ({id, vector, meta, fitness}). Returns {id, vector, profile, lapFrames
* (the verified first lap, or null), from: 'leaderboard' | 'pool'} or null.
*/
export function chooseChampion(board, pool, track) {
if (!Array.isArray(pool) || !pool.length || !track) return null;
const drivable = pool.filter(p => p?.vector?.length === 244);
const byId = new Map(drivable.map(p => [p.id, p]));
if (board?.track === track) {
for (const e of board.entries || []) {
const held = byId.get(e.id);
if (held) return {id: e.id, vector: held.vector, profile: e.profile, lapFrames: e.lapFrames, from: 'leaderboard'};
}
}
const best = drivable.find(p => p.meta?.learning?.context?.track === track);
if (!best) return null;
return {id: best.id, vector: best.vector, profile: best.meta.learning.context.profile || 'balanced', lapFrames: null, from: 'pool'};
}

/**
* The ghost. Everything it needs from the page is passed in (main.js's
* globals in the app; a vm's in tests): `Car`, `inflate(flat)` (a network
* from 244 weights), `road()`, `start()` ({x, y, heading}), `maxSpeed()`.
*/
export class Ghost {
constructor({Car, inflate, road, start, maxSpeed}) {
Object.assign(this, {Car, inflate, road, start, maxSpeed});
this.champion = null;
this.next = null;
// The player's choice; it races while there is a champion.
this.wanted = false;
this.car = null;
this.frames = 0;
this.wrecked = 0;
this.lapFrames = [];
}

get enabled() {
return this.wanted && !!this.champion;
}

/**
* The brain it drives (null: none for this track now; racing waits for
* the next). Racing, another champion waits for the next start (it does
* not jump in mid-lap); the same one (its lap time known now, say) never
* restarts it.
*/
setChampion(champion) {
if (champion && champion.id === this.champion?.id) {
this.champion = champion;
this.next = null;
return;
}
if (this.car && champion) {
this.next = champion;
return;
}
this.next = null;
this.champion = champion || null;
this.reset();
}

/** Race or not (the choice stays while champions come and go). */
enable(on) {
this.wanted = !!on;
this.reset();
return this.enabled;
}

/** Back to the start pose (with your cars, at each new run). */
reset() {
if (this.next) {
this.champion = this.next;
this.next = null;
}
this.car = null;
this.frames = 0;
this.wrecked = 0;
this.lapFrames = [];
if (!this.enabled) return;
const s = this.start?.();
if (!s || ![s.x, s.y].every(Number.isFinite)) return;
const car = new this.Car(s.x, s.y, 30, 50, 'AI', this.maxSpeed(), s.heading || 0);
car.brain = this.inflate(this.champion.vector);
car.driverProfile = this.champion.profile || 'balanced';
// Sensors every frame, as the service and the trial worker's best car
// drive (a car past the sensor stride would drive another race).
car.senseEveryFrame = true;
this.car = car;
}

/** One frame, alongside your cars. A wreck starts over after a while. */
step() {
const r = this.road?.();
if (!this.car || !r) return;
if (this.car.damaged && ++this.wrecked >= RESPAWN_FRAMES) {
this.reset();
if (!this.car) return;
}
const laps = this.car.laps;
this.car.update(r.borders, r.checkPointList);
this.frames++;
if (this.car.laps > laps) this.lapFrames.push(this.frames);
}

/** Its pose for the 3D view, or null. */
pose() {
const c = this.car;
return c ? {x: c.x, y: c.y, angle: c.angle, speed: c.speed, damaged: !!c.damaged} : null;
}

/** Its laps so far: {laps, lapFrames, crashed, frames}. */
status() {
return {laps: this.lapFrames.length, lapFrames: [...this.lapFrames], crashed: !!this.car?.damaged, frames: this.frames};
}

/** Drawn translucent on the 2D canvas. */
draw(ctx, color = '#B388FF') {
if (!this.car || !ctx) return;
const alpha = ctx.globalAlpha;
ctx.globalAlpha = 0.45;
this.car.draw(ctx, color);
ctx.globalAlpha = alpha;
}
}
39 changes: 37 additions & 2 deletions AI-Car-Racer/cloud/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
// - a brain that drove a lap is verified once it is sent (X1): the service
// drives it on the page's walls and gates, alone, from main.js's start; the
// leaderboard for the track and physics is read every minute and after a
// verification.
// verification;
// - the champion (X3): the fastest verified brain the last pull brought, or
// the pool's best, can race you as a ghost (cloud/ghost.js).
import {brainToWire, feedbackToWire, brainId, encodeF32, trackGeometry, boardQuery, parseBoard, DIMS, LIMITS} from './wire.js';
import {CloudBrainClient, contributorToken} from './client.js';
import {saveBrainMode, consented} from './mode.js';
import {mountMemoryControl, ARRIVAL_DISCLOSURE} from './ui.js';
import {Ghost, chooseChampion} from './ghost.js';
import {geometryKey} from '../graphics/state.js';

export const FLUSH_MS = 10_000;
Expand Down Expand Up @@ -108,6 +111,9 @@ export class SharedSession {
this.boardSeq = 0;
this.stopped = false;
this.onBoard = null; // (board or null, {mine, last}) when the leaderboard is read or no longer this page's
this.pool = []; // the last pull's entries (X3: the champion is among them)
this.champion = null;
this.onChampion = null; // (champion or null) when it changes
}

start() {
Expand Down Expand Up @@ -251,6 +257,16 @@ export class SharedSession {
#show(board) {
this.board = board;
try { this.onBoard?.(board, {mine: this.mine, last: this.verified.at(-1) || null}); } catch { /* the UI's problem */ }
this.#pickChampion();
}
/** The champion (X3) for this track, from the board and the last pool; told when it changes. */
#pickChampion() {
let track = null;
try { track = this.page.context()?.track || null; } catch { /* none yet */ }
const champion = chooseChampion(this.board, this.pool, track);
if (champion?.id === this.champion?.id && champion?.lapFrames === this.champion?.lapFrames) return;
this.champion = champion;
try { this.onChampion?.(champion); } catch { /* the UI's problem */ }
}

/** Offspring feedback the bridge applied: into the outbox, as cloud ids. */
Expand Down Expand Up @@ -281,13 +297,20 @@ export class SharedSession {
// The coach's count restarts with a new context: a smaller count is due too.
const due = generation !== null && this.pulledGeneration !== null && (generation < this.pulledGeneration || generation - this.pulledGeneration >= PULL_EVERY);
if (!force && key === this.pulledTrack && !due) return Promise.resolve(0);
// Another track or context: the last pool's champion is not this one's.
if (key !== this.pulledTrack && this.pool.length) {
this.pool = [];
this.#pickChampion();
}
this.pulling = (async () => {
const track = unit(trackVec);
const pool = await this.client.recall({trackVec: track, dynamicsVec: unit(this.page.dynamicsVec()), context, k: LIMITS.recallDefaultK});
if (!pool) return 0;
this.pulledTrack = key;
this.pulledGeneration = generation;
this.pulls++;
this.pool = pool;
this.#pickChampion();
const added = this.bridge.acceptCloudPool(pool, trackVec, context);
this.accepted += added;
return added;
Expand Down Expand Up @@ -354,7 +377,19 @@ export async function startCloudBrain(bridge, {win = globalThis.window, ask = te
},
});
session.onBoard = (board, about) => ui.setBoard(board, about);
if (win) win.__rvCloud = session;
// The cloud champion's ghost (X3): main.js steps and draws window.CloudGhost.
const ghost = new Ghost({
Car: typeof Car === 'undefined' ? null : Car,
// main.js's network from 244 weights (brainCodec.js is not loaded here).
inflate: flat => globalThis.inflateBrainInline(Float32Array.from(flat)),
road: () => (typeof road === 'undefined' ? null : road),
start: () => (typeof startInfo === 'undefined' ? null : startInfo),
maxSpeed: () => (typeof maxSpeed === 'undefined' ? 15 : maxSpeed),
});
const showChampion = () => ui.setChampion(session.champion, {wanted: ghost.wanted});
session.onChampion = champion => { ghost.setChampion(champion); showChampion(); };
ui.onRace = () => { ghost.enable(!ghost.wanted); showChampion(); };
if (win) { win.__rvCloud = session; win.CloudGhost = ghost; }
// (Unsent brains survive a reload in the outbox: nothing is sent on the way out.)
session.start();
return session;
Expand Down
40 changes: 34 additions & 6 deletions AI-Car-Racer/cloud/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ const STYLE = `
.rv-memory{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;margin:6px 0 2px;font-size:12px}
.rv-memory-label{opacity:.8}
.rv-memory label{display:inline-flex;align-items:center;gap:4px;cursor:pointer}
.rv-memory-switch{font:inherit;padding:2px 8px;cursor:pointer}
.rv-memory-switch,.rv-memory-race{font:inherit;padding:2px 8px;cursor:pointer}
.rv-memory-status,.rv-memory-board{flex-basis:100%;opacity:.85}
.rv-memory-status.rv-memory-offline{opacity:1;background:#6b3a12;color:#fff;border-radius:4px;padding:3px 6px}
.rv-memory-pill{position:fixed;left:50%;bottom:12px;transform:translateX(-50%);z-index:1300;background:#6b3a12;color:#fff;border-radius:999px;padding:4px 12px;font:12px/1.4 system-ui,-apple-system,sans-serif;box-shadow:0 1px 4px rgba(0,0,0,.3)}
@media (pointer:coarse){.rv-memory label,.rv-memory-switch{min-height:44px}}
@media (pointer:coarse){.rv-memory label,.rv-memory-switch,.rv-memory-race{min-height:44px}}
`;

/** The status line: {text} (announced when it changes), {retry} (not announced), {offline}. */
Expand Down Expand Up @@ -89,11 +89,23 @@ async function panelHeader(doc, ms) {
}

/**
* Mounts the control. Returns {setStatus, setBoard, ready} (ready resolves to
* the row element, or null when it is not shown).
* The race toggle's label (X3), the same pressed or not (aria-pressed says
* which): the champion's verified lap if known; while racing is wanted with
* no champion for this track, that it waits. Empty: no toggle.
*/
export function describeChampion(champion, {wanted = false} = {}) {
if (!champion) return wanted ? 'Race the cloud champion (waiting for one on this track)' : '';
return `Race the cloud champion${champion.lapFrames ? ` (${lapText(champion.lapFrames)} lap)` : ''}`;
}

/**
* Mounts the control. Returns {setStatus, setBoard, setChampion, onRace,
* ready} (onRace: set by the caller, called when the race button is
* pressed; ready resolves to the row element, or null when it is not shown).
*/
export function mountMemoryControl({mode, available, secure = true, onChoose, confirm: ask = text => globalThis.confirm?.(text) ?? false, doc = globalThis.document}) {
let row = null, textEl = null, retryEl = null, statusEl = null, boardEl = null, pill = null, last = null, ticker = null, boardText = '';
let row = null, textEl = null, retryEl = null, statusEl = null, boardEl = null, raceEl = null, pill = null, last = null, ticker = null, boardText = '';
let race = {text: '', wanted: false};
const render = () => {
if (!last) return;
const {text, retry, offline} = describe(last, {secure});
Expand Down Expand Up @@ -134,6 +146,17 @@ export function mountMemoryControl({mode, available, secure = true, onChoose, co
boardText = describeBoard(board, about);
if (boardEl) { boardEl.textContent = boardText; boardEl.hidden = !boardText; }
};
const showRace = () => {
if (!raceEl) return;
raceEl.textContent = race.text;
raceEl.hidden = !race.text;
raceEl.setAttribute('aria-pressed', String(race.wanted));
};
const setChampion = (champion, {wanted = false} = {}) => {
race = {text: describeChampion(champion, {wanted}), wanted: !!wanted};
showRace();
};
const control = {setStatus, setBoard, setChampion, onRace: null, ready: null};
const ready = (async () => {
if (!doc || (!available && mode !== 'shared')) return null;
const header = await panelHeader(doc, 10_000);
Expand All @@ -156,6 +179,7 @@ export function mountMemoryControl({mode, available, secure = true, onChoose, co
'<button type="button" class="rv-memory-switch" data-rv="memory-switch" hidden>Switch (reloads the page)</button>',
'<span class="rv-memory-status" data-rv="memory-status"><span data-rv="memory-text" role="status" aria-live="polite"></span><span data-rv="memory-retry" aria-hidden="true"></span></span>',
'<span class="rv-memory-board" data-rv="memory-board" hidden></span>',
'<button type="button" class="rv-memory-race" data-rv="memory-race" aria-pressed="false" hidden></button>',
].join('');
header.insertAdjacentElement('afterend', row);
statusEl = row.querySelector('[data-rv="memory-status"]');
Expand All @@ -164,6 +188,9 @@ export function mountMemoryControl({mode, available, secure = true, onChoose, co
boardEl = row.querySelector('[data-rv="memory-board"]');
boardEl.textContent = boardText;
boardEl.hidden = !boardText;
raceEl = row.querySelector('[data-rv="memory-race"]');
raceEl.addEventListener('click', () => control.onRace?.());
showRace();
const button = row.querySelector('[data-rv="memory-switch"]');
const chosen = () => row.querySelector('input[name="rv-memory"]:checked')?.value || mode;
for (const input of row.querySelectorAll('input[name="rv-memory"]')) {
Expand All @@ -184,5 +211,6 @@ export function mountMemoryControl({mode, available, secure = true, onChoose, co
if (last) render();
return row;
})();
return {setStatus, setBoard, ready};
control.ready = ready;
return control;
}
2 changes: 1 addition & 1 deletion AI-Car-Racer/demoPresentation.js
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@
function drawDriver(ctx, pose, color, label) {
if (!pose) return;
ctx.save();
drawProjectedCar(ctx, pose.x, pose.y, pose.angle, color, pose.damaged ? 0.4 : 1, state.carHeight);
drawProjectedCar(ctx, pose.x, pose.y, pose.angle, color, pose.alpha ?? (pose.damaged ? 0.4 : 1), state.carHeight);
const p = label && project(pose.x, pose.y, state.carHeight + 30);
if (p) {
ctx.globalAlpha = 1;
Expand Down
4 changes: 4 additions & 0 deletions AI-Car-Racer/graphics/studio.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class CircuitStudio {
this.liveLabels=document.createElement('div');this.liveLabels.className='live-driver-labels';this.host.append(this.liveLabels);
this.players=[createCar(0xc55146),createCar(0x539bbb)];this.players.forEach(c=>{c.visible=false;this.scene.add(c);});
this.ghostCars=[createCar(0x82e4c5,true),createCar(0xedb67a,true)];this.ghostCars.forEach(c=>{c.visible=false;this.scene.add(c);});
// The cloud champion's ghost (X3: cloud/ghost.js, shared mode).
this.cloudGhost=createCar(0xb388ff,true);this.cloudGhost.visible=false;this.scene.add(this.cloudGhost);
this.pack=new T.InstancedMesh(new T.BoxGeometry(.94,.30,1.62),new T.MeshStandardMaterial({color:0xffffff,roughness:.55,metalness:.15}),1600);
this.pack.setColorAt(0,new T.Color(0xffffff));
this.pack.instanceMatrix.setUsage(T.DynamicDrawUsage);this.pack.frustumCulled=false;this.pack.count=0;this.pack.castShadow=true;this.scene.add(this.pack);
Expand Down Expand Up @@ -341,6 +343,8 @@ class CircuitStudio {
this.pack.count=this.packWindows.count=count;this.pack.instanceMatrix.needsUpdate=true;this.packWindows.instanceMatrix.needsUpdate=true;
if(this.pack.instanceColor)this.pack.instanceColor.needsUpdate=true;
this.players.forEach((car,i)=>{const p=info.players[i];car.visible=finitePose(p)&&!this.replay&&(Math.abs(p.speed)>.01||(i===1&&this.followingPlayer));if(car.visible)this.placeCar(car,p,dt,[p.controls.forward,p.controls.left,p.controls.right,p.controls.reverse]);});
const cloud=info.cloudGhost;
this.cloudGhost.visible=finitePose(cloud)&&!this.replay;if(this.cloudGhost.visible)this.placeCar(this.cloudGhost,cloud,dt,null);
this.ghostCars.forEach((car,i)=>{
const run=this.archive.runs.filter(r=>r!==this.replay?.run)[i];
const time=this.replay?this.replay.time:(snap?.frameCount||0)/60;
Expand Down
9 changes: 8 additions & 1 deletion AI-Car-Racer/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,8 @@ function begin(preservePause = false, resetPlayers = false){
playerCar = new Car(startInfo.x, startInfo.y, 30, 50, "KEYS", maxSpeed, startInfo.heading);
playerCar2 = new Car(startInfo.x, startInfo.y, 30, 50, "WASD", maxSpeed, startInfo.heading);
window.LiveSession?.resetRace();
// The cloud champion's ghost starts over with you (X3, shared mode).
window.CloudGhost?.reset();
}
frameCount = 0;
wallStart = performance.now();
Expand Down Expand Up @@ -1645,7 +1647,8 @@ function animate(){
phase, road, snapshot: latestSnapshot, bestCar, generation,
runSerial: presentationRunSerial, paused: pause, simSpeed,
awaitingStart: !!window.__awaitingStart, startInfo,
players: [playerCar, playerCar2], maxSpeed, traction, invincible
players: [playerCar, playerCar2], maxSpeed, traction, invincible,
cloudGhost: window.CloudGhost?.pose() || null
};
window.LiveSession?.frame(presentationInfo);
window.PlayerAssist?.frame(presentationInfo);
Expand Down Expand Up @@ -1722,6 +1725,7 @@ function animate(){
playerCar2.update(road.borders, road.checkPointList);
window.DemonstrationRecorder?.step(playerCar2);
window.LiveSession?.step(playerCar2, road.checkPointList);
window.CloudGhost?.step();
}
} else {
// A paused human car must not catch up the paused time on Play.
Expand Down Expand Up @@ -1750,10 +1754,13 @@ function animate(){
// Studio renders its own human cars. Classic Tilt needs projected
// cars and labels; the flat view keeps its usual world-space quads.
if (!gpuActive){
const ghost = window.CloudGhost?.pose();
if (DP?.state.view3d){
if (ghost) DP.drawDriver(ctx,{...ghost,alpha:ghost.damaged?0.3:0.5},"#B388FF","Cloud champion");
DP.drawDriver(ctx,playerCar,"#E6194B");
DP.drawDriver(ctx,playerCar2,"#4FC3F7","You · WASD");
}else{
window.CloudGhost?.draw(ctx);
if (playerCar) playerCar.draw(ctx,"#E6194B",true);
if (playerCar2) playerCar2.draw(ctx,"#4FC3F7",true);
}
Expand Down
Loading
Loading