Expand Glitch operations hub, cabinet authoring, deployment, and RFID support

Operator hub
- Introduce Escape Gallery branding and Glitch: the Dark Machine room views
- Add cabinet illustrations, Pac-Man marquee/gameplay artwork, and waiting states
- Add device feedback panels with RFID readings, input/output states, and stale status
- Add logged operator hardware interventions with request deduplication

Room and cabinet authoring
- Add a visual custom-PCB connection editor with per-cabinet device inventory
- Support device IDs, bindings, wiring notes, input polarity, and validation
- Add physical-room label/dimension toggles and editable doorway annotations
- Preserve doorway changes through undo, save, and revert
- Expand cabinet profiles, room layout, and Glitch scenario resources

Scenarios and gameplay
- Add per-assignment game configuration and runtime feature validation
- Add timed test wins and Frogger individual-home objectives
- Add the three-cabinet timed/score/Frogger test scenario
- Add Cabinet 1 Pac-Man RFID credits, credit sound, and CREDIT 0/1 display
- Require physical Start after an accepted scan and prevent duplicate credits

Pi hardware and deployment
- Add GPIO feedback sampling and a separate multiplexed RFID helper
- Validate reader pin ownership and input/output conflicts
- Add SSH cabinet startup and Pi desktop-session service support
- Add master packaging, site setup, SSH enrollment, and staged cabinet releases
- Verify deployment manifests and support explicit activation/status/log commands

Documentation and verification
- Consolidate the root README around the current architecture and setup workflow
- Add the complete master/Pi/RFID export and deployment walkthrough
- Document network isolation, credentials, card identification, and rollback
- Add PCB, room-editor, feedback, credit, deployment, and controller tests
- Isolate protocol tests from designer-edited cabinet configurations

Known limitations
- RFID helper compilation and physical Pi commissioning remain unverified
- Fleet provisioning does not yet configure RFID-only installations completely
- Broader Pac-Man tests retain a ghost-capture assertion failure
- Staged exports must be rebuilt and checked before deploying runtime 2.3
This commit is contained in:
Jeff Luke committed 2026-10-17 14:53:54 -07:00
1 parent 705ec31e0b
commit c1b2195834
90 files changed
+4969 -456

No files matched your search

+44 -26
View File
@@ -1,28 +1,46 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Arcade · Room operator</title>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Escape Gallery · Operations</title>
<style>
:root{color-scheme:dark;font:16px system-ui,sans-serif;background:#0b111a;color:#e3ebf5}*{box-sizing:border-box}body{margin:0}header{padding:24px 4vw;border-bottom:1px solid #293749;display:flex;align-items:center;justify-content:space-between;gap:20px}h1{font-size:24px;margin:0}h2{font-size:18px;margin:0 0 16px}p{line-height:1.5}small,.muted{color:#9eb0c6}main{max-width:1500px;margin:auto;padding:28px 4vw}.panel{background:#121c29;border:1px solid #293749;border-radius:14px;padding:22px;margin-bottom:20px}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px}button,input,select{font:inherit;border-radius:7px;border:1px solid #45576c;padding:12px;background:#1c2a3b;color:inherit;min-height:46px}button{cursor:pointer}button.primary{background:#69ddcd;color:#092923;border-color:#69ddcd;font-weight:650}button:disabled{opacity:.4;cursor:default}button:focus-visible,select:focus-visible,input:focus-visible{outline:3px solid #69ddcd;outline-offset:3px}select{max-width:100%}#login{max-width:550px;margin:8vh auto}#login input{width:100%;margin:10px 0}#status{color:#69ddcd}#error{color:#ffce96;white-space:pre-wrap}#error:empty{display:none}#cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-top:16px}.card{text-align:left;padding:16px;background:#152335}.card[aria-pressed=true]{border-color:#69ddcd;box-shadow:inset 0 0 0 1px #69ddcd}.card span{display:block;margin-top:9px;font-size:14px}svg{width:100%;height:340px;background:#0b131e;border-radius:8px}.steps{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.steps span{padding:8px 10px;border-radius:5px;background:#223043;font-size:13px}.steps .current{background:#69ddcd;color:#092923}table{width:100%;border-collapse:collapse;font-size:14px}td,th{text-align:left;padding:10px 6px;border-bottom:1px solid #293749;overflow-wrap:anywhere}#events{max-height:270px;overflow:auto;padding-left:22px;font-size:14px}#events li{padding:6px}#assignment{white-space:pre-line}#scenarioSummary{font-size:14px;color:#b9c8d9}#output{overflow-x:auto}#signout{font-size:14px} [hidden]{display:none!important}@media(max-width:850px){.columns{grid-template-columns:1fr}header{align-items:flex-start}main{padding:18px}svg{height:280px}}
</style></head><body>
<header><div><small>ARCADE ESCAPE / OPERATIONS</small><h1>Room control</h1></div><div class="row"><span id="status" role="status">Not connected</span><button id="signout" hidden>Lock</button></div></header>
<main><div id="error" class="panel" role="alert"></div>
<form id="login" class="panel"><h2>Connect to this room</h2><p class="muted">Use the operator token configured on this controller. It stays in memory until this page closes or locks.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button class="primary">Connect</button></form>
<div id="workspace" hidden>
<section class="panel"><h2>Prepare a scenario</h2><div class="row"><select id="scenario" aria-label="Scenario"></select><button id="prepareScenario" class="primary">Prepare assigned cabinets</button></div><p id="scenarioSummary"></p><small>Preparation loads games and their event-to-hardware mappings. Start each cabinet when ready. Reset active cabinets before changing assignments.</small></section>
<div class="columns"><section class="panel"><h2>Room overview</h2><svg id="map" role="img" aria-label="Room layout and cabinet status"></svg><p class="muted"><small>Cabinet colors show runtime state. Room devices are reference locations; physical sensor feedback is not connected.</small></p><div id="cards"></div></section>
<section class="panel"><h2 id="cabinetTitle">Select a cabinet</h2><p id="connection" class="muted"></p><p id="assignment"></p><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset">Reset cabinet…</button></div><hr style="border:0;border-top:1px solid #293749;margin:24px 0"><h2>Manual game assignment</h2><div class="row"><select id="game" aria-label="Compatible game"></select><button id="prepare">Prepare game</button></div><p><small>Only compatible games are listed. Manual assignments run without scenario hardware rewards.</small></p><h2>Hardware commands</h2><p id="reward" class="muted"></p><div id="output"></div><h2 style="margin-top:24px">Activity</h2><ol id="events"></ol></section></div>
</div></main><script>
'use strict';
const $=id=>document.getElementById(id);let token='',state=null,selected='',pending=false,connected=false,refreshing=false;
const colors={ready:'#69ddcd',playing:'#7bbaff',won:'#b4ec86',lost:'#ffae9e'};
function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}
async function api(path,data={}){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:AbortSignal.timeout(6000)});const value=await response.json();if(!response.ok)throw Error(value.error||'Controller request failed');return value}
function options(id,entries){const select=$(id),before=select.value;if(select.options.length===entries.length&&entries.every(([value,name],i)=>select.options[i].value===value&&select.options[i].textContent===name))return;select.replaceChildren(...entries.map(([value,name])=>{const o=node('option',name);o.value=value;return o}));if(entries.some(x=>x[0]===before))select.value=before}
function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||!!c.gate||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value}
function scenarioSummary(){if(!state)return;const s=state.catalog.scenarios[$('scenario').value];$('scenarioSummary').textContent=s?s.assignments.map(a=>(state.catalog.cabinets[a.cabinet]?.name||a.cabinet)+' → '+(state.catalog.games[a.game]?.name||a.game)).join(' · '):'No scenarios exported'}
function drawMap(){const svg=$('map'),layout=state.catalog.layout,points=[...layout.boundary,...Object.values(state.cabinets).filter(c=>c.definition.position).map(c=>c.definition.position),...layout.walls.flat(),...layout.devices.map(d=>d.position)];svg.replaceChildren();if(!points.length)return;const xs=points.map(p=>p[0]),ys=points.map(p=>p[1]),minX=Math.min(...xs)-1,minY=Math.min(...ys)-1,w=Math.max(...xs)-minX+1,h=Math.max(...ys)-minY+1;svg.setAttribute('viewBox',`${minX} ${minY} ${w} ${h}`);const unit=Math.max(w,h)/18;function shape(tag,attrs,text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;svg.append(e);return e}if(layout.boundary.length)shape('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#152335',stroke:'#435770','stroke-width':unit/14});for(const wall of layout.walls)shape('line',{x1:wall[0][0],y1:wall[0][1],x2:wall[1][0],y2:wall[1][1],stroke:'#647a92','stroke-width':unit/10});for(const d of layout.devices){const e=shape('circle',{cx:d.position[0],cy:d.position[1],r:unit*.18,fill:'#8491a2'});const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.name+' — no sensor feedback';e.append(title)}for(const [id,c]of Object.entries(state.cabinets)){const p=c.definition.position;if(!p)continue;const e=shape('circle',{cx:p[0],cy:p[1],r:unit*.5,fill:c.online?colors[c.phase]||'#e5c57b':'#647081',stroke:id===selected?'white':'none','stroke-width':unit/10});e.style.cursor='pointer';e.onclick=()=>{selected=id;render()};shape('text',{x:p[0],y:p[1]-unit*.75,fill:'#e3ebf5','font-size':unit*.6,'text-anchor':'middle'},c.definition.name)}}
function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared')+(c.gate?'\nActivation: after '+c.gate.cabinet+' wins':'');$('steps').replaceChildren(...['idle','waiting','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()}
async function refresh(){if(!token||refreshing||pending)return;refreshing=true;try{state=await api('/operator/state');connected=true;render();$('workspace').hidden=false;$('login').hidden=true;$('signout').hidden=false;$('status').textContent='Controller connected'}catch(e){connected=false;$('status').textContent='Connection unavailable';$('error').textContent=e.message;enable()}finally{refreshing=false}}
async function command(action,extra={}){if(pending||!connected)return;pending=true;enable();$('error').textContent='';try{state=await api('/operator/command',{action,cabinet:selected,...extra});render()}catch(e){$('error').textContent=e.message}finally{pending=false;enable();refresh()}}
$('login').onsubmit=e=>{e.preventDefault();token=$('token').value.trim();$('token').value='';$('error').textContent='';refresh()};$('signout').onclick=()=>location.reload();$('scenario').onchange=scenarioSummary;$('prepareScenario').onclick=()=>command('prepare_scenario',{scenario:$('scenario').value});$('prepare').onclick=()=>command('prepare',{game:$('game').value});$('start').onclick=()=>command('start');$('reset').onclick=()=>{if(confirm('Reset '+state.cabinets[selected].definition.name+'? This ends its current game.'))command('reset')};setInterval(refresh,1000);
:root{font:16px/1.5 system-ui,sans-serif;color:#25342e;background:#f3f4ef;--muted:#627168;--line:#d7dfd5;--accent:#21644e}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 16px;min-height:46px;border:1px solid #bdccc0;border-radius:8px;background:white;color:inherit;font-weight:600;cursor:pointer;text-decoration:none}button:hover,.button:hover{background:#edf3eb}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button.danger{color:#a44031}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #d29932;outline-offset:3px}input,select,textarea{padding:11px;border:1px solid #b8c7ba;border-radius:8px;min-height:46px;background:white;color:inherit;max-width:100%}h1,h2,h3,p{margin-top:0}h1{font-size:clamp(26px,3vw,36px);line-height:1.2;letter-spacing:-1px;margin-bottom:12px}h2{font-size:20px;margin-bottom:14px}h3{font-size:16px;margin-bottom:8px}p{margin-bottom:14px}small,.muted{color:var(--muted)}small{font-size:13px}label{display:block;font-size:14px;font-weight:600;margin-bottom:6px}[hidden]{display:none!important}.eyebrow{font-size:11px;font-weight:750;letter-spacing:1.7px;color:var(--muted);text-transform:uppercase;margin-bottom:10px}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.between{justify-content:space-between}.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:24px}.columns{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,235px),1fr));gap:16px}.badge{display:inline-flex;gap:6px;align-items:center;border-radius:25px;padding:4px 9px;font-size:12px;font-weight:700;background:#edf0e9;color:#53634f}.badge:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.good{background:#e0f0e3;color:#28623a}.warn{background:#fff0cd;color:#78550e}.bad{background:#fbe8e2;color:#9c3d29}.blue{background:#e4edf9;color:#2c6095}.notice{background:#fff6e1;border:1px solid #dec18b;color:#775115;padding:14px 18px;border-radius:9px;margin-bottom:18px;white-space:pre-line}.notice:empty{display:none}#error{background:#ffede7;border-color:#dcb3a5;color:#8b3727}#toast{background:#e5f1e4;border-color:#b5d1b6;color:#2a5737}#login{max-width:470px;margin:10vh auto}#login input{width:100%;margin-bottom:16px}#login .primary{width:100%}.mark{display:grid;place-items:center;width:42px;height:42px;border-radius:10px;background:#dfb75b;color:#233c32;font-weight:850}#shell{display:grid;grid-template-columns:222px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;background:#1c322c;color:#e1e9df;padding:28px 18px}.brand{display:flex;gap:10px;align-items:center;margin-bottom:35px}.brand strong{font-size:17px}.brand small{display:block;font-size:10px;letter-spacing:2px;color:#afc4b5}.nav-label{font-size:10px;letter-spacing:1.6px;color:#a6bfac;margin:22px 12px 8px}.nav{width:100%;justify-content:flex-start;background:transparent;border:0;color:#c4d4c4;padding:12px;margin-bottom:4px;text-align:left;font-size:14px}.nav:hover{background:#2a4537}.nav[aria-current=page]{background:#3c5944;color:white}.nav span{color:#d8bb75;width:20px;font-size:18px}.sidebar footer{margin-top:auto;border-top:1px solid #405742;padding-top:20px}.sidebar footer p{font-size:12px;color:#b8c9b5}.sidebar footer button{background:transparent;color:#e2eadb;border-color:#688267;width:100%}.content{min-width:0}.topbar{padding:16px 30px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px;background:#fafbf6}.topbar strong,.topbar small{display:block}.topbar strong{font-size:14px}.topbar button{font-size:13px;padding:8px 12px;min-height:40px}main{max-width:1600px;margin:auto;padding:30px}.heading{margin-bottom:25px}.heading p{color:var(--muted);max-width:750px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:22px 0}.stat{padding:19px;background:white;border:1px solid var(--line);border-radius:10px}.stat strong{display:block;font-size:30px;line-height:1.2}.stat span{font-size:12px;color:var(--muted)}.experience{border-top:4px solid #c9a75d}.experience h2{font-size:25px;margin-top:16px}.experience p{color:var(--muted)}.experience .button,.experience button{margin-top:12px}.link{border:0;background:none;padding:0;min-height:32px;color:var(--accent);font-size:14px}.attention{display:flex;gap:14px;align-items:center;padding:14px 0;border-bottom:1px solid var(--line)}.attention:last-child{border:0}.attention div{flex:1}.attention strong,.attention small{display:block}.card{display:block;padding:18px;text-align:left;border:1px solid var(--line);border-radius:10px;background:white}.card[aria-pressed=true]{border:2px solid var(Line truncated
.cabinet-visual{margin:auto;width:min(100%,270px);padding:14px 16px 22px;background:#252f2c;border:5px solid #17201c;border-radius:10px 10px 5px 5px;color:#eff5e8;box-shadow:7px 10px 0 #00000010}.marquee{border:3px solid #101813;background:#ecce7e;color:#233420;font-weight:850;text-align:center;padding:9px 6px;letter-spacing:1px;text-transform:uppercase;font-size:15px}.screen{margin:15px 0 0;background:#101d17;border:9px solid #414b42;border-radius:5px;aspect-ratio:1.1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:8px;padding:12px}.screen .screen-game{font-size:20px;font-weight:750;color:#e7c965}.screen .screen-state{font-size:12px;letter-spacing:1px;text-transform:uppercase;color:#b8ddad}.screen .screen-detail{font-size:11px;color:#9aafa0}.deck{background:#3b473d;padding:14px 10px;display:flex;justify-content:space-around;align-items:center;transform:perspective(100px) rotateX(12deg);border-bottom:4px solid #18271d}.joystick{width:18px;height:18px;background:#d8ba60;border-radius:50%;box-shadow:0 6px 0 #142318}.buttons{display:flex;gap:6px}.buttons i{height:9px;width:9px;background:#cce0ba;border-radius:50%}.coin-door{width:58px;height:55px;border:3px solid #414d40;border-radius:3px;margin:25px auto 8px;display:grid;place-items:center}.coin-slot{width:20px;height:5px;background:#101a12}.cabinet-caption{text-align:center;font-size:12px;color:var(--muted);margin-top:14px}.visual-panel{background:#f4f6ee;padding:22px;border-radius:9px}
@media(max-width:1050px){#shell{grid-template-columns:188px 1fr}.sidebar{padding:24px 12px}.brand strong{font-size:15px}.columns{grid-template-columns:1fr}main{padding:24px}.topbar{padding:16px 24px}}@media(max-width:680px){#shell{display:block}.sidebar{height:auto;position:static;display:block;padding:16px}.brand{margin-bottom:14px}.sidebar nav{display:flex;overflow-x:auto;gap:5px}.nav-label,.sidebar footer{display:none}.nav{width:auto;white-space:nowrap;margin:0;padding:10px}.nav span{display:none}.topbar{padding:12px 16px}main{padding:22px 16px}.panel{padding:18px}.stats{grid-template-columns:repeat(2,1fr)}.activity li{grid-template-columns:70px 1fr;gap:4px 10px}.activity .message{grid-column:2}#map{height:270px}}@media(min-width:681px){#mobileLock{display:none}}
.marquee img{width:100%;height:100%;object-fit:contain;background:white}.marquee:has(img){padding:0;height:82px}.screen{aspect-ratio:224/288;position:relative}.screen-game:has(img){width:100%;height:100%}.screen img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}.screen:has(img){padding:0}.screen:has(img) .screen-state{position:absolute;bottom:4px;background:#000b;padding:3px 8px}.screen:has(img) .screen-detail{display:none}.cabinet-visual{border-left:12px solid #24392b;border-right:12px solid #24392b;box-shadow:8px 10px 0 #172a251a}.feedback-list{display:grid;gap:10px;margin-top:16px}.feedback-item{border-left:4px solid #89918d;padding:12px;background:#f4f6ee}.feedback-item.active{border-color:#287c58}.feedback-item.stale{opacity:.65}.cabinet-visual[data-game="pacman"]{border-color:#e8bf21;background:#e8bf21}.cabinet-visual[data-game="pacman"] .deck{background:#303334}.cabinet-visual[data-game="pacman"] .coin-door{background:#26292a;border-color:#171c1a}</style></head><body>
<form id="login" class="panel"><div class="row" style="margin-bottom:26px"><div class="mark">EG</div><div><strong>Escape Gallery</strong><div class="eyebrow" style="margin:0">Operations hub</div></div></div><h1>Ready when you are.</h1><p class="muted">Connect to the controller to follow the room and manage the experience.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button type="button" id="connect" class="primary">Connect to operations</button><p id="loginError" class="notice" role="alert" style="margin-top:16px"></p><p class="small muted" style="margin:18px 0 0">Your token stays in this tab’s memory. Lock the hub when you leave this device.</p></form>
<div id="shell" hidden><aside class="sidebar"><div class="brand"><div class="mark">EG</div><div><strong id="siteName">Escape Gallery</strong><small>OPERATIONS</small></div></div><nav aria-label="Main navigation"><div class="nav-label">WORKSPACE</div><button class="nav" data-view="home"><span>◈</span>Hub overview</button><div class="nav-label">CURRENT ROOM</div><button class="nav" data-view="room"><span>▤</span>Room control</button><button class="nav" data-view="cabinets"><span>▣</span>Cabinets</button><button class="nav" data-view="hardware"><span>⌘</span>Hardware</button><button class="nav" data-view="activity"><span>≡</span>Activity</button></nav><footer><p id="sidebarRoom">Glitch: the Dark Machine</p><p>Building network · Operator access</p><button class="lock">Lock this device</button></footer></aside><div class="content"><header class="topbar"><div><small id="breadcrumb">Operations / Overview</small><strong id="roomName">Glitch: the Dark Machine</strong></div><div class="row"><span id="status" class="badge" role="status">Connecting</span><button id="refresh">Refresh</button><button class="lock" id="mobileLock">Lock</button></div></header><main><div id="error" class="notice" role="alert"></div><div id="toast" class="notice" role="status"></div><div id="stale" class="notice" hidden>Connection lost. Showing last received state; commands are disabled.</div>
<section data-page="home"><div class="heading"><div class="eyebrow">Your operations, together</div><h1>Keep the experience moving.</h1><p>Open a room, check what needs attention, and get back to your players.</p></div><div class="row between"><h2>Experiences</h2><span class="badge">Local network</span></div><div id="experiences" class="grid"></div><div class="stats"><div class="stat"><strong id="onlineCount">—</strong><span>Cabinets online</span></div><div class="stat"><strong id="playingCount">—</strong><span>Games in progress</span></div><div class="stat"><strong id="readyCount">—</strong><span>Ready to start</span></div><div class="stat"><strong id="attentionCount">—</strong><span>Need attention</span></div></div><div class="columns"><section class="panel"><div class="row between"><h2>Needs attention</h2><button class="link" data-go="cabinets">View cabinets →</button></div><div id="attention"></div></section><section class="panel"><div class="row between"><h2>Recent activity</h2><button class="link" data-go="activity">View all →</button></div><ol id="recent" class="activity"></ol></section></div></section>
<section data-page="room" hidden><div class="heading"><div class="eyebrow">Game-master workspace</div><h1 id="roomHeading">Glitch: the Dark Machine</h1><p>Follow the room layout and cabinet sequence. Select a station for its live reported state and controls.</p></div><div class="columns"><section class="panel"><h2>Scenario preparation</h2><label for="scenario">Scenario</label><select id="scenario" style="width:100%"></select><ol id="scenarioPlan" class="scenario-plan"></ol><button id="prepareScenario" class="primary" style="width:100%">Prepare assigned cabinets…</button><p class="small muted" style="margin-top:14px">Preparation can run configured activation outputs. Linked cabinets wait for the preceding win; independent cabinets are started individually.</p><div id="scenarioBlockers" class="notice"></div></section><section class="panel"><h2>Room layout</h2><svg id="map" role="group" aria-label="Room layout; select a cabinet for controls"></svg><div class="legend"><span><i style="background:#287c58"></i>Ready / won</span><span><i style="background:#426ca4"></i>Playing</span><span><i style="background:#9a762d"></i>Waiting / idle</span><span><i style="background:#89918d"></i>Offline</span></div><p class="small muted">Device markers are reference locations, not live sensor readings. Cabinet rings indicate pending or unconfirmed output commands.</p></section></div><div class="row between" style="margin:24px 0 14px"><h2 style="margin:0">Cabinet status</h2><button class="link" data-go="cabinets">Open controls →</button></div><div class="grid" id="roomCards"></div></section>
<section data-page="cabinets" hidden><div class="heading"><div class="eyebrow">Connected stations</div><h1>Cabinets</h1><p>The marquee follows the assigned game. The screen represents reported state; it is not a live video feed.</p></div><div class="grid" id="cards"></div><section class="panel detail" id="detail"><div class="row between" style="margin-bottom:20px"><div><h2 id="cabinetTitle">Select a cabinet</h2><small id="connection"></small></div><span class="badge" id="phaseBadge"></span></div><div class="columns"><div><dl id="assignment" class="kv"></dl><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset" class="danger">Reset cabinet…</button><button id="openHardware">Hardware →</button></div><p id="controlHint" class="small muted" style="margin-top:14px"></p><hr><h3>Manual game assignment</h3><p class="small muted">Runs a compatible game without scenario hardware rewards.</p><label for="game">Compatible game</label><div class="row"><select id="game"></select><button id="prepare">Prepare game…</button></div><h3 style="margin-top:24px">Win output</h3><p id="reward" class="small muted"></p><h3>Device feedback</h3><div id="cabinetFeedback" class="feedback-list"></div></div><div class="visual-panel"><div class="cabinet-visual" aria-label="Cabinet state illustration"><div class="marquee" id="marquee">GAME</div><div class="screen"><div class="screen-game" id="screenGame"></div><div class="screen-state" id="screenState"></div><div class="screen-detail" id="screenDetail"></div></div><div class="deck" aria-hidden="true"><div class="joystick"></div><div class="buttons"><i></i><i></i><i></i></div><div class="joystick"></div></div><div class="coin-door" aria-hidden="true"><div class="coin-slot"></div></div></div><p class="cabinet-caption">Assigned-game artwork · reported cabinet state</p></div></div></section></section>
<section data-page="hardware" hidden><div class="heading"><div class="eyebrow">Physical devices</div><h1>Hardware &amp; outputs</h1><p>Trigger configured actions and review acknowledgements. Accepted does not prove that a lock moved or a reward was delivered.</p></div><section class="panel"><label for="hardwareCabinet">Cabinet</label><select id="hardwareCabinet"></select><p id="hardwareHint" class="small muted" style="margin-top:14px"></p><div class="grid" id="actions"></div><h2 style="margin-top:26px">Command history</h2><div class="table-wrap" id="output"></div><h2 style="margin-top:24px">Live device feedback</h2><div id="feedback" class="feedback-list"></div></section><section class="panel" style="margin-top:20px"><div class="row between"><h2>Room devices</h2><span class="badge">Integration pending</span></div><p class="small muted">Device feedback is listed above when a configured Pi bridge reports it. These room markers remain reference locations until mapped to sensor IDs.</p><div id="roomDevices" class="grid"></div></section></section>
<section data-page="activity" hidden><div class="heading"><div class="eyebrow">Room history</div><h1>Activity</h1><p>Cabinet events, operator interventions, and command acknowledgements in one place.</p></div><section class="panel"><div class="row filters"><label class="visually-hidden" for="activityCabinet">Filter cabinet</label><select id="activityCabinet"></select><label class="visually-hidden" for="activitySearch">Search activity</label><input type="search" id="activitySearch" placeholder="Search events or actions…"><span id="eventCount" class="small muted"></span></div><ol class="activity" id="events"></ol><p class="small muted" style="margin-top:20px">Latest retained history: up to 100 events per cabinet. Operator entries record actions and reasons; individual staff accounts are not configured.</p></section></section>
</main></div></div><dialog id="confirmDialog" aria-labelledby="confirmTitle"><form id="confirmForm"><div class="eyebrow">Review action</div><h2 id="confirmTitle"></h2><p id="confirmText" class="muted"></p><div id="confirmReason" hidden><label for="reason">Reason for this intervention</label><textarea id="reason" rows="3" maxlength="200"></textarea></div><div class="row"><button type="button" id="cancelConfirm">Cancel</button><button type="submit" class="primary" id="acceptConfirm">Confirm</button></div></form></dialog>
<script>
const $=id=>document.getElementById(id), phases={idle:'Idle',waiting:'Waiting',preparing:'Preparing',ready:'Ready',starting:'Starting',playing:'Playing',won:'Won',lost:'Lost',resetting:'Resetting',needs_prepare:'Needs preparation'};
let token='',state=null,selected='',view='home',connected=false,pending=false,refreshing=false,epoch=0,confirmation=null;
const node=(tag,text='',cls='')=>{const n=document.createElement(tag);n.textContent=text;n.className=cls;return n};
const name=id=>state?.cabinets[id]?.definition.name||id, gameName=id=>state?.catalog.games[id]?.name||id||'No game assigned';
const busy=c=>c.commands.some(x=>x.status==='queued'), prep=c=>c.online&&!busy(c)&&['idle','won','lost','needs_prepare'].includes(c.phase);
const hardware=c=>connected&&!pending&&c?.online&&c.gpio==='real'&&c.session&&!busy(c)&&['ready','playing','won','lost'].includes(c.phase);
function options(id,items){const s=$(id),old=s.value,key=JSON.stringify(items);if(s.dataset.options===key)return;s.dataset.options=key;s.replaceChildren(...items.map(([v,t])=>{const o=node('option',t);o.value=v;return o}));if(items.some(x=>x[0]===old))s.value=old}
function cached(id,key,fn){const e=$(id),s=JSON.stringify(key);if(e.dataset.key!==s){e.dataset.key=s;fn(e)}}
function navigate(next){view=['home','room','cabinets','hardware','activity'].includes(next)?next:'home';document.querySelectorAll('[data-page]').forEach(e=>e.hidden=e.dataset.page!==view);document.querySelectorAll('[data-view]').forEach(e=>e.setAttribute('aria-current',e.dataset.view===view?'page':'false'));$('breadcrumb').textContent='Operations / '+({home:'Overview',room:'Room control',cabinets:'Cabinets',hardware:'Hardware',activity:'Activity'}[view]);if(state)render()}
function select(id,next='cabinets'){selected=id;navigate(next)}
async function api(path,data={}){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),6000);try{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:controller.signal});const b=await r.json();if(!r.ok)throw Error(b.error||'Request failed');return b}finally{clearTimeout(timer)}}
function enable(){const c=state?.cabinets[selected],ok=connected&&!pending;['start','reset','prepare','prepareScenario'].forEach(id=>$(id).disabled=true);if(c){$('start').disabled=!(ok&&c.online&&c.phase==='ready'&&!c.gate&&!c.game_config?.rfid_credit_device&&!busy(c));$('reset').disabled=!(ok&&c.online&&!busy(c));$('prepare').disabled=!(ok&&prep(c)&&$('game').value)}const plan=state?.catalog.scenarios[$('scenario').value];$('prepareScenario').disabled=!(ok&&plan?.assignments.length&&plan.assignments.every(a=>prep(state.cabinets[a.cabinet])));document.querySelectorAll('[data-output]').forEach(b=>b.disabled=!hardware(c));$('acceptConfirm').disabled=!ok;$('refresh').disabled=pending||refreshing}
async function refresh(){if(!token||pending||refreshing)return;refreshing=true;const generation=epoch;try{const s=await api('/operator/state');if(generation!==epoch)return;state=s;connected=true;$('login').hidden=true;$('shell').hidden=false;$('loginError').textContent='';render()}catch(e){if(generation!==epoch)return;connected=false;$('loginError').textContent=e.message;enable()}finally{refreshing=false;$('status').textContent=connected?'Connected':'Disconnected';$('status').className='badge '+(connected?'good':'bad');$('stale').hidden=connected;enable()}}
async function command(action,data){if(!connected||pending)return;pending=true;const generation=++epoch;enable();$('error').textContent='';$('toast').textContent='';try{const result=await api('/operator/command',{action,...data});if(generation!==epoch)return;state=result;render();$('toast').textContent='Command submitted. Follow its acknowledgement in Activity.'}catch(e){if(generation!==epoch)return;$('error').textContent=e.name==='AbortError'?'Reply timed out. Check Activity before repeating the action.':e.message}finally{pending=false;enable();refresh()}}
function review(title,text,fn,reason=false){confirmation=fn;$('confirmTitle').textContent=title;$('confirmText').textContent=text;$('confirmReason').hidden=!reason;$('reason').value='';$('reason').required=reason;$('reason').minLength=reason?3:0;$('confirmDialog').showModal()}
function allEvents(){return Object.entries(state.cabinets).flatMap(([id,c])=>c.history.map((e,i)=>({...e,id,i}))).sort((a,b)=>b.time-a.time||b.i-a.i)}
function events(id,list){cached(id,list,e=>{e.replaceChildren(...list.map(x=>{const li=node('li');li.append(node('time',new Date(x.time*1000).toLocaleTimeString()),node('strong',name(x.id)),node('span',x.message,'message'));return li}));if(!list.length)e.append(node('li','No events yet.','empty'))})}
function cards(id){const root=$(id);for(const [key,c] of Object.entries(state.cabinets)){let b=Array.from(root.children).find(e=>e.dataset.cabinet===key);if(!b){b=node('button','','card');b.dataset.cabinet=key;b.onclick=()=>select(key);root.append(b)}b.setAttribute('aria-pressed',String(key===selected));const signature=JSON.stringify([c.online,c.phase,c.game,c.definition.name]);if(b.dataset.signature!==signature){b.dataset.signature=signature;b.replaceChildren(node('strong',name(key)),node('span',c.online?(phases[c.phase]||c.phase):'Offline','badge '+(c.online?'good':'')),node('p',gameName(c.game),'game'))}}}
function home(){const cs=Object.values(state.cabinets);$('onlineCount').textContent=cs.filter(c=>c.online).length+' / '+cs.length;$('playingCount').textContent=cs.filter(c=>c.online&&c.phase==='playing').length;$('readyCount').textContent=cs.filter(c=>c.online&&c.phase==='ready').length;const attention=Object.entries(state.cabinets).filter(([,c])=>!c.online||c.phase==='needs_prepare'||c.commands.some(x=>['failed','unknown'].includes(x.status)));$('attentionCount').textContent=attention.length;cached('experiences',state.hub,e=>{const b=node('button','','experience');b.append(node('span','ARCADE ROOM','eyebrow'),node('h2',state.hub.room_name),node('p','Room layout, cabinet controls and hardware activity'),node('span','Open room →','link'));b.onclick=()=>navigate('room');e.replaceChildren(b);for(const s of state.hub.services){const a=node('a','','experience');a.href=s.url;a.target='_blank';a.rel='noopener noreferrer';a.append(node('span',s.kind.toUpperCase(),'eyebrow'),node('h2',s.name),node('p',s.description),node('small','External service · status not monitored'));e.append(a)}});cached('attention',attention.map(([id,c])=>[id,c.online,c.phase]),e=>{e.replaceChildren(...attention.map(([id,c])=>{const b=node('button',name(id)+' — '+(!c.online?'Offline':c.phase==='needs_prepare'?'Needs preparation':'Review command history'),'attention');b.onclick=()=>select(id);return b}));if(!attention.length)e.append(node('p','All cabinets are reporting normally.','muted'))});events('recent',allEvents().slice(0,6))}
function scenario(){const s=state.catalog.scenarios[$('scenario').value];$('scenarioPlan').replaceChildren(...(s?.assignments||[]).map(a=>{const li=node('li');li.append(node('strong',name(a.cabinet)+' · '+gameName(a.game)),node('p',a.activate_after_win?'After '+name(a.activate_after_win)+' wins':'Ready for operator start'),node('small',Object.entries(a.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(' · ')));return li}));$('scenarioBlockers').textContent=(s?.assignments||[]).filter(a=>!prep(state.cabinets[a.cabinet])).map(a=>name(a.cabinet)+': must be online, reset, and free of pending commands').join('. ')}
function map(){const layout=state.catalog.layout||{},cs=Object.entries(state.cabinets);cached('map',[layout,cs.map(([id,c])=>[id,c.online,c.phase,c.commands])],svg=>{svg.replaceChildren();const pts=[...(layout.boundary||[]),...(layout.walls||[]).flat(),...(layout.devices||[]).map(d=>d.position),...cs.map(([,c])=>c.definition.position)].filter(Boolean);if(!pts.length)return;const xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]),minX=Math.min(...xs)-.7,minY=Math.min(...ys)-.7,w=Math.max(...xs)-minX+.7,h=Math.max(...ys)-minY+.7;svg.setAttribute('viewBox',[minX,minY,w,h].join(' '));const draw=(tag,attrs,parent=svg)=>{const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const[k,v]of Object.entries(attrs))e.setAttribute(k,v);parent.append(e);return e};if(layout.boundary?.length)draw('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#edf0e9',stroke:'#b6beb5','stroke-width':.04});for(const wall of layout.walls||[])draw('polyline',{points:wall.map(p=>p.join(',')).join(' '),fill:'none',stroke:'#728176','stroke-width':.07});for(const d of layout.devices||[]){if(!d.position)continue;const e=draw('rect',{x:d.position[0]-.05,y:d.position[1]-.05,width:.1,height:.1,fill:'#555'});draw('title',{},e).textContent=d.name+' — reference location; feedback unavailable'}for(const[id,c]of cs){const p=c.definition.position;if(!p)continue;const g=draw('g',{role:'button',tabindex:0,'aria-label':name(id)+': '+(c.online?c.phase:'offline')});g.style.cursor='pointer';g.onclick=()=>select(id);g.onkeydown=e=>{if(['Enter',' '].includes(e.key)){e.preventDefault();select(id)}};draw('circle',{cx:p[0],cy:p[1],r:.17,fill:!c.online?'#89918d':c.phase==='playing'?'#426ca4':['ready','won'].includes(c.phase)?'#287c58':'#9a762d'},g);const output=c.commands.filter(x=>['output','reward'].includes(x.type)).at(-1);if(output&&['queued','unknown','failed'].includes(output.status)){const ring=draw('circle',{cx:p[0],cy:p[1],r:.23,fill:'none',stroke:output.status==='queued'?'#b28934':'#bd4a3e','stroke-width':.035},g);draw('title',{},ring).textContent='Output command '+output.status+'; physical state unverified'}draw('text',{x:p[0],y:p[1]-.3,'text-anchor':'middle','font-size':.16,fill:'#263f32'},g).textContent=name(id)}})}
function detail(){renderFeedback('cabinetFeedback');const c=state.cabinets[selected];$('detail').hidden=!c;if(!c)return;$('cabinetTitle').textContent=name(selected);$('connection').textContent=selected+' · '+c.version+' · GPIO: '+c.gpio;$('phaseBadge').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('assignment').replaceChildren(...Object.entries({Game:gameName(c.game),Assignment:c.scenario||'None',Activation:c.gate?'Waiting for '+name(c.gate.cabinet)+' to win':'Independent',Objectives:Object.entries(c.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(', ')||'Game defaults'}).flatMap(([k,v])=>[node('dt',k),node('dd',v)]));$('steps').replaceChildren(...['preparing','ready','playing','won'].map(p=>node('span',phases[p],c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,gameName(id)]));$('reward').textContent=c.reward||'No win output reported.';$('controlHint').textContent=!c.online?'Reconnect this cabinet to send commands.':busy(c)?'Waiting for command acknowledgement.':c.gate?'This cabinet starts automatically after its dependency wins.':c.game_config?.rfid_credit_device?'Scan a card, then press Start on the cabinet.':'Start a ready game, or reset before changing its assignment.';renderCabinetArt(c);$('screenState').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('screenDetail').textContent=c.gate?'Waiting for '+name(c.gate.cabinet):busy(c)?'Command pending':'Reported state · '+selected}
function hardwareView(){renderFeedback();const c=state.cabinets[selected];$('hardwareCabinet').value=selected;$('hardwareHint').textContent=c?name(selected)+' · GPIO: '+c.gpio+' · '+(hardware(c)?'Configured outputs available.':'Requires an online, prepared cabinet with real GPIO and no pending commands.'):'No cabinet selected';cached('actions',[selected,c?.definition.actions],e=>{e.replaceChildren(...(c?.definition.actions||[]).map(action=>{const box=node('div','','panel'),b=node('button','Trigger…');b.dataset.output=action;b.onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Trigger '+action+'?',name(id)+' will run this configured physical action. Check the connected device before proceeding.',reason=>{const bytes=crypto.getRandomValues(new Uint8Array(16));command('trigger_output',{cabinet:id,expected_session:session,action_id:action,reason,request_id:Array.from(bytes,x=>x.toString(16).padStart(2,'0')).join('')})},true)};box.append(node('h3',action),node('p','Configured cabinet action','small muted'),b);return box}));if(!e.children.length)e.append(node('p','No actions configured.','empty'))});cached('output',[selected,c?.commands],e=>{const list=(c?.commands||[]).filter(x=>['reward','output'].includes(x.type)).slice().reverse();e.replaceChildren(...list.map(x=>node('p',(x.action_id||x.type)+' · '+(x.source==='operator'?'Operator':'Scenario / win output')+' · '+x.status+(x.status==='accepted'?' — physical delivery unconfirmed':''))));if(!list.length)e.append(node('p','No hardware commands yet.','muted'))});cached('roomDevices',state.catalog.layout?.devices,e=>e.replaceChildren(...(state.catalog.layout?.devices||[]).map(d=>{const n=node('div','','panel');n.append(node('strong',d.name),node('p',d.id+' · feedback unavailable','small muted'));return n})))}
function activity(){const id=$('activityCabinet').value,q=$('activitySearch').value.toLowerCase();const list=allEvents().filter(e=>(!id||e.id===id)&&(!q||(e.message+' '+name(e.id)).toLowerCase().includes(q)));$('eventCount').textContent=list.length+' events';events('events',list)}
function render(){if(!state?.cabinets)return;if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];const hub=state.hub||{name:'Escape Gallery',room_name:'Glitch: the Dark Machine',services:[]};state.hub=hub;$('siteName').textContent=hub.name;for(const id of ['sidebarRoom','roomName','roomHeading'])$(id).textContent=hub.room_name;document.title=hub.name+' · Operations';options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name||id]));options('hardwareCabinet',Object.keys(state.cabinets).map(id=>[id,name(id)]));options('activityCabinet',[['','All cabinets'],...Object.keys(state.cabinets).map(id=>[id,name(id)])]);if(view==='home')home();if(view==='room'){scenario();map();cards('roomCards')}if(view==='cabinets'){cards('cards');detail()}if(view==='hardware')hardwareView();if(view==='activity')activity();enable()}
$('login').onsubmit=e=>e.preventDefault();$('connect').onclick=()=>{token=$('token').value.trim();if(token)refresh()};$('token').onkeydown=e=>{if(e.key==='Enter')$('connect').click()};$('refresh').onclick=refresh;document.querySelectorAll('.lock').forEach(b=>b.onclick=()=>{++epoch;token='';state=null;connected=false;$('token').value='';$('shell').hidden=true;$('login').hidden=false;$('confirmDialog').close();$('token').focus()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>navigate(b.dataset.view));document.querySelectorAll('[data-go]').forEach(b=>b.onclick=()=>navigate(b.dataset.go));$('scenario').onchange=()=>{scenario();enable()};$('game').onchange=enable;$('hardwareCabinet').onchange=()=>select($('hardwareCabinet').value,'hardware');$('activityCabinet').onchange=activity;$('activitySearch').oninput=activity;$('openHardware').onclick=()=>navigate('hardware');$('start').onclick=()=>command('start',{cabinet:selected,expected_session:state.cabinets[selected].session});$('reset').onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Reset '+name(id)+'?','This ends its current game and clears its activation dependency.',()=>command('reset',{cabinet:id,expected_session:session}))};$('prepare').onclick=()=>{const id=selected,session=state.cabinets[id].session,game=$('game').value;review('Prepare '+gameName(game)+'?',name(id)+' will receive a manual assignment without scenario hardware rewards.',()=>command('prepare',{cabinet:id,expected_session:session,game}))};$('prepareScenario').onclick=()=>{const scenario=$('scenario').value;review('Prepare this scenario?','Assignments will be sent to its cabinets. Configured activation outputs may run. Linked cabinets wait for the preceding win.',()=>command('prepare_scenario',{scenario}))};$('cancelConfirm').onclick=()=>{$('confirmDialog').close();confirmation=null};$('confirmForm').onsubmit=e=>{e.preventDefault();if(!connected||pending)return;const reason=$('reason').value.trim();if(!$('confirmReason').hidden&&reason.length<3){$('reason').setCustomValidity('Enter at least three characters.');$('reason').reportValidity();return}const fn=confirmation;confirmation=null;$('confirmDialog').close();fn?.(reason)};$('reason').oninput=()=>$('reason').setCustomValidity('');navigate('home');setInterval(refresh,1500);
const PACMAN_ART={"screen": "data:image/png;base64,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 truncated
function renderCabinetArt(c){document.querySelector('.cabinet-visual').dataset.game=c.game;const active=c.online&&c.phase==='playing';cached('marquee',[c.game],e=>{e.replaceChildren();if(c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.marquee;img.alt='Pac-Man marquee';e.append(img)}else e.textContent=gameName(c.game)});cached('screenGame',[c.game,c.phase,c.online],e=>{e.replaceChildren();if(active&&c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.screen;img.alt='Pac-Man gameplay illustration (not live video)';e.append(img)}else e.textContent=!c.online?'Cabinet offline':active?gameName(c.game):['won','lost'].includes(c.phase)?phases[c.phase]:'Waiting for operator'});}
function renderFeedback(target='feedback'){const c=state.cabinets[selected];const f=c?.feedback;cached(target,[selected,f,c?.online],e=>{e.replaceChildren();if(!f?.devices?.length){e.append(node('p',f?.error||'No configured device feedback received.','muted'));return}for(const d of f.devices){const box=node('div','','feedback-item'+(d.value===true?' active':'')+(!c.online||!f.fresh||d.age_ms>6000?' stale':''));box.append(node('strong',d.name||d.id),node('p',(d.port||'')+' · '+d.kind+' · '+(!c.online||!f.fresh||d.age_ms>6000?'STALE / unavailable':d.error||(typeof d.value==='boolean'?(d.value?'Active':'Inactive'):String(d.value))),'small'));if(d.kind==='output')box.append(node('small','Electrical output state; physical movement is unconfirmed.'));e.append(box)}})}
</script></body></html>