JEV-27B-Demo / arena-ui.js
uremia's picture
Create arena-ui.js
109227a verified
Raw History Blame Contribute Delete
18.8 kB
(() => {
"use strict";
const $ = id => document.getElementById(id);
const GAMES = {
mario:{title:"Mario",seed:0,caption:"The model controls movement and jumping, one decision at a time.",note:"Original World 1-1 · Movement and jump choices run sequentially.",example:"/mario-example.mp4",exampleTitle:"Mario · Recorded example · 5×"},
cube:{title:"Rubik’s Cube",seed:20260930,caption:"A full 25-turn scramble. Each model choice selects a sequence of cube turns.",note:"25-turn scramble · Watch each selected algorithm unfold in 3D.",example:"/cube-example.mp4",exampleTitle:"Rubik’s Cube · Recorded example · 10×"},
tetris:{title:"Tetris",seed:0,caption:"Clear 20 lines as the pieces fall faster. Play continues at maximum speed.",note:"Natural gravity · No drop shortcut · 20-line goal.",example:"/tetris-example.mp4",exampleTitle:"Tetris · Recorded example · 5×"}
};
const TERMINAL = new Set(["completed","failed","cancelled"]);
const sleep = ms => new Promise(r=>setTimeout(r,ms));
const safelyRead = key=>{try{return JSON.parse(localStorage.getItem(key))}catch{return null}};
const safelyWrite = (key,value)=>{try{if(value===null)localStorage.removeItem(key);else localStorage.setItem(key,JSON.stringify(value))}catch{}};
let clientId=safelyRead("jev-arena-client");
if(typeof clientId!=="string" || clientId.length>100){clientId=crypto.randomUUID?.() || "client-"+Date.now().toString(36)+"-"+Math.random().toString(36).slice(2);safelyWrite("jev-arena-client",clientId);}
let selected=Object.hasOwn(GAMES,location.hash.slice(1))?location.hash.slice(1):"mario";
let online=false, initial={}, health={};
const states=Object.fromEntries(Object.keys(GAMES).map(game=>[game,{
game,seed:GAMES[game].seed,job:null,status:"ready",view:null,decision:null,metrics:{},
queue:[],draining:false,polling:false,nextPoll:0,after:0,summary:null,message:"",error:false,
restoring:false,hasMore:false,generation:0,cancelling:false,starting:false,skip:false,initialReceived:false
}]));
const rendererOptions={compact:window.innerWidth<=500};
const renderer=ArenaRenderer.create($("game-canvas"),rendererOptions);
const reducedMotion=matchMedia("(prefers-reduced-motion: reduce)").matches;
const format=value=>Number.isFinite(Number(value))?Number(value).toLocaleString("en-US"):"—";
const pretty=value=>String(value??"—").replaceAll("_"," ");
function persist(s){safelyWrite("jev-arena-"+s.game,s.job?{...s.job,game:s.game,seed:s.seed}:null);}
function isRunning(s){return !!s.job&&!TERMINAL.has(s.status);}
function isBusy(s){return s.starting||isRunning(s)||s.draining||s.hasMore||s.queue.length>0;}
function successOf(s){return s.summary?.success??s.summary?.result?.success??s.view?.success??s.view?.hud?.success??false;}
function stateLabel(s){
if(s.starting)return "Starting";
if(s.cancelling)return "Stopping";
if(s.status==="queued")return "Queued";
if(s.status==="running")return "Running";
if(s.status==="completed"&&(s.draining||s.hasMore||s.queue.length))return "Playing moves";
if(s.status==="completed")return successOf(s)?"Completed":"Run finished";
if(s.status==="failed")return "Run failed";
if(s.status==="cancelled")return "Stopped";
return "Ready";
}
function notice(s){
let text=s.message;
if(!text&&s.status==="queued")text="Your run is queued. It will start when this game’s current run finishes.";
if(!text&&s.status==="completed"&&!isBusy(s)&&!successOf(s))text="This run ended before the goal. Start again or try another seed.";
if(!text&&s.status==="failed")text="The run could not finish. Start again to try a fresh run.";
$("notice").hidden=!text;$("notice").textContent=text;$("notice").classList.toggle("error",s.error||s.status==="failed");
}
function metric(id,value){
const el=$(id);el.replaceChildren();
el.append(document.createTextNode(Number.isFinite(Number(value))&&value!==null&&value!==undefined?Number(value).toFixed(1):"—"));
const unit=document.createElement("small");unit.textContent="ms";el.append(unit);
}
function renderDecisions(s){
metric("current-inference",s.metrics.current_inference_ms);metric("average-inference",s.metrics.average_inference_ms);
const count=s.metrics.calls??0;$("call-count").textContent=format(count)+(count===1?" call":" calls");
$("chosen-action").textContent=s.decision?.action??(isRunning(s)?"Awaiting decision":"Waiting to start");
$("decision-description").textContent=s.decision?.description??(isRunning(s)?"The model is evaluating the current game state.":"Choose a game and start the model.");
const options=$("options");options.replaceChildren();
const entries=Array.isArray(s.decision?.options)?s.decision.options:[];
const probs=Array.isArray(s.decision?.probabilities)?s.decision.probabilities:[];
const subdecisions=Array.isArray(s.decision?.subdecisions)?s.decision.subdecisions:[];
const selectedChoice=s.decision?.choice??subdecisions.at(-1)?.choice??s.decision?.action;
entries.forEach((option,i)=>{
const label=typeof option==="object"?(option.label??option.name??option.action??"Option "+(i+1)):String(option);
const probability=Number(probs[i]);
const row=document.createElement("div");row.className="option-row";
const valid=Number.isFinite(probability)&&probability>=0;
row.style.setProperty("--probability",(valid?Math.min(1,probability)*100:0)+"%");
row.classList.toggle("chosen",label===selectedChoice);
const name=document.createElement("span");name.className="option-name";name.textContent=label;name.title=label;
const value=document.createElement("span");value.className="option-value";value.textContent=valid?(probability*100).toFixed(1)+"%":"—";
row.append(name,value);options.append(row);
});
$("subdecisions").replaceChildren(...subdecisions.map(sub=>{
const row=document.createElement("div");row.className="subdecision";
const name=document.createElement("span");name.className="subdecision-name";name.textContent=pretty(sub.name);
const choice=document.createElement("span");choice.className="subdecision-choice";choice.textContent=pretty(sub.choice);
const time=document.createElement("span");time.className="subdecision-time";time.textContent=Number.isFinite(sub.inference_ms)?sub.inference_ms.toFixed(1)+" ms":"—";
row.append(name,choice,time);return row;
}));
}
function renderStats(s){
const v=s.view??initial[s.game], h=v?.hud??{};
let rows=[],progress="";
if(s.game==="mario"){
progress="World "+(h.world??1)+"-"+(h.stage??1);
rows=[["Position",format(h.x_position)+" px"],["Score",format(h.score??0)],["Coins",format(h.coins??0)],["Decisions",format(h.steps??0)],["Action",pretty(v?.last_action)],["Goal",h.success?"Flag reached":"Reach the flag"]];
}else if(s.game==="cube"){
progress=(v?.solved_faces??0)+" / 6 faces";
rows=[["Faces",format(v?.solved_faces??0)+" / 6"],["Stickers",format(v?.matched_stickers??0)+" / 54"],["Quarter turns",format(v?.primitive_steps??0)],["Decisions",format(v?.steps??0)],["Phase",pretty(v?.phase)],["Scramble","25 turns"]];
}else{
progress=(v?.lines??0)+" / 20 lines";
rows=[["Lines",format(v?.lines??0)+" / 20"],["Score",format(v?.score??0)],["Pieces",format(v?.pieces??0)],["Gravity",v?.gravity_ms?Math.round(v.gravity_ms)+" ms / row":"100 ms / row"],["Speed",v?.maximum_speed_reached?"Maximum":((v?.speed_multiplier??1).toFixed(2)+"×")],["Decisions",format(v?.steps??0)]];
}
$("game-progress").textContent=progress;
$("stats").replaceChildren(...rows.map(([label,value])=>{
const div=document.createElement("div");div.className="stat";
const dt=document.createElement("dt");dt.textContent=label;
const dd=document.createElement("dd");dd.textContent=value;div.append(dt,dd);return div;
}));
}
function controls(s){
$("start").disabled=isBusy(s)||!online;
$("start").lastChild.textContent=s.job&&TERMINAL.has(s.status)?" Run again":" Start model";
$("stop").hidden=!isBusy(s);
$("stop").disabled=s.cancelling||s.starting;
$("stop").textContent=TERMINAL.has(s.status)&&(s.draining||s.queue.length)?"Skip animation":"Stop run";
$("seed").disabled=isBusy(s)||s.game==="mario";
$("shuffle").hidden=s.game==="mario";$("shuffle").disabled=isBusy(s);
const pill=$("state-pill");pill.textContent=stateLabel(s);
pill.className="state-pill"+(isBusy(s)?" running":(s.status==="completed"&&successOf(s)?" success":s.status==="failed"?" failed":""));
$("stage-label").textContent=isBusy(s)?(s.status==="queued"?"Waiting for a slot":s.status==="completed"?"Selected moves · playback":"Live run"):s.status==="ready"?"Opening state":s.status==="cancelled"?"Run stopped":"Run result";
$("canvas-loading").hidden=!(s.starting||(isRunning(s)&&!s.initialReceived&&s.status!=="queued"));
notice(s);
for(const [game,state] of Object.entries(states))$("tab-status-"+game).classList.toggle("active",isBusy(state));
}
function safeRender(view,context=null){
try{const result=renderer.render(view,context);if(result?.catch)result.catch(()=>{});}catch{}
}
function paint(s,draw=true){
if(selected!==s.game)return;
controls(s);renderDecisions(s);renderStats(s);
if(draw)safeRender(s.view??initial[s.game]??null);
}
function selectGame(game,updateHash=true){
if(!Object.hasOwn(GAMES,game))return;
selected=game;
for(const tab of document.querySelectorAll(".game-tab")){const active=tab.dataset.game===game;tab.setAttribute("aria-selected",String(active));tab.tabIndex=active?0:-1;}
$("game-panel").setAttribute("aria-labelledby","tab-"+game);
$("game-title").textContent=GAMES[game].title;
$("game-caption").textContent=GAMES[game].caption;
$("game-note").textContent=GAMES[game].note;
$("seed").value=states[game].seed;
$("game-canvas").setAttribute("aria-label",GAMES[game].title+" game view");
if(updateHash)history.replaceState(null,"","#"+game);
paint(states[game]);
}
async function request(url,options={}){
const controller=new AbortController();const timeout=setTimeout(()=>controller.abort(),18000);
try{
const response=await fetch(url,{...options,signal:controller.signal,cache:"no-store"});
let body;try{body=await response.json()}catch{body={}};
if(!response.ok){
const detail=typeof body.detail==="string"?body.detail:typeof body.error==="string"?body.error:"Request failed ("+response.status+")";
const error=new Error(detail);error.status=response.status;throw error;
}
return body;
}catch(error){if(error.name==="AbortError")throw new Error("Connection timed out. Please try again.");throw error;}
finally{clearTimeout(timeout);}
}
async function start(){
const s=states[selected];if(isBusy(s))return;
const seed=Number($("seed").value);
if(!Number.isInteger(seed)||seed<0||seed>2147483647){s.message="Enter a whole-number seed between 0 and 2147483647.";s.error=true;paint(s,false);return;}
s.starting=true;s.message="";s.error=false;paint(s,false);
try{
const job=await request("/api/arena/jobs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({game:s.game,seed,client_id:clientId})});
if(!job.id||!job.ticket)throw new Error("The server did not return a run ticket. Please try again.");
Object.assign(s,{seed,job,status:job.status??"queued",after:0,summary:null,view:null,decision:null,metrics:{},queue:[],nextPoll:0,restoring:false,hasMore:false,initialReceived:false,skip:false});
s.generation++;persist(s);
}catch(error){s.message=error.message;s.error=true;}
finally{s.starting=false;paint(s);}
}
async function stop(){
const s=states[selected];
if(TERMINAL.has(s.status)){s.skip=true;return;}
if(!s.job||s.cancelling)return;
s.cancelling=true;s.message="";paint(s,false);
try{
const result=await request("/api/arena/jobs/"+encodeURIComponent(s.job.id)+"/cancel",{method:"POST",headers:{"X-Job-Ticket":s.job.ticket}});
if(result.status==="cancelled"){s.status="cancelled";s.skip=true;}
s.message="Stopping this run…";
s.nextPoll=0;
}catch(error){s.message=error.message;s.error=true;}
finally{s.cancelling=false;paint(s,false);}
}
async function animateView(s,view,turn,generation){
const before=s.view??initial[s.game];
if(s.game!=="cube"||!turn||s.skip||s.restoring||selected!==s.game||reducedMotion){
s.view=view;paint(s);return;
}
const duration=145;let started=null;
await new Promise(resolve=>{
function frame(now){
if(generation!==s.generation)return resolve();
if(started===null)started=now;
const p=Math.min(1,(now-started)/duration);
if(selected===s.game)safeRender(view,{previousView:before,action:turn,progress:p});
if(p<1&&!s.skip&&selected===s.game)requestAnimationFrame(frame);else resolve();
}
requestAnimationFrame(frame);
});
if(generation!==s.generation)return;
s.view=view;paint(s);
}
async function processEvent(s,event,generation){
if(event.metrics)s.metrics={...s.metrics,...event.metrics};
if(event.decision)s.decision=event.decision;
if(event.message){s.message=pretty(event.message);s.error=event.kind==="error";}
if(event.kind==="started"||event.kind==="start"||event.kind==="initial")s.message="";
const frames=Array.isArray(event.frames)?event.frames:[];
if(event.view||frames.length)s.initialReceived=true;
if(s.game==="cube"&&frames.length){
paint(s,false);
for(const frame of frames){
if(generation!==s.generation)return;
const view=frame.view??frame;
await animateView(s,view,frame.primitive_turn??view.primitive_turn,generation);
}
}
if(event.view){
s.view=event.view;paint(s);
}else if(frames.length&&s.game!=="cube"){
s.view=frames[frames.length-1].view??frames[frames.length-1];paint(s);
}else paint(s,false);
}
async function drain(s){
if(s.draining)return;s.draining=true;
const generation=s.generation;
try{
while(s.queue.length&&generation===s.generation){
const event=s.queue.shift();
await processEvent(s,event,generation);
}
}finally{
s.draining=false;
if(TERMINAL.has(s.status)&&!s.queue.length)s.skip=false;
paint(s,false);
}
}
async function poll(s){
if(s.polling||!s.job||Date.now()<s.nextPoll||TERMINAL.has(s.status)&&!s.restoring&&!s.hasMore)return;
s.polling=true;
try{
const data=await request("/api/arena/jobs/"+encodeURIComponent(s.job.id)+"?after="+s.after+"&limit=30",{headers:{"X-Job-Ticket":s.job.ticket}});
const events=Array.isArray(data.events)?data.events:[];
for(const event of events){
if(!Number.isInteger(event.seq)||event.seq>s.after){s.queue.push(event);if(Number.isInteger(event.seq))s.after=event.seq;}
}
s.hasMore=!!data.has_more;
if(data.summary)s.summary=data.summary;
if(data.status)s.status=data.status;
if(TERMINAL.has(s.status)&&s.message.startsWith("Stopping this run"))s.message="";
if(s.message.startsWith("Reconnecting")){s.message="";s.error=false;}
if(s.status!=="queued"&&s.message.startsWith("Your run is queued"))s.message="";
// On reload, catch up to the latest recorded state without replaying old turns.
if(s.restoring){
for(const event of s.queue.splice(0)){
if(event.metrics)s.metrics={...s.metrics,...event.metrics};
if(event.decision)s.decision=event.decision;
if(event.view){s.view=event.view;s.initialReceived=true;}
else if(event.frames?.length){s.view=event.frames.at(-1).view??event.frames.at(-1);s.initialReceived=true;}
}
if(!data.has_more)s.restoring=false;
paint(s);
}else void drain(s);
s.nextPoll=Date.now()+(data.has_more?20:s.status==="queued"?1000:300);
paint(s,false);
}catch(error){
s.message="Reconnecting to your run… "+error.message;s.error=true;s.nextPoll=Date.now()+2500;
if(error.status===403||error.status===404||error.status===410){
s.status="failed";s.restoring=false;s.message="This run is no longer available. Start a new run.";persist({...s,job:null});
}
paint(s,false);
}finally{s.polling=false;}
}
async function checkHealth(){
try{
health=await request("/api/arena/status");online=!!health.online;
$("connection-text").textContent=online?"GPU connected":"GPU reconnecting";
$("connection").className="connection "+(online?"online":"offline");
}catch{
online=false;$("connection-text").textContent="Connection unavailable";$("connection").className="connection offline";
}
controls(states[selected]);
}
document.querySelectorAll(".game-tab").forEach(tab=>{
tab.addEventListener("click",()=>selectGame(tab.dataset.game));
tab.addEventListener("keydown",event=>{
const games=Object.keys(GAMES);let index=games.indexOf(selected);
if(event.key==="ArrowRight")index=(index+1)%games.length;
else if(event.key==="ArrowLeft")index=(index+games.length-1)%games.length;
else if(event.key==="Home")index=0;
else if(event.key==="End")index=games.length-1;
else return;
event.preventDefault();selectGame(games[index]);$("tab-"+games[index]).focus();
});
});
$("start").addEventListener("click",start);$("stop").addEventListener("click",stop);
$("shuffle").addEventListener("click",()=>{
const array=new Uint32Array(1);crypto.getRandomValues(array);
states[selected].seed=array[0]%2147483647;$("seed").value=states[selected].seed;
});
$("seed").addEventListener("change",()=>{const value=Number($("seed").value);if(Number.isInteger(value))states[selected].seed=value;});
window.addEventListener("hashchange",()=>selectGame(location.hash.slice(1),false));
$("watch-example").addEventListener("click",()=>{
$("example-title").textContent=GAMES[selected].exampleTitle;
$("example-video").src=GAMES[selected].example;
$("example-dialog").showModal();
});
function closeExample(){$("example-video").pause();$("example-dialog").close();$("example-video").removeAttribute("src");$("example-video").load();}
$("close-example").addEventListener("click",closeExample);
$("example-dialog").addEventListener("cancel",()=>{$("example-video").pause();});
$("example-dialog").addEventListener("click",event=>{if(event.target===$("example-dialog")){const rect=event.target.getBoundingClientRect();if(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom)closeExample();}});
let lastWidth=window.innerWidth;
window.addEventListener("resize",()=>{if((lastWidth<=500)!==(window.innerWidth<=500)){rendererOptions.compact=window.innerWidth<=500;paint(states[selected]);}lastWidth=window.innerWidth;});
async function init(){
for(const [game,s] of Object.entries(states)){
const saved=safelyRead("jev-arena-"+game);
if(saved&&typeof saved.id==="string"&&typeof saved.ticket==="string"&&saved.game===game){
s.job=saved;s.seed=Number.isInteger(saved.seed)?saved.seed:GAMES[game].seed;s.status="running";s.restoring=true;
}
}
selectGame(selected);
try{initial=await request("/arena-initial.json");paint(states[selected]);}catch{ /* The first live view replaces the opening preview. */ }
await checkHealth();
setInterval(checkHealth,7000);
setInterval(()=>{for(const s of Object.values(states))void poll(s);},150);
for(const s of Object.values(states))void poll(s);
}
void init();
})();