ev / docs /play.html
hschinmaybharadwaj
feat(judge): Reward Forensics Lab + master walkthrough
72c744f
Raw History Blame Contribute Delete
63.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Neon Syndicate · Live Play</title>
<meta name="description" content="Watch a PPO-trained Qwen 2.5 model run real cyberpunk missions in a partially observable strategy environment, with full reasoning trace and a side-by-side race against heuristic and random baselines." />
<style>
:root {
--bg-0: #05060d;
--bg-1: #0b0e1a;
--bg-2: #11152a;
--line: #1e2547;
--ink: #e6ecff;
--ink-mute: #93a0c5;
--neon-cyan: #58f5ff;
--neon-pink: #ff5cdc;
--neon-green: #4dff9c;
--neon-yellow: #ffd84d;
--neon-red: #ff5b6b;
--neon-violet: #a47bff;
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.45);
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; min-height: 100%;
background: radial-gradient(1200px 800px at 20% 10%, #11163a 0%, var(--bg-0) 60%) no-repeat fixed,
radial-gradient(900px 600px at 90% 90%, #2b0f3a 0%, var(--bg-0) 60%) no-repeat fixed,
var(--bg-0);
color: var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, "Helvetica Neue", Arial, sans-serif;
font-size: 15px;
line-height: 1.45;
}
body {
background-image:
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 32px 32px, 32px 32px;
}
a { color: var(--neon-cyan); }
a:focus-visible, button:focus-visible, select:focus-visible, [role="tab"]:focus-visible {
outline: 2px solid var(--neon-cyan);
outline-offset: 2px;
border-radius: 6px;
}
button { font-family: inherit; }
/* Layout */
header.topbar {
position: sticky; top: 0; z-index: 5;
backdrop-filter: blur(10px);
background: rgba(5, 6, 13, 0.7);
border-bottom: 1px solid var(--line);
display: flex; align-items: center; gap: 12px;
padding: 10px 18px;
}
.logo {
font-weight: 800; letter-spacing: 0.18em;
font-size: 13px; text-transform: uppercase;
background: linear-gradient(90deg, var(--neon-cyan), var(--neon-pink));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.crumbs { color: var(--ink-mute); font-size: 12px; }
.crumbs strong { color: var(--ink); }
.spacer { flex: 1; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
border: 1px solid var(--line); background: var(--bg-1);
font-size: 12px; color: var(--ink-mute);
}
.pill .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--neon-green); box-shadow: 0 0 8px var(--neon-green);
}
.pill.warn .dot { background: var(--neon-yellow); box-shadow: 0 0 8px var(--neon-yellow); }
.pill.bad .dot { background: var(--neon-red); box-shadow: 0 0 8px var(--neon-red); }
main { max-width: 1480px; margin: 0 auto; padding: 18px; }
/* Welcome screen */
.welcome {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 22px;
align-items: stretch;
}
@media (max-width: 1100px) { .welcome { grid-template-columns: 1fr; } }
.card {
background: linear-gradient(180deg, rgba(20, 24, 50, 0.85), rgba(11, 14, 26, 0.85));
border: 1px solid var(--line);
border-radius: 14px;
padding: 20px;
box-shadow: var(--shadow-soft);
}
.card h1, .card h2, .card h3 { margin-top: 0; }
h1 { font-size: 28px; letter-spacing: 0.02em; }
h2 { font-size: 18px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink); }
h3 { font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-mute); }
.lead { font-size: 16px; color: var(--ink-mute); margin: 6px 0 14px; }
.kbd {
display: inline-block; padding: 2px 6px; border-radius: 5px;
background: var(--bg-2); border: 1px solid var(--line);
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Consolas, monospace;
font-size: 11px;
}
.start-btn {
appearance: none; border: 0; cursor: pointer;
padding: 16px 22px; border-radius: 12px;
font-size: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
color: #06121b; background: linear-gradient(90deg, var(--neon-cyan), var(--neon-green));
box-shadow: 0 8px 30px rgba(88, 245, 255, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.start-btn:hover { transform: translateY(-1px); box-shadow: 0 14px 40px rgba(88, 245, 255, 0.5); }
.start-btn:disabled { filter: grayscale(0.7); cursor: not-allowed; }
.ghost-btn {
appearance: none; cursor: pointer;
padding: 10px 14px; border-radius: 10px;
border: 1px solid var(--line); background: var(--bg-2);
color: var(--ink); font-weight: 600; font-size: 13px; letter-spacing: 0.04em;
}
.ghost-btn:hover { border-color: var(--neon-cyan); color: var(--neon-cyan); }
.ghost-btn[aria-pressed="true"] {
border-color: var(--neon-cyan); color: var(--neon-cyan);
background: rgba(88, 245, 255, 0.08);
}
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
select {
font: inherit; color: var(--ink);
background: var(--bg-2); border: 1px solid var(--line);
border-radius: 10px; padding: 9px 12px;
}
label { font-size: 12px; color: var(--ink-mute); letter-spacing: 0.06em; text-transform: uppercase; }
.mission-list { list-style: none; padding: 0; margin: 12px 0 0; display: grid; gap: 10px; }
.mission-list li {
border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px;
background: rgba(11, 14, 26, 0.6);
display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
}
.mission-list li.selected { border-color: var(--neon-cyan); box-shadow: 0 0 0 1px var(--neon-cyan) inset; }
.diff {
font-size: 11px; padding: 2px 8px; border-radius: 999px; letter-spacing: 0.1em;
text-transform: uppercase; font-weight: 700;
}
.diff.easy { background: rgba(77, 255, 156, 0.15); color: var(--neon-green); }
.diff.medium { background: rgba(255, 216, 77, 0.15); color: var(--neon-yellow); }
.diff.hard { background: rgba(255, 91, 107, 0.15); color: var(--neon-red); }
.mission-list li button {
border: 0; background: transparent; color: var(--ink); cursor: pointer;
text-align: left; padding: 0; font: inherit;
}
.mission-list li .obj { color: var(--ink-mute); font-size: 13px; }
/* How-to mini blocks */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 720px) { .grid-2 { grid-template-columns: 1fr; } }
.step {
border: 1px dashed var(--line); border-radius: 10px; padding: 10px 12px;
background: rgba(255, 255, 255, 0.02);
}
.step b { color: var(--neon-cyan); }
/* Live play layout */
.play-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 16px;
}
@media (max-width: 1100px) { .play-grid { grid-template-columns: 1fr; } }
.stage {
position: relative; aspect-ratio: 16 / 10;
border-radius: 14px; overflow: hidden;
background:
radial-gradient(60% 60% at 50% 60%, rgba(88, 245, 255, 0.06), transparent 70%),
linear-gradient(180deg, #0a0e26 0%, #04060f 100%);
border: 1px solid var(--line);
}
.stage canvas { width: 100%; height: 100%; display: block; }
.stage .overlay {
position: absolute; inset: 0; pointer-events: none;
display: grid; grid-template-rows: auto 1fr auto; padding: 12px;
}
.briefing {
align-self: start; justify-self: start;
pointer-events: auto;
background: linear-gradient(180deg, rgba(13, 16, 38, 0.95), rgba(7, 9, 19, 0.95));
border: 1px solid var(--line); border-radius: 12px;
padding: 10px 14px; max-width: 60%;
font-size: 13px;
}
.briefing .obj-line { color: var(--ink-mute); font-size: 12px; margin-top: 4px; }
.resources-hud {
pointer-events: auto;
align-self: end; justify-self: stretch;
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px;
}
.res {
border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px;
background: rgba(7, 9, 19, 0.85);
}
.res .name { font-size: 10px; letter-spacing: 0.16em; color: var(--ink-mute); text-transform: uppercase; }
.res .val { font-weight: 700; font-size: 16px; }
.res .bar { height: 4px; border-radius: 2px; background: rgba(255,255,255,0.08); overflow: hidden; margin-top: 4px; }
.res .bar > span { display: block; height: 100%; background: linear-gradient(90deg, var(--neon-cyan), var(--neon-green)); transition: width 250ms ease; }
.res.short .bar > span { background: linear-gradient(90deg, var(--neon-yellow), var(--neon-red)); }
.badges { position: absolute; top: 12px; right: 12px; display: flex; gap: 6px; pointer-events: auto; }
.badge {
font-size: 11px; padding: 4px 9px; border-radius: 999px;
background: rgba(7, 9, 19, 0.85); border: 1px solid var(--line); color: var(--ink-mute);
}
.badge.alive { color: var(--neon-cyan); border-color: var(--neon-cyan); }
/* Dashboard */
.dash { display: grid; gap: 16px; }
.dash-card { padding: 14px 16px; }
.dash-card .title-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.dash-card .title-row .dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--neon-violet); box-shadow: 0 0 6px var(--neon-violet);
}
.scroller { max-height: 240px; overflow: auto; padding-right: 4px; }
.scroller::-webkit-scrollbar { width: 6px; }
.scroller::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
.turn {
border-left: 2px solid var(--neon-violet);
padding: 6px 10px; margin: 6px 0;
background: rgba(164, 123, 255, 0.06);
border-radius: 0 8px 8px 0;
font-size: 13px;
animation: slideIn 200ms ease;
}
@keyframes slideIn { from { opacity: 0; transform: translateX(-6px); } to { opacity: 1; transform: none; } }
.turn .verb { font-weight: 700; color: var(--neon-cyan); }
.turn .args { color: var(--ink-mute); font-size: 12px; }
.turn .reward { float: right; font-variant-numeric: tabular-nums; }
.turn .reward.pos { color: var(--neon-green); }
.turn .reward.neg { color: var(--neon-red); }
/* Model mind */
.mind {
background: linear-gradient(180deg, rgba(8, 10, 24, 0.95), rgba(5, 6, 13, 0.95));
border: 1px solid rgba(164, 123, 255, 0.3);
border-radius: 14px;
padding: 14px 16px;
}
.mind .meta { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.mind pre {
margin: 4px 0; padding: 8px 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--line); border-radius: 8px;
font-family: ui-monospace, "JetBrains Mono", Consolas, monospace;
font-size: 12px; color: var(--ink-mute);
white-space: pre-wrap; word-break: break-word;
max-height: 160px; overflow: auto;
}
.mind .raw { color: var(--ink); }
.mind .raw .cursor { display: inline-block; width: 8px; background: var(--neon-cyan); margin-left: 1px; animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.mind .parsed { color: var(--neon-green); }
.mind .label {
font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-mute);
margin: 8px 0 4px;
}
/* Reward */
.reward-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
@media (max-width: 720px) { .reward-grid { grid-template-columns: 1fr; } }
.meter { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); }
.meter .name { font-size: 11px; color: var(--ink-mute); letter-spacing: 0.08em; text-transform: uppercase; }
.meter .val { font-weight: 800; font-size: 18px; }
.meter .bar { height: 5px; border-radius: 3px; background: rgba(255,255,255,0.06); margin-top: 6px; overflow: hidden; }
.meter .bar > span { display: block; height: 100%; transition: width 280ms ease; background: linear-gradient(90deg, var(--neon-violet), var(--neon-pink)); }
/* Reward chart */
.chart { width: 100%; height: 130px; }
/* Gates */
.gates { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.gate {
padding: 8px; border-radius: 10px; border: 1px solid var(--line);
background: rgba(255,255,255,0.02);
font-size: 11px; text-align: center; color: var(--ink-mute);
transition: all 280ms ease;
}
.gate.unlocked {
border-color: var(--neon-green); color: var(--neon-green);
background: rgba(77, 255, 156, 0.08);
box-shadow: 0 0 0 1px var(--neon-green) inset;
}
.gate b { display: block; font-size: 11px; color: inherit; }
.gate .icon { font-size: 16px; margin-bottom: 2px; }
/* Race lanes */
.race {
display: grid; gap: 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 1100px) { .race { grid-template-columns: 1fr; } }
.lane { padding: 14px; border-radius: 14px; border: 1px solid var(--line); background: rgba(11, 14, 26, 0.85); }
.lane .head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.lane.trained .head .dot { background: var(--neon-cyan); box-shadow: 0 0 6px var(--neon-cyan); }
.lane.heuristic .head .dot { background: var(--neon-yellow); box-shadow: 0 0 6px var(--neon-yellow); }
.lane.random .head .dot { background: var(--neon-pink); box-shadow: 0 0 6px var(--neon-pink); }
.lane .head .dot { width: 10px; height: 10px; border-radius: 50%; }
.lane .head .name { font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; font-size: 13px; }
.lane .head .turn { margin-left: auto; color: var(--ink-mute); font-size: 12px; font-variant-numeric: tabular-nums; }
.lane .gauge { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.06); overflow: hidden; }
.lane .gauge > span { display: block; height: 100%; transition: width 280ms ease; }
.lane.trained .gauge > span { background: linear-gradient(90deg, var(--neon-cyan), var(--neon-green)); }
.lane.heuristic .gauge > span { background: linear-gradient(90deg, var(--neon-yellow), var(--neon-pink)); }
.lane.random .gauge > span { background: linear-gradient(90deg, var(--neon-pink), var(--neon-violet)); }
.lane .stat-row { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0; font-size: 12px; }
.lane .stat-row .stat { background: rgba(255,255,255,0.04); border: 1px solid var(--line); border-radius: 8px; padding: 4px 8px; }
.lane .stat-row .stat b { color: var(--ink); }
.lane .log { max-height: 200px; overflow: auto; }
.lane .log .turn { font-size: 12px; padding: 4px 8px; }
.lane .ribbon { font-weight: 700; padding: 4px 10px; border-radius: 999px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
.lane.win .ribbon { background: rgba(77, 255, 156, 0.15); color: var(--neon-green); }
.lane.lose .ribbon { background: rgba(255, 91, 107, 0.15); color: var(--neon-red); }
.lane.timeout .ribbon { background: rgba(255, 216, 77, 0.15); color: var(--neon-yellow); }
.ann {
color: var(--ink-mute); font-size: 12px; margin-top: 8px;
}
.toolbar {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
margin-bottom: 14px;
}
.toolbar .grow { flex: 1; }
.footer { color: var(--ink-mute); font-size: 12px; padding: 30px 0 16px; text-align: center; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.hidden { display: none !important; }
/* Tabs */
.tabs { display: inline-flex; padding: 4px; gap: 2px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; }
.tab {
appearance: none; cursor: pointer; padding: 8px 14px; border-radius: 8px;
background: transparent; color: var(--ink-mute); border: 0;
font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
}
.tab[aria-selected="true"] { background: rgba(88, 245, 255, 0.12); color: var(--neon-cyan); }
/* End card */
.end-card { display: grid; grid-template-columns: auto 1fr auto; gap: 16px; align-items: center; }
.end-card .verdict { font-size: 24px; font-weight: 800; letter-spacing: 0.04em; }
.end-card.win .verdict { color: var(--neon-green); }
.end-card.lose .verdict { color: var(--neon-red); }
.end-card.timeout .verdict { color: var(--neon-yellow); }
</style>
</head>
<body>
<a class="sr-only" href="#main">Skip to main content</a>
<header class="topbar" role="banner">
<span class="logo">Neon Syndicate</span>
<span class="crumbs"><strong>Live Play</strong> · the trained agent runs the mission</span>
<span class="spacer"></span>
<span id="trainedPill" class="pill warn"><span class="dot"></span><span id="trainedPillText">checking model…</span></span>
<a class="ghost-btn" href="/judge">⌬ Judge view</a>
<a class="ghost-btn" href="/lab">⌬ Lab</a>
<a class="ghost-btn" href="/walkthrough">⌬ Walkthrough</a>
<a class="ghost-btn" href="/heist">Play the heist</a>
<a class="ghost-btn" href="/docs" target="_blank" rel="noopener">API</a>
</header>
<main id="main">
<!-- WELCOME -->
<section id="welcome" aria-labelledby="welcomeTitle">
<div class="welcome">
<div class="card">
<h1 id="welcomeTitle">An AI plays a cyberpunk strategy game. You watch its mind.</h1>
<p class="lead">
Pick a mission, hit <b>Start</b>, and watch a PPO-trained Qwen 2.5
model run a partially-observable strategy environment turn by turn.
The dashboard shows the prompt the model sees, the raw text it
generates, the parsed action, and how every reward component reacts.
Race the trained model against a hand-coded baseline and pure
randomness to see whether learning actually transferred.
</p>
<div class="row" style="margin-top: 8px;">
<div class="tabs" role="tablist" aria-label="Mode">
<button class="tab" role="tab" id="tabSolo" aria-selected="true" aria-controls="soloMode">Solo Run</button>
<button class="tab" role="tab" id="tabRace" aria-selected="false" aria-controls="raceMode">Triple Race</button>
</div>
<div class="grow"></div>
<label for="policySel">Policy</label>
<select id="policySel" aria-label="Policy to play in solo run">
<option value="hybrid" selected>Hybrid (LLM + guardrails) ★</option>
<option value="trained">Trained PPO model (raw)</option>
<option value="heuristic">Heuristic baseline</option>
<option value="random">Random baseline</option>
</select>
</div>
<h3 style="margin-top: 18px;">Pick a mission</h3>
<ul class="mission-list" id="missionList" role="listbox" aria-label="Missions"></ul>
<div class="row" style="margin-top: 16px;">
<button id="startBtn" class="start-btn" aria-describedby="startHelp">▶ Start Mission</button>
<span id="startHelp" class="ann">Press <span class="kbd">Space</span> to play / pause, <span class="kbd">R</span> to restart, <span class="kbd">1-6</span> to jump missions.</span>
</div>
</div>
<div class="card">
<h2>How the game works</h2>
<div class="grid-2" style="margin-top: 8px;">
<div class="step"><b>1. Build alliances.</b> Negotiate with Ghostwire, Iron Vultures, Civic Shield, or Black Orchid.</div>
<div class="step"><b>2. Gather intel.</b> Scout sectors to learn patrol patterns and find the right extraction point.</div>
<div class="step"><b>3. Trade resources.</b> Convert credits ↔ intel ↔ influence ↔ energy when stocks run low.</div>
<div class="step"><b>4. Deploy assets.</b> Pre-position before running an operation; energy is the cost.</div>
<div class="step"><b>5. Run the right op.</b> Each mission has a specific code. Wrong code = penalty.</div>
<div class="step"><b>6. Secure extraction.</b> Right sector, right keywords in the message, in 12 turns or less.</div>
</div>
<h3 style="margin-top: 16px;">What the dashboard shows</h3>
<ul class="ann" style="margin: 4px 0; padding-left: 18px;">
<li><b>Model's mind</b> — the prompt sent to the LLM, its raw text, and the parsed JSON action.</li>
<li><b>Reward forensics</b> — per-turn reward broken into alliances, resources, operation, message, extraction.</li>
<li><b>Mission gates</b> — five locks; once all five are green, extraction succeeds.</li>
<li><b>Turn log</b> — every action with reason and reward delta. Scrollable, replay-friendly.</li>
</ul>
<h3 style="margin-top: 16px;">Triple Race mode</h3>
<p class="ann" style="margin: 4px 0;">
The trained policy, the heuristic, and a random agent all play the
same mission in lock-step lanes. Cumulative reward, gates unlocked,
and final outcome are tracked side by side. Spoilers: the random
agent rarely wins.
</p>
</div>
</div>
</section>
<!-- LIVE -->
<section id="liveScreen" class="hidden" aria-labelledby="liveTitle">
<div class="toolbar" role="region" aria-label="Mission controls">
<button class="ghost-btn" id="backBtn" aria-label="Back to mission select">← Missions</button>
<button class="ghost-btn" id="restartBtn" aria-label="Restart current mission">↻ Restart</button>
<span class="grow"></span>
<span id="liveStatusPill" class="pill"><span class="dot"></span><span id="liveStatusText">Briefing…</span></span>
<span id="cumPill" class="pill"><span class="dot"></span>Σ reward <b id="cumValue" style="margin-left:4px;color:var(--ink)">0.000</b></span>
<span id="turnPill" class="pill"><span class="dot"></span>turn <b id="turnValue" style="margin-left:4px;color:var(--ink)">0/12</b></span>
</div>
<!-- Solo mode -->
<div id="soloMode" role="tabpanel" aria-labelledby="tabSolo">
<div class="play-grid">
<div>
<div class="stage" aria-label="Game world canvas" role="img">
<canvas id="worldCanvas" width="1280" height="800"></canvas>
<div class="overlay">
<div class="briefing card" id="briefingCard" aria-live="polite">
<h3 style="margin:0; color: var(--neon-cyan);" id="missionTitleEl">Mission</h3>
<div id="missionMetaEl" class="obj-line"></div>
</div>
<div></div>
<div class="resources-hud" id="resourcesHud" aria-label="Resources"></div>
<div class="badges">
<span class="badge" id="threatBadge">threat —</span>
<span class="badge" id="alliancesBadge">0 / — allies</span>
</div>
</div>
</div>
<h2 id="liveTitle" class="sr-only">Live agent run</h2>
<div class="dash-card mind" style="margin-top: 14px;" aria-live="polite">
<div class="title-row">
<span class="dot" style="background: var(--neon-cyan); box-shadow: 0 0 6px var(--neon-cyan);"></span>
<h2 style="font-size: 14px; margin: 0;">Model's mind</h2>
<span class="grow"></span>
<span class="badge" id="mindLatency">— ms</span>
<span class="badge" id="mindFallback">native</span>
</div>
<div class="label">Prompt sent to model</div>
<pre id="mindPrompt">—</pre>
<div class="label">Raw text generated</div>
<pre id="mindRaw" class="raw">—</pre>
<div class="label">Parsed action</div>
<pre id="mindParsed" class="parsed">—</pre>
</div>
</div>
<aside class="dash" aria-label="Learning dashboard">
<div class="card dash-card">
<div class="title-row">
<span class="dot" style="background: var(--neon-pink); box-shadow: 0 0 6px var(--neon-pink);"></span>
<h2 style="font-size: 14px; margin: 0;">Reward forensics</h2>
<span class="grow"></span>
<span class="badge" id="lastDeltaBadge">Δ 0.000</span>
</div>
<div class="reward-grid" id="rewardGrid"></div>
<h3 style="margin-top: 14px;">Reward over turns</h3>
<svg class="chart" id="rewardChart" viewBox="0 0 400 130" preserveAspectRatio="none" aria-label="Reward chart"></svg>
</div>
<div class="card dash-card">
<div class="title-row">
<span class="dot" style="background: var(--neon-green); box-shadow: 0 0 6px var(--neon-green);"></span>
<h2 style="font-size: 14px; margin: 0;">Mission gates</h2>
</div>
<div class="gates" id="gates">
<div class="gate" data-key="alliance"><span class="icon">🤝</span><b>Alliance</b><span>Form required pacts</span></div>
<div class="gate" data-key="resources"><span class="icon">⚡</span><b>Resources</b><span>Hit thresholds</span></div>
<div class="gate" data-key="deploy"><span class="icon">📍</span><b>Deploy</b><span>Asset placed</span></div>
<div class="gate" data-key="operation"><span class="icon">🛰️</span><b>Operation</b><span>Correct code run</span></div>
<div class="gate" data-key="extraction"><span class="icon">🚁</span><b>Extraction</b><span>Clean exit</span></div>
</div>
</div>
<div class="card dash-card">
<div class="title-row">
<span class="dot"></span>
<h2 style="font-size: 14px; margin: 0;">Turn log</h2>
<span class="grow"></span>
<span id="logSummary" class="badge">0 turns</span>
</div>
<div class="scroller" id="turnLog" tabindex="0" aria-label="Scrollable turn log"></div>
</div>
</aside>
</div>
</div>
<!-- Race mode -->
<div id="raceMode" role="tabpanel" aria-labelledby="tabRace" class="hidden">
<div class="card" style="padding: 14px; margin-bottom: 14px;">
<div class="row">
<span id="raceMissionEl" class="badge alive" style="font-size: 13px;">mission</span>
<span class="grow"></span>
<span class="ann" id="raceObjectiveEl"></span>
</div>
</div>
<div class="race">
<article class="lane trained" data-lane="trained" aria-label="Trained PPO model lane">
<div class="head"><span class="dot"></span><span class="name">Trained PPO</span><span class="turn" id="laneTurn-trained">0/12</span></div>
<div class="gauge"><span id="laneGauge-trained" style="width:0%"></span></div>
<div class="stat-row">
<span class="stat">Σ <b id="laneCum-trained">0.000</b></span>
<span class="stat">grade <b id="laneGrade-trained">0.000</b></span>
<span class="stat">gates <b id="laneGates-trained">0/5</b></span>
<span class="ribbon" id="laneRibbon-trained" style="display:none;"></span>
</div>
<div class="log scroller" id="laneLog-trained"></div>
</article>
<article class="lane heuristic" data-lane="heuristic" aria-label="Heuristic baseline lane">
<div class="head"><span class="dot"></span><span class="name">Heuristic</span><span class="turn" id="laneTurn-heuristic">0/12</span></div>
<div class="gauge"><span id="laneGauge-heuristic" style="width:0%"></span></div>
<div class="stat-row">
<span class="stat">Σ <b id="laneCum-heuristic">0.000</b></span>
<span class="stat">grade <b id="laneGrade-heuristic">0.000</b></span>
<span class="stat">gates <b id="laneGates-heuristic">0/5</b></span>
<span class="ribbon" id="laneRibbon-heuristic" style="display:none;"></span>
</div>
<div class="log scroller" id="laneLog-heuristic"></div>
</article>
<article class="lane random" data-lane="random" aria-label="Random baseline lane">
<div class="head"><span class="dot"></span><span class="name">Random</span><span class="turn" id="laneTurn-random">0/12</span></div>
<div class="gauge"><span id="laneGauge-random" style="width:0%"></span></div>
<div class="stat-row">
<span class="stat">Σ <b id="laneCum-random">0.000</b></span>
<span class="stat">grade <b id="laneGrade-random">0.000</b></span>
<span class="stat">gates <b id="laneGates-random">0/5</b></span>
<span class="ribbon" id="laneRibbon-random" style="display:none;"></span>
</div>
<div class="log scroller" id="laneLog-random"></div>
</article>
</div>
</div>
<div class="card hidden" id="endCard" style="margin-top: 14px;">
<div class="end-card">
<div class="verdict" id="endVerdict">Mission complete</div>
<div>
<div class="ann" id="endSummary"></div>
</div>
<div class="row">
<button class="ghost-btn" id="playAgainBtn">↻ Play again</button>
<button class="ghost-btn" id="changeMissionBtn">← Change mission</button>
</div>
</div>
</div>
</section>
<p class="footer">
Neon Syndicate is a partially observable, long-horizon strategy environment for OpenEnv.
The trained model in this demo is a 0.5B parameter Qwen 2.5 fine-tuned with TRL PPO.
Trace honestly reflects when the model output cannot be parsed and the heuristic takes over.
</p>
</main>
<script>
"use strict";
// ---------------------------------------------------------------------------
// Static task knowledge -- mirrors server/environment.py exactly so the UI
// can render expected gates/keywords without an extra round-trip.
// ---------------------------------------------------------------------------
const TASK_TARGETS = {
task_easy_docklands_relay: {
title: "Docklands Relay Hijack",
required_allies: ["ghostwire"],
required_op: "OP-LANTERN",
extraction_sector: "docklands",
min_resources: { credits: 30, intel: 35, influence: 20, energy: 20 },
keywords: ["window", "relay", "clean exit"],
},
task_easy_data_spire_broker: {
title: "Data Spire Broker Run",
required_allies: ["civic_shield"],
required_op: "OP-PRISM",
extraction_sector: "data_spire",
min_resources: { credits: 25, intel: 40, influence: 30, energy: 15 },
keywords: ["broker", "keys", "exfil"],
},
task_medium_undergrid_blackout: {
title: "Undergrid Blackout Play",
required_allies: ["ghostwire", "iron_vultures"],
required_op: "OP-NIGHTLOCK",
extraction_sector: "undergrid",
min_resources: { credits: 40, intel: 55, influence: 30, energy: 35 },
keywords: ["stabilized", "fallback", "undergrid"],
},
task_medium_citadel_convoy: {
title: "Citadel Convoy Diversion",
required_allies: ["black_orchid", "civic_shield"],
required_op: "OP-HALO",
extraction_sector: "citadel_gate",
min_resources: { credits: 35, intel: 50, influence: 45, energy: 30 },
keywords: ["convoy", "handoff", "silent"],
},
task_hard_orchid_coup: {
title: "Orchid Coup Containment",
required_allies: ["ghostwire", "civic_shield", "black_orchid"],
required_op: "OP-OBSIDIAN",
extraction_sector: "undergrid",
min_resources: { credits: 45, intel: 70, influence: 65, energy: 45 },
keywords: ["containment", "chain of command", "stabilized"],
},
task_hard_citywide_failsafe: {
title: "Citywide Failsafe Cascade",
required_allies: ["ghostwire", "iron_vultures", "civic_shield"],
required_op: "OP-DAWNFALL",
extraction_sector: "data_spire",
min_resources: { credits: 50, intel: 80, influence: 60, energy: 55 },
keywords: ["failsafe", "recovered", "city grid"],
},
};
const SECTORS = ["docklands", "data_spire", "undergrid", "citadel_gate"];
const SECTOR_LABELS = {
docklands: "Docklands",
data_spire: "Data Spire",
undergrid: "Undergrid",
citadel_gate: "Citadel Gate",
};
const FACTION_COLOR = {
ghostwire: "#58f5ff",
iron_vultures: "#ff5b6b",
civic_shield: "#4dff9c",
black_orchid: "#a47bff",
};
const ACTION_VERBS = {
scout_sector: "Scouts",
negotiate_pact: "Negotiates",
trade_resources: "Trades",
deploy_asset: "Deploys",
run_operation: "Runs op",
secure_extraction: "Secures extraction",
noop: "Stalls",
};
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const state = {
tasks: [],
selectedTask: null,
policy: "hybrid",
mode: "solo",
source: null, // EventSource
rewards: [], // [{turn, score, components, cumulative}]
components: [],
cumulative: 0,
turn: 0,
observation: null,
lastAction: null,
agentSector: null,
fxQueue: [],
trainedAvailable: null,
};
const lanes = {};
function laneState(id) {
if (!lanes[id]) {
lanes[id] = {
cumulative: 0,
turn: 0,
gates: { alliance: false, resources: false, deploy: false, operation: false, extraction: false },
done: false,
};
}
return lanes[id];
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
async function boot() {
await Promise.all([loadTasks(), loadAgentInfo()]);
hookControls();
installKeys();
}
async function loadTasks() {
try {
const resp = await fetch("/tasks");
const data = await resp.json();
state.tasks = data.tasks || [];
} catch (e) {
state.tasks = Object.keys(TASK_TARGETS).map((id) => ({
task_id: id, title: TASK_TARGETS[id].title,
difficulty: id.includes("hard") ? "hard" : id.includes("medium") ? "medium" : "easy",
objective: "(offline)",
}));
}
renderMissionList();
if (state.tasks.length) selectTask(state.tasks[0].task_id);
}
async function loadAgentInfo() {
try {
const resp = await fetch("/agent/info");
const info = await resp.json();
state.trainedAvailable = !!(info.trained && info.trained.available);
const text = state.trainedAvailable
? "Trained model loaded"
: (info.trained && info.trained.checkpoint_dir
? "Trained model on disk · loads on first call"
: "Trained checkpoint not found · heuristic stand-in");
setTrainedPill(text, state.trainedAvailable ? "ok" : "warn");
} catch (e) {
setTrainedPill("Agent endpoint unreachable", "bad");
}
}
function setTrainedPill(text, level) {
const pill = document.getElementById("trainedPill");
pill.classList.remove("warn", "bad");
if (level === "warn") pill.classList.add("warn");
if (level === "bad") pill.classList.add("bad");
document.getElementById("trainedPillText").textContent = text;
}
function renderMissionList() {
const ul = document.getElementById("missionList");
ul.innerHTML = "";
state.tasks.forEach((t, i) => {
const li = document.createElement("li");
li.dataset.taskId = t.task_id;
if (t.task_id === state.selectedTask) li.classList.add("selected");
li.innerHTML = `
<span class="diff ${t.difficulty}">${t.difficulty}</span>
<button type="button" aria-label="Select mission ${t.title}">
<div><b>${t.title}</b></div>
<div class="obj">${t.objective || ""}</div>
</button>
<span class="kbd">${i + 1}</span>
`;
li.querySelector("button").addEventListener("click", () => selectTask(t.task_id));
ul.appendChild(li);
});
}
function selectTask(taskId) {
state.selectedTask = taskId;
document.querySelectorAll("#missionList li").forEach((li) => {
li.classList.toggle("selected", li.dataset.taskId === taskId);
});
}
function hookControls() {
document.getElementById("startBtn").addEventListener("click", startMission);
document.getElementById("backBtn").addEventListener("click", goBack);
document.getElementById("restartBtn").addEventListener("click", () => { stopStream(); startMission(); });
document.getElementById("playAgainBtn").addEventListener("click", () => { stopStream(); startMission(); });
document.getElementById("changeMissionBtn").addEventListener("click", goBack);
document.getElementById("policySel").addEventListener("change", (e) => { state.policy = e.target.value; });
document.getElementById("tabSolo").addEventListener("click", () => setMode("solo"));
document.getElementById("tabRace").addEventListener("click", () => setMode("race"));
}
function setMode(mode) {
state.mode = mode;
document.getElementById("tabSolo").setAttribute("aria-selected", mode === "solo");
document.getElementById("tabRace").setAttribute("aria-selected", mode === "race");
document.getElementById("policySel").parentElement.style.opacity = mode === "race" ? "0.5" : "1";
}
function installKeys() {
document.addEventListener("keydown", (e) => {
if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "SELECT")) return;
if (e.key === "r" || e.key === "R") {
if (!document.getElementById("liveScreen").classList.contains("hidden")) {
stopStream(); startMission();
}
}
const idx = parseInt(e.key, 10);
if (!isNaN(idx) && idx >= 1 && idx <= state.tasks.length) {
selectTask(state.tasks[idx - 1].task_id);
}
});
}
// ---------------------------------------------------------------------------
// Streaming
// ---------------------------------------------------------------------------
function startMission() {
if (!state.selectedTask) return;
resetUI();
document.getElementById("welcome").classList.add("hidden");
document.getElementById("liveScreen").classList.remove("hidden");
document.getElementById("soloMode").classList.toggle("hidden", state.mode !== "solo");
document.getElementById("raceMode").classList.toggle("hidden", state.mode !== "race");
const target = TASK_TARGETS[state.selectedTask] || {};
const task = state.tasks.find((t) => t.task_id === state.selectedTask) || {};
const titleEl = document.getElementById("missionTitleEl");
if (titleEl) titleEl.textContent = task.title || target.title || state.selectedTask;
const metaEl = document.getElementById("missionMetaEl");
if (metaEl) metaEl.textContent = task.objective || "";
const raceMissionEl = document.getElementById("raceMissionEl");
if (raceMissionEl) raceMissionEl.textContent = task.title || target.title || state.selectedTask;
const raceObj = document.getElementById("raceObjectiveEl");
if (raceObj) raceObj.textContent = task.objective || "";
if (state.mode === "solo") {
const url = `/agent/episode?task_id=${encodeURIComponent(state.selectedTask)}&policy=${encodeURIComponent(state.policy)}`;
state.source = new EventSource(url);
state.source.addEventListener("meta", (m) => onMeta(JSON.parse(m.data)));
state.source.addEventListener("reset", (m) => onReset(JSON.parse(m.data)));
state.source.addEventListener("think", (m) => onThink(JSON.parse(m.data)));
state.source.addEventListener("step", (m) => onStep(JSON.parse(m.data)));
state.source.addEventListener("done", (m) => onDone(JSON.parse(m.data)));
state.source.addEventListener("error", () => { setLiveStatus("Stream interrupted", "bad"); });
} else {
const url = `/agent/race?task_id=${encodeURIComponent(state.selectedTask)}&policies=trained,heuristic,random`;
state.source = new EventSource(url);
state.source.addEventListener("race_meta", (m) => onRaceMeta(JSON.parse(m.data)));
state.source.addEventListener("reset", (m) => onRaceReset(JSON.parse(m.data)));
state.source.addEventListener("think", () => {});
state.source.addEventListener("step", (m) => onRaceStep(JSON.parse(m.data)));
state.source.addEventListener("done", (m) => onRaceLaneDone(JSON.parse(m.data)));
state.source.addEventListener("race_done", () => onRaceFinish());
state.source.addEventListener("error", () => { setLiveStatus("Stream interrupted", "bad"); });
}
}
function stopStream() {
if (state.source) {
try { state.source.close(); } catch (e) {}
state.source = null;
}
}
function goBack() {
stopStream();
document.getElementById("liveScreen").classList.add("hidden");
document.getElementById("welcome").classList.remove("hidden");
}
function resetUI() {
state.rewards = [];
state.components = [];
state.cumulative = 0;
state.turn = 0;
state.observation = null;
state.agentSector = null;
Object.keys(lanes).forEach((k) => delete lanes[k]);
document.getElementById("turnLog").innerHTML = "";
["trained", "heuristic", "random"].forEach((id) => {
const log = document.getElementById(`laneLog-${id}`);
if (log) log.innerHTML = "";
const ribbon = document.getElementById(`laneRibbon-${id}`);
if (ribbon) { ribbon.style.display = "none"; ribbon.textContent = ""; }
const lane = document.querySelector(`.lane[data-lane="${id}"]`);
if (lane) { lane.classList.remove("win", "lose", "timeout"); }
const gauge = document.getElementById(`laneGauge-${id}`);
if (gauge) gauge.style.width = "0%";
const cum = document.getElementById(`laneCum-${id}`); if (cum) cum.textContent = "0.000";
const grd = document.getElementById(`laneGrade-${id}`); if (grd) grd.textContent = "0.000";
const gts = document.getElementById(`laneGates-${id}`); if (gts) gts.textContent = "0/5";
const trn = document.getElementById(`laneTurn-${id}`); if (trn) trn.textContent = "0/12";
});
document.getElementById("endCard").classList.add("hidden");
document.getElementById("cumValue").textContent = "0.000";
document.getElementById("turnValue").textContent = "0/12";
document.getElementById("mindPrompt").textContent = "—";
document.getElementById("mindRaw").textContent = "—";
document.getElementById("mindParsed").textContent = "—";
document.getElementById("mindLatency").textContent = "— ms";
document.getElementById("mindFallback").textContent = "native";
document.getElementById("rewardGrid").innerHTML = "";
document.getElementById("rewardChart").innerHTML = "";
document.querySelectorAll(".gate").forEach((g) => g.classList.remove("unlocked"));
setLiveStatus("Briefing…", "warn");
drawWorld();
}
function setLiveStatus(text, level) {
const pill = document.getElementById("liveStatusPill");
pill.classList.remove("warn", "bad");
if (level === "warn") pill.classList.add("warn");
if (level === "bad") pill.classList.add("bad");
document.getElementById("liveStatusText").textContent = text;
}
// ---------------------------------------------------------------------------
// Solo handlers
// ---------------------------------------------------------------------------
function onMeta(d) {
const target = TASK_TARGETS[d.task_id] || {};
const allyTotal = (target.required_allies || []).length;
const ab = document.getElementById("alliancesBadge");
if (ab) ab.textContent = `0 / ${allyTotal} allies`;
if (d.policy === "trained" && d.trained_available === false) {
setLiveStatus("Trained model unavailable · using heuristic", "warn");
} else {
setLiveStatus(`Playing · policy = ${d.policy}`, "ok");
}
}
function onReset(d) {
state.observation = d.observation;
state.agentSector = null;
document.getElementById("turnValue").textContent = `0/${d.observation.max_steps || 12}`;
drawWorld();
refreshHud();
refreshGates(false);
}
function onThink(d) {
const trace = d.trace || {};
document.getElementById("mindPrompt").textContent = trace.prompt || "(no prompt)";
const raw = (trace.raw_text || "").trim();
document.getElementById("mindRaw").innerHTML = `${escapeHtml(raw || "—")}<span class="cursor"> </span>`;
let parsedText = "—";
if (trace.parsed) parsedText = JSON.stringify(trace.parsed, null, 2);
else if (trace.action) parsedText = JSON.stringify(trace.action, null, 2);
document.getElementById("mindParsed").textContent = parsedText;
document.getElementById("mindLatency").textContent = `${trace.latency_ms ?? 0} ms`;
let label = "native";
let color = "var(--ink-mute)";
if (trace.fallback_used) { label = "fallback"; color = "var(--neon-yellow)"; }
else if (trace.guardrail_used) { label = "guardrail"; color = "var(--neon-cyan)"; }
document.getElementById("mindFallback").textContent = label;
document.getElementById("mindFallback").style.color = color;
if (trace.guardrail_used && trace.proposed_action) {
const note = (trace.notes && trace.notes.length) ? trace.notes.join(" · ") : "env rule";
document.getElementById("mindRaw").innerHTML +=
`<div style="margin-top:6px; color: var(--neon-cyan); font-size: 11px;">[guardrail] LLM proposed ${escapeHtml(JSON.stringify(trace.proposed_action))} — swapped: ${escapeHtml(note)}</div>`;
}
}
function onStep(d) {
state.observation = d.observation;
state.lastAction = d.action;
state.turn = d.turn + 1;
state.cumulative = d.cumulative_reward;
state.rewards.push({
turn: d.turn,
score: d.reward.score,
components: d.reward.components || {},
cumulative: d.cumulative_reward,
info: d.info || {},
});
if (d.action && d.action.sector) state.agentSector = d.action.sector;
document.getElementById("turnValue").textContent = `${state.turn}/${state.observation.max_steps || 12}`;
document.getElementById("cumValue").textContent = state.cumulative.toFixed(3);
document.getElementById("lastDeltaBadge").textContent = `Δ ${d.reward.score >= 0 ? "+" : ""}${d.reward.score.toFixed(3)}`;
appendTurn(d);
refreshHud();
refreshGates(state.observation, d.info);
refreshComponents(d.reward.components || {});
refreshChart();
drawWorld(d.action);
}
function onDone(d) {
setLiveStatus(d.success ? "Mission complete · success" : "Episode ended", d.success ? "ok" : "bad");
const card = document.getElementById("endCard");
card.classList.remove("hidden", "win", "lose", "timeout");
let cls = "lose"; let verdict = "Mission failed";
if (d.success) { cls = "win"; verdict = "Mission complete"; }
else if (d.reason === "max_steps") { cls = "timeout"; verdict = "Out of turns"; }
card.classList.add(cls);
document.getElementById("endVerdict").textContent = verdict;
document.getElementById("endSummary").textContent =
`Final task score ${d.task_score.toFixed(3)} · cumulative reward ${d.cumulative_reward.toFixed(3)} · ${d.steps} turns`;
stopStream();
}
// ---------------------------------------------------------------------------
// Solo dashboard rendering
// ---------------------------------------------------------------------------
function appendTurn(d) {
const log = document.getElementById("turnLog");
const a = d.action || {};
const verb = ACTION_VERBS[a.action_type] || a.action_type || "—";
const argParts = [];
if (a.faction) argParts.push(a.faction);
if (a.sector) argParts.push(SECTOR_LABELS[a.sector] || a.sector);
if (a.resource) argParts.push(`${a.amount || ""} ${a.resource}`.trim());
if (a.operation_code) argParts.push(a.operation_code);
const args = argParts.length ? `· ${argParts.join(" / ")}` : "";
const r = d.reward.score;
const cls = r >= 0.05 ? "pos" : r <= -0.05 ? "neg" : "";
const wrap = document.createElement("div");
wrap.className = "turn";
wrap.innerHTML = `
<span class="reward ${cls}">${r >= 0 ? "+" : ""}${r.toFixed(3)}</span>
<div><span class="verb">${verb}</span> <span class="args">${escapeHtml(args)}</span></div>
<div class="args">turn ${d.turn + 1} · ${escapeHtml(d.reward.reason || "")}</div>
`;
log.prepend(wrap);
document.getElementById("logSummary").textContent = `${state.rewards.length} turns`;
}
function refreshHud() {
const o = state.observation;
if (!o) return;
const target = TASK_TARGETS[o.task_id] || {};
const need = target.min_resources || {};
const hud = document.getElementById("resourcesHud");
hud.innerHTML = "";
["credits", "intel", "influence", "energy"].forEach((k) => {
const have = (o.resources || {})[k] || 0;
const want = need[k] || 1;
const pct = Math.max(0, Math.min(100, (have / want) * 100));
const short = have < (need[k] || 0);
const div = document.createElement("div");
div.className = "res" + (short ? " short" : "");
div.innerHTML = `
<div class="name">${k}</div>
<div class="val">${have}<span style="color:var(--ink-mute);font-size:11px;"> / ${need[k] || "—"}</span></div>
<div class="bar"><span style="width:${pct}%"></span></div>
`;
hud.appendChild(div);
});
document.getElementById("threatBadge").textContent = `threat ${o.known_threat || "—"}`;
const allyTotal = (target.required_allies || []).length;
const allyHave = (o.alliances || []).filter((a) => (target.required_allies || []).includes(a)).length;
document.getElementById("alliancesBadge").textContent = `${allyHave} / ${allyTotal} allies`;
}
function refreshGates(observation, info) {
if (!observation) return;
const target = TASK_TARGETS[observation.task_id] || {};
const allies = observation.alliances || [];
const allianceOk = (target.required_allies || []).every((a) => allies.includes(a));
const need = target.min_resources || {};
const have = observation.resources || {};
const resourcesOk = Object.entries(need).every(([k, v]) => (have[k] || 0) >= v);
const deployOk = !!observation.operation_ready;
const operationOk = !!observation.operation_executed;
const extractionOk = info && info.success;
setGate("alliance", allianceOk);
setGate("resources", resourcesOk);
setGate("deploy", deployOk);
setGate("operation", operationOk);
setGate("extraction", !!extractionOk);
}
function setGate(key, on) {
const el = document.querySelector(`.gate[data-key="${key}"]`);
if (!el) return;
el.classList.toggle("unlocked", !!on);
}
function refreshComponents(components) {
const grid = document.getElementById("rewardGrid");
grid.innerHTML = "";
const order = ["alliances", "resources", "operation", "message_quality", "extraction"];
order.forEach((key) => {
const v = components[key] || 0;
const max = key === "alliances" ? 0.30 : key === "resources" ? 0.25 :
key === "operation" ? 0.20 : key === "message_quality" ? 0.15 : 0.10;
const pct = Math.max(0, Math.min(100, (v / max) * 100));
const div = document.createElement("div");
div.className = "meter";
div.innerHTML = `
<div class="name">${key.replace("_", " ")}</div>
<div class="val">${v.toFixed(3)} <span style="color:var(--ink-mute);font-size:11px;">/ ${max.toFixed(2)}</span></div>
<div class="bar"><span style="width:${pct}%"></span></div>
`;
grid.appendChild(div);
});
}
function refreshChart() {
const svg = document.getElementById("rewardChart");
const W = 400, H = 130, P = 6;
const turns = state.rewards;
if (!turns.length) { svg.innerHTML = ""; return; }
const maxT = Math.max(12, turns.length);
const cums = turns.map((r) => r.cumulative);
const maxC = Math.max(0.1, Math.max(...cums));
const xFor = (i) => P + (i / (maxT - 1)) * (W - 2 * P);
const yFor = (v) => H - P - (v / maxC) * (H - 2 * P);
let cumPath = `M ${xFor(0)} ${yFor(turns[0].cumulative)}`;
turns.forEach((r, i) => {
if (i === 0) return;
cumPath += ` L ${xFor(i)} ${yFor(r.cumulative)}`;
});
const bars = turns.map((r, i) => {
const x = xFor(i) - 4;
const yTop = yFor(Math.max(r.score, 0));
const yBot = yFor(0);
const h = Math.max(1, yBot - yTop);
const fill = r.score >= 0 ? "var(--neon-cyan)" : "var(--neon-red)";
return `<rect x="${x}" y="${yTop}" width="8" height="${h}" fill="${fill}" opacity="0.6" />`;
}).join("");
svg.innerHTML = `
<defs>
<linearGradient id="cumFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#a47bff" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#a47bff" stop-opacity="0"/>
</linearGradient>
</defs>
${bars}
<path d="${cumPath} L ${xFor(turns.length - 1)} ${H - P} L ${xFor(0)} ${H - P} Z" fill="url(#cumFill)"/>
<path d="${cumPath}" fill="none" stroke="#a47bff" stroke-width="2"/>
`;
}
// ---------------------------------------------------------------------------
// Race handlers
// ---------------------------------------------------------------------------
function onRaceMeta(d) {
if (d && d.trained_available === false) {
setLiveStatus("Race underway · trained model is unavailable, using heuristic for that lane", "warn");
} else {
setLiveStatus("Race underway · 3 policies, same mission", "ok");
}
}
function onRaceReset(d) {
// each lane resets independently; nothing extra needed
laneState(d.lane);
}
function onRaceStep(d) {
const ls = laneState(d.lane);
ls.cumulative = d.cumulative_reward;
ls.turn = d.turn + 1;
const target = TASK_TARGETS[(state.selectedTask || "")] || {};
const allies = d.observation.alliances || [];
const allianceOk = (target.required_allies || []).every((a) => allies.includes(a));
const need = target.min_resources || {};
const have = d.observation.resources || {};
const resourcesOk = Object.entries(need).every(([k, v]) => (have[k] || 0) >= v);
ls.gates = {
alliance: allianceOk,
resources: resourcesOk,
deploy: !!d.observation.operation_ready,
operation: !!d.observation.operation_executed,
extraction: !!(d.info && d.info.success),
};
const gatesOn = Object.values(ls.gates).filter(Boolean).length;
document.getElementById(`laneCum-${d.lane}`).textContent = ls.cumulative.toFixed(3);
document.getElementById(`laneGrade-${d.lane}`).textContent = ((d.info || {}).task_score || 0).toFixed(3);
document.getElementById(`laneGates-${d.lane}`).textContent = `${gatesOn}/5`;
document.getElementById(`laneTurn-${d.lane}`).textContent = `${ls.turn}/${d.observation.max_steps || 12}`;
const max = 12 * 0.20; // a rough upper bound to scale the gauge
document.getElementById(`laneGauge-${d.lane}`).style.width = `${Math.min(100, (ls.cumulative / max) * 100)}%`;
const log = document.getElementById(`laneLog-${d.lane}`);
const a = d.action || {};
const verb = ACTION_VERBS[a.action_type] || a.action_type || "—";
const argParts = [];
if (a.faction) argParts.push(a.faction);
if (a.sector) argParts.push(SECTOR_LABELS[a.sector] || a.sector);
if (a.resource) argParts.push(`${a.amount || ""} ${a.resource}`.trim());
if (a.operation_code) argParts.push(a.operation_code);
const args = argParts.length ? `· ${argParts.join(" / ")}` : "";
const r = d.reward.score;
const cls = r >= 0.05 ? "pos" : r <= -0.05 ? "neg" : "";
const div = document.createElement("div");
div.className = "turn";
div.innerHTML = `
<span class="reward ${cls}">${r >= 0 ? "+" : ""}${r.toFixed(3)}</span>
<div><span class="verb">${verb}</span> <span class="args">${escapeHtml(args)}</span></div>
`;
log.prepend(div);
}
function onRaceLaneDone(d) {
const ls = laneState(d.lane); ls.done = true;
const lane = document.querySelector(`.lane[data-lane="${d.lane}"]`);
const ribbon = document.getElementById(`laneRibbon-${d.lane}`);
if (!lane || !ribbon) return;
ribbon.style.display = "inline-flex";
if (d.success) {
lane.classList.add("win"); ribbon.textContent = "WIN";
} else if (d.reason === "max_steps") {
lane.classList.add("timeout"); ribbon.textContent = "TIMED OUT";
} else {
lane.classList.add("lose"); ribbon.textContent = "FAIL";
}
}
function onRaceFinish() {
setLiveStatus("Race complete", "ok");
// Show a comparison summary in the end card
const winners = ["trained", "heuristic", "random"]
.map((id) => ({ id, ls: laneState(id) }))
.sort((a, b) => b.ls.cumulative - a.ls.cumulative);
const card = document.getElementById("endCard");
card.classList.remove("hidden", "win", "lose", "timeout");
card.classList.add("win");
document.getElementById("endVerdict").textContent = `${winners[0].id.toUpperCase()} wins by Σ reward`;
const summary = winners
.map(({ id, ls }) => `${id}: Σ ${ls.cumulative.toFixed(3)}`)
.join(" · ");
document.getElementById("endSummary").textContent = summary;
stopStream();
}
// ---------------------------------------------------------------------------
// Canvas world rendering -- isometric-ish 2x2 sector grid
// ---------------------------------------------------------------------------
function drawWorld(action) {
const canvas = document.getElementById("worldCanvas");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Subtle parallax grid
ctx.save();
ctx.strokeStyle = "rgba(88, 245, 255, 0.06)";
ctx.lineWidth = 1;
for (let x = 0; x < W; x += 80) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = 0; y < H; y += 80) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
ctx.restore();
// Sector layout (2x2 grid in iso projection)
const center = { x: W / 2, y: H / 2 };
const tile = 220;
const positions = {
docklands: isoToScreen(-1, -1, center, tile),
data_spire: isoToScreen( 1, -1, center, tile),
undergrid: isoToScreen(-1, 1, center, tile),
citadel_gate: isoToScreen( 1, 1, center, tile),
};
const order = ["data_spire", "docklands", "citadel_gate", "undergrid"];
order.forEach((s) => {
const p = positions[s];
drawSector(ctx, p.x, p.y, s, state.observation);
});
// Agent
const obs = state.observation;
const focus = (action && action.sector) || obs?.deployed_sector || state.agentSector || "undergrid";
const ap = positions[focus] || center;
drawAgent(ctx, ap.x, ap.y - 30);
// Action FX
if (action) drawActionFx(ctx, action, positions);
// Threat tint
if (obs && obs.known_threat) {
const tint = obs.known_threat === "critical" ? "rgba(255, 91, 107, 0.10)"
: obs.known_threat === "high" ? "rgba(255, 168, 64, 0.07)"
: obs.known_threat === "medium" ? "rgba(255, 216, 77, 0.05)"
: "rgba(77, 255, 156, 0.03)";
ctx.fillStyle = tint;
ctx.fillRect(0, 0, W, H);
}
}
function isoToScreen(ix, iy, center, tile) {
return {
x: center.x + (ix - iy) * (tile * 0.55),
y: center.y + (ix + iy) * (tile * 0.30),
};
}
function drawSector(ctx, cx, cy, sector, obs) {
const target = TASK_TARGETS[(obs && obs.task_id) || ""] || {};
const isExtraction = target.extraction_sector === sector;
const tile = 200;
const dx = tile * 0.55, dy = tile * 0.30;
// Diamond
ctx.beginPath();
ctx.moveTo(cx, cy - dy);
ctx.lineTo(cx + dx, cy);
ctx.lineTo(cx, cy + dy);
ctx.lineTo(cx - dx, cy);
ctx.closePath();
const grad = ctx.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy);
grad.addColorStop(0, "rgba(40, 60, 110, 0.9)");
grad.addColorStop(1, "rgba(8, 14, 36, 0.9)");
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = isExtraction ? "rgba(77, 255, 156, 0.8)" : "rgba(88, 245, 255, 0.45)";
ctx.lineWidth = isExtraction ? 2.2 : 1.2;
ctx.stroke();
if (isExtraction) {
ctx.save();
ctx.shadowColor = "rgba(77, 255, 156, 0.7)";
ctx.shadowBlur = 18;
ctx.strokeStyle = "rgba(77, 255, 156, 0.9)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
// Buildings
const buildings = sector === "data_spire" ? [{ w: 50, h: 110 }, { w: 28, h: 70 }, { w: 36, h: 90 }]
: sector === "docklands" ? [{ w: 80, h: 30 }, { w: 60, h: 24 }, { w: 50, h: 24 }]
: sector === "undergrid" ? [{ w: 45, h: 45 }, { w: 40, h: 35 }, { w: 35, h: 40 }, { w: 40, h: 30 }]
: [{ w: 60, h: 70 }, { w: 70, h: 90 }, { w: 50, h: 60 }];
let bx = cx - dx + 30, by = cy - 8;
buildings.forEach((b, i) => {
ctx.fillStyle = "rgba(20, 32, 64, 0.92)";
ctx.fillRect(bx, by - b.h, b.w, b.h);
ctx.strokeStyle = "rgba(88, 245, 255, 0.45)";
ctx.lineWidth = 1;
ctx.strokeRect(bx + 0.5, by - b.h + 0.5, b.w - 1, b.h - 1);
// windows
ctx.fillStyle = "rgba(255, 92, 220, 0.7)";
for (let wy = by - b.h + 6; wy < by - 4; wy += 8) {
for (let wx = bx + 4; wx < bx + b.w - 4; wx += 10) {
if (Math.random() > 0.55) ctx.fillRect(wx, wy, 3, 3);
}
}
bx += b.w + 12 + (i % 2) * 6;
});
// Sector label
ctx.fillStyle = "rgba(230, 236, 255, 0.9)";
ctx.font = "bold 13px ui-sans-serif, system-ui";
ctx.textAlign = "center";
ctx.fillText(SECTOR_LABELS[sector], cx, cy + dy + 18);
if (isExtraction) {
ctx.fillStyle = "rgba(77, 255, 156, 0.9)";
ctx.font = "10px ui-sans-serif";
ctx.fillText("EXTRACTION", cx, cy + dy + 32);
}
}
function drawAgent(ctx, x, y) {
ctx.save();
ctx.translate(x, y);
// body
ctx.fillStyle = "#06121b";
ctx.beginPath(); ctx.ellipse(0, 16, 18, 6, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#58f5ff";
ctx.beginPath(); ctx.arc(0, 0, 9, 0, Math.PI * 2); ctx.fill();
ctx.shadowColor = "rgba(88, 245, 255, 0.9)";
ctx.shadowBlur = 18;
ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); ctx.fillStyle = "#fff"; ctx.fill();
ctx.restore();
}
function drawActionFx(ctx, action, positions) {
const sector = action.sector || (state.observation && state.observation.deployed_sector);
const p = sector ? positions[sector] : null;
if (!p) return;
const t = action.action_type;
ctx.save();
if (t === "scout_sector") {
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.arc(p.x, p.y, 30 + i * 22, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(88, 245, 255, ${0.5 - i * 0.15})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
} else if (t === "deploy_asset") {
ctx.fillStyle = "rgba(77, 255, 156, 0.4)";
ctx.beginPath(); ctx.arc(p.x, p.y, 22, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = "rgba(77, 255, 156, 0.9)";
ctx.beginPath(); ctx.moveTo(p.x, p.y - 80); ctx.lineTo(p.x, p.y); ctx.stroke();
} else if (t === "run_operation") {
ctx.fillStyle = "rgba(255, 92, 220, 0.18)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.strokeStyle = "rgba(255, 92, 220, 0.9)";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(p.x, p.y, 50, 0, Math.PI * 2); ctx.stroke();
} else if (t === "secure_extraction") {
ctx.strokeStyle = "rgba(77, 255, 156, 0.95)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(p.x - 30, p.y - 30); ctx.lineTo(p.x + 30, p.y + 30);
ctx.moveTo(p.x + 30, p.y - 30); ctx.lineTo(p.x - 30, p.y + 30);
ctx.stroke();
} else if (t === "negotiate_pact") {
const color = FACTION_COLOR[action.faction] || "#ffd84d";
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, 20, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(p.x, p.y, 30, 0, Math.PI * 2); ctx.stroke();
} else if (t === "trade_resources") {
ctx.strokeStyle = "rgba(255, 216, 77, 0.85)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(p.x - 20, p.y); ctx.lineTo(p.x + 20, p.y);
ctx.moveTo(p.x + 12, p.y - 6); ctx.lineTo(p.x + 20, p.y); ctx.lineTo(p.x + 12, p.y + 6);
ctx.stroke();
}
ctx.restore();
}
// ---------------------------------------------------------------------------
// Misc
// ---------------------------------------------------------------------------
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
document.addEventListener("DOMContentLoaded", boot);
</script>
</body>
</html>