← back to AbramsEgo

public/dungeon.html

478 lines

<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>AbramsEgo — The Dungeon</title>
<style>
:root{--bg:#0a0810;--wall:#1a1526;--wall2:#221a33;--floor:#14101e;--ink:#e9e4f5;--mut:#8a7fa8;--gold:#d4af6a;
  --good:#37d67a;--warn:#e6b84c;--bad:#e5484d;--work:#5b8cff;--idle:#4a4360}
*{box-sizing:border-box;margin:0}
html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--ink);
  font:14px/1.4 "Avenir Next",system-ui,sans-serif}
#hud{position:fixed;top:0;left:0;right:0;z-index:10;display:flex;gap:16px;align-items:center;
  padding:10px 18px;background:linear-gradient(#0a0810ee,#0a081099);border-bottom:1px solid #2a2338}
#hud h1{font-size:16px;font-weight:600;letter-spacing:.14em;text-transform:uppercase}
#hud h1 em{color:var(--gold);font-style:normal}
.stat{color:var(--mut);font-size:12.5px}.stat b{color:var(--ink);font-variant-numeric:tabular-nums}
.legend{margin-left:auto;display:flex;gap:12px;font-size:11.5px;color:var(--mut)}
.legend span::before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
.lg-online::before{background:var(--good)}.lg-work::before{background:var(--work)}
.lg-idle::before{background:var(--idle)}.lg-bad::before{background:var(--bad)}
canvas{display:block;cursor:grab}canvas:active{cursor:grabbing}
#tip{position:fixed;z-index:20;pointer-events:none;background:#0d0a16f0;border:1px solid var(--gold);
  border-radius:8px;padding:6px 10px;font-size:12px;max-width:240px;display:none;box-shadow:0 6px 24px #000a}
#tip b{color:var(--gold)}
#panel{position:fixed;top:0;right:0;bottom:0;width:360px;z-index:30;background:#0d0a16f7;
  border-left:1px solid var(--gold);transform:translateX(100%);transition:transform .22s;
  padding:54px 20px 20px;overflow:auto;backdrop-filter:blur(6px)}
#panel.open{transform:none}
#panel .close{position:absolute;top:14px;right:16px;cursor:pointer;color:var(--mut);font-size:20px}
#panel h2{font-size:18px;margin-bottom:2px}#panel .role{color:var(--gold);font-size:12px;letter-spacing:.06em;text-transform:uppercase;margin-bottom:14px}
.kv{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #211a30;font-size:13px}
.kv span:first-child{color:var(--mut)}.kv b{font-weight:600;font-variant-numeric:tabular-nums}
.doing{margin:14px 0;padding:12px;border-radius:10px;background:#160f22;border:1px solid #2a2038}
.doing .lbl{color:var(--gold);font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin-bottom:6px}
.pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:11px;font-weight:600}
.pill.good{background:#0f2a1c;color:var(--good)}.pill.work{background:#141f3a;color:var(--work)}
.pill.idle{background:#1a1826;color:var(--mut)}.pill.bad{background:#2a1112;color:var(--bad)}
.hint{position:fixed;bottom:12px;left:18px;z-index:10;color:var(--mut);font-size:11.5px}
#zoombar{position:fixed;bottom:12px;right:18px;z-index:10;display:flex;gap:6px}
#zoombar button{width:30px;height:30px;border-radius:7px;border:1px solid #2a2338;background:#140f20;color:var(--ink);cursor:pointer;font-size:16px}
#mini{position:fixed;left:18px;bottom:44px;z-index:10;width:200px;height:132px;border:1px solid #2a2338;border-radius:8px;background:#0a0810cc;cursor:pointer}
</style></head><body>
<div id="hud">
  <h1>The <em>Dungeon</em></h1>
  <div class="stat">Chambers <b id="sChambers">–</b></div>
  <div class="stat">Agents <b id="sAgents">–</b></div>
  <div class="stat">Working <b id="sWork">–</b></div>
  <div class="stat">Alerts <b id="sBad">–</b></div>
  <div class="stat">Slain <b id="sSlain">0</b></div>
  <div class="stat" style="color:var(--gold)">🕓 <b id="sClock">–</b></div>
  <div class="legend"><span class="lg-online">online</span><span class="lg-work">working</span><span class="lg-idle">idle</span><span class="lg-bad">alert</span></div>
</div>
<canvas id="c"></canvas>
<div id="tip"></div>
<div id="panel"><div class="close" onclick="closePanel()">✕</div><div id="panelBody"></div></div>
<div class="hint">drag to pan · scroll to zoom · click an agent to see what it's doing · auto-refreshes every 15s</div>
<canvas id="mini"></canvas>
<div id="zoombar"><button id="followBtn" onclick="toggleFollow()" title="auto-tour">🎥</button><button id="sfxBtn" onclick="toggleSfx()" title="sound">🔇</button><button onclick="zoomBy(1.2)">+</button><button onclick="zoomBy(0.83)">−</button><button onclick="resetView()">⌂</button></div>
<script>
const cv=document.getElementById('c'),ctx=cv.getContext('2d');
let W,H,DPR;function resize(){DPR=devicePixelRatio||1;W=cv.width=innerWidth*DPR;H=cv.height=innerHeight*DPR;cv.style.width=innerWidth+'px';cv.style.height=innerHeight+'px';}
addEventListener('resize',resize);resize();

// ---- world state ----
let cam={x:0,y:0,z:1},chambers=[],agents=[],selected=null,hover=null,t=0;
let particles=[],monsters=[],couriers=[],prevAlertKeys=new Set(),slain=0;
let ultron={x:0,y:0,ph:0},packets=[];
// data-flow packet traveling one point→another along an edge
function packet(ax,ay,bx,by,color){packets.push({ax,ay,bx,by,tt:0,color:color||'#d4af6a'});}
function updatePackets(){for(const p of packets){p.tt+=0.025;}packets=packets.filter(p=>p.tt<1);}
const AUTH=''; // same-origin, cookie/basic-auth already present

// ---- FX: particle bursts (explosions, sparks, poofs) ----
function burst(x,y,color,n,spd){for(let i=0;i<n;i++){const a=Math.random()*6.28,s=(spd||2)*(.4+Math.random());
  particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:1,color,r:1.5+Math.random()*2.5});}}
function updateParticles(){for(const p of particles){p.x+=p.vx;p.y+=p.vy;p.vy+=0.04;p.vx*=.96;p.life-=0.02;}
  particles=particles.filter(p=>p.life>0);}
function drawParticles(){for(const p of particles){ctx.globalAlpha=Math.max(0,p.life);ctx.fillStyle=p.color;
  ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,6.28);ctx.fill();}ctx.globalAlpha=1;}

// ---- sound (WebAudio synth, off by default) ----
let sfxOn=false,actx=null,lastKey=0;
function toggleSfx(){sfxOn=!sfxOn;document.getElementById('sfxBtn').textContent=sfxOn?'🔊':'🔇';
  if(sfxOn&&!actx){actx=new(window.AudioContext||window.webkitAudioContext)();}}
function beep(freq,dur,type,vol){if(!sfxOn||!actx)return;const o=actx.createOscillator(),g=actx.createGain();
  o.type=type||'square';o.frequency.value=freq;g.gain.value=(vol||.05);o.connect(g).connect(actx.destination);
  const n=actx.currentTime;g.gain.setValueAtTime(vol||.05,n);g.gain.exponentialRampToValueAtTime(.0001,n+(dur||.1));
  o.start(n);o.stop(n+(dur||.1));}
const sfx={clash:()=>beep(320+Math.random()*140,.06,'square',.03),
  deliver:()=>{beep(660,.07,'sine',.04);setTimeout(()=>beep(990,.08,'sine',.04),60);},
  boom:()=>beep(70,.35,'sawtooth',.08),
  slain:()=>{beep(523,.09,'triangle',.05);setTimeout(()=>beep(784,.12,'triangle',.05),90);},
  type:()=>{const now=performance.now();if(now-lastKey>55){lastKey=now;beep(1400+Math.random()*400,.02,'square',.015);}}};

// ---- minimap ----
const mini=document.getElementById('mini'),mctx=mini.getContext('2d');
function drawMini(){const w=mini.width=200,h=mini.height=132;mctx.clearRect(0,0,w,h);
  if(!chambers.length)return;const b=worldBounds();const sw=b.x1-b.x0||1,sh=b.y1-b.y0||1;
  const pad=8,sc=Math.min((w-2*pad)/sw,(h-2*pad)/sh),ox=pad-b.x0*sc,oy=pad-b.y0*sc;
  chambers.forEach(c=>{mctx.fillStyle='#221a33';mctx.fillRect(c.x*sc+ox,c.y*sc+oy,c.w*sc,c.h*sc);});
  agents.forEach(a=>{const st=statusOf(a);mctx.fillStyle=COL[st];
    mctx.fillRect(a.x*sc+ox-1,a.y*sc+oy-1,st==='bad'?3:2,st==='bad'?3:2);});
  monsters.forEach(m=>{mctx.fillStyle='#e5484d';mctx.fillRect(m.x*sc+ox-1,m.y*sc+oy-1,3,3);});
  // viewport rect
  const vx=cam.x*sc+ox,vy=cam.y*sc+oy,vw=(innerWidth/cam.z)*sc,vh=(innerHeight/cam.z)*sc;
  mctx.strokeStyle='#d4af6a';mctx.lineWidth=1;mctx.strokeRect(vx,vy,vw,vh);
  mini._m={sc,ox,oy};}
mini.addEventListener('click',e=>{if(!mini._m)return;const r=mini.getBoundingClientRect();
  const {sc,ox,oy}=mini._m;const wx=((e.clientX-r.left)-ox)/sc,wy=((e.clientY-r.top)-oy)/sc;
  cam.x=wx-innerWidth/2/cam.z;cam.y=wy-innerHeight/2/cam.z;followOn=false;syncFollowBtn();});

// ---- chatter: real commit subjects + role-flavored lines ----
let commits=[];
const CHATTER={proc_scrape:['scraping…','parsing feed','importing SKUs','crawling'],
  proc_build:['building…','running tests','committing','deploying'],
  proc_dash:['serving…','rendering panel','200 OK'],
  proc_watch:['watching…','all green','scanning drift','heartbeat'],
  proc_mail:['checking inbox','triaging','drafting reply'],
  proc_render:['rendering room','gen image','upscaling'],
  proc:['working…','processing','ticking'],
  officer:['delegating…','reviewing','who owns this?','ship it'],
  canary_ok:['all clear','PASS ✓','nothing to report'],
  session:['thinking…','editing…','running…']};
function chatterFor(a){
  // real commit for a matching repo?
  const c=commits.find(c=>{const r=(c.repo||'').toLowerCase(),n=a.name.toLowerCase();
    return r&&(n.includes(r.slice(0,6))||r.includes(n.slice(0,6)));});
  if(c&&Math.random()<0.5)return '✓ '+(c.subject||'').slice(0,34);
  if(a.kind==='officer')return pick(CHATTER.officer);
  if(a.kind==='session')return a.doing&&Math.random()<.6?('“'+(a.doing.replace(/^“|”.*$/g,'')).slice(0,26)+'”'):pick(CHATTER.session);
  if(a.kind==='canary')return a.bad?'⚠ '+(a.verdict||'FAIL'):pick(CHATTER.canary_ok);
  const n=a.name.toLowerCase();
  if(/scrap|import|catalog/.test(n))return pick(CHATTER.proc_scrape);
  if(/build|loop|deploy/.test(n))return pick(CHATTER.proc_build);
  if(/dash|viewer|panel|cncp|token/.test(n))return pick(CHATTER.proc_dash);
  if(/watch|monitor|hawk|keep|canary/.test(n))return pick(CHATTER.proc_watch);
  if(/mail|george|inbox/.test(n))return pick(CHATTER.proc_mail);
  if(/render|image|design|room/.test(n))return pick(CHATTER.proc_render);
  return pick(CHATTER.proc);}
const pick=a=>a[(Math.random()*a.length)|0];

// ---- follow cam (auto-tour busiest chamber) ----
let followOn=false,followTgt=null,followTimer=0;
function toggleFollow(){followOn=!followOn;followTimer=0;syncFollowBtn();}
function syncFollowBtn(){document.getElementById('followBtn').style.background=followOn?'#2a2038':'#140f20';
  document.getElementById('followBtn').style.borderColor=followOn?'#d4af6a':'#2a2338';}
function chamberScore(ch){let s=0;agents.forEach(a=>{if(a.chamber!==ch)return;
  const st=statusOf(a);s+=st==='bad'?6:st==='work'?2:0.2;});return s;}
function updateFollow(){if(!followOn||!chambers.length)return;
  if(followTimer--<=0){followTimer=260; // ~4.3s per stop
    followTgt=chambers.reduce((b,c)=>chamberScore(c)>chamberScore(b)?c:b,chambers[0]);}
  if(followTgt){const tz=Math.min(1.1,Math.min(innerWidth/(followTgt.w+260),innerHeight/(followTgt.h+260)));
    const tx=followTgt.x+followTgt.w/2-innerWidth/2/tz, ty=followTgt.y+followTgt.h/2-innerHeight/2/tz;
    cam.x+=(tx-cam.x)*.05;cam.y+=(ty-cam.y)*.05;cam.z+=(tz-cam.z)*.05;}}

// status → color/kind
function statusOf(a){
  if(a.kind==='canary') return a.bad?'bad':'online';
  if(a.status && a.status!=='online') return 'bad';
  if(a.working) return 'work';
  if(a.idle) return 'idle';
  return 'online';
}
const COL={online:'#37d67a',work:'#5b8cff',idle:'#4a4360',bad:'#e5484d'};
// what-it's-doing icon from name/role
function iconFor(a){
  const n=(a.name+' '+(a.doing||'')).toLowerCase();
  if(a.kind==='officer')return '👑';
  if(a.kind==='canary')return a.bad?'🚨':'🛡️';
  if(a.kind==='session')return '🎮';
  if(/scrap|import|catalog/.test(n))return '🕷️';
  if(/build|loop|deploy/.test(n))return '⚙️';
  if(/dash|viewer|panel|cncp/.test(n))return '📊';
  if(/watch|monitor|hawk|keep/.test(n))return '👁️';
  if(/mail|george|inbox/.test(n))return '✉️';
  if(/render|image|design|room/.test(n))return '🎨';
  return '🤖';
}

// ---- build world from /api/data ----
function build(d){
  const offs=(d.officers&&d.officers.officers)||[];
  const lf=(d.localFleet&&d.localFleet.procs)||[];
  const km=(d.kamatera&&d.kamatera.procs)||[];
  const cans=(d.canaries&&d.canaries.canaries)||[];
  const ses=(d.sessions&&d.sessions.recent)||[];
  commits=(d.activity&&d.activity.commits)||[];
  // map each pm2 proc to an owning officer by keyword, else "Commons"
  const officerFor=(name)=>{
    const n=name.toLowerCase();
    if(/dw|shopify|vendor|catalog|kravet|thibaut|scalamandre/.test(n))return 'vp-dw-commerce';
    if(/insta|tiktok|social|marketing|reel/.test(n))return 'vp-dw-marketing';
    if(/lawyer|doctor|directory|animals|ventura|corridor/.test(n))return 'vp-directories';
    if(/security|firewall|secret|monitor/.test(n))return 'vp-security';
    if(/wallpapersback|wallco|apartment|abramsego|pattern/.test(n))return 'vp-special-projects';
    if(/cncp|pm2|keep|hawk|watch|dash|viewer|token/.test(n))return 'vp-operations';
    return 'vp-operations';
  };
  // chambers = officers (+ a Sentries ring for canaries, + a Players nook for sessions)
  const roster={};
  offs.forEach(o=>roster[o.vp]={vp:o.vp,domain:o.domain,members:[]});
  if(!roster['vp-operations'])roster['vp-operations']={vp:'vp-operations',domain:'Infra',members:[]};
  const recent=new Set(commits.slice(0,12).map(c=>(c.repo||'').toLowerCase()));
  [...lf.map(p=>({...p,loc:'Mac2'})),...km.map(p=>({...p,loc:'Kamatera'}))].forEach(p=>{
    const vp=officerFor(p.name);
    const working=(+p.cpu>3)|| [...recent].some(r=>p.name.toLowerCase().includes(r)||r.includes(p.name.toLowerCase().slice(0,6)));
    (roster[vp]||roster['vp-operations']).members.push({
      kind:'proc',name:p.name,status:p.status,cpu:+p.cpu||0,memMB:+p.memMB||0,
      loc:p.loc,uptimeMs:+p.uptimeMs||0,restarts:p.restarts,working,idle:!working&&p.status==='online',
      doing: working?('running · '+(p.cpu||0)+'% cpu'):(p.status==='online'?'idle':'DOWN')});
  });
  // canary sentries chamber
  roster['__sentries']={vp:'Sentries',domain:'Canary watch — who watches the watchers',members:
    cans.map(c=>({kind:'canary',name:c.name,verdict:c.verdict,bad:/FAIL|CRIT|DOWN/i.test(c.verdict||''),
      ageMin:c.ageMin,stale:c.stale==='True'||c.stale===true,doing:(c.verdict||'?')+' · '+(c.ageMin||'?')+'m ago'}))};
  // players (live sessions)
  roster['__players']={vp:'Sessions',domain:'Live Claude sessions in flight',members:
    ses.slice(0,8).map(s=>({kind:'session',name:(s.cwd||'').split('/').pop()||'session',
      working:true,doing:'“'+(s.lastPrompt||'').slice(0,40)+'” · '+(s.msgCount||0)+' msgs',
      branch:s.gitBranch,cwd:s.cwd,msgs:s.msgCount}))};

  // layout chambers on a grid
  const list=Object.values(roster).filter(r=>r.members.length);
  const cols=Math.ceil(Math.sqrt(list.length));
  const CW=460,CH=340,GAP=60;
  chambers=list.map((r,i)=>{
    const cx=(i%cols)*(CW+GAP), cy=Math.floor(i/cols)*(CH+GAP);
    return {...r,x:cx,y:cy,w:CW,h:CH};
  });
  // place agents wandering inside their chamber
  agents=[];
  chambers.forEach(ch=>{
    ch.members.forEach((m,j)=>{
      const pad=54;
      agents.push({...m,chamber:ch,
        x:ch.x+pad+Math.random()*(ch.w-2*pad), y:ch.y+pad+Math.random()*(ch.h-2*pad),
        vx:(Math.random()-.5)*.5, vy:(Math.random()-.5)*.5, ph:Math.random()*6.28, r:16});
    });
  });
  // ---- alerts → spawn glitch monsters + explosions on NEW alerts ----
  const alerts=agents.filter(a=>statusOf(a)==='bad');
  const keyOf=a=>a.kind+':'+a.name;
  const curKeys=new Set(alerts.map(keyOf));
  // new alerts → explosion at that agent's chamber
  alerts.forEach(a=>{if(!prevAlertKeys.has(keyOf(a))){burst(a.x,a.y,'#e5484d',26,3.2);burst(a.x,a.y,'#ffb020',14,2.2);sfx.boom();}});
  // build monster list (one per active alert), engaged by the nearest sentry
  const sentries=agents.filter(a=>a.kind==='canary'&&!a.bad);
  monsters=alerts.map(a=>{
    const prev=monsters.find(m=>m.key===keyOf(a));
    const foe=sentries.length?sentries.reduce((b,s)=>Math.hypot(s.x-a.x,s.y-a.y)<Math.hypot(b.x-a.x,b.y-a.y)?s:b):null;
    return prev? Object.assign(prev,{target:a,foe}):
      {key:keyOf(a),target:a,foe,x:a.x+30,y:a.y-30,ph:Math.random()*6.28,hp:1};});
  // cleared alerts → poof + tally
  prevAlertKeys.forEach(k=>{if(!curKeys.has(k)){slain++;sfx.slain();}});
  prevAlertKeys=curKeys;
  S('sSlain',slain);
  // hud
  S('sChambers',chambers.length);S('sAgents',agents.length);
  S('sWork',agents.filter(a=>statusOf(a)==='work').length);
  S('sBad',agents.filter(a=>statusOf(a)==='bad').length);
  S('sClock',new Date().toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}));
  // Ultron sits at the center of the whole map, wired to every chamber
  const b=worldBounds();ultron.x=(b.x0+b.x1)/2;ultron.y=(b.y0+b.y1)/2;
  if(!chambers._fit){resetView();chambers._fit=1;}
}
const S=(id,v)=>document.getElementById(id).textContent=v;

// ---- camera / interaction ----
function worldBounds(){let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;chambers.forEach(c=>{x0=Math.min(x0,c.x);y0=Math.min(y0,c.y);x1=Math.max(x1,c.x+c.w);y1=Math.max(y1,c.y+c.h)});return{x0,y0,x1,y1}}
function resetView(){if(!chambers.length)return;const b=worldBounds();const w=b.x1-b.x0,h=b.y1-b.y0;
  cam.z=Math.min(innerWidth/(w+120),innerHeight/(h+160))*.95;cam.z=Math.max(.15,Math.min(cam.z,1.4));
  cam.x=(b.x0+b.x1)/2 - innerWidth/2/cam.z; cam.y=(b.y0+b.y1)/2 - innerHeight/2/cam.z;}
function zoomBy(f){const cx=innerWidth/2,cy=innerHeight/2;const wx=cam.x+cx/cam.z,wy=cam.y+cy/cam.z;
  cam.z=Math.max(.12,Math.min(cam.z*f,4));cam.x=wx-cx/cam.z;cam.y=wy-cy/cam.z;}
let drag=null;
cv.addEventListener('mousedown',e=>drag={x:e.clientX,y:e.clientY,cx:cam.x,cy:cam.y,moved:0});
addEventListener('mouseup',e=>{if(drag&&drag.moved<4)clickAt(e.clientX,e.clientY);drag=null;});
addEventListener('mousemove',e=>{
  if(drag){cam.x=drag.cx-(e.clientX-drag.x)/cam.z;cam.y=drag.cy-(e.clientY-drag.y)/cam.z;drag.moved+=Math.abs(e.movementX)+Math.abs(e.movementY);}
  else hoverAt(e.clientX,e.clientY);
});
cv.addEventListener('wheel',e=>{e.preventDefault();const f=e.deltaY<0?1.12:0.89;
  const wx=cam.x+e.clientX/cam.z,wy=cam.y+e.clientY/cam.z;cam.z=Math.max(.12,Math.min(cam.z*f,4));
  cam.x=wx-e.clientX/cam.z;cam.y=wy-e.clientY/cam.z;},{passive:false});
function screenToWorld(sx,sy){return{x:cam.x+sx/cam.z,y:cam.y+sy/cam.z}}
function agentAt(sx,sy){const w=screenToWorld(sx,sy);let best=null,bd=1e9;
  agents.forEach(a=>{const d=Math.hypot(a.x-w.x,a.y-w.y);if(d<a.r+8&&d<bd){bd=d;best=a;}});return best;}
function chamberAt(sx,sy){const w=screenToWorld(sx,sy);
  return chambers.find(c=>w.x>=c.x&&w.x<=c.x+c.w&&w.y>=c.y&&w.y<=c.y+c.h);}
function hoverAt(sx,sy){hover=agentAt(sx,sy);const tip=document.getElementById('tip');
  if(hover){tip.style.display='block';tip.style.left=(sx+14)+'px';tip.style.top=(sy+14)+'px';
    tip.innerHTML='<b>'+hover.name+'</b><br>'+(hover.doing||'');return;}
  // chamber aggregate health
  const ch=chamberAt(sx,sy);
  if(ch){const mem=agents.filter(a=>a.chamber===ch);
    const work=mem.filter(a=>statusOf(a)==='work').length,bad=mem.filter(a=>statusOf(a)==='bad').length;
    const cpu=mem.filter(a=>a.kind==='proc').reduce((s,a)=>s+(a.cpu||0),0);
    const hp=bad?'⚠ '+bad+' alert'+(bad>1?'s':''):'✓ all healthy';
    tip.style.display='block';tip.style.left=(sx+14)+'px';tip.style.top=(sy+14)+'px';
    tip.innerHTML='<b>'+(ch.vp||'').replace('__','')+'</b><br>'+mem.length+' agents · '+work+' working · '+
      (cpu?cpu.toFixed(0)+'% cpu · ':'')+'<span style="color:'+(bad?'#e5484d':'#37d67a')+'">'+hp+'</span>';
  }else tip.style.display='none';}
function clickAt(sx,sy){const a=agentAt(sx,sy);if(a){selected=a;openPanel(a);}else closePanel();}

// ---- detail panel (zoom-in on what an agent is doing) ----
function openPanel(a){
  const st=statusOf(a),cls={online:'good',work:'work',idle:'idle',bad:'bad'}[st];
  const role={officer:'Cabinet Officer',proc:'Process · '+(a.loc||''),canary:'Sentry / Canary',session:'Live Session'}[a.kind]||'Agent';
  let kv='';
  const add=(k,v)=>{if(v!==undefined&&v!==''&&v!==null)kv+='<div class="kv"><span>'+k+'</span><b>'+v+'</b></div>';};
  if(a.kind==='proc'){add('Status',a.status);add('CPU',a.cpu+'%');add('Memory',a.memMB+' MB');
    add('Uptime',a.uptimeMs?Math.round(a.uptimeMs/60000)+' min':'–');add('Restarts',a.restarts);add('Host',a.loc);}
  if(a.kind==='canary'){add('Verdict',a.verdict);add('Last run',(a.ageMin||'?')+' min ago');add('Stale',a.stale?'yes ⚠':'no');}
  if(a.kind==='session'){add('Branch',a.branch);add('Messages',a.msgs);add('Dir',(a.cwd||'').replace('/Users/stevestudio2/Projects/','~/'));}
  if(a.kind==='officer'){add('Domain',a.domain);add('Reports',a.members?a.members.length:'–');}
  document.getElementById('panelBody').innerHTML=
    '<h2>'+iconFor(a)+' '+a.name+'</h2><div class="role">'+role+'</div>'+
    '<span class="pill '+cls+'">'+st.toUpperCase()+'</span>'+
    '<div class="doing"><div class="lbl">What it\'s doing</div>'+(a.doing||'—')+'</div>'+kv+
    '<div class="kv"><span>Chamber</span><b>'+(a.chamber?a.chamber.vp:'–')+'</b></div>';
  document.getElementById('panel').classList.add('open');
}
function closePanel(){selected=null;document.getElementById('panel').classList.remove('open');}

// ---- render loop ----
function draw(){
  t+=1;updateFollow();
  ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,W,H);
  // ---- day/night backdrop (tied to real local time) ----
  const now=new Date(),hr=now.getHours()+now.getMinutes()/60;
  const day=Math.max(0,Math.sin((hr-6)/12*Math.PI));           // 0 night → 1 noon
  const dusk=Math.max(0,1-Math.abs(hr-18)/3)+Math.max(0,1-Math.abs(hr-6.5)/2.5); // warm at dawn/dusk
  const g=ctx.createLinearGradient(0,0,0,H);
  const top=`rgb(${10+day*10+dusk*14|0},${8+day*7+dusk*4|0},${16+day*16|0})`;
  const bot=`rgb(${8+day*4|0},${6+day*4|0},${12+day*8|0})`;
  g.addColorStop(0,top);g.addColorStop(1,bot);ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
  // torch-glow vignette at night
  if(day<0.4){const vg=ctx.createRadialGradient(W/2,H/2,H*0.2,W/2,H/2,H*0.8);
    vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,`rgba(0,0,0,${0.35*(1-day)})`);
    ctx.fillStyle=vg;ctx.fillRect(0,0,W,H);}
  document.getElementById('sClock').textContent=(day>0.15?'☀ ':'🌙 ')+now.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
  ctx.setTransform(cam.z*DPR,0,0,cam.z*DPR,-cam.x*cam.z*DPR,-cam.y*cam.z*DPR);
  // ---- Ultron orchestrator: edges to every chamber + directive/heartbeat packets ----
  if(chambers.length){
    ultron.ph+=0.04;
    // edges (drawn under everything)
    chambers.forEach(ch=>{const cx=ch.x+ch.w/2,cy=ch.y+ch.h/2;
      const hot=chamberScore(ch)>3, alert=agents.some(a=>a.chamber===ch&&statusOf(a)==='bad');
      ctx.strokeStyle=alert?'#e5484d40':hot?'#d4af6a55':'#3a305033';ctx.lineWidth=alert?2:1;
      ctx.beginPath();ctx.moveTo(ultron.x,ultron.y);ctx.lineTo(cx,cy);ctx.stroke();});
    // spawn packets: Ultron → busiest chamber (directive), chambers → Ultron (heartbeat)
    if(t%22===0){const busy=chambers.reduce((a,c)=>chamberScore(c)>chamberScore(a)?c:a,chambers[0]);
      packet(ultron.x,ultron.y,busy.x+busy.w/2,busy.y+busy.h/2,'#5b8cff');}
    if(t%14===0){const c=chambers[(Math.random()*chambers.length)|0];
      packet(c.x+c.w/2,c.y+c.h/2,ultron.x,ultron.y, agents.some(a=>a.chamber===c&&statusOf(a)==='bad')?'#e5484d':'#37d67a');}
    // draw packets
    packets.forEach(p=>{const e=p.tt<.5?2*p.tt*p.tt:1-Math.pow(-2*p.tt+2,2)/2; // ease
      const x=p.ax+(p.bx-p.ax)*p.tt,y=p.ay+(p.by-p.ay)*p.tt;
      ctx.fillStyle=p.color;ctx.globalAlpha=.9;ctx.beginPath();ctx.arc(x,y,3,0,6.28);ctx.fill();
      ctx.globalAlpha=.3;ctx.beginPath();ctx.arc(x,y,6,0,6.28);ctx.fill();ctx.globalAlpha=1;});
    updatePackets();
    // Ultron core node
    const pr=26+Math.sin(ultron.ph)*3;
    ctx.beginPath();ctx.arc(ultron.x,ultron.y,pr+14,0,6.28);ctx.fillStyle='#d4af6a18';ctx.fill();
    ctx.beginPath();ctx.arc(ultron.x,ultron.y,pr,0,6.28);ctx.fillStyle='#120d1e';ctx.fill();
    ctx.lineWidth=3;ctx.strokeStyle='#d4af6a';ctx.stroke();
    ctx.font='26px sans-serif';ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText('🧠',ultron.x,ultron.y+1);
    ctx.fillStyle='#d4af6a';ctx.font='700 12px Avenir Next,sans-serif';ctx.fillText('ULTRON',ultron.x,ultron.y+pr+12);
    ctx.fillStyle='#8a7fa8';ctx.font='10px Avenir Next,sans-serif';ctx.fillText('orchestrator · keeps the fleet busy',ultron.x,ultron.y+pr+26);ctx.textAlign='left';
  }
  // chambers
  chambers.forEach(ch=>{
    ctx.fillStyle='#14101e';ctx.strokeStyle='#2a2038';ctx.lineWidth=2;
    roundRect(ch.x,ch.y,ch.w,ch.h,14);ctx.fill();ctx.stroke();
    // floor tiles
    ctx.strokeStyle='#191322';ctx.lineWidth=1;
    for(let gx=ch.x+30;gx<ch.x+ch.w;gx+=38){ctx.beginPath();ctx.moveTo(gx,ch.y+30);ctx.lineTo(gx,ch.y+ch.h-14);ctx.stroke();}
    for(let gy=ch.y+34;gy<ch.y+ch.h;gy+=38){ctx.beginPath();ctx.moveTo(ch.x+14,gy);ctx.lineTo(ch.x+ch.w-14,gy);ctx.stroke();}
    // nameplate
    ctx.fillStyle='#d4af6a';ctx.font='600 17px Avenir Next,sans-serif';ctx.textBaseline='top';
    ctx.fillText((ch.vp||'').replace('vp-','').replace('__',''),ch.x+16,ch.y+12);
    ctx.fillStyle='#8a7fa8';ctx.font='12px Avenir Next,sans-serif';
    ctx.fillText((ch.domain||'').slice(0,54),ch.x+16,ch.y+32);
  });
  // handoff journeys: occasionally a working agent carries work to another chamber
  if(t%40===0){const workers=agents.filter(a=>statusOf(a)==='work'&&!a.travel&&a.kind==='proc');
    if(workers.length&&chambers.length>1&&Math.random()<0.6){
      const a=workers[(Math.random()*workers.length)|0];
      let dest=chambers[(Math.random()*chambers.length)|0];let g=0;
      while(dest===a.chamber&&g++<5)dest=chambers[(Math.random()*chambers.length)|0];
      a.travel={dest,home:a.chamber,phase:'go',hx:a.x,hy:a.y};}}
  // agents (wander/travel + draw)
  agents.forEach(a=>{
    const ch=a.chamber,pad=40;
    if(a.travel){
      const tg=a.travel.phase==='go'?a.travel.dest:a.travel.home;
      const tx=tg.x+tg.w/2, ty=tg.y+tg.h/2;
      const dx=tx-a.x, dy=ty-a.y, dist=Math.hypot(dx,dy);
      a.x+=dx/dist*3.2; a.y+=dy/dist*3.2;
      if((t>>1)%2===0)particles.push({x:a.x,y:a.y,vx:0,vy:0,life:.5,color:'#5b8cff',r:2}); // trail
      if(dist<26){
        if(a.travel.phase==='go'){a.travel.phase='return';burst(tx,ty,'#5b8cff',10,1.6);sfx.deliver();} // deliver
        else{a.chamber=a.travel.home;a.x=a.travel.hx;a.y=a.travel.hy;a.travel=null;}}
    } else {
    // wander within chamber
    a.ph+=0.03;a.vx+=Math.cos(a.ph)*.03+(Math.random()-.5)*.04;a.vy+=Math.sin(a.ph*1.3)*.03+(Math.random()-.5)*.04;
    a.vx*=.94;a.vy*=.94;a.x+=a.vx;a.y+=a.vy;
    if(a.x<ch.x+pad){a.x=ch.x+pad;a.vx=Math.abs(a.vx);}if(a.x>ch.x+ch.w-pad){a.x=ch.x+ch.w-pad;a.vx=-Math.abs(a.vx);}
    if(a.y<ch.y+pad+18){a.y=ch.y+pad+18;a.vy=Math.abs(a.vy);}if(a.y>ch.y+ch.h-pad){a.y=ch.y+ch.h-pad;a.vy=-Math.abs(a.vy);}
    }
    const st=statusOf(a),col=COL[st];
    // status glow / working pulse
    const pulse=st==='work'?(1+Math.sin(t*.12+a.ph)*.25):1;
    ctx.beginPath();ctx.arc(a.x,a.y,a.r*1.7*pulse,0,6.28);
    ctx.fillStyle=col+'22';ctx.fill();
    // body
    ctx.beginPath();ctx.arc(a.x,a.y,a.r,0,6.28);ctx.fillStyle='#0d0a16';ctx.fill();
    ctx.lineWidth=2.5;ctx.strokeStyle=col;ctx.stroke();
    if(a===selected||a===hover){ctx.lineWidth=2;ctx.strokeStyle='#d4af6a';ctx.beginPath();ctx.arc(a.x,a.y,a.r+5,0,6.28);ctx.stroke();}
    // icon
    ctx.font=(a.r+2)+'px sans-serif';ctx.textAlign='center';ctx.textBaseline='middle';
    ctx.fillText(iconFor(a),a.x,a.y+1);ctx.textAlign='left';
    // working action bubble
    if(st==='work'&&(t>>4)%2===0){ctx.font='11px sans-serif';ctx.fillStyle='#5b8cff';
      ctx.fillText('•••',a.x+a.r,a.y-a.r);}
    if(st==='bad'){ctx.font='12px sans-serif';ctx.fillText('!',a.x+a.r-2,a.y-a.r);}
    // live sessions "type": blinking cursor + code particles floating up
    if(a.kind==='session'){
      if((t>>4)%2===0){ctx.fillStyle='#37d67a';ctx.font='bold 12px monospace';ctx.fillText('▌',a.x+a.r-1,a.y-a.r+2);}
      if(t%12===0){particles.push({x:a.x+(Math.random()-.5)*14,y:a.y-a.r,vx:(Math.random()-.5)*.3,vy:-.8,life:1,color:'#37d67a',r:1.6});sfx.type();}
    }
    // label when zoomed in enough
    if(cam.z>0.5){ctx.fillStyle='#8a7fa8';ctx.font='10px Avenir Next,sans-serif';ctx.textAlign='center';
      ctx.fillText(a.name.slice(0,16),a.x,a.y+a.r+11);ctx.textAlign='left';}
    // chatter speech bubbles (real commits + role lines) — only when zoomed in
    if(!a.bubble && cam.z>0.42 && Math.random()<(st==='bad'?0.010:st==='work'?0.006:0.0018))
      a.bubble={text:chatterFor(a),life:130};
    if(a.bubble){a.bubble.life--;if(a.bubble.life<=0){a.bubble=null;}
      else{const b=a.bubble,tw=ctx.measureText(b.text).width;ctx.font='11px Avenir Next,sans-serif';
        const w=Math.min(220,ctx.measureText(b.text).width+16),bx=a.x-w/2,byy=a.y-a.r-26;
        ctx.globalAlpha=Math.min(1,b.life/20);
        ctx.fillStyle='#0d0a16ee';ctx.strokeStyle=st==='bad'?'#e5484d':'#3a3050';ctx.lineWidth=1;
        roundRect(bx,byy,w,20,6);ctx.fill();ctx.stroke();
        ctx.beginPath();ctx.moveTo(a.x-4,byy+20);ctx.lineTo(a.x+4,byy+20);ctx.lineTo(a.x,byy+26);ctx.closePath();ctx.fillStyle='#0d0a16ee';ctx.fill();
        ctx.fillStyle=st==='bad'?'#e5a0a2':'#cdbfe6';ctx.textAlign='center';ctx.textBaseline='middle';
        ctx.fillText(b.text,a.x,byy+10);ctx.textAlign='left';ctx.globalAlpha=1;}}
  });
  // ---- monsters + combat ----
  monsters.forEach(m=>{
    const tg=m.target;m.ph+=0.08;m.x+=((tg.x+34)-m.x)*.06;m.y+=((tg.y-34)-m.y)*.06;
    const by=m.y+Math.sin(m.ph)*4;
    // a sentry engages: it advances on the monster and trades hit-sparks
    if(m.foe){const f=m.foe;const dx=m.x-f.x,dy=by-f.y,d=Math.hypot(dx,dy);
      if(d>60){f.x+=dx/d*1.6;f.y+=dy/d*1.6;} // charge in
      else if(t%18===0){ // clash
        const mx=(f.x+m.x)/2,my=(f.y+by)/2;burst(mx,my,'#ffd24a',6,2);burst(mx,my,'#e5484d',4,1.6);sfx.clash();}
      // sword line
      ctx.strokeStyle='#ffd24a55';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(f.x,f.y);ctx.lineTo(m.x,by);ctx.stroke();}
    // menace aura + body
    ctx.beginPath();ctx.arc(m.x,by,20,0,6.28);ctx.fillStyle='#e5484d22';ctx.fill();
    ctx.font='26px sans-serif';ctx.textAlign='center';ctx.textBaseline='middle';
    ctx.fillText('👾',m.x,by);
    // threat label
    ctx.fillStyle='#e5484d';ctx.font='700 10px Avenir Next,sans-serif';
    ctx.fillText((tg.verdict||tg.status||'ALERT').toString().toUpperCase(),m.x,by-24);ctx.textAlign='left';
  });
  drawParticles();updateParticles();
  if(t%3===0)drawMini();
  requestAnimationFrame(draw);
}
function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
  ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}

// ---- data ----
async function load(){try{const r=await fetch('/api/data',{headers:AUTH?{Authorization:AUTH}:{}});
  const d=await r.json();build(d);}catch(e){console.error(e);}}
load();setInterval(load,15000);draw();
</script></body></html>