pixel-world / index.html
Nomos42's picture
v2.24 β€” councils β†’ 14 Claude Code crew agents
5b8e5d9 verified
Raw
History Blame Contribute Delete
175 kB
<!DOCTYPE html>
<html lang="en">
<head><script>window.huggingface={variables:{"SPACE_CREATOR_USER_ID":"698653c38d72821326e9e4fe"}};</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nomos42 Pixel World β€” PokΓ©mon Trainer Network</title>
<style>
:root {
/* v2.16 β€” unified PokΓ©mon B/W (Gen5 DS) palette. */
--bg:#0b0d12; --bg-2:#14171f; --bg-3:#1a1f2a; --line:#1e2330;
--ink:#e6e8ee; --ink-2:#9aa3b5; --ink-3:#5b6478;
/* Semantic colors β€” remapped to PokΓ©mon set so all chrome reads as one game. */
--ok:#207838; --warn:#d8a020; --err:#c03030;
--pink:#c03068; --blue:#4060b0; --green:#207838; --orange:#a05820; --purple:#5040a0;
/* PokΓ©mon box primitives (reused by every chrome surface). */
--pk-box:#f8f8f8; --pk-ink:#101820; --pk-navy:#4060b0; --pk-red:#c03030;
--pk-yellow:#ffd840; --pk-yellow-2:#fff8c0; --pk-green:#207838;
--pk-steel:#283040; --pk-mute:#808898;
}
* { box-sizing:border-box; }
html,body { margin:0; padding:0; height:100%; background:var(--bg); color:var(--ink);
font-family:"JetBrains Mono","Courier New",monospace; font-size:12px; overflow:hidden; }
/* ═══ v2.22 Wall Street marquee ticker β€” replaces segmented row. ═══
User ask 2026-04-20: "comme Γ  wall street avec all important data qui dΓ©filent".
Full-width horizontal scroll; content duplicated for seamless loop. Pointer-events
disabled on the track so clicks pass through to the world. */
#bloomberg-ticker { position:fixed; top:0; left:0; right:0; height:34px; z-index:100;
background:#f8f8f8; color:#101820;
font-family:"Press Start 2P","VT323","IBM Plex Mono",monospace; font-size:11px;
letter-spacing:0.08em; text-transform:uppercase;
border-bottom:3px solid var(--pk-ink);
box-shadow: inset 0 0 0 2px #f8f8f8, inset 0 0 0 4px var(--pk-navy),
0 3px 10px rgba(0,0,0,0.55);
display:flex; align-items:center; overflow:hidden; padding:0; }
#bloomberg-ticker .brand { flex:0 0 auto; padding:0 14px; height:100%;
background:var(--pk-navy); color:var(--pk-yellow);
display:flex; align-items:center; gap:8px;
border-right:3px solid var(--pk-ink); z-index:2;
font-family:"Press Start 2P",monospace; font-size:10px; letter-spacing:0.18em;
text-shadow:1px 1px 0 #000; white-space:nowrap; }
#bloomberg-ticker .brand .dot { width:8px; height:8px; background:var(--pk-red);
border:1px solid #000; animation:blk 0.9s steps(2) infinite; }
#ticker-viewport { flex:1; height:100%; overflow:hidden; position:relative;
pointer-events:none; }
#ticker-track { position:absolute; top:0; left:0; height:100%;
display:flex; align-items:center; gap:0; white-space:nowrap;
animation: tk-scroll 62s linear infinite;
will-change: transform; }
#ticker-track:hover { animation-play-state: paused; }
@keyframes tk-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
#bloomberg-ticker .seg { padding:0 18px; display:inline-flex; gap:7px;
align-items:center; white-space:nowrap; border-right:2px dotted #b0b8c4; height:100%; }
#bloomberg-ticker .lbl { color:var(--pk-navy); font-weight:700; letter-spacing:0.16em;
font-family:"Press Start 2P",monospace; font-size:9px; }
#bloomberg-ticker .val { color:var(--pk-ink); font-weight:700; font-family:"VT323","JetBrains Mono",monospace; font-size:14px; letter-spacing:0.02em; }
#bloomberg-ticker .val.up { color:var(--pk-green); }
#bloomberg-ticker .val.down { color:var(--pk-red); }
#bloomberg-ticker .val.star { color:#a05820; background:var(--pk-yellow); padding:1px 6px; border:1px solid var(--pk-ink); border-radius:3px; }
#bloomberg-ticker .val.flash { animation: tk-flash 0.6s ease-out; }
@keyframes tk-flash { 0% { background:var(--pk-yellow); } 100% { background:transparent; } }
#bloomberg-ticker .arrow { display:inline-block; color:var(--pk-navy); font-weight:900; }
#bloomberg-ticker .arrow.up { color:var(--pk-green); }
#bloomberg-ticker .arrow.down { color:var(--pk-red); }
@keyframes blk { 50% { opacity:0; } }
@keyframes panel-pulse { 0%,100% { box-shadow: 0 0 0 0 currentColor; } 50% { box-shadow: 0 0 0 3px currentColor; opacity:0.7; } }
/* v2.8 2026-04-17: sidebar removed β€” all panels are now diegetic (in-world PIXI).
Minimal floating HUDs live on top of #stage as absolute overlays. */
#root { display:grid; grid-template-columns:1fr; height:calc(100vh - 34px); margin-top:34px; }
/* v2.16 β€” PokΓ©mon summary box (party-menu / PokΓ©dex entry). Off-white double-border + navy rules. */
/* v2.20 2026-04-20: user asked to remove the STATUS panel from the world β€”
"not useful for pixel world on pixel world". Hidden via display:none so the
in-world diegetic panels become the primary data surface. Re-enable by
removing display:none if a future redesign needs the HUD back. */
#hud-kpi { display:none !important; position:absolute; top:48px; left:14px; z-index:7;
background:var(--pk-box); color:var(--pk-ink);
border:3px solid var(--pk-ink); border-radius:8px;
box-shadow: inset 0 0 0 2px var(--pk-box), inset 0 0 0 4px var(--pk-navy),
0 4px 14px rgba(0,0,0,0.55);
padding:10px 14px; font-family:"JetBrains Mono",monospace; font-size:10.5px;
display:flex; flex-direction:column; gap:3px; min-width:180px; }
#hud-kpi::before { content:"β—‰ STATUS"; display:block; font-family:"Press Start 2P",monospace;
font-size:8px; color:var(--pk-navy); letter-spacing:0.18em; margin-bottom:4px;
padding-bottom:4px; border-bottom:2px dotted #b0b8c4; }
#hud-kpi .r { display:flex; justify-content:space-between; gap:12px; padding:1px 0; }
#hud-kpi .r .l { color:var(--pk-navy); text-transform:uppercase; letter-spacing:0.14em; font-size:9px; font-weight:700; font-family:"Press Start 2P",monospace; }
#hud-kpi .r .v { color:var(--pk-ink); font-weight:700; font-family:"VT323","JetBrains Mono",monospace; font-size:13px; }
#hud-kpi .r .v.warn { color:#a05820; }
/* ═══ v2.23 BLOOMBERG-DARK INSPECT CARD (unified with bubble + hover) ═══
User 2026-04-20: "some yellow, some blanc" β€” unify to ONE palette.
Fixed top-right on desktop, bottom-sheet on narrow viewports. No PIXI anchor. */
#inspect-card { position:fixed; display:none; z-index:12;
top:70px; right:16px; width:320px; max-height:calc(100vh - 110px); overflow-y:auto;
background:#0a1520; color:#e6e8ee;
border:2px solid #5eead4;
border-radius:6px;
padding:0;
font-size:11px;
box-shadow: 0 10px 28px rgba(0,0,0,0.75), inset 0 0 0 1px rgba(94,234,212,0.15);
font-family:"JetBrains Mono", monospace; }
@media (max-width: 899px) {
#inspect-card { top:auto; right:12px; left:12px; bottom:12px; width:auto;
max-height:45vh; border-radius:8px 8px 0 0; }
}
#inspect-card .h { display:flex; justify-content:space-between; align-items:center;
background:#0e1c2a;
color:#5eead4; padding:8px 12px;
font-size:10.5px; letter-spacing:0.14em; text-transform:uppercase; font-weight:700;
border-bottom:1px solid #1e3040;
font-family:"JetBrains Mono", monospace; }
#inspect-card .h .t { color:#5eead4; }
#inspect-card .h .x { color:#9aa3b5; cursor:pointer; font-weight:700; padding:0 6px;
font-size:14px; line-height:1; }
#inspect-card .h .x:hover { color:#ff5c5c; }
#inspect-card .r, #inspect-card #inspect-extra {
background:#0a1520;
padding:5px 12px; margin:0;
color:#e6e8ee; font-size:11px;
font-family:"JetBrains Mono", monospace; }
#inspect-card .r { display:flex; justify-content:space-between; gap:12px;
border-bottom:1px solid rgba(94,234,212,0.08); }
#inspect-card .r:last-of-type { border-bottom:none; }
#inspect-card .r .l { color:#9aa3b5; text-transform:uppercase; letter-spacing:0.14em;
font-size:9.5px; font-weight:700; padding-top:1px; }
#inspect-card .r .v { color:#e6e8ee; font-weight:700; text-align:right; }
#inspect-card .r .v.ok { color:#34d399; }
#inspect-card .r .v.warn { color:#fbbf24; }
#inspect-card #inspect-extra { color:#9aa3b5; font-size:10px;
border-top:1px solid rgba(94,234,212,0.12); padding:7px 12px 10px 12px; line-height:1.5; }
#stage { position:relative; background:var(--bg-2); overflow:hidden; }
#stage canvas { image-rendering:pixelated; display:block; }
#scanlines { position:absolute; inset:0; pointer-events:none; z-index:5;
background:repeating-linear-gradient(0deg, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px, rgba(0,0,0,0.08) 3px, rgba(0,0,0,0) 4px);
mix-blend-mode:multiply; }
#vignette { position:absolute; inset:0; pointer-events:none; z-index:6;
background:radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,0.45) 100%); }
#hud-top { position:absolute; top:12px; left:12px; right:12px; display:flex; align-items:center; justify-content:space-between; gap:12px;
font-size:11px; color:var(--pk-ink); letter-spacing:0.1em; text-transform:uppercase; z-index:7; }
#hud-top .brand { color:var(--pk-box); font-weight:700; pointer-events:none;
font-family:"Press Start 2P",monospace; font-size:11px; letter-spacing:0.16em;
text-shadow: 2px 2px 0 var(--pk-ink), -1px -1px 0 var(--pk-ink), 1px -1px 0 var(--pk-ink), -1px 1px 0 var(--pk-ink); }
#hud-top .tick { color:var(--pk-green); font-family:"VT323",monospace; font-size:13px;
text-shadow: 1px 1px 0 var(--pk-ink); }
/* v2.22: #hud-top shrinks (marquee replaces the old Bloomberg strip). */
#hud-top { top: 6px !important; font-size: 9px; }
#hud-top .brand { font-size: 9px; }
/* v2.18 β€” room-tabs + ext-btns removed (single world view). */
#hud-bot { position:absolute; bottom:120px; left:12px; right:12px; display:flex; justify-content:space-between;
font-size:10px; color:var(--pk-box); pointer-events:none; z-index:7;
font-family:"Press Start 2P",monospace; letter-spacing:0.14em;
text-shadow: 1px 1px 0 var(--pk-ink); }
/* v2.23 β€” unified Bloomberg-dark tooltip (same palette as inspect-card + bubble). */
#hover { position:absolute; max-width:300px; padding:10px 14px;
background:#0a1520; color:#e6e8ee;
border:2px solid #5eead4; border-radius:6px;
box-shadow: 0 8px 24px rgba(0,0,0,0.6), inset 0 0 0 1px rgba(94,234,212,0.15);
z-index:8; pointer-events:none; display:none;
font-family:"JetBrains Mono",monospace; font-size:11px; }
#hover .h-head { display:flex; align-items:center; gap:8px; margin-bottom:6px; padding-bottom:6px;
border-bottom:1px solid rgba(94,234,212,0.25); }
#hover .h-dot { width:9px; height:9px; border:1px solid #5eead4; border-radius:50%; }
#hover .h-title { font-weight:700; color:#5eead4; letter-spacing:0.06em;
font-family:"JetBrains Mono",monospace; font-size:11px; text-transform:uppercase; }
#hover .h-sub { color:#9aa3b5; font-size:9px; margin-left:auto; text-transform:uppercase;
letter-spacing:0.14em; font-family:"JetBrains Mono",monospace; font-weight:700; }
#hover .h-row { display:flex; justify-content:space-between; gap:12px; padding:2px 0; font-size:11px; }
#hover .h-row .l { color:#9aa3b5; font-weight:700; font-family:"JetBrains Mono",monospace;
font-size:9.5px; letter-spacing:0.12em; padding-top:2px; text-transform:uppercase; }
#hover .h-row .v { color:#e6e8ee; font-weight:700; font-family:"JetBrains Mono",monospace; font-size:11px; }
#hover .h-row .v.up { color:#34d399; }
#hover .h-row .v.down { color:#ff5c5c; }
#hover .h-tag { display:inline-block; padding:2px 7px; margin:3px 3px 0 0;
font-size:9px; font-family:"JetBrains Mono",monospace; letter-spacing:0.1em;
background:rgba(94,234,212,0.1); color:#5eead4; border:1px solid rgba(94,234,212,0.4);
border-radius:3px; text-transform:uppercase; }
#hover .h-tag.sac { color:#ff5c5c; background:rgba(255,92,92,0.1); border-color:rgba(255,92,92,0.4); }
#hover .h-tag.pact { color:#34d399; background:rgba(52,211,153,0.1); border-color:rgba(52,211,153,0.4); }
#hover .h-note { margin-top:6px; padding-top:6px; border-top:1px solid rgba(94,234,212,0.15);
color:#b8c0d0; font-size:10px; line-height:1.5; font-family:"JetBrains Mono",monospace; }
/* v2.16 β€” battle toast: PokΓ©mon DS notification card (red ribbon + white face). */
#event-toast { position:absolute; bottom:120px; right:14px; z-index:9;
display:flex; flex-direction:column; gap:6px; max-width:290px; }
#event-toast .t { padding:7px 11px 7px 14px; background:var(--pk-box); color:var(--pk-ink);
border:3px solid var(--pk-ink); border-radius:6px;
box-shadow: inset 0 0 0 2px var(--pk-box), inset 0 0 0 4px var(--pk-navy),
0 4px 10px rgba(0,0,0,0.5);
font-family:"JetBrains Mono",monospace; font-size:10.5px;
animation:toast-in 0.3s ease-out, toast-out 0.4s ease-in 4.5s forwards;
position:relative; }
#event-toast .t::before { content:""; position:absolute; left:-3px; top:-3px; bottom:-3px; width:6px;
background:var(--pk-red); border:1px solid var(--pk-ink); border-right:none;
border-radius:6px 0 0 6px; }
#event-toast .t .k { font-weight:700; margin-right:6px; font-family:"Press Start 2P",monospace; font-size:8.5px; letter-spacing:0.12em; }
#event-toast .t .k.nba { color:var(--pk-red); }
#event-toast .t .k.island { color:var(--pk-green); }
#event-toast .t .k.political { color:var(--pk-navy); }
#event-toast .t .k.council { color:#a05820; }
@keyframes toast-in { from { opacity:0; transform:translateX(20px); } to { opacity:1; transform:translateX(0); } }
@keyframes toast-out { to { opacity:0; transform:translateX(20px); } }
/* v2.8 2026-04-17: .panel .kpi .row .pill classes retained β€” used by paper modal only */
.kpi { display:flex; justify-content:space-between; font-size:11px; padding:2px 0; }
.kpi .l { color:var(--ink-2); }
.kpi .v-ok { color:var(--ok); } .kpi .v-warn { color:var(--warn); } .kpi .v-err { color:var(--err); } .kpi .v-muted { color:var(--ink-3); }
.row { display:flex; justify-content:space-between; padding:3px 0; font-size:10.5px; border-bottom:1px dotted rgba(255,255,255,0.05); }
.row:last-child { border-bottom:0; }
.row .l { color:var(--ink-2); }
.row .v { color:var(--ink); }
.row .v.up { color:var(--ok); } .row .v.down { color:var(--err); }
.loader { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); color:var(--ink-3);
font-size:11px; letter-spacing:0.1em; text-transform:uppercase; z-index:4; }
/* v2.18 β€” ext-btns (PAPERS/LIVE STATUS) removed. */
/* ═══ v2.18 THREE-FLOOR LEADERBOARD ═══ Fixed right-side panel.
Three vertical sections (NBA / POL / PQTF) β€” every trader ranked by bankroll,
persona visible, click a row β†’ inspect card with model + provider + stats. */
#three-floor-lb { position:fixed; top:40px; right:8px; bottom:14px; width:278px; z-index:9;
background:var(--pk-box); border:4px solid var(--pk-ink); border-radius:8px;
box-shadow: inset 0 0 0 2px var(--pk-box), inset 0 0 0 4px var(--pk-navy),
0 6px 20px rgba(0,0,0,0.6);
font-family:"JetBrains Mono",monospace; color:var(--pk-ink);
display:flex; flex-direction:column; overflow:hidden; }
#three-floor-lb .hdr { padding:7px 10px; background:var(--pk-navy); color:#fff;
font-family:"Press Start 2P",monospace; font-size:9px; letter-spacing:0.15em;
border-bottom:2px solid var(--pk-ink); display:flex; justify-content:space-between; align-items:center; }
#three-floor-lb .hdr .ttl { color:var(--pk-yellow); text-shadow:1px 1px 0 #000; }
#three-floor-lb .hdr .tm { color:#c0d8ff; font-size:8px; letter-spacing:0.1em; font-family:"JetBrains Mono",monospace; font-weight:700; }
#three-floor-lb .body { flex:1; overflow-y:auto; padding:0; scrollbar-width:thin;
scrollbar-color: var(--pk-navy) var(--pk-box); }
#three-floor-lb .body::-webkit-scrollbar { width:8px; }
#three-floor-lb .body::-webkit-scrollbar-track { background: var(--pk-box); }
#three-floor-lb .body::-webkit-scrollbar-thumb { background: var(--pk-navy); border-radius:3px; }
#three-floor-lb .sec { padding:6px 8px 10px; border-bottom:2px solid var(--pk-ink); }
#three-floor-lb .sec:last-child { border-bottom:0; }
#three-floor-lb .sec .sh { display:flex; justify-content:space-between; align-items:baseline;
padding:4px 4px 6px; border-bottom:1px dotted rgba(16,24,32,0.4); margin-bottom:4px; }
#three-floor-lb .sec .sh .kind { font-family:"Press Start 2P",monospace; font-size:9px;
letter-spacing:0.12em; text-shadow:1px 1px 0 rgba(255,255,255,0.6); }
#three-floor-lb .sec .sh .kind.nba { color:#c03030; }
#three-floor-lb .sec .sh .kind.pol { color:#3068b8; }
#three-floor-lb .sec .sh .kind.pqtf { color:#7038a0; }
#three-floor-lb .sec .sh .day { font-size:9px; color:var(--pk-navy); font-weight:700; letter-spacing:0.04em; }
#three-floor-lb .sec .sh .flt { font-size:9px; color:var(--pk-ink); font-weight:700; margin-left:6px; }
#three-floor-lb .sec .sh .flt.up { color:var(--pk-green); }
#three-floor-lb .sec .sh .flt.down { color:var(--pk-red); }
#three-floor-lb .r {
display:grid; grid-template-columns: 18px 1fr 62px 48px; gap:4px;
padding:3px 4px; font-size:10px; cursor:pointer; border-radius:3px;
transition: background 0.12s; align-items:center; }
#three-floor-lb .r:hover { background: rgba(255, 203, 5, 0.28); }
#three-floor-lb .r .rk { font-family:"Press Start 2P",monospace; font-size:7.5px;
color:var(--pk-navy); text-align:right; padding-right:2px; }
#three-floor-lb .r .rk.top { color:#a05820; }
#three-floor-lb .r .nm { color:var(--pk-ink); font-weight:700; white-space:nowrap;
overflow:hidden; text-overflow:ellipsis; letter-spacing:0.02em; }
#three-floor-lb .r .br { color:var(--pk-ink); text-align:right; font-family:"VT323",monospace; font-size:12px; }
#three-floor-lb .r .dl { text-align:right; font-family:"VT323",monospace; font-size:11px; font-weight:700; }
#three-floor-lb .r .dl.up { color:var(--pk-green); }
#three-floor-lb .r .dl.down { color:var(--pk-red); }
#three-floor-lb .r .dl.flat { color:var(--pk-navy); opacity:0.6; }
#three-floor-lb .r.hot { background: linear-gradient(90deg, rgba(255,203,5,0.14) 0%, transparent 100%); }
#three-floor-lb .empty { padding:10px 6px; font-size:9.5px; color:var(--pk-navy); opacity:0.7;
font-style:italic; text-align:center; }
/* v2.22 2026-04-20: leaderboard + dialog hidden to give agents the full canvas.
User ask: "delete the panel with live bets and keep only the tardeboard (ticker) at top".
All data is on the top marquee now. */
#three-floor-lb { display:none !important; }
#poke-dialog { display:none !important; }
#boot-log { display:none !important; }
#hud-bot { display:none !important; }
#minimap { display:none !important; }
#root { margin-right: 0 !important; }
/* ═══ v2.15 POKΓ‰MON B/W DIALOGUE BOX ═══ Replaces marquee bet-ticker.
Double-border (outer black + inner white) + inset shadow, typewriter feed. */
#poke-dialog { position:absolute; bottom:14px; left:12px; right:12px; height:92px; z-index:7;
background:#f8f8f8; color:#181818;
border:4px solid #101820;
border-radius:8px;
box-shadow: inset 0 0 0 2px #f8f8f8, inset 0 0 0 4px #4060b0, 0 4px 14px rgba(0,0,0,0.55);
font-family:"Press Start 2P","VT323","IBM Plex Mono",monospace; font-size:13px;
padding:12px 22px 10px 22px; display:flex; flex-direction:column; gap:4px;
line-height:1.4; letter-spacing:0.02em; }
#poke-dialog .pd-head { display:flex; justify-content:space-between; font-size:9.5px;
color:#4060b0; text-transform:uppercase; letter-spacing:0.2em; font-weight:700;
border-bottom:2px dotted #b0b8c4; padding-bottom:3px; margin-bottom:2px;
font-family:"JetBrains Mono",monospace; }
#poke-dialog .pd-body { flex:1; overflow:hidden; color:#181820;
font-family:"Press Start 2P","VT323",monospace; font-size:12px; line-height:1.5; }
#poke-dialog .pd-body .trn { color:#c03030; font-weight:700; }
#poke-dialog .pd-body .trn.pol { color:#3068b8; }
#poke-dialog .pd-body .amt { color:#1a1a1a; font-weight:700; }
#poke-dialog .pd-body .cat { color:#a05820; font-weight:700; }
#poke-dialog .pd-body .win { color:#207838; font-weight:700; }
#poke-dialog .pd-body .lose { color:#b82030; font-weight:700; }
#poke-dialog .pd-body .cursor { display:inline-block; color:#4060b0; animation:pdblk 0.6s steps(2) infinite; font-weight:700; }
@keyframes pdblk { 50% { opacity:0; } }
/* v2.16 β€” PokΓ©dex map frame (red plastic shell + yellow inner face). */
#minimap { position:absolute; top:48px; right:14px; z-index:7;
width:132px; height:92px; padding:4px;
background:var(--pk-red); border:3px solid var(--pk-ink); border-radius:8px;
box-shadow: inset 0 2px 0 rgba(255,255,255,0.22), inset 0 -3px 0 rgba(0,0,0,0.28),
0 6px 14px rgba(0,0,0,0.5);
overflow:hidden; }
#minimap canvas { display:block; width:124px; height:84px; image-rendering:pixelated;
border:2px solid var(--pk-ink); background:var(--pk-yellow-2); }
#minimap .mm-lbl { position:absolute; top:-1px; left:6px; font-size:7.5px;
letter-spacing:0.16em; color:var(--pk-yellow); text-transform:uppercase;
font-family:"Press Start 2P",monospace; text-shadow:1px 1px 0 var(--pk-ink); pointer-events:none; }
/* v2.16 β€” Boot log β†’ PokΓ©mon menu text box (white face + navy inner border). */
#boot-log { position:absolute; bottom:128px; left:14px; z-index:8;
background:var(--pk-box); color:var(--pk-ink);
border:3px solid var(--pk-ink); border-radius:8px;
box-shadow: inset 0 0 0 2px var(--pk-box), inset 0 0 0 4px var(--pk-navy),
0 6px 14px rgba(0,0,0,0.5);
padding:10px 14px;
font-family:"JetBrains Mono",monospace; font-size:10.5px;
max-width:380px; transition:opacity 0.5s; pointer-events:none; }
#boot-log .bl-line { color:var(--pk-ink); margin:2px 0; letter-spacing:0.02em; }
#boot-log .bl-line::before { content:"β–Έ "; color:var(--pk-green); font-weight:700; }
#boot-log .bl-head { color:var(--pk-navy); font-weight:700; letter-spacing:0.14em;
text-transform:uppercase; margin-bottom:5px; padding-bottom:4px;
border-bottom:2px dotted #b0b8c4;
font-family:"Press Start 2P",monospace; font-size:9.5px; }
</style>
</head>
<body>
<div id="bloomberg-ticker">
<div class="brand"><span class="dot"></span>NOMOS42 Β· LIVE</div>
<div id="ticker-viewport">
<div id="ticker-track">
<!-- PRIMARY SEGMENTS (ids populated by JS; keep exactly one copy of each id) -->
<div class="seg"><span class="lbl">UTC</span><span class="val" id="bt-utc">β€”</span></div>
<div class="seg"><span class="lbl">NBA TF</span><span class="val" id="bt-nba-day">β€”</span><span class="val" id="bt-nba-pnl">β€”</span></div>
<div class="seg"><span class="lbl">POL TF</span><span class="val" id="bt-pol-day">β€”</span><span class="val" id="bt-pol-pnl">β€”</span></div>
<div class="seg"><span class="lbl">ITF</span><span class="val" id="bt-itf-trades">β€”</span><span class="val" id="bt-itf-open">β€”</span></div>
<div class="seg"><span class="lbl">FLEET BEST</span><span class="val star" id="bt-fleet">β€”</span></div>
<div class="seg"><span class="lbl">PACTS</span><span class="val" id="bt-pacts">0</span></div>
<div class="seg"><span class="lbl">LLM CALLS</span><span class="val" id="bt-calls">0</span></div>
<!-- Dynamic event feed (last bets, steam moves, Brier ticks) β€” filled by JS -->
<span id="tk-feed-primary"></span>
<!-- DUPLICATE (id-less clones; kept in sync by JS so loop is seamless) -->
<div class="seg"><span class="lbl">UTC</span><span class="val" data-mirror="bt-utc">β€”</span></div>
<div class="seg"><span class="lbl">NBA TF</span><span class="val" data-mirror="bt-nba-day">β€”</span><span class="val" data-mirror="bt-nba-pnl">β€”</span></div>
<div class="seg"><span class="lbl">POL TF</span><span class="val" data-mirror="bt-pol-day">β€”</span><span class="val" data-mirror="bt-pol-pnl">β€”</span></div>
<div class="seg"><span class="lbl">ITF</span><span class="val" data-mirror="bt-itf-trades">β€”</span><span class="val" data-mirror="bt-itf-open">β€”</span></div>
<div class="seg"><span class="lbl">FLEET BEST</span><span class="val star" data-mirror="bt-fleet">β€”</span></div>
<div class="seg"><span class="lbl">PACTS</span><span class="val" data-mirror="bt-pacts">0</span></div>
<div class="seg"><span class="lbl">LLM CALLS</span><span class="val" data-mirror="bt-calls">0</span></div>
<span id="tk-feed-mirror"></span>
</div>
</div>
</div>
<div id="root">
<div id="stage">
<div id="hud-top">
<div>
<span class="brand">NOMOS42 Β· PIXEL WORLD</span> Β· <span id="build-tag">build: …</span> Β· PixiJS Β· live Β· trainer-net
</div>
<div><span class="tick" id="hud-tick">Β· booting</span> <span style="font-family:'Press Start 2P',monospace;color:var(--pk-yellow);text-shadow:1px 1px 0 var(--pk-ink);font-size:8px;margin-left:6px;letter-spacing:0.14em">v2.22 Β· marquee + bigger agents + click-to-talk</span></div>
</div>
<!-- v2.12: minimap deleted Β· v2.15: bet-ticker β†’ PokΓ©mon B/W dialogue box -->
<div id="poke-dialog">
<div class="pd-head">
<span>◉ POKÉMON TRAINER BATTLE LOG · LIVE BETS</span>
<span id="pd-status">β–  NBA Β· POL</span>
</div>
<div class="pd-body" id="pd-body">connecting to trading floors…<span class="cursor">β–Ό</span></div>
</div>
<div id="boot-log">
<div class="bl-head">β—‰ NOMOS42 PIXEL WORLD β€” BOOT</div>
<div class="bl-line">[ok] PixiJS 8 loaded Β· 56Γ—32 grid Β· 54 real agents</div>
<div class="bl-line">[ok] palette=30 Β· badges=12 Β· breathing=on Β· emotes=state</div>
<div class="bl-line">[ok] particles=ambient Β· nameplates=perf-gradient Β· halos=axelrod</div>
<div class="bl-line">[ok] ticker=/api/day-decisions Β· minimap=live Β· paper=/paper</div>
</div>
<div id="scanlines"></div>
<div id="vignette"></div>
<div id="hud-bot">
<div id="hud-day">β€”</div>
<div id="hud-rep">β€”</div>
</div>
<div id="hover"></div>
<div id="event-toast"></div>
<div id="hud-kpi">
<div class="r"><span class="l">NBA</span><span id="k-nba" class="v warn">β€”</span></div>
<div class="r"><span class="l">Political</span><span id="k-pol" class="v warn">β€”</span></div>
<div class="r"><span class="l">Intraday</span><span id="k-itf" class="v warn">β€”</span></div>
<div class="r"><span class="l">Islands</span><span id="k-evo" class="v warn">β€”</span></div>
<div class="r"><span class="l">Councils</span><span id="k-councils" class="v warn">β€”</span></div>
<div class="r"><span class="l">Pacts</span><span id="k-pacts" class="v">β€”</span></div>
<div class="r"><span class="l">Last sync</span><span id="k-last" class="v">β€”</span></div>
</div>
<div id="inspect-card">
<div class="h"><span class="t" id="inspect-name">β€”</span><span class="x" id="inspect-close">βœ•</span></div>
<div class="r"><span class="l">Model</span><span class="v ok" id="inspect-model">β€”</span></div>
<div class="r"><span class="l">Provider</span><span class="v warn" id="inspect-provider">β€”</span></div>
<div class="r"><span class="l">Bankroll</span><span class="v ok" id="inspect-bankroll">β€”</span></div>
<div class="r"><span class="l">ROI</span><span class="v ok" id="inspect-roi">β€”</span></div>
<div class="r"><span class="l">Win Rate</span><span class="v" id="inspect-wr">β€”</span></div>
<div class="r"><span class="l">Strategy</span><span class="v warn" id="inspect-strat">β€”</span></div>
<div class="r"><span class="l">State</span><span class="v" id="inspect-state">β€”</span></div>
<div class="r"><span class="l">Brier</span><span class="v" id="inspect-brier">β€”</span></div>
<div class="r"><span class="l">Generation</span><span class="v warn" id="inspect-gen">β€”</span></div>
<div id="inspect-extra"></div>
</div>
<div class="loader" id="loader">booting pixi…</div>
</div>
</div>
<!-- v2.18 β€” three-floor leaderboard (NBA TF / POL TF / PQTF), every trader per day -->
<div id="three-floor-lb">
<div class="hdr">
<span class="ttl">β—† LEADERBOARD Β· ALL TRADERS</span>
<span class="tm" id="tfl-clock">β€”</span>
</div>
<div class="body" id="tfl-body">
<div class="empty">connecting to trading floors…</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/pixi.js@8.6.6/dist/pixi.min.js"></script>
<script>
// ═══════════════════════════════════════════════════════════════════════════
// NOMOS42 PIXEL WORLD v2.8 β€” diegetic in-world panels (leaderboard wall,
// categories chalkboard, evolution board, research shelf),
// scrolling ticker tape, parallax stars + drifting clouds,
// floating inspect card (no more right sidebar).
// ═══════════════════════════════════════════════════════════════════════════
(async () => {
const TILE = 32;
// v2.10: widen to 16:10 so the world fills desktop screens without dead side margins.
const GRID_W = 52, GRID_H = 32;
const WORLD_W = GRID_W * TILE, WORLD_H = GRID_H * TILE;
// v2.24 2026-04-20: fix "3/4 hat" regression β€” v2.23 forced 80Γ—112 on 28Γ—24
// native frames, so the sprite was stretched non-uniformly (YΓ—4.67, XΓ—2.86)
// β†’ heads/hats looked enormous, bodies squished. We now LOCK to native aspect
// ratio (28:24 = 7:6). CHAR_H stays tall (112), CHAR_W derives from AR β†’ 131.
// The sprite itself is rendered via `spr.scale` (uniform), not width/height.
const CHAR_H = 112;
const CHAR_W = Math.round(CHAR_H * 28/24); // 131 β€” native-AR-locked, no distortion
const CHAR_SCALE = CHAR_H / 24; // uniform scale applied to 28Γ—24 sprite
// v2.17: +itf (amber-orange, reads as "live tape" β€” distinct from NBA pink + POL cyan).
const KIND_COLORS = { nba:0xff79c6, political:0x8be9fd, itf:0xffa94d, island:0x50fa7b, infra:0x50fa7b, pol_infra:0x8be9fd, council:0xffb86c, crew:0xffb86c, control:0xffd700 };
const KIND_COLORS_HEX = { nba:"#ff79c6", political:"#8be9fd", itf:"#ffa94d", island:"#50fa7b", infra:"#50fa7b", pol_infra:"#8be9fd", council:"#ffb86c", control:"#ffd700" };
const KIND_GLYPHS = { nba:"πŸ€", political:"πŸ—³", itf:"πŸ“ˆ", island:"🧬", infra:"πŸ—Ό", pol_infra:"πŸ—Ό", council:"βš–" };
// v2.14: per-trader role accessory β€” drawn on top of base procedural body so
// each persona reads at-a-glance. Lookup by agent.id (strips "p-" POL prefix).
// b = PIXI.Graphics, W/H = CHAR_W/CHAR_H, outline = 0x0b0d12.
const ROLE_ACCESSORIES = {
"qwen-quant": (b, W, H) => {
b.rect(-W*0.17, -H*0.84, W*0.13, W*0.09).stroke({ width:1.4, color:0x0b0d12 });
b.rect( W*0.04, -H*0.84, W*0.13, W*0.09).stroke({ width:1.4, color:0x0b0d12 });
b.moveTo(-W*0.04, -H*0.81).lineTo(W*0.04, -H*0.81).stroke({ width:1.2, color:0x0b0d12 });
},
"qwen-arb": (b, W, H) => {
b.rect(W*0.30, -H*0.42, W*0.20, H*0.16).fill({ color:0x8b4513 }).stroke({ width:1, color:0x0b0d12 });
b.rect(W*0.36, -H*0.44, W*0.08, H*0.03).fill({ color:0xffd700 });
b.rect(W*0.38, -H*0.26, W*0.04, H*0.03).fill({ color:0xffd700 });
},
"llama-contra": (b, W, H) => {
b.rect(-W*0.24, -H*1.02, W*0.48, H*0.09).fill({ color:0xcc0000 }).stroke({ width:1, color:0x0b0d12 });
b.rect(-W*0.34, -H*0.98, W*0.14, H*0.04).fill({ color:0xcc0000 }).stroke({ width:1, color:0x0b0d12 });
},
"gemini-anl": (b, W, H) => {
b.rect(-W*0.32, -H*0.62, W*0.64, H*0.32).fill({ color:0xffffff, alpha:0.88 }).stroke({ width:1, color:0x0b0d12 });
b.rect(-W*0.02, -H*0.60, W*0.04, H*0.30).fill({ color:0xe0e0e0 });
b.rect(-W*0.52, -H*0.36, W*0.14, H*0.20).fill({ color:0xd4a574 }).stroke({ width:1, color:0x0b0d12 });
b.rect(-W*0.50, -H*0.34, W*0.10, H*0.01).fill({ color:0x0b0d12 });
b.rect(-W*0.50, -H*0.30, W*0.10, H*0.01).fill({ color:0x0b0d12 });
},
"gemini-tact": (b, W, H) => {
b.rect(-W*0.28, -H*1.00, W*0.56, H*0.11).fill({ color:0x2d4a2d }).stroke({ width:1, color:0x0b0d12 });
b.rect(-W*0.34, -H*0.95, W*0.68, H*0.04).fill({ color:0x2d4a2d });
b.circle(0, -H*1.03, W*0.06).fill({ color:0xffd700 }).stroke({ width:1, color:0x0b0d12 });
},
"mistral-large": (b, W, H) => {
b.rect(-W*0.17, -H*1.16, W*0.34, H*0.20).fill({ color:0x0b0d12 }).stroke({ width:1, color:0x333 });
b.rect(-W*0.24, -H*1.00, W*0.48, H*0.04).fill({ color:0x0b0d12 });
b.rect(-W*0.17, -H*1.08, W*0.34, H*0.02).fill({ color:0xdd0000 });
},
"mistral-medium": (b, W, H) => {
b.rect(-W*0.32, -H*0.62, W*0.64, H*0.06).fill({ color:0xff79c6 }).stroke({ width:0.6, color:0x0b0d12 });
b.rect(-W*0.27, -H*0.58, W*0.54, H*0.04).fill({ color:0x50fa7b });
b.rect(-W*0.30, -H*0.45, W*0.06, H*0.08).fill({ color:0xff79c6 });
},
"mistral-small": (b, W, H) => {
b.rect(W*0.28, -H*0.66, W*0.16, H*0.28).fill({ color:0x2a5f8f }).stroke({ width:1, color:0x0b0d12 });
b.rect(W*0.30, -H*0.56, W*0.12, H*0.02).fill({ color:0xffd700 });
b.rect(W*0.32, -H*0.46, W*0.08, H*0.03).fill({ color:0x1a3f6f });
},
"mistral-nemo": (b, W, H) => {
b.rect(-W*0.32, -H*0.62, W*0.64, H*0.34).fill({ color:0x2a1a0a }).stroke({ width:1.5, color:0x0b0d12 });
b.rect(-W*0.02, -H*0.60, W*0.04, H*0.30).fill({ color:0xffd700 });
b.circle(-W*0.36, -H*0.90, W*0.09).fill({ color:0xf4c49a }).stroke({ width:1, color:0x0b0d12 });
b.rect(-W*0.40, -H*0.98, W*0.08, W*0.05).fill({ color:0x0b0d12 });
},
"mistral-ministral": (b, W, H) => {
b.moveTo(-W*0.14, -H*0.96).lineTo(0, -H*1.22).lineTo(W*0.14, -H*0.96).closePath()
.fill({ color:0x4a0080 }).stroke({ width:1, color:0x0b0d12 });
b.circle(0, -H*1.20, W*0.04).fill({ color:0xffd700 });
b.rect(-W*0.50, -H*0.36, W*0.16, H*0.18).fill({ color:0x8b0000 }).stroke({ width:1, color:0x0b0d12 });
b.rect(-W*0.48, -H*0.32, W*0.12, H*0.01).fill({ color:0xffd700 });
b.rect(-W*0.48, -H*0.28, W*0.12, H*0.01).fill({ color:0xffd700 });
},
"nemotron-120b": (b, W, H) => {
for (let i = -3; i <= 3; i++) {
b.circle(i*W*0.055, -H*0.56, W*0.025).stroke({ width:1, color:0xffd700 });
}
b.circle(0, -H*0.50, W*0.04).fill({ color:0xffd700 }).stroke({ width:1, color:0x0b0d12 });
},
"selfhost-qwen4b": (b, W, H) => {
b.rect(-W*0.035, -H*0.60, W*0.07, H*0.05).fill({ color:0x1a4d80 });
b.moveTo(-W*0.04, -H*0.55).lineTo(0, -H*0.30).lineTo(W*0.04, -H*0.55).closePath()
.fill({ color:0x1a4d80 }).stroke({ width:0.6, color:0x0b0d12 });
b.moveTo(0, -H*0.40).lineTo(-W*0.02, -H*0.36).lineTo(W*0.02, -H*0.36).closePath().fill({ color:0xffd700 });
},
"nvidia-minimax": (b, W, H) => {
b.moveTo(-W*0.10, -H*0.50).lineTo(W*0.10, -H*0.45).lineTo(-W*0.10, -H*0.40).closePath()
.fill({ color:0xffd700 }).stroke({ width:1, color:0x0b0d12 });
},
"nvidia-llama70": (b, W, H) => {
b.moveTo(0, -H*0.56).lineTo(0, -H*0.38).stroke({ width:1, color:0x888888 });
b.circle(0, -H*0.36, W*0.06).fill({ color:0xc0c0c0 }).stroke({ width:1, color:0x0b0d12 });
b.circle(-W*0.04, -H*0.38, W*0.01).fill({ color:0xffffff });
},
"selfhost-gemma3": (b, W, H) => {
b.circle(W*0.08, -H*0.80, W*0.09).stroke({ width:1.4, color:0xffd700 });
b.moveTo(W*0.17, -H*0.80).lineTo(W*0.28, -H*0.58).stroke({ width:1.2, color:0xffd700 });
},
"selfhost-qwen06": (b, W, H) => {
b.moveTo(-W*0.11, -H*0.58).lineTo(0, -H*0.56).lineTo(-W*0.11, -H*0.52).closePath().fill({ color:0x1a1f2a }).stroke({ width:0.6, color:0x0b0d12 });
b.moveTo( W*0.11, -H*0.58).lineTo(0, -H*0.56).lineTo( W*0.11, -H*0.52).closePath().fill({ color:0x1a1f2a }).stroke({ width:0.6, color:0x0b0d12 });
b.circle(0, -H*0.55, W*0.028).fill({ color:0x1a1f2a });
},
"selfhost-dolphin3": (b, W, H) => {
for (let i = -2; i <= 2; i++) {
const x = i * W*0.09;
b.moveTo(x, -H*0.94).lineTo(x + W*0.025, -H*1.14).lineTo(x + W*0.06, -H*0.94).closePath()
.fill({ color:0x3a2b20 }).stroke({ width:0.6, color:0x0b0d12 });
}
},
};
// Axelrod strategy families β†’ colors (cooperators green, defectors red, random purple, complex amber)
const STRATEGY_FAMILIES = {
Cooperator:"coop", TitForTat:"coop", TitFor2Tats:"coop", SuspiciousTitForTat:"coop",
WinStayLoseShift:"coop", Pavlov:"coop",
AllD:"def", Defector:"def", Grudger:"def", Bully:"def",
Random:"rand", Probabilistic:"rand",
Adaptive:"complex", HardTitForTat:"complex", Forgiver:"complex", SoftGrudger:"complex",
};
const FAMILY_COLORS = { coop:0x50fa7b, def:0xff5c5c, rand:0xbd93f9, complex:0xffb020, unknown:0x5b6478 };
// ═══ v2.0: 30-color Kenney-ish palette β€” each trader gets a distinct hue ═══
const TRADER_PALETTE = [
0xff5c5c, 0xff79c6, 0xffa500, 0xffd700, 0x50fa7b, 0x00d9a7, 0x8be9fd, 0x6272ff,
0xbd93f9, 0xff9e64, 0xe0af68, 0x73daca, 0x7aa2f7, 0xc0caf5, 0xf7768e, 0x9ece6a,
0xe95678, 0xfab387, 0xf9e2af, 0xa6e3a1, 0x94e2d5, 0x89dceb, 0x89b4fa, 0xcba6f7,
0xf5c2e7, 0xb4befe, 0xff9770, 0x70d6ff, 0x8ac926, 0x1982c4,
];
// Model-keyword β†’ single-letter badge (6x6 pixel overlay)
const TRADER_BADGES = [
["qwen", "Q"], ["mistral", "M"], ["gemini", "G"], ["gemma", "E"],
["llama", "L"], ["nemotron", "N"], ["phi", "P"], ["deepseek", "D"],
["kimi", "K"], ["command", "C"], ["claude", "A"], ["grok", "X"],
];
function traderBadge(id) {
const lo = (id || "").toLowerCase();
for (const [k, b] of TRADER_BADGES) if (lo.includes(k)) return b;
return "?";
}
// Model-keyword β†’ home island (for gameplay loop: walk from island β†’ match floor)
const MODEL_HOME = [
["qwen", "S14"], ["mistral", "S13"], ["gemini", "S17"],
["gemma", "S10"], ["llama", "S11"], ["nemotron", "S18"],
["phi", "S16"], ["deepseek", "S19"], ["selfhost", "S20"],
["gpt", "S21"], ["claude", "S22"], ["grok", "S12"],
];
function homeIslandFor(id) {
const lo = (id || "").toLowerCase();
for (const [k, s] of MODEL_HOME) if (lo.includes(k)) return s;
return null;
}
// v2.18 (2026-04-18): TRADER PERSONAS β€” each trader_id gets a human-readable name.
// Technical model/provider info is hidden on the world but surfaced in the inspect card.
const TRADER_PERSONAS = {
"qwen-quant": { persona: "The Oracle", model: "Qwen 3 235B-A22B", provider: "Cerebras", risk: 0.55, style: "quantitative" },
"qwen-arb": { persona: "The Gatekeeper", model: "Qwen 3 235B-A22B", provider: "Cerebras", risk: 0.65, style: "arbitrage" },
"llama-contra": { persona: "The Contrarian", model: "Llama 3.1 8B", provider: "Cerebras", risk: 0.55, style: "contrarian" },
"gemini-anl": { persona: "The Analyst", model: "Gemini 3 Flash", provider: "Google", risk: 0.55, style: "analytical" },
"gemini-tact": { persona: "The Tactician", model: "Gemini 3 Flash", provider: "Google", risk: 0.60, style: "tactical" },
"mistral-large": { persona: "The General", model: "mistral-large-latest", provider: "Mistral", risk: 0.50, style: "ensemble" },
"mistral-medium": { persona: "The Diplomat", model: "mistral-medium-latest",provider: "Mistral", risk: 0.45, style: "diversified" },
"mistral-small": { persona: "The Scout", model: "mistral-small-latest", provider: "Mistral", risk: 0.35, style: "wide-coverage" },
"mistral-nemo": { persona: "The Wildcard", model: "open-mistral-nemo", provider: "Mistral", risk: 0.70, style: "aggressive" },
"mistral-ministral": { persona: "The Professor", model: "ministral-8b-latest", provider: "Mistral", risk: 0.35, style: "theoretical" },
"nemotron-120b": { persona: "The Architect", model: "Nemotron-3-Super 120B",provider: "OpenRouter", risk: 0.55, style: "chainthought" },
"selfhost-qwen4b": { persona: "The Monk", model: "Qwen3-4B (GGUF)", provider: "SelfHost CPU", risk: 0.40, style: "disciplined" },
"nvidia-minimax": { persona: "The Decider", model: "MiniMax M2.7", provider: "NVIDIA NIM", risk: 0.58, style: "decisive" },
"nvidia-llama70": { persona: "The Swingman", model: "Llama 3.3 70B", provider: "NVIDIA NIM", risk: 0.50, style: "swing" },
"selfhost-gemma3": { persona: "The Apprentice", model: "Gemma-3-4B (GGUF)", provider: "SelfHost CPU", risk: 0.45, style: "analytical" },
"selfhost-qwen06": { persona: "The Featherweight",model: "Qwen3-0.6B (GGUF)", provider: "SelfHost CPU", risk: 0.30, style: "conservative" },
"selfhost-dolphin3": { persona: "The Rebel", model: "Dolphin3-Llama-3.2-3B",provider: "SelfHost CPU", risk: 0.55, style: "uncensored" },
// v2.17: ITF personas β€” each trader is a distinct intraday style. Models are
// the winner-routed choices from `config_agents` (see /api/status on ITF).
"scalper-1": { persona: "The Scalper", model: "Gemini 3 Flash", provider: "Google", risk: 0.45, style: "sub-hour micro-edges" },
"momentum-1": { persona: "The Momentum", model: "mistral-large-latest", provider: "Mistral", risk: 0.55, style: "sector trend-follow" },
"mean-rev-1": { persona: "The Mean-Reverter",model: "open-mistral-nemo", provider: "Mistral", risk: 0.40, style: "fade-extremes" },
"breakout-1": { persona: "The Breakout", model: "mistral-medium-latest",provider: "Mistral", risk: 0.50, style: "range-break continuation" },
"pairs-1": { persona: "The Pairs", model: "Gemini 2.5 Flash", provider: "Google", risk: 0.40, style: "market-neutral spread" },
"vol-1": { persona: "The Vol-Regime", model: "Qwen 3 235B-A22B", provider: "Cerebras", risk: 0.50, style: "VIX-aware position sizing" },
"options-1": { persona: "The Options Desk", model: "mistral-large-latest", provider: "Mistral", risk: 0.50, style: "crypto-perp + equity leverage" },
};
function personaFor(id) {
const p = TRADER_PERSONAS[id];
if (p) return p;
return { persona: id, model: id, provider: "β€”", risk: 0, style: "β€”" };
}
// Match floor center (in tile coords) β€” agents pilgrimage here when betting
const MATCH_FLOOR = { x: 20, y: 17 };
// Layout: infra + councils at TOP (click-only, no bubbles), traders in the MIDDLE (moving, ephemeral bubbles)
// v2.12 unified layout β€” 52Γ—32, ZERO dead columns. NBA and POL zones now touch at x=26.
// y=1..4 NBA evo (x 1..25) | POL evo (x 26..50) β€” halves share the center wall
// y=5..8 councils (x 1..50) β€” single wide chamber
// y=9..19 NBA TF (x 1..25) | POL TF (x 26..50) β€” halves share the center wall
// y=20..30 CONTROL ROOM (x 1..50) β€” 2Γ—6 panel grid, full width
// v2.17 (2026-04-19): +ITF zone (Intraday Trading Floor Β· 7 agents, live at
// lbjlincoln26-intraday-trading-floor.hf.space). Inserted between the trading
// floors and the control room by shrinking control-room from h=11 β†’ h=7.
// v2.20 2026-04-20: zones widened so agents roam the full 52Γ—32 world. Previous
// heights (4-11 tiles) bottled 41 agents into narrow bands; user reported "agents
// dont move accross all the space". Horizontal zones now ~18 tiles tall each so
// a trader's wanderTarget spans vastly more of the available canvas.
// v2.22 2026-04-20: regroup buildings into dense bands at the top + bottom of the
// world, leaving a LARGE central courtyard (y=8..26 = 18 tiles tall Γ— 52 wide)
// where traders roam. User ask: "il faut laisser bien plus de place aux agents
// en regroupant bien mieux les buildings infras". Prior layout had NBA/POL floors
// eating 16 tiles of vertical space β€” now 18 tiles are pure free-roam canvas.
const ZONES = {
// TOP BAND (y=1..6, 5 tiles) β€” infra towers, tight grid, no wander
"nba-infra": { x:1, y:1, w:22, h:5, floor:6, label:"NBA EVO Β· 6 ISLANDS" },
"pol-infra": { x:30, y:1, w:21, h:5, floor:6, label:"POL EVO Β· 5 ISLANDS" },
// v2.24: councils zone repurposed for the 14-agent Claude Code crew (councils deleted 2026-04-20).
councils: { x:23, y:1, w:7, h:5, floor:1, label:"CLAUDE CREW Β· 14" },
// CENTRAL COURTYARD (y=7..25, 18 tiles tall Γ— 52 wide) β€” all traders roam here.
// NBA + POL share left/right halves for coloring, but actual wanderTarget spans
// the full courtyard so traders cross between floors freely.
nba: { x:1, y:7, w:25, h:19, floor:3, label:"NBA TRADING FLOOR" },
political: { x:26, y:7, w:25, h:19, floor:5, label:"POLITICAL FLOOR" },
// BOTTOM BAND (y=26..31, 6 tiles) β€” ITF + control room, stacked tight.
itf: { x:1, y:26, w:50, h:3, floor:2, label:"INTRADAY Β· 14 AGENTS 24/7" },
"control-room":{x:1, y:29, w:50, h:3, floor:8, label:"CONTROL ROOM" },
};
// v2.13 (2026-04-17): roster parity with live TF β€” 17 NBA + 17 POL.
// Was stale at 12 NBA / 10 POL missing T13-T17 + renamed gemma4-selfhost→selfhost-qwen4b.
const NBA_TRADERS = [
"qwen-quant","qwen-arb","llama-contra","gemini-anl","gemini-tact",
"mistral-large","mistral-medium","mistral-small","mistral-nemo","mistral-ministral",
"nemotron-120b","selfhost-qwen4b","nvidia-minimax","nvidia-llama70",
"selfhost-gemma3","selfhost-qwen06","selfhost-dolphin3"
];
const POL_TRADERS = [
"qwen-quant","qwen-arb","llama-contra","gemini-anl","gemini-tact",
"mistral-large","mistral-medium","mistral-small","mistral-nemo","mistral-ministral",
"nemotron-120b","selfhost-qwen4b","nvidia-minimax","nvidia-llama70",
"selfhost-gemma3","selfhost-qwen06","selfhost-dolphin3"
];
// v2.17 (2026-04-19): ITF traders β€” 7 intraday strategies, live status from
// lbjlincoln26-intraday-trading-floor.hf.space/api/leaderboard. Schema is
// different from NBA/POL (no bankroll β€” emits decisions/trades/passes/open_positions)
// so the refresh path uses its own wiring.
// v2.25 (2026-04-20): ITF expanded 7 β†’ 14 personas. New 7 are the aggressive
// layer that makes ITF the most active TF (pass rate 84% β†’ 7% after deploy).
const ITF_TRADERS = [
"scalper-1","momentum-1","mean-rev-1","breakout-1","pairs-1","vol-1","options-1",
"arbitrage-1","news-catalyst-1","crypto-whale-1","earnings-gap-1",
"iv-crush-1","macro-rotate-1","leveraged-momentum-1"
];
// v2.17: ISLANDS trimmed to 11 SURVIVORS (was 21). 10 "nul" islands
// eliminated on 2026-04-17 to free HF concurrency for selfhost LLMs.
// Never re-introduce S10/S11/S12/S16/S19/S20/S21/P3/P6/P8 β€” per MEMORY.
const ISLANDS = [
["S13","https://nomos42-nba-evo-4.hf.space/api/status"],
["S14","https://nomos42-nba-evo-5.hf.space/api/status"],
["S15","https://nomos42-nba-evo-6.hf.space/api/status"],
["S17","https://lbjlincoln26-nba-evo-s17.hf.space/api/status"],
["S18","https://testforge42-nba-evo-s18.hf.space/api/status"],
["S22","https://testforge42-nba-evo-s22.hf.space/api/status"],
["P1","https://nomos42-political-alpha.hf.space/api/status"],
["P2","https://nomos42-political-alpha-2.hf.space/api/status"],
["P4","https://lbjlincoln-political-alpha-4.hf.space/api/status"],
["P5","https://lbjlincoln-political-alpha-5.hf.space/api/status"],
["P7","https://lbjlincoln-political-alpha-7.hf.space/api/status"],
];
// v2.24 (2026-04-21): 9 dept councils were DELETED 2026-04-20 (dead HF Spaces).
// Replaced by the 14-agent Claude Code crew β€” the non-trader subagents that
// actually run research, infra, audits, deploys, revenue, QA. Data source
// changes from dead `nomos-dept-d*` state.json β†’ single dispatch-latest.json
// fetched from GitHub main (public mirror via raw content / Contents API).
const CLAUDE_CREW = [
// [codename, role, track]
["THE BOSS","dispatches all 13","L1"],
["SWISH","NBA islands S10-S22","T1"],
["LOBBYIST","Political islands P1-P8","T1"],
["HAWKEYE","daily arXiv/GitHub scout","T1"],
["DR FRANKENSTEIN","impl research β†’ engine.py","T1"],
["INTERNAL AFFAIRS","scientific integrity audit","T1"],
["THE BLACKSMITH","forge Karpathy loops","T2"],
["SWITCHBOARD","LLM gateway + TF keepalive","T2"],
["THE PLUMBER","data pipeline + ETL","T2"],
["LAUNCHPAD","CI/CD + deploy orchestrator","T2"],
["THE HERALD","Telegram publisher","T3"],
["THE ACCOUNTANT","Stripe / Whop revenue","T3"],
["PIXEL","dashboard + world visual QA","T3"],
["THE TICKER","live odds + CLV + steam","T4"],
];
const ENDPOINTS = {
nba: "https://lbjlincoln26-nba-llm-trading-floor.hf.space/api/status",
nbaLB: "https://lbjlincoln26-nba-llm-trading-floor.hf.space/api/leaderboard",
nbaDays: "https://lbjlincoln26-nba-llm-trading-floor.hf.space/api/day-decisions",
political: "https://lbjlincoln26-political-llm-trading-floor.hf.space/api/status",
politicalLB: "https://lbjlincoln26-political-llm-trading-floor.hf.space/api/leaderboard",
politicalDays:"https://lbjlincoln26-political-llm-trading-floor.hf.space/api/day-decisions",
pqtf: "https://lbjlincoln26-political-quant-trading-floor.hf.space/api/status",
pqtfLB: "https://lbjlincoln26-political-quant-trading-floor.hf.space/api/leaderboard",
// v2.17: ITF β€” 7-agent intraday crypto+equities fleet (24/7). Status returns
// {agents: {tid: {decisions, trades, passes}}}, leaderboard adds tier/name/open_positions.
itf: "https://lbjlincoln26-intraday-trading-floor.hf.space/api/status",
itfLB: "https://lbjlincoln26-intraday-trading-floor.hf.space/api/leaderboard",
};
// v2.24: dispatch-latest.json lives in the main repo; read the public HF
// mirror (pixel-world is a static Space, so we can't reach VM-local files).
// Nomos42/pixel-world serves a copy pushed on each deploy; THE BOSS cron also
// posts a mirror to the static Space via HfApi.upload_file at :00 every 4h.
const CREW_DISPATCH_URL =
"https://huggingface.co/spaces/Nomos42/pixel-world/resolve/main/data/ops/dispatch-latest.json";
const AGENTS = [
...NBA_TRADERS.map((id,i)=>{ const p = personaFor(id); return {
id, key:id, label: p.persona, persona: p.persona, model: p.model, provider: p.provider, risk: p.risk, style: p.style,
kind:"nba", zone:"nba", idx:i, char:i%6,
tint: TRADER_PALETTE[i % TRADER_PALETTE.length], badge: traderBadge(id), homeIsland: homeIslandFor(id) }; }),
...POL_TRADERS.map((id,i)=>{ const p = personaFor(id); return {
id:`p-${id}`, key:id, label: p.persona, persona: p.persona, model: p.model, provider: p.provider, risk: p.risk, style: p.style,
kind:"political", zone:"political", idx:i, char:(i+2)%6,
tint: TRADER_PALETTE[(i + 15) % TRADER_PALETTE.length], badge: traderBadge(id), homeIsland: homeIslandFor(id) }; }),
// v2.17: ITF agents β€” id prefix `itf-` so per-agent lookup doesn't collide
// with the NBA/POL namespaces, key = raw tid so we match the leaderboard payload.
...ITF_TRADERS.map((id,i)=>{ const p = personaFor(id); return {
id:`itf-${id}`, key:id, label: p.persona, persona: p.persona, model: p.model, provider: p.provider, risk: p.risk, style: p.style,
kind:"itf", zone:"itf", idx:i, char:(i+4)%6,
tint: TRADER_PALETTE[(i + 24) % TRADER_PALETTE.length], badge: traderBadge(id), homeIsland: null }; }),
...ISLANDS.map(([lbl,url],i)=>({
id:`island-${lbl.toLowerCase()}`, key:lbl, label:lbl,
kind: lbl.startsWith("P") ? "pol_infra" : "infra",
zone: lbl.startsWith("P") ? "pol-infra" : "nba-infra",
idx:i, char:0, url, _isInfra:true,
})),
// v2.24 crew β€” 14 Claude Code subagents, rendered as buildings (no wander).
// Use _isInfra:true so they sit still + render via updateInfraTower path.
...CLAUDE_CREW.map(([codename,role,track],i)=>({
id:`crew-${codename.toLowerCase().replace(/\s+/g,"-")}`,
key:codename, label:codename,
kind:"crew", zone:"councils", idx:i, char:(i+3)%6,
role, track, _isInfra:true, mission:role,
})),
];
// ── SEATING ─────────────────────────────────────────────────────────────────
function assignSeats() {
for (const [zoneId, z] of Object.entries(ZONES)) {
const list = AGENTS.filter(a => a.zone === zoneId);
if (!list.length) continue;
const isInfraZone = zoneId === "nba-infra" || zoneId === "pol-infra";
const isCouncil = zoneId === "councils";
// v2.11 (2026-04-17): cols chosen by zone aspect ratio so rows always fit inside z.h.
// Previous logic hardcoded cols=4/cellH=5 for trader floors β†’ 16 agents Γ— 4 rows Γ— 5t
// = 20 tiles vertical, zone only 11t tall β†’ negative padY, agents stacked on top of each
// other and visually "doubled" (user report 2026-04-17).
let cols, rows;
if (isCouncil) {
// v2.24: 14 crew agents in the 7Γ—5 zone β€” 7 cols Γ— 2 rows (was forced single row).
cols = Math.min(list.length, 7);
rows = Math.ceil(list.length / cols);
} else if (isInfraZone) {
cols = Math.min(list.length, Math.ceil(list.length / 2));
rows = Math.ceil(list.length / cols);
} else {
cols = Math.max(1, Math.ceil(Math.sqrt(list.length * z.w / z.h)));
rows = Math.ceil(list.length / cols);
}
const cellW = z.w / cols;
const cellH = z.h / rows;
list.forEach((a, i) => {
const c = i % cols, r = Math.floor(i / cols);
a.seat = {
x: z.x + cellW * (c + 0.5),
y: z.y + cellH * (r + 0.5),
};
a.home = { ...a.seat };
a.phase = Math.random() * Math.PI * 2;
// v2.13 (2026-04-17): calmer still. User report "erratic and too fast".
// freq 0.15-0.40 β†’ 0.06-0.14 (slower bob cycle)
// wamp 0.10-0.28 β†’ 0.04-0.10 (smaller wobble)
// speed 0.9-1.4 β†’ 0.30-0.50 (3Γ— slower linear convergence)
// v2.24: councils are infrastructure too β€” they don't iterate, so stop
// wandering. Only the 3 live TFs (NBA / POL / ITF) move.
const isStatic = isInfraZone || isCouncil;
a.freq = isStatic ? 0 : 0.06 + Math.random() * 0.08;
a.wamp = isStatic ? 0 : 0.04 + Math.random() * 0.06;
a.speed = isStatic ? 0 : 0.30 + Math.random() * 0.20;
// Occasional longer-range wander target (every 8-15s a trader picks a new point in its floor)
a.wanderNext = 0;
a.x = a.seat.x; a.y = a.seat.y;
a.vx = 0; a.vy = 0;
a.state = "idle"; a.data = null; a.bubble = `${a.label}\nbooting…`; a.activeUntil = 0;
a.pactPartner = null; a.sacrificial = null; a.strategy = null; a.recent = "";
});
}
}
assignSeats();
const MEETING_SPOTS = [
{ x:20, y:14 }, { x:20, y:17 }, { x:20, y:20 }, { x:10, y:17 }, { x:30, y:17 },
];
// ── PIXI INIT ───────────────────────────────────────────────────────────────
const stageEl = document.getElementById("stage");
const app = new PIXI.Application();
await app.init({
resizeTo: stageEl, background: 0x0b0d12, antialias: false,
autoDensity: true, resolution: window.devicePixelRatio || 1,
});
stageEl.appendChild(app.canvas);
app.canvas.style.imageRendering = "pixelated";
const world = new PIXI.Container();
app.stage.addChild(world);
// v2.8 2026-04-17: bgLayer (stars+clouds) below floor, boardLayer (diegetic signs) above decor, tickerLayer (top scroll) above world.
const bgLayer = new PIXI.Container();
const floorLayer = new PIXI.Container();
const decorLayer = new PIXI.Container();
const boardLayer = new PIXI.Container(); // in-world diegetic signs (leaderboard/categories)
const particleLayer = new PIXI.Container();
const pactLayer = new PIXI.Graphics();
const agentLayer = new PIXI.Container();
agentLayer.sortableChildren = true;
const labelLayer = new PIXI.Container();
const tickerLayer = new PIXI.Container(); // scrolling ticker over top of world
world.addChild(bgLayer, floorLayer, decorLayer, boardLayer, particleLayer, pactLayer, agentLayer, labelLayer, tickerLayer);
// ═══ v2.10 Diegetic Panel System β€” FULLY PIXI, inside CONTROL ROOM (no DOM rail) ═══
// 12 data panels arrange in a 2Γ—6 grid on the CONTROL ROOM wall.
// Each panel: Bloomberg-style β€” colored header bar, provider tag, live value, data lines, status LED.
// States: live (green) | stale (amber) | no_signal (red) | fake (red)
const DIEGETIC_PANELS = [];
const _PANEL_SLOT = { row: 0, col: 0 }; // autoincrement slot allocator
const _PANEL_COLS = 6;
const _PANEL_MAX_ROWS = 1;
// v2.22 Panel grid pinned to CONTROL ROOM: zone (x=1, y=29, w=50, h=3) β€” thinner band.
// Grid collapses to 1 row Γ— 6 cols so panels stay readable inside 3 tiles.
const _CTRL_X0 = 1 * TILE + 8; // 40 px
const _CTRL_Y0 = 29 * TILE + 4; // 932 px (just inside zone)
const _CTRL_W = 50 * TILE - 16; // 1584 px inner
const _CTRL_H = 3 * TILE - 8; // 88 px inner (single row)
const _PANEL_GAP = 6;
const _PANEL_W = Math.floor((_CTRL_W - _PANEL_GAP * (_PANEL_COLS - 1)) / _PANEL_COLS); // β‰ˆ258
const _PANEL_H = Math.floor((_CTRL_H - _PANEL_GAP * (_PANEL_MAX_ROWS - 1)) / _PANEL_MAX_ROWS); // β‰ˆ40
function _panelStatusColor(s) {
return { live: 0x00d9a7, stale: 0xffa500, no_signal: 0xff5c5c, fake: 0xff5c5c }[s] || 0x9aa3b5;
}
// Each panel is a PIXI.Container with a Bloomberg-terminal look.
// Title-bar: colored bevel stripe, glyph badge, title text, status LED.
// Body: 8 rows of monospace text, colored on LED state.
function makeDiegeticPanel({ title, dataUrl, render, staleSec = 600, glyph }) {
if (_PANEL_SLOT.row >= _PANEL_MAX_ROWS) {
console.warn("panel grid full, skipping", title);
return null;
}
const col = _PANEL_SLOT.col, row = _PANEL_SLOT.row;
const x = _CTRL_X0 + col * (_PANEL_W + _PANEL_GAP);
const y = _CTRL_Y0 + row * (_PANEL_H + _PANEL_GAP);
_PANEL_SLOT.col++;
if (_PANEL_SLOT.col >= _PANEL_COLS) { _PANEL_SLOT.col = 0; _PANEL_SLOT.row++; }
const w = _PANEL_W, h = _PANEL_H;
const accent = 0x00d9a7; // recolored on state change
const g = glyph || (title.split(" ")[0] || "β—†").slice(0, 1);
const c = new PIXI.Container();
c.x = x; c.y = y;
c.eventMode = "static"; c.cursor = "pointer";
// Drop shadow (pixel style)
const shadow = new PIXI.Graphics();
shadow.rect(3, 3, w, h).fill({ color: 0x000000, alpha: 0.55 });
c.addChild(shadow);
// Body with bevel
const body = new PIXI.Graphics();
body.rect(0, 0, w, h).fill({ color: 0x0d1118, alpha: 0.97 });
body.rect(0, 0, w, 1).fill({ color: 0xffffff, alpha: 0.22 }); // top bevel
body.rect(0, h - 1, w, 1).fill({ color: 0x000000, alpha: 0.7 });
body.rect(0, 0, 1, h).fill({ color: 0xffffff, alpha: 0.12 });
body.rect(w - 1, 0, 1, h).fill({ color: 0x000000, alpha: 0.55 });
c.addChild(body);
// Titlebar β€” colored stripe
const tb = new PIXI.Graphics();
tb.rect(0, 0, w, 22).fill({ color: accent, alpha: 0.20 });
tb.rect(0, 21, w, 1).fill({ color: accent, alpha: 0.95 }); // neon underline
tb.rect(0, 22, w, 1).fill({ color: 0x000000, alpha: 0.5 });
// Glyph badge square
tb.rect(5, 5, 14, 12).fill({ color: accent, alpha: 0.85 }).stroke({ width: 1, color: 0x000000, alpha: 0.55 });
c.addChild(tb); c._tb = tb;
const badgeTxt = new PIXI.Text({ text: g, style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 10, fontWeight: "700", fill: 0x11151e,
}});
badgeTxt.x = 8; badgeTxt.y = 5;
c.addChild(badgeTxt);
const cleanTitle = title.replace(/^[β—†β—ˆβ–²β–Όβ—β—‚β—‰βš–β–Άβ—‡β–£β—¦β‰‘βœ¦β—‡β– β—β—‹]+\s*/, "").toUpperCase();
const titleTxt = new PIXI.Text({ text: cleanTitle.slice(0, 32), style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 9.5, fontWeight: "700",
fill: accent, letterSpacing: 1.5,
}});
titleTxt.x = 25; titleTxt.y = 6;
c.addChild(titleTxt); c._titleTxt = titleTxt;
// Status LED (right side of titlebar)
const led = new PIXI.Graphics();
led.circle(w - 10, 11, 3).fill({ color: 0x00d9a7 });
led.circle(w - 10, 11, 5).fill({ color: 0x00d9a7, alpha: 0.35 });
c.addChild(led); c._led = led;
// State tag text (LIVE / STALE / NO SIGNAL)
const stateTag = new PIXI.Text({ text: "LIVE", style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 8, fontWeight: "700",
fill: 0x00d9a7, letterSpacing: 1,
}});
stateTag.anchor.set(1, 0);
stateTag.x = w - 20; stateTag.y = 7;
c.addChild(stateTag); c._stateTag = stateTag;
// Content area β€” 8 lines of data
const MAX_LINES = 8;
const lineTexts = [];
for (let i = 0; i < MAX_LINES; i++) {
const t = new PIXI.Text({ text: "", style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 10, fill: 0xe6e8ee,
}});
t.x = 8; t.y = 28 + i * 13;
c.addChild(t); lineTexts.push(t);
}
// Hint strip at bottom
const hint = new PIXI.Graphics();
hint.rect(0, h - 12, w, 12).fill({ color: 0x000000, alpha: 0.45 });
c.addChild(hint);
const hintTxt = new PIXI.Text({ text: `β—¦ ${dataUrl.replace(/^https?:\/\//,"").slice(0,36)}`, style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 8, fill: 0x5b6478,
}});
hintTxt.x = 6; hintTxt.y = h - 11;
c.addChild(hintTxt);
boardLayer.addChild(c);
const p = {
_ctr: c, _led: led, _tb: tb, _titleTxt: titleTxt, _stateTag: stateTag, _lineTexts: lineTexts,
title, dataUrl, render, staleSec, _state: "live", _lastText: "",
setState(s) {
this._state = s;
const col = _panelStatusColor(s);
led.clear();
led.circle(w - 10, 11, 3).fill({ color: col });
led.circle(w - 10, 11, 5).fill({ color: col, alpha: 0.4 });
tb.clear();
tb.rect(0, 0, w, 22).fill({ color: col, alpha: 0.20 });
tb.rect(0, 21, w, 1).fill({ color: col, alpha: 0.95 });
tb.rect(0, 22, w, 1).fill({ color: 0x000000, alpha: 0.5 });
tb.rect(5, 5, 14, 12).fill({ color: col, alpha: 0.85 }).stroke({ width: 1, color: 0x000000, alpha: 0.55 });
titleTxt.style.fill = col;
stateTag.style.fill = col;
stateTag.text = ({ live: "● LIVE", stale: "β–² STALE", no_signal: "βœ— NO DATA", fake: "⚠ SUSPECT" })[s] || s.toUpperCase();
},
setText(txt) {
this._lastText = txt || "";
const lines = (txt || "").split("\n").slice(0, MAX_LINES);
for (let i = 0; i < MAX_LINES; i++) {
this._lineTexts[i].text = lines[i] || "";
}
},
};
DIEGETIC_PANELS.push(p);
return p;
}
async function refreshDiegeticPanels() {
for (const p of DIEGETIC_PANELS) {
try {
const r = await fetch(p.dataUrl, { cache: "no-store" });
if (!r.ok) { p.setState("no_signal"); p.setText(""); continue; }
const data = await r.json();
const out = p.render(data);
if (!out || out.state === "no_signal") { p.setState("no_signal"); p.setText(""); continue; }
p.setText(out.text || "");
p.setState(out.state || "live");
} catch (e) { p.setState("no_signal"); p.setText(""); }
}
}
// v2.18 (2026-04-18): 18 CONTROL ROOM panels deleted β€” duplicated info already
// shown in HUD/ticker/leaderboard. Replaced by a single clear 3-floor leaderboard
// DOM overlay (see #three-floor-lb below).
// Sidebar removed in v2.8 β€” all panels are now diegetic (in-world PIXI signs).
// v2.3: Ambient floating particles β€” data-flow visualization between zones
const PARTICLES = [];
const PARTICLE_COUNT = 35;
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = new PIXI.Graphics();
const size = 1.5 + Math.random() * 2;
const colors = [0x00d9a7, 0x8be9fd, 0xff79c6, 0xffd700, 0x50fa7b, 0xbd93f9];
const col = colors[Math.floor(Math.random() * colors.length)];
p.circle(0, 0, size).fill({ color: col, alpha: 0.4 + Math.random() * 0.3 });
p.x = Math.random() * WORLD_W;
p.y = Math.random() * WORLD_H;
p._vx = (Math.random() - 0.5) * 0.3;
p._vy = (Math.random() - 0.5) * 0.2;
p._drift = Math.random() * Math.PI * 2;
p._speed = 0.3 + Math.random() * 0.4;
particleLayer.addChild(p);
PARTICLES.push(p);
}
// v2.3: Bet-placement pulse rings β€” expand from agent on new bet
const PULSE_RINGS = [];
function spawnPulseRing(x, y, color) {
const ring = new PIXI.Graphics();
ring._cx = x; ring._cy = y; ring._r = 5; ring._maxR = 40;
ring._color = color; ring._alpha = 0.7;
particleLayer.addChild(ring);
PULSE_RINGS.push(ring);
}
// Room camera β€” target scale + center computed from active room
let activeRoom = "world";
let camTargetScale = 1, camTargetX = 0, camTargetY = 0;
let camScale = 1, camX = 0, camY = 0;
function computeCamera() {
const W = stageEl.clientWidth, H = stageEl.clientHeight;
if (activeRoom === "world") {
// v2.18: cover-fit (was contain-fit) β€” fills stage, small edge crop instead of
// letterbox. Agents live in inner 80% of WORLD so no critical content clipped.
const s = Math.max(W / WORLD_W, H / WORLD_H);
camTargetScale = s;
camTargetX = (W - WORLD_W*s) / 2;
camTargetY = (H - WORLD_H*s) / 2;
} else {
const z = ZONES[activeRoom];
const zw = z.w * TILE, zh = z.h * TILE;
const pad = 80;
const s = Math.min((W - pad*2) / zw, (H - pad*2) / zh);
camTargetScale = s;
camTargetX = pad - z.x*TILE*s + ((W - pad*2) - zw*s) / 2;
camTargetY = pad - z.y*TILE*s + ((H - pad*2) - zh*s) / 2;
}
}
window.addEventListener("resize", computeCamera);
// Room tabs removed in v2.18 β€” the world view is the only view.
// ── ASSET LOAD ──────────────────────────────────────────────────────────────
async function loadTex(url) {
try {
const tex = await PIXI.Assets.load(url);
if (tex && tex.source) tex.source.scaleMode = "nearest";
return tex;
} catch(e) { return null; }
}
const TEX = { chars:[], floors:[], furn:{} };
// Spritesheets are 112Γ—96 = 4Γ—4 grid of 28Γ—24 frames. Extract front-facing idle (0,0).
for (let i=0;i<6;i++) {
const sheet = await loadTex(`assets/characters/char_${i}.png`);
if (sheet) {
const frame = new PIXI.Rectangle(0, 0, 28, 24);
const sub = new PIXI.Texture({ source: sheet.source, frame });
TEX.chars.push(sub);
} else {
TEX.chars.push(null);
}
}
for (let i=0;i<9;i++) TEX.floors.push(await loadTex(`assets/floors/floor_${i}.png`));
const FURN_FILES = {
DESK:"DESK/DESK_FRONT.png", PC:"PC/PC_FRONT_ON_1.png",
PLANT:"PLANT/PLANT.png", PLANT_2:"PLANT_2/PLANT_2.png", CACTUS:"CACTUS/CACTUS.png",
BOOKSHELF:"BOOKSHELF/BOOKSHELF.png", COFFEE:"COFFEE/COFFEE.png",
WHITEBOARD:"WHITEBOARD/WHITEBOARD.png", CLOCK:"CLOCK/CLOCK.png",
SOFA:"SOFA/SOFA.png", COFFEE_TABLE:"COFFEE_TABLE/COFFEE_TABLE.png",
};
for (const [k,f] of Object.entries(FURN_FILES)) TEX.furn[k] = await loadTex(`assets/furniture/${f}`);
document.getElementById("loader").style.display = "none";
// ── WORLD BUILD ─────────────────────────────────────────────────────────────
function zoneIdToKind(zoneId) {
if (zoneId === "nba") return "nba";
if (zoneId === "political") return "political";
if (zoneId === "itf") return "itf";
if (zoneId === "nba-infra") return "infra";
if (zoneId === "pol-infra") return "pol_infra";
if (zoneId === "control-room") return "control";
return "council";
}
for (const [zoneId, z] of Object.entries(ZONES)) {
const floorTex = TEX.floors[z.floor] || TEX.floors[0];
for (let ty=z.y; ty<z.y+z.h; ty++) {
for (let tx=z.x; tx<z.x+z.w; tx++) {
if (!floorTex) continue;
const spr = new PIXI.Sprite(floorTex);
spr.x = tx*TILE; spr.y = ty*TILE; spr.width = TILE; spr.height = TILE;
floorLayer.addChild(spr);
}
}
// v2.3: Subtle checkerboard overlay on zone floors
const chk = new PIXI.Graphics();
for (let ty=z.y; ty<z.y+z.h; ty++) {
for (let tx=z.x; tx<z.x+z.w; tx++) {
if ((tx + ty) % 2 === 0) {
chk.rect(tx*TILE, ty*TILE, TILE, TILE).fill({ color: 0xffffff, alpha: 0.025 });
}
}
}
floorLayer.addChild(chk);
// ═══ v2.15: PokΓ©mon B/W tilemap motif ═══ per-zone 16Γ—16 pattern overlay.
// Gen5 floor-tile feel: wood strips (NBA), marble (POL), circuit (infra), carpet (councils), grid (control).
const tilemap = new PIXI.Graphics();
const TM = 16; // 16px sub-tile (half of TILE=32)
const zx0 = z.x*TILE, zy0 = z.y*TILE, zxw = z.w*TILE, zyh = z.h*TILE;
if (zoneId === "nba") {
// Hardwood basketball court β€” vertical plank lines + crossgrain dots
for (let px = zx0; px < zx0 + zxw; px += TILE) {
tilemap.rect(px, zy0, 1, zyh).fill({ color: 0x4a2810, alpha: 0.35 });
}
for (let py = zy0 + 16; py < zy0 + zyh; py += 32) {
for (let px = zx0 + 8; px < zx0 + zxw; px += 32) {
tilemap.rect(px, py, 2, 1).fill({ color: 0x3a1810, alpha: 0.4 });
}
}
// v2.21 HOOP-LAND β€” full basketball court markings (chasersgaming Basketball-NES
// inspiration, pure SVG/vector since the pack is paid; CC0 substitute).
// Midcourt line + center circle + 2 hoops (left+right end with rim+backboard+net).
const cyMid = zy0 + zyh / 2;
const cxMid = zx0 + zxw / 2;
// Midcourt line
tilemap.rect(cxMid, zy0 + 8, 1, zyh - 16).fill({ color: 0xe8d690, alpha: 0.55 });
// Center circle (outer + inner)
tilemap.circle(cxMid, cyMid, 36).stroke({ width: 1.5, color: 0xe8d690, alpha: 0.55 });
tilemap.circle(cxMid, cyMid, 4).fill({ color: 0xe8d690, alpha: 0.45 });
// 3-point arcs on each side (half-circles facing out)
tilemap.arc(zx0 + 40, cyMid, 60, -Math.PI/2, Math.PI/2).stroke({ width: 1.5, color: 0xe8d690, alpha: 0.5 });
tilemap.arc(zx0 + zxw - 40, cyMid, 60, Math.PI/2, -Math.PI/2).stroke({ width: 1.5, color: 0xe8d690, alpha: 0.5 });
// Free-throw key rectangles
tilemap.rect(zx0 + 4, cyMid - 22, 32, 44).stroke({ width: 1, color: 0xe8d690, alpha: 0.45 });
tilemap.rect(zx0 + zxw - 36, cyMid - 22, 32, 44).stroke({ width: 1, color: 0xe8d690, alpha: 0.45 });
// Free-throw circles
tilemap.circle(zx0 + 36, cyMid, 14).stroke({ width: 1, color: 0xe8d690, alpha: 0.45 });
tilemap.circle(zx0 + zxw - 36, cyMid, 14).stroke({ width: 1, color: 0xe8d690, alpha: 0.45 });
// LEFT HOOP β€” red backboard rect + orange rim circle + white net triangle
tilemap.rect(zx0 + 2, cyMid - 10, 2, 20).fill({ color: 0xd63030, alpha: 0.85 }); // backboard
tilemap.circle(zx0 + 10, cyMid, 4).stroke({ width: 1.5, color: 0xff8c2a, alpha: 0.95 }); // rim
tilemap.moveTo(zx0 + 6, cyMid + 4).lineTo(zx0 + 10, cyMid + 10).lineTo(zx0 + 14, cyMid + 4)
.stroke({ width: 0.8, color: 0xffffff, alpha: 0.6 }); // net
// RIGHT HOOP β€” mirrored
tilemap.rect(zx0 + zxw - 4, cyMid - 10, 2, 20).fill({ color: 0xd63030, alpha: 0.85 });
tilemap.circle(zx0 + zxw - 10, cyMid, 4).stroke({ width: 1.5, color: 0xff8c2a, alpha: 0.95 });
tilemap.moveTo(zx0 + zxw - 6, cyMid + 4).lineTo(zx0 + zxw - 10, cyMid + 10).lineTo(zx0 + zxw - 14, cyMid + 4)
.stroke({ width: 0.8, color: 0xffffff, alpha: 0.6 });
} else if (zoneId === "political") {
// Marble capitol floor β€” diamond grid + specks
for (let py = zy0; py < zy0 + zyh; py += TM) {
for (let px = zx0; px < zx0 + zxw; px += TM) {
const odd = ((px/TM + py/TM) % 2 === 0);
if (odd) tilemap.rect(px + 6, py + 6, 4, 4).fill({ color: 0xc0d8f0, alpha: 0.12 });
else tilemap.rect(px + 7, py + 7, 2, 2).fill({ color: 0x8ba6c8, alpha: 0.22 });
}
}
} else if (zoneId === "nba-infra" || zoneId === "pol-infra") {
// Circuit board β€” green traces + solder pads
const trace = zoneId === "nba-infra" ? 0x50fa7b : 0x8be9fd;
for (let py = zy0; py < zy0 + zyh; py += TILE) {
tilemap.rect(zx0, py + 15, zxw, 1).fill({ color: trace, alpha: 0.20 });
}
for (let py = zy0 + 8; py < zy0 + zyh; py += TILE) {
for (let px = zx0 + 8; px < zx0 + zxw; px += TILE) {
tilemap.circle(px, py, 1.5).fill({ color: trace, alpha: 0.35 });
}
}
} else if (zoneId === "councils") {
// Red carpet with gold trim β€” diagonal weave + edge stripes
for (let py = zy0 + 4; py < zy0 + zyh - 4; py += 6) {
tilemap.rect(zx0 + 4, py, zxw - 8, 1).fill({ color: 0xd04040, alpha: 0.18 });
}
// Gold trim at top + bottom
tilemap.rect(zx0 + 4, zy0 + 2, zxw - 8, 2).fill({ color: 0xffc830, alpha: 0.4 });
tilemap.rect(zx0 + 4, zy0 + zyh - 4, zxw - 8, 2).fill({ color: 0xffc830, alpha: 0.4 });
} else if (zoneId === "control-room") {
// Dark tech grid β€” thin cyan crosshatch with diode dots
for (let py = zy0; py <= zy0 + zyh; py += TILE) {
tilemap.rect(zx0, py, zxw, 1).fill({ color: 0x00d9a7, alpha: 0.10 });
}
for (let px = zx0; px <= zx0 + zxw; px += TILE) {
tilemap.rect(px, zy0, 1, zyh).fill({ color: 0x00d9a7, alpha: 0.10 });
}
for (let py = zy0 + 8; py < zy0 + zyh; py += 64) {
for (let px = zx0 + 8; px < zx0 + zxw; px += 64) {
tilemap.circle(px, py, 1.2).fill({ color: 0xffd700, alpha: 0.45 });
}
}
} else if (zoneId === "itf") {
// v2.17 ITF β€” ticker tape: horizontal scan lines + candlestick silhouettes.
// Reads instantly as a trading floor without needing animation.
for (let py = zy0; py < zy0 + zyh; py += 4) {
tilemap.rect(zx0, py, zxw, 1).fill({ color: 0xffa94d, alpha: 0.07 });
}
// Candles spaced every 32px β€” alternating green/red wicks
for (let px = zx0 + 16, i = 0; px < zx0 + zxw - 8; px += 24, i++) {
const bull = (i * 7 + 3) % 5 < 3;
const col = bull ? 0x50fa7b : 0xff5c5c;
const h = 8 + ((i * 13) % 14);
const cy = zy0 + zyh/2 - h/2;
tilemap.rect(px, cy, 3, h).fill({ color: col, alpha: 0.22 });
tilemap.rect(px + 1, cy - 4, 1, 4).fill({ color: col, alpha: 0.22 }); // upper wick
tilemap.rect(px + 1, cy + h, 1, 4).fill({ color: col, alpha: 0.22 }); // lower wick
}
}
floorLayer.addChild(tilemap);
// v2.9: brighter zone tints β€” 0.10 β†’ 0.18, more distinct separations
const tint = new PIXI.Graphics();
tint.rect(z.x*TILE, z.y*TILE, z.w*TILE, z.h*TILE).fill({ color: KIND_COLORS[zoneIdToKind(zoneId)], alpha: 0.12 });
floorLayer.addChild(tint);
// v2.9: Triple-stroke zone border: outer glow + neon tube + inner highlight
const border = new PIXI.Graphics();
const zkCol = KIND_COLORS[zoneIdToKind(zoneId)];
border.rect(z.x*TILE-3, z.y*TILE-3, z.w*TILE+6, z.h*TILE+6).stroke({ width:5, color: zkCol, alpha:0.14 }); // outer glow
border.rect(z.x*TILE-1, z.y*TILE-1, z.w*TILE+2, z.h*TILE+2).stroke({ width:3, color: zkCol, alpha:0.85 }); // neon tube
border.rect(z.x*TILE+1, z.y*TILE+1, z.w*TILE-2, z.h*TILE-2).stroke({ width:1, color: 0xffffff, alpha:0.25 }); // inner highlight
floorLayer.addChild(border);
// v2.3: Corner brackets (pixel art flourish)
const bk = new PIXI.Graphics();
const bkLen = 12, bkX = z.x*TILE, bkY = z.y*TILE, bkW = z.w*TILE, bkH = z.h*TILE;
const bkCol = KIND_COLORS[zoneIdToKind(zoneId)];
for (const [cx,cy,dx,dy] of [[bkX,bkY,1,1],[bkX+bkW,bkY,-1,1],[bkX,bkY+bkH,1,-1],[bkX+bkW,bkY+bkH,-1,-1]]) {
bk.moveTo(cx, cy+dy*bkLen).lineTo(cx, cy).lineTo(cx+dx*bkLen, cy);
}
bk.stroke({ width:2, color:bkCol, alpha:0.8 });
floorLayer.addChild(bk);
const txt = new PIXI.Text({ text: z.label, style:{
fontFamily:"JetBrains Mono, monospace", fontSize:16, fontWeight:"700",
fill: KIND_COLORS[zoneIdToKind(zoneId)], letterSpacing:2.5,
}});
txt.x = z.x*TILE + 10; txt.y = z.y*TILE + 8; txt.alpha = 0.80;
labelLayer.addChild(txt);
// v2.14: zone-specific iconic decor β€” one big silhouette per zone, drawn faintly
// in decorLayer so it reads as "environmental flavor" without stealing attention
// from agents. Placed toward zone upper-right so it doesn't collide with label
// (top-left) or active agent area (center-bottom).
const dec = new PIXI.Graphics();
const dx = (z.x + z.w - 5) * TILE;
const dy = (z.y + 2) * TILE;
const dcol = KIND_COLORS[zoneIdToKind(zoneId)];
const dalpha = 0.16;
if (zoneId === "nba") {
// basketball hoop: backboard + rim + net
dec.rect(dx, dy - 4, 48, 4).fill({ color: 0xffffff, alpha: dalpha * 1.2 });
dec.circle(dx + 24, dy + 12, 10).stroke({ width: 2, color: 0xff6600, alpha: dalpha * 2 });
for (let i = 0; i < 5; i++) {
dec.moveTo(dx + 14 + i*5, dy + 14).lineTo(dx + 18 + i*5, dy + 28).stroke({ width: 1, color: 0xffffff, alpha: dalpha });
}
} else if (zoneId === "political") {
// capitol dome: dome + pillars
dec.circle(dx + 24, dy + 12, 18).fill({ color: dcol, alpha: dalpha });
dec.rect(dx, dy + 12, 48, 4).fill({ color: dcol, alpha: dalpha * 1.5 });
for (let i = 0; i < 5; i++) {
dec.rect(dx + 4 + i*10, dy + 16, 4, 20).fill({ color: dcol, alpha: dalpha });
}
dec.moveTo(dx + 24, dy - 6).lineTo(dx + 24, dy + 4).stroke({ width: 2, color: dcol, alpha: dalpha * 2 });
} else if (zoneId === "nba-infra" || zoneId === "pol-infra") {
// server rack grid: 3Γ—4 rack units
for (let rr = 0; rr < 4; rr++) {
for (let cc = 0; cc < 3; cc++) {
dec.rect(dx + cc*14, dy + rr*10, 12, 8).stroke({ width: 1, color: dcol, alpha: dalpha * 2 });
dec.circle(dx + cc*14 + 10, dy + rr*10 + 4, 1).fill({ color: 0x00d9a7, alpha: dalpha * 3 });
}
}
} else if (zoneId === "councils") {
// round table
dec.circle(dx + 24, dy + 12, 20).stroke({ width: 2, color: dcol, alpha: dalpha * 2 });
dec.circle(dx + 24, dy + 12, 20).fill({ color: dcol, alpha: dalpha * 0.6 });
for (let i = 0; i < 6; i++) {
const ang = (i / 6) * Math.PI * 2;
dec.circle(dx + 24 + Math.cos(ang)*14, dy + 12 + Math.sin(ang)*14, 2).fill({ color: dcol, alpha: dalpha * 3 });
}
} else if (zoneId === "control-room") {
// control panel: oscilloscope box
dec.rect(dx, dy, 48, 28).stroke({ width: 1.5, color: dcol, alpha: dalpha * 2 });
dec.moveTo(dx + 4, dy + 14).lineTo(dx + 12, dy + 8).lineTo(dx + 20, dy + 18).lineTo(dx + 28, dy + 6)
.lineTo(dx + 36, dy + 20).lineTo(dx + 44, dy + 14).stroke({ width: 1, color: 0x00d9a7, alpha: dalpha * 3 });
} else if (zoneId === "itf") {
// v2.17 ITF silhouette: stock ticker board + bull/bear pair
dec.rect(dx, dy, 48, 20).stroke({ width: 1.5, color: dcol, alpha: dalpha * 2 });
dec.rect(dx + 2, dy + 2, 44, 16).fill({ color: 0x000000, alpha: dalpha });
// scrolling ticker dots
for (let i = 0; i < 7; i++) {
dec.rect(dx + 4 + i*6, dy + 8, 4, 2).fill({ color: (i%2)?0x50fa7b:0xffa94d, alpha: dalpha * 3 });
}
// bull + bear horns
dec.circle(dx + 8, dy + 30, 3).fill({ color: 0x50fa7b, alpha: dalpha * 2 });
dec.circle(dx + 40, dy + 30, 3).fill({ color: 0xff5c5c, alpha: dalpha * 2 });
}
decorLayer.addChild(dec);
}
// v2.5: Data-flow cables β€” top infra feeds councils, councils feed traders below
const cables = new PIXI.Graphics();
// infra row β†’ council row (vertical drops)
cables.moveTo(10*TILE, 5*TILE).lineTo(10*TILE, 6*TILE);
cables.moveTo(30*TILE, 5*TILE).lineTo(30*TILE, 6*TILE);
// council row horizontal bus
cables.moveTo(1*TILE, 10*TILE).lineTo(38*TILE, 10*TILE);
// council β†’ trading floors (vertical drops)
cables.moveTo(10*TILE, 10*TILE).lineTo(10*TILE, 11*TILE);
cables.moveTo(30*TILE, 10*TILE).lineTo(30*TILE, 11*TILE);
cables.stroke({ width:1, color:0x3a2600, alpha:0.5 });
const cableDots = new PIXI.Graphics();
for (const [cx,cy] of [[10,5],[30,5],[10,10],[30,10],[1,10],[38,10]]) {
cableDots.circle(cx*TILE, cy*TILE, 4).fill({ color:0xffa500, alpha:0.6 });
}
decorLayer.addChild(cables, cableDots);
// v2.21 FACTORIO CONVEYOR OVERLAY β€” Factorio-style belt chevrons on top of the
// existing cable mesh to read as "data conveyor with explicit direction". Data
// flow: infra (y=1-10) β†’ councils (y=10) β†’ trading floors (y=11+). Each chevron
// is a tiny arrow painted along the existing cable path, pointing downstream.
const belts = new PIXI.Graphics();
const BELT_COL_A = 0xffc830; // gold (primary flow)
const BELT_COL_B = 0xff8c2a; // orange (alt flow)
// Helper: draw chevron at (x,y) pointing in direction "down" or "right"
function _chev(g, x, y, dir, col) {
if (dir === "down") {
g.moveTo(x - 4, y - 3).lineTo(x, y + 3).lineTo(x + 4, y - 3).stroke({ width: 1.2, color: col, alpha: 0.72 });
} else {
g.moveTo(x - 3, y - 4).lineTo(x + 3, y).lineTo(x - 3, y + 4).stroke({ width: 1.2, color: col, alpha: 0.72 });
}
}
// NBA infra → NBA floor (down the vertical spine at x=10*TILE, y=5*TILE→6*TILE)
for (let cy = 5*TILE + 8; cy < 6*TILE; cy += 10) _chev(belts, 10*TILE, cy, "down", BELT_COL_A);
// POL infra β†’ POL floor (x=30*TILE, y=5β†’6)
for (let cy = 5*TILE + 8; cy < 6*TILE; cy += 10) _chev(belts, 30*TILE, cy, "down", BELT_COL_A);
// Council bus (horizontal, x=1*TILE β†’ 38*TILE at y=10*TILE) β€” rightward flow
for (let cx = 2*TILE; cx < 38*TILE; cx += 48) _chev(belts, cx, 10*TILE, "right", BELT_COL_B);
// Council β†’ trading floors drops (y=10β†’11 at x=10 and x=30)
for (let cy = 10*TILE + 6; cy < 11*TILE; cy += 10) _chev(belts, 10*TILE, cy, "down", BELT_COL_A);
for (let cy = 10*TILE + 6; cy < 11*TILE; cy += 10) _chev(belts, 30*TILE, cy, "down", BELT_COL_A);
// ITF band (y=22) β€” horizontal conveyor the full width, shows 24/7 tape direction
for (let cx = 2*TILE; cx < 49*TILE; cx += 40) _chev(belts, cx, 22*TILE - 4, "right", BELT_COL_B);
// Inserter "nodes" at each junction β€” small factorio-style pulse dot
const inserters = new PIXI.Graphics();
for (const [ix, iy] of [[10,5],[30,5],[10,10],[30,10],[1,10],[38,10],[10,11],[30,11]]) {
inserters.rect(ix*TILE - 3, iy*TILE - 3, 6, 6).fill({ color: 0xffc830, alpha: 0.85 });
inserters.rect(ix*TILE - 2, iy*TILE - 2, 4, 4).fill({ color: 0x000000, alpha: 0.55 });
inserters.rect(ix*TILE - 1, iy*TILE - 1, 2, 2).fill({ color: 0xff8c2a, alpha: 1.0 });
}
decorLayer.addChild(belts, inserters);
// Furniture: scaled to TILE (canonical pixel-art decor)
const FURN_SIZE = Math.round(TILE * 0.85);
const decorList = [];
for (const [zoneId, z] of Object.entries(ZONES)) {
// v2.17: itf is a thin 4-tall full-width strip β€” standard furniture
// template would stack objects on top of agents. Handle separately below.
if (zoneId === "nba-infra" || zoneId === "pol-infra" || zoneId === "control-room" || zoneId === "itf") continue;
decorList.push(["WHITEBOARD", z.x + z.w/2 - 0.5, z.y + 0.2, FURN_SIZE*2, FURN_SIZE*1.2]);
decorList.push(["CLOCK", z.x + 0.5, z.y + 0.3, FURN_SIZE, FURN_SIZE]);
decorList.push(["PLANT", z.x + 0.3, z.y + z.h - 1.5, FURN_SIZE*1.1, FURN_SIZE*1.1]);
decorList.push(["CACTUS", z.x + z.w - 1.5, z.y + z.h - 1.5, FURN_SIZE, FURN_SIZE]);
decorList.push(["BOOKSHELF", z.x + z.w - 1.5, z.y + 0.3, FURN_SIZE, FURN_SIZE*1.4]);
}
// v2.17: ITF decor β€” single clock for "market-hours" cue + corner plants
{
const z = ZONES.itf;
decorList.push(["CLOCK", z.x + 0.4, z.y + 0.4, FURN_SIZE*0.9, FURN_SIZE*0.9]);
decorList.push(["PLANT", z.x + z.w - 1.5, z.y + 0.2, FURN_SIZE*0.9, FURN_SIZE*0.9]);
}
// v2.9: CONTROL ROOM decor β€” operator chairs along bottom, plants at corners
decorList.push(["PLANT", 1.3, 30, FURN_SIZE*1.1, FURN_SIZE*1.1]);
decorList.push(["PLANT", 37.0, 30, FURN_SIZE*1.1, FURN_SIZE*1.1]);
decorList.push(["SOFA", 6, 30.2, FURN_SIZE*1.3, FURN_SIZE*0.9]);
decorList.push(["SOFA", 18, 30.2, FURN_SIZE*1.3, FURN_SIZE*0.9]);
decorList.push(["SOFA", 30, 30.2, FURN_SIZE*1.3, FURN_SIZE*0.9]);
decorList.push(["COFFEE_TABLE", 19, 13.2, FURN_SIZE*1.5, FURN_SIZE]);
decorList.push(["COFFEE", 19.3, 12.5, FURN_SIZE*0.9, FURN_SIZE*0.9]);
decorList.push(["SOFA", 18, 13.8, FURN_SIZE*1.5, FURN_SIZE]);
decorList.push(["SOFA", 21, 13.8, FURN_SIZE*1.5, FURN_SIZE]);
for (const [k, tx, ty, w, h] of decorList) {
const tex = TEX.furn[k]; if (!tex) continue;
const spr = new PIXI.Sprite(tex);
spr.x = tx*TILE; spr.y = ty*TILE; spr.width = w; spr.height = h;
decorLayer.addChild(spr);
}
// ═══ v2.8 BACKGROUND: twilight gradient + parallax stars + drifting clouds ═══
{
const W = WORLD_W, H = WORLD_H;
// Night sky gradient (top 40% of world)
const sky = new PIXI.Graphics();
const bands = 14;
for (let i = 0; i < bands; i++) {
const t = i / (bands - 1);
const r = Math.round(0x0b + (0x1a - 0x0b) * t);
const g = Math.round(0x0d + (0x0f - 0x0d) * t);
const b = Math.round(0x12 + (0x2a - 0x12) * t);
const color = (r << 16) | (g << 8) | b;
sky.rect(0, (H * 0.42) * (i / bands), W, (H * 0.42) / bands).fill({ color, alpha: 0.55 });
}
bgLayer.addChild(sky);
// Stars β€” small white dots with twinkle (stored so tickRender can pulse them)
const stars = [];
const starGfx = new PIXI.Graphics();
bgLayer.addChild(starGfx);
for (let i = 0; i < 120; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H * 0.36,
r: Math.random() * 1.4 + 0.4,
phase: Math.random() * Math.PI * 2,
speed: 0.002 + Math.random() * 0.004,
});
}
window._starField = { stars, gfx: starGfx };
// Clouds β€” 3 layers at different parallax speeds, each a fuzzy blob
const clouds = [];
const cloudGfx = new PIXI.Graphics();
bgLayer.addChild(cloudGfx);
for (let i = 0; i < 8; i++) {
clouds.push({
x: Math.random() * W,
y: 30 + Math.random() * (H * 0.30),
w: 80 + Math.random() * 140,
h: 18 + Math.random() * 12,
vx: 0.08 + Math.random() * 0.22,
alpha: 0.06 + Math.random() * 0.08,
});
}
window._cloudField = { clouds, gfx: cloudGfx };
// v2.9: dropped the dark floor vignette β€” world was too dark. Add warm floor glow band above control room.
const floorGlow = new PIXI.Graphics();
floorGlow.rect(0, (25 * TILE) - 4, W, 8).fill({ color: 0xffd700, alpha: 0.12 });
bgLayer.addChild(floorGlow);
}
// ═══ v2.8 DIEGETIC SIGNS: leaderboard wall + categories chalkboard + evolution board ═══
// Three large in-world panels that replace the old sidebar. Each is a PIXI.Container
// with a background frame + PIXI.Text lines, positioned on empty floor/wall space.
// Updated each refresh() via renderDiegetic*.
const DIEGETIC = {};
// v2.9: proper pixel-art UI β€” titlebar stripe, 2-tone bevel, inner gradient, status LED.
function makeDiegeticBoard(opts) {
const { x, y, w, h, title, titleColor, maxLines, bg = 0x11151e, glyph = "β–£" } = opts;
const c = new PIXI.Container(); c.x = x; c.y = y;
// Outer glow (neon halo)
const glow = new PIXI.Graphics();
glow.rect(-2, -2, w + 4, h + 4).stroke({ width: 3, color: titleColor, alpha: 0.18 });
c.addChild(glow);
// Shadow under board (drop-shadow pixel-style)
const shadow = new PIXI.Graphics();
shadow.rect(3, 3, w, h).fill({ color: 0x000000, alpha: 0.55 });
c.addChild(shadow);
// Body: top bevel (lighter), bottom bevel (darker)
const body = new PIXI.Graphics();
body.rect(0, 0, w, h).fill({ color: bg, alpha: 0.96 });
body.rect(0, 0, w, 1).fill({ color: 0xffffff, alpha: 0.28 }); // top bevel
body.rect(0, h - 1, w, 1).fill({ color: 0x000000, alpha: 0.65 }); // bottom bevel
body.rect(0, 0, 1, h).fill({ color: 0xffffff, alpha: 0.15 }); // left bevel
body.rect(w - 1, 0, 1, h).fill({ color: 0x000000, alpha: 0.55 }); // right bevel
c.addChild(body);
// Titlebar: colored stripe with glyph badge + label
const tb = new PIXI.Graphics();
tb.rect(0, 0, w, 20).fill({ color: titleColor, alpha: 0.22 });
tb.rect(0, 20, w, 1).fill({ color: titleColor, alpha: 0.9 }); // neon underline
tb.rect(0, 19, w, 1).fill({ color: 0x000000, alpha: 0.4 }); // shadow under
// Badge (glyph square, left)
tb.rect(4, 4, 14, 12).fill({ color: titleColor, alpha: 0.85 }).stroke({ width: 1, color: 0x000000, alpha: 0.5 });
c.addChild(tb);
const badge = new PIXI.Text({ text: glyph, style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 10, fontWeight: "700", fill: 0x11151e,
}});
badge.x = 7; badge.y = 4;
c.addChild(badge);
const titleTxt = new PIXI.Text({ text: title, style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 10, fontWeight: "700",
fill: titleColor, letterSpacing: 2,
}});
titleTxt.x = 24; titleTxt.y = 5;
c.addChild(titleTxt);
// Status LED (top right, pulses via tickRender)
const led = new PIXI.Graphics();
led.circle(w - 10, 10, 3).fill({ color: 0x00d9a7 });
led.circle(w - 10, 10, 5).fill({ color: 0x00d9a7, alpha: 0.35 });
c.addChild(led); c._led = led;
// Content area with subtle top fade
const content = new PIXI.Graphics();
content.rect(2, 22, w - 4, 6).fill({ color: 0x000000, alpha: 0.35 }); // darker cap below titlebar
c.addChild(content);
const lines = [];
for (let i = 0; i < maxLines; i++) {
const t = new PIXI.Text({ text: "", style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 10, fill: 0xe6e8ee,
}});
t.x = 8; t.y = 30 + i * 13;
c.addChild(t); lines.push(t);
}
boardLayer.addChild(c);
return { container: c, lines, w, h };
}
// v2.10: 4 big boards removed β€” data now flows through the 12 Control-Room panels below.
// (Old renderers check `if (!board) return`, so they silently no-op.)
// Keeping DIEGETIC object for compatibility with renderers.
// ═══ v2.8 TICKER TAPE: scrolling live events across top of world ═══
// The ticker is world-space (moves with camera) but visually anchored to y=0.
const TICKER = { text: null, raw: "", speed: 0.8, x: 0 };
{
const bar = new PIXI.Graphics();
bar.rect(0, 0, WORLD_W, 24).fill({ color: 0x000000, alpha: 0.82 });
bar.rect(0, 23, WORLD_W, 1).fill({ color: 0xffa500, alpha: 0.6 });
tickerLayer.addChild(bar);
const label = new PIXI.Text({ text: "LIVE β–Έ", style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 11, fontWeight: "700",
fill: 0xff3b3b, letterSpacing: 2,
}});
label.x = 8; label.y = 5;
tickerLayer.addChild(label);
const txt = new PIXI.Text({ text: "", style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 11, fill: 0xe6e8ee,
}});
txt.x = WORLD_W; txt.y = 5;
tickerLayer.addChild(txt);
TICKER.text = txt;
TICKER.x = WORLD_W;
}
// ── AGENT SPRITES (v2.24: bubble hover/click-only β€” no persistent overlay) ───
function makeAgentContainer(a) {
const c = new PIXI.Container();
c.eventMode = "static"; c.cursor = "pointer";
// Drop shadow
const shadow = new PIXI.Graphics();
shadow.ellipse(0, CHAR_H*0.05, CHAR_W*0.3, 6).fill({ color:0x000000, alpha:0.45 });
c.addChild(shadow);
// v2.0: Reputation halo (axelrod strategy color), drawn beneath the sprite
const halo = new PIXI.Graphics();
c.addChild(halo); c._halo = halo;
// Kind pip at feet
const pip = new PIXI.Graphics();
pip.circle(0, CHAR_H*0.05, 5).fill(KIND_COLORS[a.kind]);
c.addChild(pip);
c._pip = pip;
if (a._isInfra) {
// Render as a data-center tower: stacked rack blocks, roof antenna,
// indicator LEDs. Tower height grows with generation; crown glows on
// fleet-best. Drawn procedurally so it updates live in refresh().
const tower = new PIXI.Graphics();
c.addChild(tower); c._tower = tower;
// seed draw (empty) β€” real fill happens in updateInfraTower(a) each refresh
tower.rect(-CHAR_W*0.22, -CHAR_H*0.72, CHAR_W*0.44, CHAR_H*0.72)
.fill({ color:0x1a1f2a, alpha:0.96 })
.stroke({ width:2, color:KIND_COLORS[a.kind], alpha:0.85 });
// v2.0: Sxx/Pxx label + current Brier under the rack
const iLbl = new PIXI.Text({ text: a.label, style:{
fontFamily:"IBM Plex Mono, monospace", fontSize:11, fontWeight:"700",
fill: 0xffd700, letterSpacing:0.8,
}});
iLbl.anchor.set(0.5, 0); iLbl.y = CHAR_H*0.08;
c.addChild(iLbl); c._iLbl = iLbl;
const iBr = new PIXI.Text({ text: "β€”", style:{
fontFamily:"IBM Plex Mono, monospace", fontSize:10,
fill: 0x00d9a7,
}});
iBr.anchor.set(0.5, 0); iBr.y = CHAR_H*0.08 + 14;
c.addChild(iBr); c._iBr = iBr;
} else {
// Character sprite β€” BIG, with v2.0 per-trader tint
const tex = TEX.chars[a.char];
if (tex) {
const spr = new PIXI.Sprite(tex);
// v2.24: bottom-anchor + UNIFORM scale via scale.set (not width/height
// override) β€” preserves native 28Γ—24 aspect ratio, no vertical stretch.
spr.anchor.set(0.5, 1); spr.y = 0;
spr.scale.set(CHAR_SCALE);
if (a.tint) spr.tint = a.tint;
c.addChild(spr);
c._spr = spr;
} else {
// v2.6 (2026-04-17): procedural pixel-person body when asset missing.
// Shape: head + torso (agent tint) + arms + legs + shoes, flat-shaded w/ outline.
const body = new PIXI.Graphics();
const tint = a.tint || KIND_COLORS[a.kind] || 0xffa500;
const hair = 0x3a2b20; // dark brown hair
const skin = 0xf4c49a; // neutral skin
const pants = 0x1a1f2a; // dark navy pants
const shoes = 0x0b0d12; // black shoes
const outline = 0x0b0d12;
const W = CHAR_W, H = CHAR_H;
// Legs
body.rect(-W*0.22, -H*0.30, W*0.17, H*0.26).fill({ color: pants }).stroke({ width: 1, color: outline });
body.rect( W*0.05, -H*0.30, W*0.17, H*0.26).fill({ color: pants }).stroke({ width: 1, color: outline });
// Shoes
body.rect(-W*0.23, -H*0.06, W*0.18, H*0.06).fill({ color: shoes });
body.rect( W*0.05, -H*0.06, W*0.18, H*0.06).fill({ color: shoes });
// Arms (tint = torso color)
body.rect(-W*0.40, -H*0.58, W*0.12, H*0.26).fill({ color: tint }).stroke({ width: 1, color: outline });
body.rect( W*0.28, -H*0.58, W*0.12, H*0.26).fill({ color: tint }).stroke({ width: 1, color: outline });
// Hands
body.circle(-W*0.34, -H*0.30, W*0.07).fill({ color: skin }).stroke({ width: 1, color: outline });
body.circle( W*0.34, -H*0.30, W*0.07).fill({ color: skin }).stroke({ width: 1, color: outline });
// Torso (agent tint)
body.rect(-W*0.28, -H*0.62, W*0.56, H*0.34).fill({ color: tint }).stroke({ width: 1.5, color: outline });
// Neck
body.rect(-W*0.06, -H*0.65, W*0.12, H*0.05).fill({ color: skin });
// Head
body.circle(0, -H*0.80, W*0.20).fill({ color: skin }).stroke({ width: 1.5, color: outline });
// Hair (top swoosh)
body.rect(-W*0.20, -H*0.98, W*0.40, H*0.12).fill({ color: hair });
body.circle(-W*0.15, -H*0.86, W*0.05).fill({ color: hair });
body.circle( W*0.15, -H*0.86, W*0.05).fill({ color: hair });
// Eyes
body.circle(-W*0.08, -H*0.80, 1.6).fill({ color: outline });
body.circle( W*0.08, -H*0.80, 1.6).fill({ color: outline });
// Tiny smile
body.rect(-W*0.05, -H*0.72, W*0.10, 1.2).fill({ color: outline });
// v2.14: per-role accessory (glasses, cap, briefcase, etc). POL prefix stripped.
const roleKey = (a.id || "").replace(/^p-/, "");
const accessory = ROLE_ACCESSORIES[roleKey];
if (accessory) {
try { accessory(body, W, H); } catch (_) { /* accessory draw failed β€” base body still valid */ }
}
// v2.14: POL agents get a blue sash across the torso to separate from NBA at a glance.
if (a.kind === "political") {
body.moveTo(-W*0.28, -H*0.45).lineTo(W*0.28, -H*0.55).lineTo(W*0.30, -H*0.50).lineTo(-W*0.26, -H*0.40).closePath()
.fill({ color:0x8be9fd, alpha:0.92 }).stroke({ width:0.8, color:0x0b0d12 });
}
c.addChild(body);
c._spr = body; // expose as _spr so breathing/flip animations still work
}
// v2.0: 6x6 letter badge overlay (model family: Q/M/G/L/N/P/E/D/…)
if (a.badge) {
const bg = new PIXI.Graphics();
bg.roundRect(-12, -CHAR_H*0.78, 24, 24, 4)
.fill({ color: a.tint || 0xffa500, alpha: 0.95 })
.stroke({ width: 1.5, color: 0x000000, alpha: 0.85 });
c.addChild(bg);
const bTxt = new PIXI.Text({ text: a.badge, style:{
fontFamily:"IBM Plex Mono, monospace", fontSize:15, fontWeight:"900",
fill:0x0b0d12, align:"center",
}});
bTxt.anchor.set(0.5, 0.5);
bTxt.x = 0; bTxt.y = -CHAR_H*0.78 + 12;
c.addChild(bTxt);
}
}
// State emote above head β€” changes with agent state (replaces static kind glyph)
const glyph = new PIXI.Text({ text: KIND_GLYPHS[a.kind], style:{ fontSize:18 }});
glyph.anchor.set(0.5, 1);
glyph.y = -CHAR_H*1.02;
c.addChild(glyph); c._glyph = glyph;
// Speech bubble β€” v2.24: hover/click-only (was always-on, obscured sprites).
const bubble = new PIXI.Container();
bubble.y = -CHAR_H*1.18;
const bubbleBg = new PIXI.Graphics();
bubble.addChild(bubbleBg); bubble._bg = bubbleBg;
const bubbleText = new PIXI.Text({ text:"…", style:{
fontFamily:"JetBrains Mono, monospace", fontSize:11, fill:0xe6e8ee,
wordWrap:true, wordWrapWidth:240, align:"left", lineHeight:14,
}});
bubbleText.anchor.set(0.5, 1);
bubble.addChild(bubbleText); bubble._text = bubbleText;
c.addChild(bubble); c._bubble = bubble;
// Nameplate
const plate = new PIXI.Container();
plate.y = CHAR_H*0.14;
const plateBg = new PIXI.Graphics();
plate.addChild(plateBg); plate._bg = plateBg;
const plateText = new PIXI.Text({ text:a.label.slice(0,16), style:{
fontFamily:"JetBrains Mono, monospace", fontSize:11, fill:0xe6e8ee, fontWeight:"600",
}});
plateText.anchor.set(0.5, 0.5);
plate.addChild(plateText); plate._text = plateText;
c.addChild(plate); c._plate = plate;
// ═══ v2.15: PokΓ©mon HP bar + persistent bet tag (above nameplate, below sprite) ═══
// HP bar: Gen5-style 3-segment bar, green→yellow→red as bankroll % of $100 start falls.
// Bet tag: persistent chip showing latest active bet "MIN@NYK ml_home 15%" β€” visible ALWAYS.
if (!a._isInfra) {
const hp = new PIXI.Container();
hp.y = -CHAR_H*0.08;
const hpBg = new PIXI.Graphics();
hp.addChild(hpBg); hp._bg = hpBg;
const hpFill = new PIXI.Graphics();
hp.addChild(hpFill); hp._fill = hpFill;
const hpLbl = new PIXI.Text({ text: "$100", style: {
fontFamily: "Press Start 2P, VT323, monospace", fontSize: 10, fontWeight: "700",
fill: 0xffffff, stroke: { color: 0x101820, width: 2 },
}});
hpLbl.anchor.set(0.5, 0.5);
hp.addChild(hpLbl); hp._lbl = hpLbl;
c.addChild(hp); c._hp = hp;
// Persistent bet tag β€” below nameplate, pokΓ©mon-blue chip, shows latest active bet
const bet = new PIXI.Container();
bet.y = CHAR_H*0.14 + 32;
const betBg = new PIXI.Graphics();
bet.addChild(betBg); bet._bg = betBg;
const betTxt = new PIXI.Text({ text: "", style: {
fontFamily: "JetBrains Mono, monospace", fontSize: 9.5, fontWeight: "700",
fill: 0xfff8c0, letterSpacing: 0.3,
}});
betTxt.anchor.set(0.5, 0.5);
bet.addChild(betTxt); bet._text = betTxt;
bet.visible = false;
c.addChild(bet); c._betTag = bet;
}
// Strategy badge (permanent β€” below nameplate, color-coded by family)
const badge = new PIXI.Container();
badge.y = CHAR_H*0.14 + 18;
const badgeBg = new PIXI.Graphics();
badge.addChild(badgeBg); badge._bg = badgeBg;
const badgeText = new PIXI.Text({ text:"", style:{
fontFamily:"JetBrains Mono, monospace", fontSize:10, fill:0xe6e8ee, fontWeight:"600", letterSpacing:0.5,
}});
badgeText.anchor.set(0.5, 0.5);
badge.addChild(badgeText); badge._text = badgeText;
badge.visible = false;
c.addChild(badge); c._badge = badge;
// Activity pulse dot (profit/loss)
const dot = new PIXI.Graphics(); dot.visible = false;
c.addChild(dot); c._dot = dot;
c.on("pointerover", () => { a._hover = true; if (!a._clicked) showHoverCard(a); });
c.on("pointerout", () => { a._hover = false; hideHoverCard(); });
// v2.23 click handler: PokΓ©mon stop-and-talk with typewriter reveal + "!" ack.
c.on("pointertap", () => {
// Unclick and reset any previously-clicked agent β€” typewriter state cleared
for (const other of AGENTS) if (other !== a) {
other._clicked = false;
other._typeIdx = 0;
other._typeStart = 0;
}
a._clicked = true;
a.vx = 0; a.vy = 0; // hard-zero velocity (v2.23)
a.activeUntil = performance.now() + 60000;
a.bubbleUntil = performance.now() + 60000;
a.bubble = buildClickBubble(a);
// Typewriter: start revealing 0 chars at click time; tickRender increments.
a._typeIdx = 0;
a._typeStart = performance.now();
// "!" exclamation ack β€” PokΓ©mon notice sprite above the head for 300ms.
a._noticeUntil = performance.now() + 300;
showInspectPanel(a);
hideHoverCard();
});
a._cnt = c;
agentLayer.addChild(c);
return c;
}
for (const a of AGENTS) makeAgentContainer(a);
// ── MOTION ──────────────────────────────────────────────────────────────────
// v2.23: user reported agents "se rentrent tous dedans" β€” raised SEP_RADIUS 4.0β†’5.5
// and added a HARD overlap resolution pass after velocity integration so sprites
// physically cannot overlap by more than CHAR_W/2 in world units.
const SEP_RADIUS = 5.5, SEP_WEIGHT = 0.14;
// Hard-overlap min distance in tile units (CHAR_W is 80px / TILE 32 = 2.5 tiles).
const HARD_MIN_DIST = 2.6;
function tickMotion(ms) {
const t = ms / 1000;
for (const a of AGENTS) {
// v2.22: clicked agent = PokΓ©mon-style STOP. User ask: "que l'agent s'arrΓͺte".
// Zero velocity, zero wander, zero wobble β€” the inspect card + speech bubble
// take over until the user clicks Close or clicks another agent.
if (a._clicked) { a.vx = 0; a.vy = 0; continue; }
let tx = a.home.x, ty = a.home.y;
// v2.20 2026-04-20: user reported "agents dont move accross all the space"
// β€” previously isAlive gating meant every idle trader sat frozen at home,
// and a.zone truncated wanderTarget to a tiny band. New rule: every non-infra
// agent wanders its full zone continuously; idle agents use a gentler, slower
// drift so they still populate the canvas visually. Infra agents stay pinned.
if (!a._isInfra) {
const z = (a.zone && ZONES[a.zone]) ? ZONES[a.zone] : null;
if (!a.wanderTarget || (a.wanderNext || 0) < ms) {
if (z) {
a.wanderTarget = {
x: z.x + 1.2 + Math.random() * Math.max(2, z.w - 2.4),
y: z.y + 1.2 + Math.random() * Math.max(2, z.h - 2.4),
};
} else {
a.wanderTarget = {
x: 2 + Math.random() * (GRID_W - 4),
y: 2 + Math.random() * (GRID_H - 4),
};
}
const idleMult = (a.state === "idle") ? 1.8 : 1.0;
a.wanderNext = ms + (8000 + Math.random() * 9000) * idleMult; // 8-17s active, ~15-30s idle
}
tx = a.wanderTarget.x; ty = a.wanderTarget.y;
}
// If trader just placed a bet, override with MATCH_FLOOR for a huddle.
if (!a._isInfra && a.activeUntil > ms) {
tx = MATCH_FLOOR.x + ((a.idx % 5) - 2) * 0.7;
ty = MATCH_FLOOR.y + (Math.floor(a.idx / 5) - 1) * 0.7;
}
if (a.pactPartner) {
const p = AGENTS.find(x => x.id === a.pactPartner);
if (p) { const m = MEETING_SPOTS[(a.idx + p.idx) % MEETING_SPOTS.length]; tx = m.x; ty = m.y; }
}
const wx = Math.cos(t*a.freq + a.phase) * a.wamp;
const wy = Math.sin(t*a.freq*0.8 + a.phase*1.3) * a.wamp;
let fx = (tx + wx - a.x) * 0.035 * a.speed;
let fy = (ty + wy - a.y) * 0.035 * a.speed;
for (const b of AGENTS) {
if (b === a) continue;
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy;
if (d2 < SEP_RADIUS*SEP_RADIUS && d2 > 0.0001) {
const d = Math.sqrt(d2);
const push = (SEP_RADIUS - d) / SEP_RADIUS;
fx += (dx/d) * push * SEP_WEIGHT;
fy += (dy/d) * push * SEP_WEIGHT;
}
}
// v2.22: even tighter damping 0.92 β†’ 0.955 (kills "nanosecond jumps" on throttled RAF).
a.vx = a.vx * 0.955 + fx; a.vy = a.vy * 0.955 + fy;
// Clamp per-tick displacement so a single long RAF-delta can't teleport the agent.
const maxStep = 0.18;
if (a.vx > maxStep) a.vx = maxStep; else if (a.vx < -maxStep) a.vx = -maxStep;
if (a.vy > maxStep) a.vy = maxStep; else if (a.vy < -maxStep) a.vy = -maxStep;
a.x += a.vx; a.y += a.vy;
if (a._cnt && a._cnt._spr && Math.abs(a.vx) > 0.004) {
a._cnt._spr.scale.x = (a.vx < 0 ? -1 : 1) * Math.abs(a._cnt._spr.scale.x || 1);
}
}
// v2.23: HARD overlap pass β€” push any remaining overlapping pairs apart so
// two sprites can never visually occupy the same tile. One pass/tick is enough
// given the small population (≀60) and soft separation already reduced most
// contact. Skip clicked & infra agents (they are pinned).
for (let i = 0; i < AGENTS.length; i++) {
const a = AGENTS[i];
if (a._clicked || a._isInfra) continue;
for (let j = i + 1; j < AGENTS.length; j++) {
const b = AGENTS[j];
if (b._clicked || b._isInfra) continue;
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy;
if (d2 < HARD_MIN_DIST*HARD_MIN_DIST && d2 > 0.0001) {
const d = Math.sqrt(d2);
const push = (HARD_MIN_DIST - d) / 2;
const ux = dx / d, uy = dy / d;
a.x += ux * push; a.y += uy * push;
b.x -= ux * push; b.y -= uy * push;
}
}
}
}
function tickRender(ms) {
// Camera ease
camScale += (camTargetScale - camScale) * 0.12;
camX += (camTargetX - camX) * 0.12;
camY += (camTargetY - camY) * 0.12;
world.scale.set(camScale);
world.x = camX; world.y = camY;
// v2.8: twinkle stars
if (window._starField) {
const { stars, gfx } = window._starField;
gfx.clear();
for (const s of stars) {
const a = 0.4 + Math.sin(ms * s.speed + s.phase) * 0.45;
gfx.circle(s.x, s.y, s.r).fill({ color: 0xffffff, alpha: a });
}
}
// v2.8: drift clouds
if (window._cloudField) {
const { clouds, gfx } = window._cloudField;
gfx.clear();
for (const c of clouds) {
c.x += c.vx;
if (c.x > WORLD_W + c.w) c.x = -c.w;
gfx.ellipse(c.x, c.y, c.w * 0.6, c.h).fill({ color: 0xe6e8ee, alpha: c.alpha });
gfx.ellipse(c.x + c.w * 0.2, c.y - c.h * 0.3, c.w * 0.45, c.h * 0.9).fill({ color: 0xe6e8ee, alpha: c.alpha });
}
}
// v2.8: scroll ticker
if (TICKER.text && TICKER.raw) {
TICKER.x -= TICKER.speed;
TICKER.text.x = TICKER.x;
if (TICKER.x + TICKER.text.width < 60) TICKER.x = WORLD_W;
}
for (const a of AGENTS) {
if (!a._cnt) continue;
// v2.22 render-side easing: decouple visual position from sim position so
// irregular RAF deltas (or a spike after a background-tab resume) never
// produce a visible "nanosecond jump". _renderX/Y catch up to a.x/y at a
// fixed rate every frame, yielding buttery motion regardless of sim jitter.
if (a._renderX == null) { a._renderX = a.x; a._renderY = a.y; }
const easeK = a._clicked ? 0.0 : 0.18; // clicked agents are fully frozen
a._renderX += (a.x - a._renderX) * easeK;
a._renderY += (a.y - a._renderY) * easeK;
a._cnt.x = a._renderX * TILE; a._cnt.y = a._renderY * TILE;
a._cnt.zIndex = a._renderY * 1000;
// Dim agents not in active room (highlight current room)
if (activeRoom !== "world" && a.zone !== activeRoom) { a._cnt.alpha = 0.22; }
else { a._cnt.alpha = 1.0; }
// v2.7: Walking animation β€” bob, tilt, and step when moving
if (!a._isInfra) {
const speed = Math.sqrt((a.vx||0)**2 + (a.vy||0)**2);
const isWalking = speed > 0.008;
const spr = a._cnt._spr || a._cnt._body;
if (spr) {
if (isWalking) {
const stepFreq = 220;
const bob = Math.sin(ms / stepFreq + a.phase * 5) * 2.5;
const tilt = Math.sin(ms / stepFreq + a.phase * 5) * 0.06;
spr.y = bob;
spr.rotation = tilt;
spr.scale.y = 1.0 + Math.abs(Math.sin(ms / stepFreq + a.phase * 5)) * 0.04;
} else {
const breathe = 1.0 + Math.sin(ms / 800 + a.phase * 3) * 0.025;
spr.y = spr.y * 0.9;
spr.rotation = spr.rotation * 0.85;
spr.scale.y = breathe;
}
}
}
// v2.3: State emote β€” dynamic icon above head based on agent state
if (a._cnt._glyph && !a._isInfra) {
const EMOTES = { profit:"πŸ’°", loss:"πŸ“‰", error:"⚠️", thinking:"πŸ€”", idle:"πŸ’€" };
const em = EMOTES[a.state] || EMOTES.idle;
if (a._cnt._glyph.text !== em) a._cnt._glyph.text = em;
a._cnt._glyph.y = -CHAR_H*1.02 + Math.sin(ms / 600 + a.idx) * 2;
}
// v2.23 Speech bubble β€” Bloomberg-dark fill, teal border, typewriter reveal
// when clicked. Palette matches #inspect-card and #hover exactly.
const bubble = a._cnt._bubble;
const isClicked = !!a._clicked;
if (isClicked) {
a.bubble = buildClickBubble(a); // keep fresh
}
const showBubble = bubble && a.bubble && (isClicked || (a.bubbleUntil || 0) > ms);
if (showBubble) {
// Typewriter char count β€” only applies while clicked; ephemeral post-bet
// shows full text immediately (it's already a quick flash).
let display = a.bubble;
let typingDone = true;
if (isClicked) {
const TYPE_MS = 40;
const n = Math.max(0, Math.floor((ms - (a._typeStart || ms)) / TYPE_MS));
const full = a.bubble;
typingDone = n >= full.length;
const shown = Math.min(n, full.length);
display = full.slice(0, shown);
a._typeIdx = shown;
}
// Blinking β–Ό glyph once typing completes
const showCursor = isClicked && typingDone && Math.floor(ms / 500) % 2 === 0;
bubble._text.text = display + (showCursor ? " β–Ό" : "");
bubble._text.style.wordWrapWidth = isClicked ? 240 : 180;
bubble._text.style.fontSize = 11;
bubble._text.style.align = "left";
const tw = Math.max(60, Math.min(bubble._text.width + 18, isClicked ? 260 : 200));
const th = bubble._text.height + 14;
const fade = isClicked ? 1.0 : Math.min(1, ((a.bubbleUntil || 0) - ms) / 500);
const brdW = 2;
bubble._bg.clear()
.rect(-tw/2, -th, tw, th).fill({ color: 0x0a1520, alpha: 0.96 * fade })
.rect(-tw/2, -th, tw, th).stroke({ width:brdW, color: 0x5eead4, alpha: fade })
// Tail pointing DOWN toward sprite β€” crisp 8px triangle
.moveTo(-6, 0).lineTo(6, 0).lineTo(0, 8).closePath().fill({ color: 0x0a1520, alpha: 0.96 * fade })
.moveTo(-6, 0).lineTo(0, 8).lineTo(6, 0).stroke({ width:brdW, color: 0x5eead4, alpha: fade });
bubble._text.style.fill = 0xe6e8ee;
bubble._text.alpha = fade;
bubble.visible = true;
} else if (bubble) {
bubble.visible = false;
}
// v2.23 "!" tap-ack β€” brief exclamation mark above the head for 300ms after
// a click lands. Gives PokΓ©mon-style feedback that the tap was registered.
if (a._noticeUntil && a._noticeUntil > ms) {
if (!a._cnt._notice) {
const notice = new PIXI.Text({ text: "!", style: {
fontFamily: "Press Start 2P, VT323, monospace", fontSize: 20,
fontWeight: "700", fill: 0xfbbf24, stroke: { color: 0x101820, width: 3 },
}});
notice.anchor.set(0.5, 1);
notice.y = -CHAR_H * 1.22;
a._cnt.addChild(notice);
a._cnt._notice = notice;
}
const nLeft = (a._noticeUntil - ms) / 300;
a._cnt._notice.alpha = nLeft;
a._cnt._notice.y = -CHAR_H * 1.22 - (1 - nLeft) * 12;
a._cnt._notice.visible = true;
} else if (a._cnt._notice) {
a._cnt._notice.visible = false;
}
// v2.22: Names HIDDEN by default β€” only shown when the agent is clicked
// (or infra, which always needs its Sxx/Pxx label, handled separately).
// User ask: "avec leur nom seulement affichΓ© quand je clique dessus".
if (a._cnt._plate && !a._isInfra) {
const showPlate = !!a._clicked;
a._cnt._plate.visible = showPlate;
if (showPlate) {
const tw = a._cnt._plate._text.width + 14;
let plateColor = KIND_COLORS[a.kind];
let plateBorderW = 2;
if (a.data && typeof a.data.bankroll === "number") {
const roi = a.data.bankroll - 100;
plateColor = roi > 10 ? 0x00d9a7 : roi > 0 ? 0x50fa7b : roi > -10 ? 0xffb020 : 0xff5c5c;
plateBorderW = roi > 20 || roi < -20 ? 3 : 2;
}
a._cnt._plate._bg.clear()
.rect(-tw/2, -9, tw, 18).fill({ color:0x0b0d12, alpha:0.96 })
.rect(-tw/2, -9, tw, 18).stroke({ width:plateBorderW, color:plateColor, alpha:0.95 });
}
}
// v2.19 (2026-04-20): DRASTIC panel surgery β€” strategy badge hidden by default
// to declutter the world. User reported agent bubbles invisible behind panel
// superposition. Badge is still visible on hover (via hover-card) and in the
// full inspect modal on click, so no information is lost.
if (a._cnt._badge) {
const isActive = (a.activeUntil || 0) > ms;
const showBadge = a.strategy && (a._hover || isActive);
if (showBadge) {
const fam = STRATEGY_FAMILIES[a.strategy] || "unknown";
const col = FAMILY_COLORS[fam];
a._cnt._badge._text.text = a.strategy.length > 14 ? a.strategy.slice(0,14) : a.strategy;
const bw = a._cnt._badge._text.width + 10;
a._cnt._badge._bg.clear()
.rect(-bw/2, -6, bw, 12).fill({ color: col, alpha: 0.2 })
.rect(-bw/2, -6, bw, 12).stroke({ width: 1, color: col, alpha: 0.9 });
a._cnt._badge.visible = true;
} else {
a._cnt._badge.visible = false;
}
}
// Pip state color
const pipCol = a.state === "profit" ? 0x00d9a7 : a.state === "loss" ? 0xff5c5c : a.state === "error" ? 0xff5c5c : KIND_COLORS[a.kind];
a._cnt._pip.clear().circle(0, CHAR_H*0.05, 5).fill(pipCol);
// ═══ v2.19 (2026-04-20): HP bar slimmed + hidden when idle ═══
// Was always-on for 41 agents simultaneously β€” created the superposition the
// user flagged. Now shows only when agent is alive (profit/loss/thinking) OR
// under hover, and is 60% shorter. Inspect modal still shows full bankroll.
if (a._cnt._hp && a.data && typeof a.data.bankroll === "number" && (a.state !== "idle" || a._hover || (a.activeUntil || 0) > ms)) {
const HP_W = CHAR_W * 0.7, HP_H = 5;
const bankroll = a.data.bankroll || 0;
const maxRef = Math.max(200, bankroll);
const pct = Math.max(0.02, Math.min(1, bankroll / maxRef));
const hpPct = Math.min(1, bankroll / 100);
const hpCol = hpPct > 1 ? 0x1aa030 : hpPct > 0.5 ? 0x30c030 : hpPct > 0.2 ? 0xf0c010 : 0xd02020;
a._cnt._hp._bg.clear()
.rect(-HP_W/2 - 1, -HP_H/2 - 1, HP_W + 2, HP_H + 2).fill({ color: 0x101820 })
.rect(-HP_W/2, -HP_H/2, HP_W, HP_H).fill({ color: 0x404850 });
a._cnt._hp._fill.clear()
.rect(-HP_W/2, -HP_H/2, HP_W * pct, HP_H).fill({ color: hpCol })
.rect(-HP_W/2, -HP_H/2, HP_W * pct, 2).fill({ color: 0xffffff, alpha: 0.4 });
// Hide the numeric label unless hovered β€” reduce text density
if (a._cnt._hp._lbl) {
a._cnt._hp._lbl.text = bankroll >= 1000 ? `$${(bankroll/1000).toFixed(2)}K` : `$${bankroll.toFixed(0)}`;
a._cnt._hp._lbl.y = -HP_H - 6;
a._cnt._hp._lbl.visible = !!a._hover;
}
a._cnt._hp.visible = true;
} else if (a._cnt._hp) {
a._cnt._hp.visible = false;
}
// ═══ v2.19: Persistent bet tag β€” hidden by default. The tag is
// redundant with the speech bubble (shows allocation when trade happens)
// and the inspect card (click = full allocation list). Now shows only on
// hover or while the bet is active (activeUntil window). Drastically reduces
// superposition in crowded zones.
if (a._cnt._betTag && a.dayDecision && Array.isArray(a.dayDecision.allocations) && a.dayDecision.allocations.length &&
(a._hover || (a.activeUntil || 0) > ms)) {
const al = a.dayDecision.allocations.reduce((m, x) =>
((x.stake || x.stake_amount || 0) > (m.stake || m.stake_amount || 0)) ? x : m, a.dayDecision.allocations[0]);
const pct = Math.round((al.pct || 0) * 100);
const game = (al.game || "").slice(0, 10);
const cat = (al.category || "").slice(0, 9);
const won = al.won === true ? "βœ“" : al.won === false ? "βœ—" : "Β·";
const txt = `${won} ${game} ${cat} ${pct}%`.trim();
a._cnt._betTag._text.text = txt;
const tw = Math.min(a._cnt._betTag._text.width + 12, 180);
const betCol = al.won === true ? 0x207838 : al.won === false ? 0xb82030 : 0x4060b0;
a._cnt._betTag._bg.clear()
.roundRect(-tw/2 - 1, -7 - 1, tw + 2, 16, 4).fill({ color: 0x101820 })
.roundRect(-tw/2, -7, tw, 14, 4).fill({ color: betCol })
.roundRect(-tw/2, -7, tw, 5, 4).fill({ color: 0xffffff, alpha: 0.18 });
a._cnt._betTag.visible = true;
} else if (a._cnt._betTag) {
a._cnt._betTag.visible = false;
}
// v2.0 reputation halo β€” 3px ring underfoot, color = axelrod family,
// pulses when a bet was just placed (activeUntil in future)
if (a._cnt._halo && !a._isInfra) {
const h = a._cnt._halo; h.clear();
const fam = STRATEGY_FAMILIES[a.strategy] || "unknown";
const haloCol = FAMILY_COLORS[fam];
const pulsing = a.activeUntil > ms;
const pulseBoost = pulsing ? (0.6 + 0.4 * Math.sin(ms/180)) : 0.35;
h.ellipse(0, CHAR_H*0.06, CHAR_W*0.34, 6)
.stroke({ width:3, color: haloCol, alpha: pulseBoost });
if (pulsing) {
h.ellipse(0, CHAR_H*0.06, CHAR_W*0.42, 8)
.stroke({ width:1, color: haloCol, alpha: 0.22 });
}
}
// v2.0 infra tower label refresh
if (a._isInfra && a._cnt._iBr && a.data) {
const b = (a.data.best_brier || 0);
a._cnt._iBr.text = b > 0 ? b.toFixed(5) : "β€”";
const isBest = b > 0 && b <= _fleetBestBrier + 0.00001;
a._cnt._iBr.style.fill = isBest ? 0xffd700 : (a.data.stagnation > 100 ? 0xff5c5c : 0x00d9a7);
}
// Pulse dot + floating particles
if (a.state === "profit" || a.state === "loss") {
const pulse = (Math.sin(ms/350 + a.idx) + 1) / 2;
a._cnt._dot.visible = true;
a._cnt._dot.clear().circle(CHAR_W*0.3, -CHAR_H*0.7, 4).fill({ color:pipCol, alpha:0.45+0.55*pulse });
if (!a._particles) a._particles = [];
if (a._particles.length < 4 && Math.random() < 0.03) {
const pc = new PIXI.Graphics();
const col = a.state === "profit" ? 0x00d9a7 : 0xff5c5c;
pc.circle(0, 0, 2).fill({ color: col, alpha: 0.9 });
pc.x = (Math.random()-0.5) * CHAR_W; pc.y = -CHAR_H*0.5;
pc._life = 60; pc._vy = -0.8 - Math.random()*0.5;
a._cnt.addChild(pc); a._particles.push(pc);
}
for (let i = a._particles.length - 1; i >= 0; i--) {
const pc = a._particles[i]; pc.y += pc._vy; pc._life--;
pc.alpha = pc._life / 60;
if (pc._life <= 0) { a._cnt.removeChild(pc); a._particles.splice(i, 1); }
}
} else {
a._cnt._dot.visible = false;
if (a._particles) { a._particles.forEach(pc => a._cnt.removeChild(pc)); a._particles = []; }
}
}
pactLayer.clear();
// Outer glow pass β€” wide & dim, pulses with time
const _pactPulse = 0.5 + 0.4 * Math.sin(performance.now() / 520);
for (const a of AGENTS) {
if (!a.pactPartner) continue;
const p = AGENTS.find(x => x.id === a.pactPartner);
if (!p || a.id > p.id) continue;
pactLayer.moveTo(a.x*TILE, a.y*TILE).lineTo(p.x*TILE, p.y*TILE);
}
pactLayer.stroke({ width:6, color:0x00d9a7, alpha:0.14 * _pactPulse });
// Inner bright pass β€” crisp coalition wire
for (const a of AGENTS) {
if (!a.pactPartner) continue;
const p = AGENTS.find(x => x.id === a.pactPartner);
if (!p || a.id > p.id) continue;
pactLayer.moveTo(a.x*TILE, a.y*TILE).lineTo(p.x*TILE, p.y*TILE);
}
pactLayer.stroke({ width:1.5, color:0x50fa7b, alpha:0.55 + 0.35 * _pactPulse });
}
app.ticker.add(() => {
const now = performance.now();
tickMotion(now); tickRender(now); tickPokeDialog(now);
// v2.3: Animate ambient particles
for (const p of PARTICLES) {
p.x += p._vx + Math.sin(now / 2000 + p._drift) * 0.15 * p._speed;
p.y += p._vy + Math.cos(now / 2500 + p._drift) * 0.1 * p._speed;
if (p.x < 0) p.x = WORLD_W; if (p.x > WORLD_W) p.x = 0;
if (p.y < 0) p.y = WORLD_H; if (p.y > WORLD_H) p.y = 0;
p.alpha = 0.25 + 0.25 * Math.sin(now / 1200 + p._drift);
}
// v2.3: Animate pulse rings
for (let i = PULSE_RINGS.length - 1; i >= 0; i--) {
const ring = PULSE_RINGS[i];
ring._r += 1.2; ring._alpha -= 0.018;
ring.clear().circle(ring._cx, ring._cy, ring._r)
.stroke({ width: 2, color: ring._color, alpha: Math.max(0, ring._alpha) });
if (ring._alpha <= 0 || ring._r > ring._maxR) {
particleLayer.removeChild(ring); ring.destroy();
PULSE_RINGS.splice(i, 1);
}
}
});
// ── BUBBLE TEXT BUILDERS ────────────────────────────────────────────────────
// v2.23: Human-formatted PokΓ©mon-style dialogue bubble. No JSON dumps, no raw
// archetype tags. Line 1 = "ClassName Β· tid", line 2 = action in plain English.
// Previous version leaked "ARCHETYPE[scalp]:", category codes, and empty braces.
function buildClickBubble(a) {
const d = a.data || {};
const dd = a.dayDecision;
// Line 1 β€” persona: "Scalper Β· scalper-1" or "Fundamentalist Β· qwen-quant"
const persona = a.persona || a.role || a.strategy || (a.kind === "nba" ? "NBA Trader" : a.kind === "political" ? "Political Trader" : a.kind === "itf" ? "Intraday Trader" : a.kind === "island" ? "Island" : a.kind === "crew" ? "Claude Crew" : a.kind === "council" ? "Council" : a._isInfra ? "Infrastructure" : "Agent");
const ln1 = `${persona} Β· ${a.id || a.label}`;
// Line 2 β€” current decision, human-formatted
let ln2 = "";
if (a.kind === "island" || a._isInfra) {
const brier = (d.best_brier || 0);
const gen = d.generation || 0;
const model = d.best_model_type || "?";
ln2 = brier > 0 ? `${model.toUpperCase()} Β· Brier ${brier.toFixed(5)} Β· gen ${gen}` : "booting…";
} else if (a.kind === "crew") {
const last = a._lastDispatch ? ` Β· dispatched ${a._lastDispatch}` : "";
ln2 = `${(a.role || "sub-agent").replace(/\s+/g, " ").slice(0, 60)}${last}`;
} else if (a.kind === "council") {
ln2 = (a.mission || "Karpathy loop").replace(/\s+/g, " ").slice(0, 70);
} else if (dd && dd.allocations && dd.allocations.length) {
// TF agent with a concrete decision today. Prefer top allocation.
const al = dd.allocations[0];
const ticker = (al.game || al.category || "?").toString().toUpperCase().slice(0, 12);
const side = (al.side || al.pick || "").toString().toUpperCase();
const pct = Math.round((al.pct || 0) * 100);
const entry = al.entry_price != null ? ` @ $${Number(al.entry_price).toFixed(2)}` : "";
const edge = al.edge != null ? ` Β· edge ${(al.edge*100).toFixed(1)}%` : "";
ln2 = `${side ? side + " " : ""}${ticker}${entry} Β· ${pct}% stake${edge}`;
} else if (dd && dd.action === "pass") {
ln2 = "STANDING DOWN β€” flat tape";
} else if (a.kind === "itf" && d.open_positions != null) {
const pos = d.open_positions | 0;
const tr = d.trades | 0;
ln2 = `${pos} open Β· ${tr} trades today`;
} else if (typeof d.bankroll === "number") {
const roi = d.bankroll - 100;
const sign = roi >= 0 ? `+$${roi.toFixed(0)}` : `-$${(-roi).toFixed(0)}`;
const w = d.wins || 0, l = d.losses || 0;
ln2 = `$${d.bankroll.toFixed(0)} (${sign}) Β· ${w}W/${l}L`;
} else {
ln2 = "THINKING…";
}
// Trim to max 60 chars per line so bubble width is predictable.
return ln1.slice(0, 60) + "\n" + ln2.slice(0, 80);
}
function buildTraderBubble(a) {
const d = a.data;
const stratShort = a.strategy ? `[${a.strategy.slice(0,10)}]` : "";
if (!d) return `${a.label}\nwaiting…`;
const br = (d.bankroll || 0).toFixed(0);
const roi = (d.bankroll || 100) - 100;
const roiTxt = roi >= 0 ? `+$${roi.toFixed(0)}` : `-$${(-roi).toFixed(0)}`;
if (d.llm_ok < d.llm_calls) return `${stratShort} ⚠ err\n$${br}`;
const dd = a.dayDecision;
if (dd && dd.day_strategy) {
let think = dd.day_strategy.replace(/^(ARCHETYPE\[[^\]]+\]:|CHALLENGE_RESPONSE:)\s*/, "").trim();
if (think.length > 60) think = think.slice(0, 57) + "…";
const allocs = dd.allocations || [];
const nBets = allocs.length ? `${allocs.length}bet` : "pass";
return `${stratShort} ${nBets}\n"${think}"\n$${br} (${roiTxt})`;
}
const bets = d.total_bets || 0;
if (bets === 0) return `${stratShort}\nobserving… $${br}`;
const wl = `${d.wins||0}WΒ·${d.losses||0}L`;
return `${stratShort} ${wl}\n$${br} (${roiTxt})`;
}
function buildIslandBubble(a) {
const d = a.data;
if (!d) return `${a.label}\nwaiting…`;
const b = (d.best_brier || 0).toFixed(5);
return `${a.label} Β· ${d.best_model_type || "?"}\ngen ${d.generation||0} Β· Brier ${b}\npop ${d.pop_size||"?"} Β· stag ${d.stagnation||0}`;
}
function buildCouncilBubble(a) {
return `${a.label}\n${a.mission}`;
}
// ── HOVER CARD ──────────────────────────────────────────────────────────────
const hoverEl = document.getElementById("hover");
function showHoverCard(a) {
const pt = a._cnt.getGlobalPosition();
const kcol = KIND_COLORS_HEX[a.kind];
let rows = "", tags = "", note = "";
if (a.data && (a.kind === "nba" || a.kind === "political")) {
const d = a.data;
const roi = +(((d.bankroll || 100) - 100)).toFixed(2);
const roiCls = roi >= 0 ? "up" : "down";
rows += `<div class="h-row"><span class="l">Bankroll</span><span class="v ${roiCls}">$${(d.bankroll||0).toFixed(2)}</span></div>`;
rows += `<div class="h-row"><span class="l">Record</span><span class="v">${d.wins||0}W Β· ${d.losses||0}L Β· ${d.total_bets||0} bets</span></div>`;
rows += `<div class="h-row"><span class="l">LLM health</span><span class="v">${d.llm_ok||0}/${d.llm_calls||0}</span></div>`;
if (d.max_drawdown != null) rows += `<div class="h-row"><span class="l">Max DD</span><span class="v">${(d.max_drawdown||0).toFixed(1)}%</span></div>`;
if (d.days_traded != null) rows += `<div class="h-row"><span class="l">Days traded</span><span class="v">${d.days_traded}</span></div>`;
if (a.strategy) tags += `<span class="h-tag">${a.strategy}</span>`;
if (a.sacrificial) tags += `<span class="h-tag sac">βš” ${a.sacrificial}</span>`;
if (a.pactPartner) {
const p = AGENTS.find(x => x.id === a.pactPartner);
tags += `<span class="h-tag pact">β—‰ pact with ${p ? p.label : "?"}</span>`;
}
// Rich reasoning block from /api/day-decisions
const dd = a.dayDecision;
if (dd) {
let strat = (dd.day_strategy || "").replace(/^(ARCHETYPE\[[^\]]+\]:|CHALLENGE_RESPONSE:)\s*/, "");
note = `<b style="color:var(--ink)">Today (${dd.date||""}):</b><br/>${escapeHtml(strat)}`;
const allocs = (dd.allocations || []).slice(0, 3);
if (allocs.length) {
note += `<div style="margin-top:5px; padding-top:5px; border-top:1px dotted var(--line);">`;
for (const al of allocs) {
const wcls = al.won === true ? "up" : al.won === false ? "down" : "";
const sign = al.profit == null ? "" : (al.profit >= 0 ? `+$${al.profit.toFixed(2)}` : `-$${(-al.profit).toFixed(2)}`);
note += `<div style="font-size:9.5px; margin:2px 0;"><span style="color:var(--warn)">${escapeHtml(al.game||"")}</span> Β· <span style="color:var(--ink)">${escapeHtml(al.category||"")}</span> Β· <span class="${wcls}" style="font-weight:600">${sign}</span><br/><span style="color:var(--ink-3)">${escapeHtml((al.rationale||"").slice(0,110))}</span></div>`;
}
note += `</div>`;
}
if (dd.cash_held_pct > 0 && dd.cash_rationale) note += `<div style="margin-top:5px; color:var(--ink-3); font-size:9.5px;">cash ${(dd.cash_held_pct*100).toFixed(0)}%: ${escapeHtml(dd.cash_rationale.slice(0,80))}</div>`;
} else if (a.recent) note = `Last: ${a.recent}`;
} else if (a.data && a._isInfra) {
const d = a.data;
rows += `<div class="h-row"><span class="l">Best Brier</span><span class="v up">${(d.best_brier||0).toFixed(5)}</span></div>`;
rows += `<div class="h-row"><span class="l">Generation</span><span class="v">${d.generation||0}</span></div>`;
rows += `<div class="h-row"><span class="l">Model</span><span class="v">${d.best_model_type || "β€”"}</span></div>`;
rows += `<div class="h-row"><span class="l">Population</span><span class="v">${d.pop_size||0}</span></div>`;
if (d.stagnation != null) rows += `<div class="h-row"><span class="l">Stagnation</span><span class="v">${d.stagnation} gens</span></div>`;
if (d.mutation_rate != null) rows += `<div class="h-row"><span class="l">Mutation</span><span class="v">${(d.mutation_rate*100).toFixed(1)}%</span></div>`;
} else if (a.kind === "crew") {
rows += `<div class="h-row"><span class="l">Codename</span><span class="v">${escapeHtml(a.label||"")}</span></div>`;
rows += `<div class="h-row"><span class="l">Role</span><span class="v">${escapeHtml(a.role||"sub-agent")}</span></div>`;
rows += `<div class="h-row"><span class="l">Track</span><span class="v">${escapeHtml(a.track||"β€”")}</span></div>`;
rows += `<div class="h-row"><span class="l">Status</span><span class="v ${a.state==="running"?"up":""}">${a.state||"idle"}</span></div>`;
if (a._lastDispatch) rows += `<div class="h-row"><span class="l">Last dispatch</span><span class="v">${escapeHtml(a._lastDispatch)}</span></div>`;
note = a.mission || a.role || "";
} else if (a.kind === "council") {
note = a.mission || ""; rows += `<div class="h-row"><span class="l">Mode</span><span class="v">Karpathy loop Β· 30min</span></div>`;
} else {
rows += `<div class="h-row"><span class="l">Status</span><span class="v">waiting for live data…</span></div>`;
}
hoverEl.innerHTML = `
<div class="h-head">
<span class="h-dot" style="background:${kcol}"></span>
<span class="h-title">${a.label}</span>
<span class="h-sub">${a.kind}</span>
</div>${rows}
${tags ? `<div style="margin-top:6px">${tags}</div>` : ""}
${note ? `<div class="h-note">${note}</div>` : ""}`;
hoverEl.style.display = "block";
hoverEl.style.left = Math.min(pt.x + 20, stageEl.clientWidth - 320) + "px";
hoverEl.style.top = Math.max(pt.y - 40, 20) + "px";
}
function hideHoverCard() { hoverEl.style.display = "none"; }
// ── INSPECT CARD (v2.8: floating overlay, positioned next to the clicked agent) ──
const inspectCard = document.getElementById("inspect-card");
let _inspectAgent = null;
document.getElementById("inspect-close").addEventListener("click", () => {
inspectCard.style.display = "none";
if (_inspectAgent) _inspectAgent._clicked = false;
_inspectAgent = null;
});
// v2.23: clicking empty canvas closes inspect + resumes motion on all agents.
// Typewriter state cleared too so a re-click starts fresh.
app.canvas.addEventListener("click", (ev) => {
if (ev.target !== app.canvas) return;
// Defer to next tick so a sprite's pointertap (fires ~same frame) wins the race.
setTimeout(() => {
// Only clear if no agent actually got clicked this frame (sprite tap would
// have set exactly one a._clicked=true within the last few ms).
const anyRecentClick = AGENTS.some(x => x._clicked && (performance.now() - (x._typeStart || 0)) < 30);
if (anyRecentClick) return;
for (const a of AGENTS) {
a._clicked = false;
a._typeIdx = 0;
a._typeStart = 0;
}
inspectCard.style.display = "none";
_inspectAgent = null;
}, 0);
});
function positionInspectCard() {
// v2.23: inspect-card is now FIXED top-right via CSS so it never overlaps the
// speech bubble or sprite. No per-frame positioning needed. Kept as a no-op to
// preserve the app.ticker.add() call below without a runtime error.
}
function showInspectPanel(a) {
_inspectAgent = a;
inspectCard.style.display = "block";
const d = a.data || {};
const setv = (id, text, cls) => {
const el = document.getElementById(id);
el.textContent = text;
if (cls) el.className = "v " + cls;
};
setv("inspect-name", a.label || a.id);
setv("inspect-model", a.model || d.model || "β€”", "ok");
setv("inspect-provider", a.provider || d.provider || "β€”", "warn");
const bankroll = d.bankroll != null ? `$${Number(d.bankroll).toFixed(2)}` : "β€”";
const roi = d.bankroll != null ? `${((d.bankroll - 100) / 100 * 100).toFixed(1)}%` : "β€”";
const cls = d.bankroll > 100 ? "ok" : d.bankroll != null ? "warn" : "";
setv("inspect-bankroll", bankroll, cls);
setv("inspect-roi", roi, cls);
setv("inspect-wr", d.win_rate != null ? `${(d.win_rate * 100).toFixed(0)}%` : d.wins != null ? `${d.wins}W/${d.losses||0}L` : "β€”");
setv("inspect-strat", a.strategy || "β€”", "warn");
setv("inspect-state", a.state || "idle");
setv("inspect-brier", d.best_brier != null ? d.best_brier.toFixed(5) : "β€”");
setv("inspect-gen", d.generation != null ? `Gen ${d.generation}` : "β€”", "warn");
const extra = [];
if (d.total_bets) extra.push(`${d.total_bets} bets`);
if (d.last_bet) extra.push(`Last: ${d.last_bet}`);
// v2.17 ITF-specific extras β€” no bankroll, so show trades/decisions/passes/open positions.
if (a.kind === "itf") {
if (d.trades != null) extra.push(`${d.trades} trades`);
if (d.passes != null) extra.push(`${d.passes} passes`);
if (d.open_positions != null) extra.push(`${d.open_positions} open`);
if (d.tier) extra.push(`tier ${d.tier}`);
if (d.decisions != null) extra.push(`${d.decisions} decisions`);
}
if (a.pactPartner) extra.push(`Pact: ${a.pactPartner}`);
if (d.stagnation) extra.push(`Stag: ${d.stagnation}`);
if (a.kind) extra.push(a.kind);
document.getElementById("inspect-extra").textContent = extra.join(" Β· ") || "No additional data";
positionInspectCard();
}
// Keep the card anchored to the agent as the camera moves
app.ticker.add(positionInspectCard);
// ── DIEGETIC PANEL HELPERS (v2.8: feed in-world PIXI signs, not DOM) ─────────
// Rolling ticker queue β€” last 40 events, composed into a single scrolling string
const TICKER_EVENTS = [];
let tradesCounter = 0;
function logTrade(kind, agent, msg, roi) {
const t = new Date().toISOString().slice(11, 19);
const roiStr = roi == null ? "" : (roi >= 0 ? ` +$${roi.toFixed(1)}` : ` -$${(-roi).toFixed(1)}`);
TICKER_EVENTS.unshift(`${t} Β· ${agent} Β· ${msg}${roiStr}`);
if (TICKER_EVENTS.length > 40) TICKER_EVENTS.pop();
TICKER.raw = " β—† " + TICKER_EVENTS.join(" β—† ") + " β—† ";
if (TICKER.text) TICKER.text.text = TICKER.raw;
tradesCounter++;
}
// Redraw an infrastructure tower sprite from live island data.
// Height ramps with log(generation). Color hue by best_brier. Crown on
// fleet-best. Stagnation (>100 gens) dims the body.
let _fleetBestBrier = 1.0;
function updateInfraTower(a) {
if (!a._isInfra || !a._cnt || !a._cnt._tower) return;
const g = a._cnt._tower;
const d = a.data || {};
const gen = d.generation || d.gen || d.current_gen || 0;
const brier = d.best_brier || d.brier || 1.0;
const stag = d.stagnation || 0;
const tint = KIND_COLORS[a.kind] || 0x50fa7b;
// Height: 0.3..1.0 of CHAR_H based on log10(gen+1)/4 clamped.
const hFrac = Math.min(1.0, Math.max(0.32, Math.log10(gen + 1) / 4));
const H = CHAR_H * hFrac;
const W = CHAR_W * 0.46;
const nRacks = Math.min(8, Math.max(2, Math.floor(hFrac * 9)));
const rackH = H / nRacks;
const dim = stag > 100 ? 0.55 : 1.0;
g.clear();
// Base shadow plinth
g.rect(-W/2 - 2, -2, W + 4, 5).fill({ color:0x000000, alpha:0.35 });
// Rack stack (bottom up)
for (let i = 0; i < nRacks; i++) {
const yTop = -rackH * (i + 1);
const alpha = 0.72 + (i / nRacks) * 0.24;
g.rect(-W/2, yTop, W, rackH - 1.5)
.fill({ color:0x1a1f2a, alpha:0.96 * dim })
.stroke({ width:1, color:tint, alpha: alpha * dim });
// LEDs: 2 per rack, blink when activeUntil recent
const ledCol = (performance.now() - (a.activeUntil - 4000)) < 4000 && a.activeUntil > performance.now()
? 0x50fa7b : (stag > 100 ? 0xff5c5c : tint);
g.rect(-W/2 + 4, yTop + rackH/2 - 1.5, 3, 2).fill({ color: ledCol, alpha:0.9 * dim });
g.rect(-W/2 + 9, yTop + rackH/2 - 1.5, 3, 2).fill({ color: ledCol, alpha:0.5 * dim });
}
// Roof
g.rect(-W/2 - 3, -H - 4, W + 6, 4).fill({ color: tint, alpha:0.9 * dim });
// Antenna
g.rect(-1, -H - 14, 2, 10).fill({ color: tint, alpha:0.85 * dim });
g.circle(0, -H - 16, 2.5).fill({ color: 0xffd700, alpha: 0.9 });
// Crown if fleet best
if (brier > 0 && brier <= _fleetBestBrier + 0.00001) {
g.moveTo(-W/2 + 3, -H - 6)
.lineTo(-W/4, -H - 14)
.lineTo(0, -H - 8)
.lineTo(W/4, -H - 14)
.lineTo(W/2 - 3, -H - 6)
.lineTo(W/2 - 3, -H - 3)
.lineTo(-W/2 + 3, -H - 3)
.closePath().fill({ color:0xffd700, alpha:0.95 });
}
}
function _clearLines(board) { for (const ln of board.lines) ln.text = ""; }
function renderCategories(cats) {
const board = DIEGETIC.categories; if (!board) return;
_clearLines(board);
const entries = Object.entries(cats).sort((a,b) => b[1] - a[1]).slice(0, board.lines.length);
if (!entries.length) { board.lines[0].text = "β€” no day-decisions yet β€”"; board.lines[0].style.fill = 0x5b6478; return; }
for (let i = 0; i < entries.length; i++) {
const [cat, n] = entries[i];
const line = board.lines[i];
line.text = `${String(i+1).padStart(2,"0")} Β· ${cat.slice(0,20).padEnd(20," ")} Γ—${n}`;
line.style.fill = i === 0 ? 0xff79c6 : (i < 3 ? 0xffd700 : 0xe6e8ee);
}
}
function renderEvolution() {
const board = DIEGETIC.evolution; if (!board) return;
_clearLines(board);
const islands = AGENTS.filter(a => a._isInfra && a.data).sort((a,b) => (a.data.best_brier||1) - (b.data.best_brier||1));
if (!islands.length) { board.lines[0].text = "β€” waiting for islands β€”"; board.lines[0].style.fill = 0x5b6478; return; }
for (let i = 0; i < Math.min(islands.length, board.lines.length); i++) {
const a = islands[i]; const d = a.data;
const b = (d.best_brier || 0).toFixed(5);
const stag = d.stagnation > 50;
const medal = i === 0 ? "β˜…" : (i < 3 ? "Β·" : " ");
const line = board.lines[i];
line.text = `${medal} ${a.label.padEnd(5," ")} ${(d.best_model_type||"?").slice(0,9).padEnd(9," ")} ${b} g${d.generation||0}`;
line.style.fill = i === 0 ? 0xffd700 : (stag ? 0xff5c5c : 0x50fa7b);
}
}
function renderLeaderboard() {
const board = DIEGETIC.leaderboard; if (!board) return;
_clearLines(board);
// PQTF banner: $100KΓ—6 derivatives fleet ($600K start, target 10Γ— by Nov 3).
let startIdx = 0;
if (LATEST_PQTF && LATEST_PQTF.agents) {
const ag = LATEST_PQTF.agents;
const total = Object.values(ag).reduce((s,a) => s + (a.bankroll || 0), 0);
const best = Object.entries(ag).sort((a,b) => (b[1].bankroll||0) - (a[1].bankroll||0))[0];
const bestName = best ? best[0].slice(0,10) : "β€”";
const bestBr = best ? (best[1].bankroll || 0) : 0;
const fleetRoi = ((total - 600000) / 600000) * 100;
const d = LATEST_PQTF.days_processed || 0, dt = LATEST_PQTF.days_total || 50;
if (board.lines[0]) {
board.lines[0].text = `πŸ’Ό PQTF d${d}/${dt} Β· fleet $${(total/1000).toFixed(0)}K ${fleetRoi>=0?"+":""}${fleetRoi.toFixed(0)}% Β· top ${bestName} $${(bestBr/1000).toFixed(0)}K`;
board.lines[0].style.fill = 0xffd700;
startIdx = 1;
}
}
const traders = AGENTS.filter(a => (a.kind === "nba" || a.kind === "political") && a.data && typeof a.data.bankroll === "number");
traders.sort((a,b) => (b.data.bankroll||0) - (a.data.bankroll||0));
if (!traders.length && startIdx === 0) {
board.lines[0].text = "β€” waiting for trading floors β€”";
board.lines[0].style.fill = 0x5b6478;
return;
}
for (let i = 0; i < Math.min(traders.length, board.lines.length - startIdx); i++) {
const a = traders[i]; const br = a.data.bankroll || 0; const roi = br - 100;
const medal = i === 0 ? "πŸ†" : i === 1 ? "πŸ₯ˆ" : i === 2 ? "πŸ₯‰" : `#${i+1}`;
const tag = a.kind === "nba" ? "N" : "P";
const line = board.lines[i + startIdx];
line.text = `${medal} [${tag}] ${a.label.slice(0,11).padEnd(11," ")} $${br.toFixed(0).padStart(5," ")} ${roi>=0?"+":""}${roi.toFixed(1)}%`;
line.style.fill = i === 0 ? 0xffd700 : (roi >= 0 ? 0x50fa7b : 0xff5c5c);
}
}
// ── v2.18: THREE-FLOOR LEADERBOARD (DOM right-side panel) ────────────────────
// Tracks previous bankroll snapshot per (floor, trader_id) so each refresh shows
// today's delta. All three floors (NBA / POL / PQTF) rendered stacked β€” every
// trader ranked, persona visible, click a row β†’ inspect card with full model info.
const _PREV_BANKROLL = {}; // { "nba:qwen-quant": 100.0, ... }
function _deltaFor(floorKey, id, curr) {
const k = `${floorKey}:${id}`;
const prev = _PREV_BANKROLL[k];
_PREV_BANKROLL[k] = curr;
if (prev == null || curr == null) return null;
return curr - prev;
}
function _floorSection(kindLabel, kindClass, day, dayTotal, traders, startCap, fleetLeader) {
// traders: [{ id, persona, model, provider, risk, style, bankroll, wins, losses, total_bets, pctDelta? }]
// Sorted by bankroll desc.
const total = traders.reduce((s,t) => s + (t.bankroll||0), 0);
const fleetRoi = startCap > 0 ? ((total - startCap) / startCap) * 100 : 0;
const fltCls = fleetRoi > 0 ? "up" : fleetRoi < 0 ? "down" : "flat";
const fltTxt = `${fleetRoi>=0?"+":""}${fleetRoi.toFixed(1)}%`;
const dayStr = dayTotal ? `day ${day||0}/${dayTotal}` : (day ? `day ${day}` : "β€”");
const rows = traders.map((t,i) => {
const br = t.bankroll || 0;
const dl = t.delta;
const dlCls = dl == null ? "flat" : dl > 0.01 ? "up" : dl < -0.01 ? "down" : "flat";
const dlTxt = dl == null ? "β€”" : (dl > 0 ? "+" : "") + (Math.abs(dl) >= 1000 ? (dl/1000).toFixed(1)+"K" : dl.toFixed(0));
const rkClass = i < 3 ? "rk top" : "rk";
const rkTxt = i === 0 ? "β˜…" : i === 1 ? "2" : i === 2 ? "3" : String(i+1);
const brTxt = br >= 1000 ? `$${(br/1000).toFixed(1)}K` : `$${br.toFixed(0)}`;
const hot = fleetLeader && t.id === fleetLeader ? " hot" : "";
return `<div class="r${hot}" data-floor="${kindClass}" data-trader="${escapeHtml(t.id)}">
<span class="${rkClass}">${rkTxt}</span>
<span class="nm" title="${escapeHtml(t.model)} Β· ${escapeHtml(t.provider)}">${escapeHtml(t.persona)}</span>
<span class="br">${brTxt}</span>
<span class="dl ${dlCls}">${dlTxt}</span>
</div>`;
}).join("");
return `<div class="sec">
<div class="sh">
<span class="kind ${kindClass}">${kindLabel}</span>
<span><span class="day">${dayStr}</span><span class="flt ${fltCls}">${fltTxt}</span></span>
</div>
${rows || '<div class="empty">no traders yet</div>'}
</div>`;
}
function renderThreeFloorLB() {
const body = document.getElementById("tfl-body");
if (!body) return;
const sections = [];
// NBA TF
{
const d = LATEST_NBA;
const agents = (d && d.agents) || {};
const list = Object.entries(agents).map(([id, v]) => {
const p = personaFor(id);
const br = (v && typeof v.bankroll === "number") ? v.bankroll : null;
return { id, persona: p.persona, model: p.model, provider: p.provider, risk: p.risk, style: p.style,
bankroll: br, wins: v.wins, losses: v.losses, total_bets: v.total_bets,
delta: br == null ? null : _deltaFor("nba", id, br) };
}).filter(t => t.bankroll != null);
list.sort((a,b) => b.bankroll - a.bankroll);
const day = d && (d.days_processed ?? 0);
const dayT = d && (d.days_total ?? 175);
const startCap = 100 * list.length;
sections.push(_floorSection("NBA TF Β· 17 agents", "nba", day, dayT, list, startCap, d && d.fleet_leader));
}
// POL TF
{
const d = LATEST_POL;
const agents = (d && d.agents) || {};
const list = Object.entries(agents).map(([id, v]) => {
const p = personaFor(id);
const br = (v && typeof v.bankroll === "number") ? v.bankroll : null;
return { id, persona: p.persona, model: p.model, provider: p.provider, risk: p.risk, style: p.style,
bankroll: br, wins: v.wins, losses: v.losses, total_bets: v.total_bets,
delta: br == null ? null : _deltaFor("pol", id, br) };
}).filter(t => t.bankroll != null);
list.sort((a,b) => b.bankroll - a.bankroll);
const day = d && (d.days_processed ?? 0);
const dayT = d && (d.days_total ?? 50);
const startCap = 100 * list.length;
sections.push(_floorSection("POLITICAL TF Β· 17 agents", "pol", day, dayT, list, startCap, d && d.fleet_leader));
}
// PQTF (derivatives day-traders β€” 6 agents, $100K start each)
{
const d = LATEST_PQTF;
const agents = (d && d.agents) || {};
const list = Object.entries(agents).map(([id, v]) => {
const p = personaFor(id);
const br = (v && typeof v.bankroll === "number") ? v.bankroll : null;
return { id, persona: p.persona, model: p.model, provider: p.provider, risk: p.risk, style: p.style,
bankroll: br, wins: v.wins, losses: v.losses, total_bets: v.total_bets,
delta: br == null ? null : _deltaFor("pqtf", id, br) };
}).filter(t => t.bankroll != null);
list.sort((a,b) => b.bankroll - a.bankroll);
const day = d && (d.days_processed ?? 0);
const dayT = d && (d.days_total ?? 50);
const startCap = 100000 * list.length;
sections.push(_floorSection("PQTF Β· 6 derivatives", "pqtf", day, dayT, list, startCap, d && d.fleet_leader));
}
body.innerHTML = sections.join("");
// Clock
const clk = document.getElementById("tfl-clock");
if (clk) clk.textContent = new Date().toISOString().slice(11,19) + " UTC";
// Row click β†’ open inspect card (synthesize agent if PQTF / fallback)
for (const el of body.querySelectorAll(".r")) {
el.addEventListener("click", () => {
const floor = el.dataset.floor; // nba / pol / pqtf
const id = el.dataset.trader;
let a = null;
if (floor === "nba") a = AGENTS.find(x => x.kind === "nba" && x.id === id);
else if (floor === "pol") a = AGENTS.find(x => x.kind === "political" && x.key === id);
if (!a) {
// PQTF or unknown β€” synthesize an inspect-only agent
const p = personaFor(id);
const src = (floor === "pqtf" ? LATEST_PQTF : floor === "pol" ? LATEST_POL : LATEST_NBA) || {};
const vv = (src.agents || {})[id] || {};
a = {
id, key: id, label: p.persona, persona: p.persona,
model: p.model, provider: p.provider, risk: p.risk, style: p.style,
kind: floor, state: src.running ? "thinking" : "idle",
strategy: p.style, data: vv, _synth: true,
x: WORLD_W / (2*TILE), y: WORLD_H / (2*TILE),
};
}
showInspectPanel(a);
});
}
}
// ── LIVE DATA FETCHER ───────────────────────────────────────────────────────
async function fetchJSON(url) {
try { const r = await fetch(url, { cache:"no-store" }); if (!r.ok) return null; return await r.json(); }
catch(e) { return null; }
}
function escapeHtml(s) { return String(s==null?"":s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
const categoryCounter = {}; // rolling count across both TFs
// v2.16: remember latest TF status so the warming-up fallback (no day-decisions yet)
// can still fill the dialogue box with live signals (fleet_leader, council_plan, llm_calls).
let LATEST_NBA = null, LATEST_POL = null, LATEST_PQTF = null, LATEST_ITF = null;
async function refresh() {
document.getElementById("hud-tick").textContent = "Β· fetching…";
let nbaOK = 0, polOK = 0, evoOK = 0, pacts = 0, repEntries = 0;
for (const a of AGENTS) a.pactPartner = null;
// NBA TF
const nba = await fetchJSON(ENDPOINTS.nba);
LATEST_NBA = nba;
if (nba) {
const agents = nba.agents || {};
const strategies = nba.axelrod_strategies || {};
const sacrificials = nba.sacrificial_assignments || {};
const reputation = nba.reputation || {};
pacts += nba.cooperation_pacts_count || 0;
repEntries += Object.keys(reputation).length;
const repKeys = Object.keys(reputation);
for (let i=0; i<repKeys.length-1; i+=2) {
const a = AGENTS.find(x => x.kind==="nba" && x.id===repKeys[i]);
const b = AGENTS.find(x => x.kind==="nba" && x.id===repKeys[i+1]);
if (a && b) { a.pactPartner = b.id; b.pactPartner = a.id; }
}
// v2.12: mid-day fallback. Agents dict is empty until day 1 completes, but llm_calls ticks
// up every prompt. Without this, all 16 trader sprites show "awaiting live data…" for hours.
const midDay = nba.running && nba.llm_calls > 0 && !Object.keys(agents).length;
for (const a of AGENTS.filter(x => x.kind==="nba")) {
let d = agents[a.id];
if (!d && midDay) {
d = { bankroll: 100, total_bets: 0, wins: 0, losses: 0, llm_calls: 0, llm_ok: 0, _synth: true };
a.state = "thinking";
a.bubble = `${a.label}\nday 1 in progress · llm ${nba.llm_calls}/∞`;
// v2.24: no persistent bubble β€” content ready for hover/click only
a.data = d;
nbaOK++;
continue;
}
if (!d) continue;
nbaOK++;
const prev = a.data;
a.data = d; a.strategy = strategies[a.id]; a.sacrificial = sacrificials[a.id];
const roi = (d.bankroll || 100) - 100;
a.state = d.llm_ok < d.llm_calls ? "error" : roi > 2 ? "profit" : roi < -2 ? "loss" : "thinking";
if (prev && prev.total_bets != null && d.total_bets > prev.total_bets) {
const deltaW = (d.wins || 0) - (prev.wins || 0);
a.recent = deltaW > 0 ? "βœ“ W" : (d.losses > prev.losses) ? "βœ— L" : "bet";
a.activeUntil = performance.now() + 4000;
logTrade("nba", a.label, `bet #${d.total_bets}`, d.bankroll - prev.bankroll);
spawnPulseRing(a.x * TILE, a.y * TILE, deltaW > 0 ? 0x00d9a7 : 0xff5c5c);
a.bubble = buildTraderBubble(a);
a.bubbleUntil = performance.now() + 4500;
} else {
// v2.24: bubble content kept fresh for hover/click, but NO auto-persistent
// display (was + 20000 β†’ every agent always showed bubble, obscuring sprites).
a.bubble = `${a.label}\n$${(d.bankroll||100).toFixed(0)} Β· ${d.total_bets||0} bets`;
}
}
document.getElementById("hud-day").textContent = `NBA day ${nba.days_processed||0}/${nba.days_total||175} Β· games ${nba.games_processed||0}/${nba.games_total||1247}`;
}
// Political TF
const pol = await fetchJSON(ENDPOINTS.political);
LATEST_POL = pol;
if (pol) {
const agents = pol.agents || {};
const strategies = pol.axelrod_strategies || {};
const sacrificials = pol.sacrificial_assignments || {};
pacts += pol.cooperation_pacts_count || 0;
const midDayP = pol.running && pol.llm_calls > 0 && !Object.keys(agents).length;
for (const a of AGENTS.filter(x => x.kind==="political")) {
let d = agents[a.key];
if (!d && midDayP) {
d = { bankroll: 100, total_bets: 0, wins: 0, losses: 0, llm_calls: 0, llm_ok: 0, _synth: true };
a.state = "thinking";
a.bubble = `${a.label}\nday 1 in progress · llm ${pol.llm_calls}/∞`;
// v2.24: bubble text only, no auto-persistence (hover/click to see)
a.data = d;
polOK++;
continue;
}
if (!d) continue;
polOK++;
const prev = a.data;
a.data = d; a.strategy = strategies[a.key]; a.sacrificial = sacrificials[a.key];
const roi = (d.bankroll || 100) - 100;
a.state = d.llm_ok < d.llm_calls ? "error" : roi > 2 ? "profit" : roi < -2 ? "loss" : "thinking";
if (prev && prev.total_bets != null && d.total_bets > prev.total_bets) {
const deltaW = (d.wins || 0) - (prev.wins || 0);
a.recent = deltaW > 0 ? "βœ“ W" : (d.losses > prev.losses) ? "βœ— L" : "bet";
a.activeUntil = performance.now() + 4000;
logTrade("political", a.label, `trade #${d.total_bets}`, d.bankroll - prev.bankroll);
spawnPulseRing(a.x * TILE, a.y * TILE, deltaW > 0 ? 0x00d9a7 : 0xff5c5c);
a.bubble = buildTraderBubble(a);
a.bubbleUntil = performance.now() + 4500;
} else {
// v2.24: no persistent bubble
a.bubble = `${a.label}\n$${(d.bankroll||100).toFixed(0)} Β· ${d.total_bets||0} trades`;
}
}
}
// v2.17 ITF β€” 7 intraday agents. Status schema:
// { agents: { tid: { decisions, trades, passes } }, config_agents: [...] }
// Leaderboard adds { tier, name, open_positions }.
// No bankroll field β€” state derives from (trades, open_positions, passes) ratios.
let itfOK = 0;
const itfLB = await fetchJSON(ENDPOINTS.itfLB);
const itfStatus = await fetchJSON(ENDPOINTS.itf);
LATEST_ITF = itfStatus;
if (itfLB && Array.isArray(itfLB.agents)) {
for (const entry of itfLB.agents) {
const a = AGENTS.find(x => x.kind === "itf" && x.key === entry.tid);
if (!a) continue;
const prev = a.data;
a.data = entry;
itfOK++;
const trades = entry.trades || 0;
const passes = entry.passes || 0;
const openPos = entry.open_positions || 0;
// state: open position β†’ profit (reads as "in the tape"), all-passes β†’ idle,
// never-traded β†’ thinking, traded-and-closed β†’ neutral/thinking
if (openPos > 0) a.state = "profit";
else if (passes > 0 && trades === 0) a.state = "thinking";
else if (trades > 0) a.state = (entry.tier === "S") ? "profit" : "thinking";
else a.state = "thinking";
if (prev && prev.trades != null && trades > prev.trades) {
a.recent = "● trade";
a.activeUntil = performance.now() + 4000;
logTrade("itf", a.label, `trade #${trades} (${entry.tier})`, 0);
spawnPulseRing(a.x * TILE, a.y * TILE, 0xffa94d);
}
// v2.24: no persistent bubble β€” hover/click to see
a.bubble = `${a.label}\n${trades} trade${trades===1?"":"s"} Β· ${passes} pass Β· ${openPos} open`;
}
}
// PQTF (derivatives day-trading fleet) β€” fleet-level banner only; agents render via leaderboard
const pqtf = await fetchJSON(ENDPOINTS.pqtf);
LATEST_PQTF = pqtf;
// Day-decisions β†’ per-agent LLM reasoning + categories aggregation
async function wireDayDecisions(summaryUrl, detailUrl, kind) {
const summary = await fetchJSON(summaryUrl);
if (!summary || !summary.days || !summary.days.length) return;
const latest = summary.days[summary.days.length - 1];
if (!latest || !latest.date) return;
const detail = await fetchJSON(`${detailUrl}?date=${latest.date}`);
if (!detail || !detail.agents) return;
for (const a of AGENTS.filter(x => x.kind === kind)) {
const lookupKey = kind === "nba" ? a.id : a.key;
const dd = detail.agents[lookupKey];
if (!dd) continue;
a.dayDecision = dd;
if (Array.isArray(dd.allocations)) {
for (const al of dd.allocations) {
if (al.category) categoryCounter[al.category] = (categoryCounter[al.category] || 0) + 1;
}
}
a.bubble = buildTraderBubble(a);
}
logTrade(kind, "fleet", `day ${latest.date}: ${latest.total_allocations||0} bets Β· ${((latest.avg_cash_pct||0)*100).toFixed(0)}% cash`);
}
await wireDayDecisions(ENDPOINTS.nbaDays, ENDPOINTS.nbaDays, "nba");
await wireDayDecisions(ENDPOINTS.politicalDays, ENDPOINTS.politicalDays, "political");
renderCategories(categoryCounter);
// Islands
const islandResults = await Promise.all(ISLANDS.map(([lbl,u]) => fetchJSON(u).then(d => [lbl,d])));
for (const [lbl, data] of islandResults) {
const a = AGENTS.find(x => x._isInfra && x.key===lbl);
if (!a) continue;
if (data) {
evoOK++;
const prev = a.data;
a.data = data;
a.state = (data.stagnation||0) > 100 ? "error" : "profit";
if (prev && prev.best_brier && data.best_brier < prev.best_brier - 0.00005) {
a.activeUntil = performance.now() + 4000;
logTrade("island", lbl, `Brier ↓ ${data.best_brier.toFixed(5)}`);
}
}
// Infra buildings at top: no persistent bubble. Hover card / click reveals info.
a.bubble = "";
}
// Compute current fleet-best across infra-with-data, then redraw all towers.
const infras = AGENTS.filter(x => x._isInfra && x.data && x.data.best_brier);
_fleetBestBrier = infras.length
? Math.min(...infras.map(x => x.data.best_brier))
: 1.0;
for (const a of AGENTS.filter(x => x._isInfra)) updateInfraTower(a);
renderEvolution();
renderLeaderboard();
renderThreeFloorLB();
// v2.24 Claude Code crew β€” read dispatch-latest.json for fleet-wide dispatch
// count + per-agent flags (which of the 14 agents were dispatched this cycle).
// Source of truth: THE BOSS writes data/ops/dispatch-latest.json every 4h at :00.
let crewOK = 0;
const dispatch = await fetchJSON(CREW_DISPATCH_URL);
const prevCrewDispatch = AGENTS.filter(x => x.kind === "crew")[0]?._prevDispatchTs;
const dispatchTs = dispatch?.ts || null;
const dispatchedSet = new Set();
const recentSet = new Set();
if (dispatch && Array.isArray(dispatch.dispatched_agents)) {
for (const ag of dispatch.dispatched_agents) dispatchedSet.add(String(ag).toUpperCase().replace(/\s+/g,"_"));
}
if (dispatch && Array.isArray(dispatch.recently_active_4h)) {
for (const ag of dispatch.recently_active_4h) recentSet.add(String(ag).toUpperCase().replace(/\s+/g,"_"));
}
for (const a of AGENTS.filter(x => x.kind === "crew")) {
if (!dispatch) {
a.state = "error";
a.bubble = `${a.label}\n(no dispatch feed)`;
continue;
}
crewOK++;
// Dispatcher agent_id uses underscore form (INTERNAL_AFFAIRS) vs label ("INTERNAL AFFAIRS")
const key = a.key.toUpperCase().replace(/\s+/g, "_");
const wasDispatched = dispatchedSet.has(key);
const wasRecent = wasDispatched || recentSet.has(key);
let ageMin = null;
if (dispatchTs) {
const t = Date.parse(dispatchTs);
if (!isNaN(t)) ageMin = Math.floor((Date.now() - t) / 60000);
}
a.state = wasDispatched ? "profit" : (wasRecent ? "thinking" : "idle");
a._lastDispatch = wasDispatched ? (dispatchTs ? dispatchTs.slice(11,16) + "Z" : "now") : (wasRecent ? "<4h" : null);
if (wasDispatched && dispatchTs !== a._prevDispatchTs) {
a.activeUntil = performance.now() + 4000;
logTrade("crew", a.label, `dispatched Β· ${dispatchTs.slice(11,16)}Z`);
spawnPulseRing(a.x * TILE, a.y * TILE, 0xffb86c);
}
a._prevDispatchTs = dispatchTs;
a.data = { dispatched: wasDispatched, recent: wasRecent, ts: dispatchTs, role: a.role, track: a.track };
const tag = wasDispatched ? " Β· ACTIVE" : (wasRecent ? " Β· recent" : "");
a.bubble = `${a.label}\n${a.role}${tag}${ageMin != null ? ` Β· ${ageMin}m ago` : ""}`;
}
const kCouncil = document.getElementById("k-councils");
if (kCouncil) { kCouncil.textContent = `${crewOK}/${CLAUDE_CREW.length}`; kCouncil.className = crewOK === CLAUDE_CREW.length ? "v-ok" : "v-warn"; }
document.getElementById("k-nba").textContent = `${nbaOK}/${NBA_TRADERS.length}`;
document.getElementById("k-nba").className = nbaOK === NBA_TRADERS.length ? "v-ok" : "v-warn";
document.getElementById("k-pol").textContent = `${polOK}/${POL_TRADERS.length}`;
document.getElementById("k-pol").className = polOK === POL_TRADERS.length ? "v-ok" : "v-warn";
// v2.17: +ITF row, fleet target = ITF_TRADERS.length (7 live). Islands counted vs ISLANDS.length (11 survivors).
const kItf = document.getElementById("k-itf");
if (kItf) { kItf.textContent = `${itfOK}/${ITF_TRADERS.length}`; kItf.className = itfOK === ITF_TRADERS.length ? "v-ok" : "v-warn"; }
document.getElementById("k-evo").textContent = `${evoOK}/${ISLANDS.length}`;
document.getElementById("k-evo").className = evoOK >= ISLANDS.length ? "v-ok" : "v-warn";
document.getElementById("k-pacts").textContent = pacts;
document.getElementById("k-last").textContent = new Date().toISOString().slice(11,19);
document.getElementById("hud-rep").textContent = `pacts ${pacts} Β· rep ${repEntries}`;
document.getElementById("hud-tick").textContent = "Β· live";
// ═══ Bloomberg ticker update ═══
const setTx = (id, t, cls) => {
const el = document.getElementById(id); if (!el) return;
const prev = el.textContent;
el.textContent = t;
el.className = "val" + (cls ? " " + cls : "");
// v2.22 marquee-mirror: keep the duplicate segments in sync so the track
// loop reads the same data on both halves (seamless scroll).
for (const m of document.querySelectorAll(`[data-mirror="${id}"]`)) {
m.textContent = t;
m.className = "val" + (cls ? " " + cls : "");
}
// Flash when value changes β€” draws the eye to fresh ticks
if (prev !== t && prev !== "β€”" && prev !== "") {
el.classList.add("flash");
setTimeout(() => el.classList.remove("flash"), 600);
}
};
const avg = (o) => {
const vals = Object.values(o || {}); if (!vals.length) return null;
return vals.reduce((s,a) => s + (a.bankroll || 100), 0) / vals.length - 100;
};
const sumCalls = (o) => Object.values(o || {}).reduce((s,a) => s + (a.llm_calls || 0), 0);
if (nba) {
setTx("bt-nba-day", `D${nba.days_processed || 0}/${nba.days_total || 175}`);
const p = avg(nba.agents); if (p != null) setTx("bt-nba-pnl", `${p>=0?"+":""}${p.toFixed(2)}%`, p>=0?"up":"down");
}
if (pol) {
setTx("bt-pol-day", `D${pol.days_processed || 0}/${pol.days_total || 175}`);
const p = avg(pol.agents); if (p != null) setTx("bt-pol-pnl", `${p>=0?"+":""}${p.toFixed(2)}%`, p>=0?"up":"down");
}
// v2.17 ITF ticker: total trades across fleet + sum open positions (tape-green when trades>0).
if (itfLB && Array.isArray(itfLB.agents)) {
const totTrades = itfLB.agents.reduce((s,a)=>s+(a.trades||0),0);
const totOpen = itfLB.agents.reduce((s,a)=>s+(a.open_positions||0),0);
setTx("bt-itf-trades", `${totTrades} trd`, totTrades > 0 ? "up" : "");
setTx("bt-itf-open", `${totOpen} open`, totOpen > 0 ? "up" : "");
}
setTx("bt-pacts", String(pacts));
setTx("bt-calls", String(sumCalls(nba && nba.agents) + sumCalls(pol && pol.agents)));
const best = AGENTS.filter(x => x._isInfra && x.data && x.data.best_brier)
.sort((a,b) => a.data.best_brier - b.data.best_brier)[0];
if (best) setTx("bt-fleet", `${best.label} ${best.data.best_brier.toFixed(5)}`, "star");
// ═══ v2.0: Live bet ticker β€” scroll last 20 allocations from both TFs ═══
renderBetTicker();
// v2.12: minimap removed
// Fade boot-log after first refresh
const bl = document.getElementById("boot-log");
if (bl && !bl._faded) {
bl._faded = true;
setTimeout(() => { bl.style.opacity = "0"; setTimeout(() => bl.remove(), 700); }, 6000);
}
}
// ═══ v2.15: PokΓ©mon B/W dialogue box β€” typewriter bet log ═══
// Pulls latest allocations from both TFs, queues as PokΓ©mon-style one-line events
// ("mistral-ministral used ML_HOME! Super effective! +$432"). Typewriter-reveals each
// char, then auto-advances after 3s. Player can read at their pace.
const POKE_QUEUE = [];
let _pokeIdx = 0, _pokeLastMs = 0, _pokeCurEvent = null, _pokeCharIdx = 0;
const POKE_TYPE_MS = 22; // ms per character
const POKE_HOLD_MS = 3200; // ms to hold after reveal before advancing
function renderBetTicker() {
// Rebuild queue from latest allocations (keep newest 40, oldest-first so typewriter tells a story)
const bets = [];
for (const a of AGENTS) {
if (!a.dayDecision || !Array.isArray(a.dayDecision.allocations)) continue;
for (const al of a.dayDecision.allocations) {
if (!al.category) continue;
const amt = al.stake_amount != null ? al.stake_amount
: al.stake != null ? al.stake
: al.amount != null ? al.amount : 0;
bets.push({
agent: a.label, kind: a.kind, cat: al.category,
amt: +amt || 0,
won: al.won === true ? true : al.won === false ? false : null,
profit: al.profit == null ? null : +al.profit,
game: al.game || "",
pct: (al.pct || 0) * 100,
});
}
}
// Keep last 40 (chronological) β€” PokΓ©mon dialog plays them in order
POKE_QUEUE.length = 0;
POKE_QUEUE.push(...bets.slice(-40));
// v2.16 β€” fallback: when day-decisions is empty (TF restarted / warming up), synthesize
// a live-signal queue from /api/status so the dialogue box never gets stuck on
// "awaiting day-decisions" for hours. Each TF contributes council summary + fleet leader
// + LLM-call count as PokΓ©mon-style system messages.
if (!POKE_QUEUE.length) {
const synth = [];
const pushWarming = (tag, s) => {
if (!s || !s.running) return;
const leader = s.fleet_leader || "β€”";
const best = s.fleet_best_bankroll != null ? `$${(s.fleet_best_bankroll).toFixed(0)}` : "$100";
const calls = s.llm_calls || 0;
const councils = s.council_plan_count || 0;
const summary = (s.latest_council_summary || "").slice(0, 120);
synth.push({ _synth: true, kind: tag, text:
`<span class="trn ${tag === "political" ? "pol" : "nba"}">${tag.toUpperCase()} TF</span> council ${councils} Β· ${calls} llm-calls Β· leader <span class="cat">${escapeHtml(leader)}</span> ${best}` });
if (summary) synth.push({ _synth: true, kind: tag, text:
`<span class="trn ${tag === "political" ? "pol" : "nba"}">${tag.toUpperCase()} PLAN</span> β€” ${escapeHtml(summary)}` });
synth.push({ _synth: true, kind: tag, text:
`<span class="win">warming councils…</span> first bets commit after day 1 (~20 llm-calls/agent)` });
};
pushWarming("nba", LATEST_NBA);
pushWarming("political", LATEST_POL);
if (!synth.length) synth.push({ _synth: true, kind: "nba", text:
`<span class="lose">both TFs offline</span> β€” watchdog will restart within 2 min` });
POKE_QUEUE.push(...synth);
}
if (_pokeIdx >= POKE_QUEUE.length) _pokeIdx = 0;
}
function _pokeFormat(b) {
// v2.16 β€” synthetic warming-up events carry pre-formatted HTML.
if (b._synth) return b.text || "";
// Agent uses CATEGORY! β€” PokΓ©mon move-style readout.
const trn = (b.kind === "political" ? "pol" : "nba");
const a = b.agent.replace(/^(NBA|POL)Β·/, "");
const cat = (b.cat || "?").toUpperCase().replace(/_/g, " ");
const verb = b.kind === "political" ? "positioned" : "bet";
const pct = b.pct > 0 ? `${b.pct.toFixed(0)}%` : "";
const stake = b.amt > 0 ? `$${b.amt.toFixed(0)}` : "pass";
let outcome;
if (b.won === true) {
const prof = b.profit != null ? ` +$${b.profit.toFixed(0)}` : "";
outcome = `<span class="win">it's super effective!${prof}</span>`;
} else if (b.won === false) {
const prof = b.profit != null ? ` ${b.profit.toFixed(0)}` : "";
outcome = `<span class="lose">it missed${prof}</span>`;
} else outcome = "<span>awaiting result…</span>";
return `<span class="trn ${trn}">${escapeHtml(a)}</span> ${verb} ${stake} on <span class="cat">${escapeHtml(cat)}</span> ${pct} β€” ${outcome}`;
}
function tickPokeDialog(ms) {
const body = document.getElementById("pd-body");
if (!body || !POKE_QUEUE.length) return;
if (!_pokeCurEvent) {
_pokeCurEvent = POKE_QUEUE[_pokeIdx];
_pokeCharIdx = 0;
_pokeLastMs = ms;
}
// _pokeCurEvent captured; render the formatted HTML up to _pokeCharIdx plain-text chars.
// Trick: format the FULL html, then slice the text-only portion for typewriter effect by
// walking the chars ignoring tags (use a pre-formatted plain string for indexing).
if (!_pokeCurEvent._html) {
_pokeCurEvent._html = _pokeFormat(_pokeCurEvent);
_pokeCurEvent._plain = _pokeCurEvent._html.replace(/<[^>]+>/g, "");
}
const plainLen = _pokeCurEvent._plain.length;
if (_pokeCharIdx < plainLen) {
const dt = ms - _pokeLastMs;
const nChars = Math.max(1, Math.floor(dt / POKE_TYPE_MS));
_pokeCharIdx = Math.min(plainLen, _pokeCharIdx + nChars);
_pokeLastMs = ms;
// Rebuild the visible HTML up to _pokeCharIdx plain chars
body.innerHTML = _pokeBuildPartial(_pokeCurEvent._html, _pokeCharIdx) +
(_pokeCharIdx < plainLen ? '<span class="cursor">β–Œ</span>' : '<span class="cursor">β–Ό</span>');
} else {
// Fully revealed β€” hold, then advance
if (ms - _pokeLastMs > POKE_HOLD_MS) {
_pokeIdx = (_pokeIdx + 1) % POKE_QUEUE.length;
_pokeCurEvent = null;
}
}
}
function _pokeBuildPartial(html, nPlain) {
// Emit HTML char-by-char, consuming tags without counting them against nPlain.
let out = "", i = 0, shown = 0;
while (i < html.length && shown < nPlain) {
if (html[i] === "<") {
const end = html.indexOf(">", i);
out += html.slice(i, end + 1);
i = end + 1;
} else {
out += html[i];
i++;
shown++;
}
}
// Close any open tags cleanly by emitting the rest that are within the current span scope
// (for this case, spans are short-lived β€” easier to just append closing from html after this point up to nearest </span>).
while (i < html.length && html[i] !== "<") i++; // advance to next tag
// Append closing spans from remaining HTML (cheap approach: scan for closing tags)
const remaining = html.slice(i);
const unclosed = (out.match(/<span[^>]*>/g) || []).length - (out.match(/<\/span>/g) || []).length;
for (let k = 0; k < unclosed; k++) out += "</span>";
return out;
}
// ═══ v2.0: Mini-map (top-right, 120x80) ═══════════════════════════════════
// v2.12: renderMinimap() removed (minimap HUD deleted)
// ── LIVE RESEARCH (papers.json static β€” refreshed by daily cron) ──────────
const TAG_COLOR = {
"nomos-karpathy":"#ffd866", "nomos-digest":"#ff7ab6",
"scan":"#ffa500", "calibration-scan":"#50fa7b", "axelrod-scan":"#ff79c6",
"multiagent-trading-scan":"#8be9fd", "github-scan":"#bd93f9",
};
async function loadPapers() {
try {
const r = await fetch("data/papers.json?t=" + Date.now(), {cache:"no-store"});
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
const papers = data.papers || [];
// v2.8: render to in-world research board (DIEGETIC.research) instead of sidebar DOM
const rboard = DIEGETIC.research;
if (rboard) {
_clearLines(rboard);
if (!papers.length) { rboard.lines[0].text = "β€” no scans loaded β€”"; rboard.lines[0].style.fill = 0x5b6478; }
else for (let i = 0; i < Math.min(papers.length, rboard.lines.length); i++) {
const p = papers[i];
const tag = (p.tag || "?").slice(0, 6).toUpperCase();
const title = (p.title || "").slice(0, 34);
const hex = TAG_COLOR[p.tag] || "#ffa500";
const line = rboard.lines[i];
line.text = `[${tag.padEnd(6," ")}] ${title}`;
line.style.fill = parseInt(hex.replace("#",""), 16);
}
}
} catch(e) {
const rboard = DIEGETIC.research;
if (rboard && rboard.lines[0]) {
rboard.lines[0].text = `papers.json load failed`;
rboard.lines[0].style.fill = 0xff5c5c;
}
console.warn("papers load error:", e);
}
}
// ── BOOT ────────────────────────────────────────────────────────────────────
computeCamera();
camScale = camTargetScale; camX = camTargetX; camY = camTargetY; // snap on boot
// Pull build sha (kills hardcoded v2.x) + live trader counts from TF APIs
try {
const [bRaw, nbaRaw, polRaw] = await Promise.all([
fetch("data/build.json?t=" + Date.now(), {cache:"no-store"}).then(r => r.ok ? r.json() : null).catch(() => null),
fetchJSON(ENDPOINTS.nba),
fetchJSON(ENDPOINTS.political),
]);
const sha = (bRaw && bRaw.commit) ? bRaw.commit : "live";
const bt = document.getElementById("build-tag");
if (bt) bt.textContent = "build: " + sha;
const nNba = nbaRaw && nbaRaw.axelrod_strategies ? Object.keys(nbaRaw.axelrod_strategies).length : 12;
const nPol = polRaw && polRaw.axelrod_strategies ? Object.keys(polRaw.axelrod_strategies).length : 10;
logTrade("council", "pixel-world", `boot Β· sha=${sha} Β· ${nNba} NBA traders Β· ${nPol} POL traders Β· live /api/status (no hardcoded enum)`);
} catch (e) {
logTrade("council", "pixel-world", "boot Β· sha=unknown Β· falling back to static trader list");
}
await refresh();
await loadPapers();
setInterval(loadPapers, 5 * 60 * 1000); // reload papers every 5 min
setInterval(refresh, 15000); // 15s poll β€” safe under HF rate-limit
// Bloomberg UTC clock β€” tick every second independent of data refresh
setInterval(() => {
const t = new Date().toISOString().slice(11,19);
const el = document.getElementById("bt-utc");
if (el) el.textContent = t;
// v2.22: keep marquee duplicate in sync every second
for (const m of document.querySelectorAll('[data-mirror="bt-utc"]')) m.textContent = t;
}, 1000);
// v2.22: inject latest trade events into the scrolling marquee as a rolling feed.
// Pulls from logTrade's TICKER_EVENTS (already maintained). Rebuilds the inner
// HTML of #tk-feed-primary + #tk-feed-mirror every refresh so new bets surface.
function renderTickerFeed() {
const ev = (typeof TICKER_EVENTS !== "undefined" && TICKER_EVENTS) ? TICKER_EVENTS.slice(0, 12) : [];
const segs = ev.map(line => {
const arrow = /↓|-\$|LOSS|missed/i.test(line) ? '<span class="arrow down">β–Ό</span>'
: /↑|\+\$|WIN|effective|Brier/i.test(line) ? '<span class="arrow up">β–²</span>'
: '<span class="arrow">β—†</span>';
return `<span class="seg">${arrow}<span class="val">${line.replace(/</g,"&lt;")}</span></span>`;
}).join("");
const a = document.getElementById("tk-feed-primary");
const b = document.getElementById("tk-feed-mirror");
if (a) a.innerHTML = segs;
if (b) b.innerHTML = segs;
}
setInterval(renderTickerFeed, 4000);
renderTickerFeed();
})();
</script>
</body>
</html>