File size: 18,792 Bytes
109227a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
(() => {
"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();
})();