Spaces:
Running
Running
Download docs/play.html from hsbharadwaj/ev: direct link, hf CLI and curl.
- Browser
- Download file 63.9 kB
-
https://huggingface.co/spaces/hsbharadwaj/ev/resolve/main/docs/play.html
- Command line
-
hf download hf://spaces/hsbharadwaj/ev/docs/play.html
-
curl -L -o play.html https://huggingface.co/spaces/hsbharadwaj/ev/resolve/main/docs/play.html
63.9 kB
| <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 ; transition-duration: 0.001ms ; } | |
| } | |
| * { 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 ; } | |
| /* 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> | |
| ; | |
| // --------------------------------------------------------------------------- | |
| // 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) => ({ | |
| "&": "&", "<": "<", ">": ">", '"': """, "'": "'", | |
| }[c])); | |
| } | |
| document.addEventListener("DOMContentLoaded", boot); | |
| </script> | |
| </body> | |
| </html> | |