visual_memory / play.html
kdemon1011's picture
Upload folder using huggingface_hub
15503f9 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Phantom Grid — Visual Memory Game</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;800&family=Orbitron:wght@500;700;900&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d0d1a;
--panel: #141428;
--border: #2a2a50;
--accent: #7b61ff;
--accent-glow: rgba(123,97,255,0.3);
--danger: #ff4d4d;
--success: #4dff88;
--warning: #ffb84d;
--text: #e0e0f0;
--text-dim: #8888aa;
--text-bright: #ffffff;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'JetBrains Mono', monospace;
min-height: 100vh;
}
.header {
text-align: center;
padding: 24px 16px 16px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #12122a 0%, var(--bg) 100%);
}
.header h1 {
font-family: 'Orbitron', sans-serif;
font-weight: 900;
font-size: 28px;
letter-spacing: 3px;
background: linear-gradient(135deg, var(--accent), #ff61a6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 4px;
}
.header p { color: var(--text-dim); font-size: 12px; }
.layout {
display: flex;
gap: 16px;
padding: 16px;
max-width: 1400px;
margin: 0 auto;
align-items: flex-start;
justify-content: center;
}
.board-panel {
flex: 0 0 auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
.board-wrap { position: relative; cursor: crosshair; }
.board-wrap svg { display: block; }
.click-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.click-cell {
position: absolute;
border: 2px solid transparent;
transition: border-color 0.15s;
cursor: pointer;
}
.click-cell:hover { border-color: var(--accent); }
.click-cell.selected { border-color: var(--warning); background: rgba(255,184,77,0.15); }
.selected-label {
position: absolute;
bottom: -22px;
left: 0;
right: 0;
text-align: center;
font-size: 11px;
color: var(--warning);
font-weight: 600;
}
.controls {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 300px;
max-width: 380px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
}
.card h3 {
font-family: 'Orbitron', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 10px;
}
.scenario-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.scenario-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 8px 6px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.scenario-btn:hover { border-color: var(--accent); background: var(--accent-glow); }
.scenario-btn.active { border-color: var(--accent); background: var(--accent-glow); color: var(--text-bright); }
.scenario-btn:disabled { opacity: 0.5; cursor: wait; }
.action-row {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.btn {
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
font-weight: 600;
white-space: nowrap;
}
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-reveal { background: #1a3a5c; color: #7bc8ff; border-color: #2a5a8c; }
.btn-reveal:hover:not(:disabled) { background: #2a4a6c; }
.btn-flag { background: #5c1a1a; color: #ff7b7b; border-color: #8c2a2a; }
.btn-flag:hover:not(:disabled) { background: #6c2a2a; }
.btn-inspect { background: #1a3a2a; color: #7bffaa; border-color: #2a5a3a; }
.btn-inspect:hover:not(:disabled) { background: #2a4a3a; }
.btn-viewport { background: #3a2a1a; color: #ffcc7b; border-color: #5a4a2a; }
.btn-viewport:hover:not(:disabled) { background: #4a3a2a; }
.btn-submit {
background: var(--accent);
color: white;
border-color: var(--accent);
width: 100%;
padding: 10px;
font-size: 13px;
letter-spacing: 1px;
}
.btn-submit:hover:not(:disabled) { filter: brightness(1.2); }
.btn-secondary { background: transparent; color: var(--text-dim); border-color: var(--border); }
.btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--text-dim); }
.status-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.stat { text-align: center; padding: 8px 4px; background: var(--bg); border-radius: 6px; }
.stat-val { font-family: 'Orbitron', sans-serif; font-size: 18px; font-weight: 700; color: var(--text-bright); }
.stat-label { font-size: 9px; color: var(--text-dim); margin-top: 2px; }
.mode-toggle {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.mode-btn {
flex: 1;
padding: 8px 4px;
border: 1px solid var(--border);
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
background: transparent;
color: var(--text-dim);
}
.mode-btn.active-reveal { border-color: #2a5a8c; background: #1a3a5c; color: #7bc8ff; }
.mode-btn.active-flag { border-color: #8c2a2a; background: #5c1a1a; color: #ff7b7b; }
.help-text { font-size: 10px; color: var(--text-dim); line-height: 1.5; margin-top: 6px; }
#log {
max-height: 200px;
overflow-y: auto;
font-size: 11px;
line-height: 1.6;
color: var(--text-dim);
background: var(--bg);
border-radius: 6px;
padding: 8px;
}
.log-entry { border-bottom: 1px solid #1a1a30; padding: 2px 0; }
.log-success { color: var(--success) !important; }
.log-danger { color: var(--danger) !important; }
.log-warn { color: var(--warning) !important; }
.log-info { color: var(--accent) !important; }
.empty-board {
color: var(--text-dim);
font-size: 14px;
text-align: center;
padding: 80px 20px;
line-height: 2;
}
.flagged-list {
font-size: 11px;
color: var(--text-dim);
background: var(--bg);
border-radius: 6px;
padding: 8px;
min-height: 28px;
word-break: break-all;
}
.game-over-banner {
text-align: center;
padding: 12px;
border-radius: 8px;
font-family: 'Orbitron', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 2px;
animation: pulse 1.5s infinite;
}
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.7; } }
.win { background: rgba(77,255,136,0.15); color: var(--success); border: 1px solid var(--success); }
.lose { background: rgba(255,77,77,0.15); color: var(--danger); border: 1px solid var(--danger); }
.scenario-info {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-top: 10px;
font-size: 12px;
line-height: 1.6;
color: var(--text);
max-width: 540px;
width: 100%;
}
.scenario-info .info-title {
font-family: 'Orbitron', sans-serif;
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--success);
margin-bottom: 6px;
}
.scenario-info .info-goal {
color: var(--warning);
font-weight: 600;
margin-bottom: 4px;
font-size: 11px;
}
.scenario-info .info-text {
color: var(--text-dim);
font-size: 11px;
}
.legend {
display: flex; gap: 12px; flex-wrap: wrap;
justify-content: center; margin-top: 10px; font-size: 10px;
}
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-swatch {
width: 14px; height: 14px; border-radius: 3px; border: 1px solid #3d3d5a;
}
</style>
</head>
<body>
<div class="header">
<h1>PHANTOM GRID</h1>
<p>Visual Memory Gym — Click cells to reveal or flag. Play server on port 8001.</p>
</div>
<div class="layout">
<div class="board-panel">
<div id="board-container">
<div class="empty-board">
Select a scenario on the right to begin.<br><br>
<b>How to play:</b><br>
1. Pick a scenario<br>
2. Click cells on the board to reveal or flag them<br>
3. Use signals (numbers) to deduce hazard locations<br>
4. Flag all hazards, then submit your solution
</div>
</div>
<div class="scenario-info" id="scenario-info" style="display:none">
<div class="info-title">HOW TO PLAY</div>
<div class="info-goal" id="info-goal"></div>
<div class="info-text" id="info-text"></div>
</div>
<div class="legend" id="legend" style="display:none">
<div class="legend-item"><div class="legend-swatch" style="background:#2d2d4a"></div> Hidden</div>
<div class="legend-item"><div class="legend-swatch" style="background:#d0e8ff"></div> Signal</div>
<div class="legend-item"><div class="legend-swatch" style="background:#e8e8f0"></div> Empty</div>
<div class="legend-item"><div class="legend-swatch" style="background:#ff4d4d"></div> Hazard</div>
<div class="legend-item"><div class="legend-swatch" style="background:#ff6b35"></div> Flagged</div>
<div class="legend-item"><div class="legend-swatch" style="background:#ffd700"></div> Key</div>
<div class="legend-item"><div class="legend-swatch" style="background:#c8b8e8"></div> Decoy</div>
<div class="legend-item"><div class="legend-swatch" style="background:#50fa7b"></div> Goal</div>
<div class="legend-item"><div class="legend-swatch" style="background:#111122"></div> Fog</div>
</div>
</div>
<div class="controls">
<div class="card">
<h3>Scenario</h3>
<div class="scenario-grid" id="scenario-grid"></div>
</div>
<div class="card">
<h3>Status</h3>
<div class="status-bar">
<div class="stat"><div class="stat-val" id="stat-steps"></div><div class="stat-label">STEPS</div></div>
<div class="stat"><div class="stat-val" id="stat-max"></div><div class="stat-label">MAX</div></div>
<div class="stat"><div class="stat-val" id="stat-flags"></div><div class="stat-label">FLAGS</div></div>
<div class="stat"><div class="stat-val" id="stat-revealed"></div><div class="stat-label">REVEALED</div></div>
</div>
<div id="game-over-slot" style="margin-top:8px"></div>
</div>
<div class="card">
<h3>Click Mode</h3>
<div class="mode-toggle">
<button class="mode-btn active-reveal" id="mode-reveal" onclick="setMode('reveal')">
Reveal
</button>
<button class="mode-btn" id="mode-flag" onclick="setMode('flag')">
Flag Hazard
</button>
</div>
<p class="help-text" id="mode-help">Click any dark (hidden) cell on the board to reveal it.</p>
</div>
<div class="card">
<h3>Tools</h3>
<div style="display:flex;flex-direction:column;gap:8px">
<div class="action-row">
<button class="btn btn-inspect" onclick="doInspect()">Inspect Region</button>
<button class="btn btn-viewport" onclick="doMoveViewport()">Move Viewport</button>
</div>
<div class="action-row">
<button class="btn btn-secondary" onclick="doRecall()">Recall Log</button>
<button class="btn btn-secondary" onclick="doGetStatus()">Get Status</button>
</div>
</div>
</div>
<div class="card">
<h3>Flagged Cells <span id="flag-count" style="color:var(--text-dim)">(0)</span></h3>
<div class="flagged-list" id="flagged-list">No cells flagged yet</div>
</div>
<div class="card">
<button class="btn btn-submit" onclick="doSubmit()">SUBMIT SOLUTION</button>
</div>
<div class="card">
<h3>Game Log</h3>
<div id="log"></div>
</div>
</div>
</div>
<script>
const API = 'http://localhost:8001';
let clickMode = 'reveal';
let boardWidth = 0;
let boardHeight = 0;
let flaggedCells = [];
let selectedRow = -1;
let selectedCol = -1;
let gameOver = false;
const CELL_SIZE = 48;
const PADDING = 24;
const SCENARIOS = [
{id:'flash_fade_minefield_7x7', label:'Flash Fade 7x7', desc:'Pattern memory'},
{id:'directional_trap_8x8', label:'Directional Trap 8x8', desc:'1 hit = fatal'},
{id:'partial_intel_9x9', label:'Partial Intel 9x9', desc:'Incomplete signals'},
{id:'delayed_recall_keys_8x8', label:'Delayed Recall 8x8', desc:'Collect 5 keys'},
{id:'ambiguous_cluster_10x10', label:'Ambiguous Cluster 10x10', desc:'Range signals'},
{id:'decoy_minefield_8x10', label:'Decoy Minefield 8x10', desc:'4 keys, 8 decoys'},
{id:'fog_labyrinth_10x10', label:'Fog Labyrinth 10x10', desc:'Viewport radius 2'},
{id:'fog_key_hunt_8x8', label:'Fog Key Hunt 8x8', desc:'Tiny viewport'},
{id:'cascading_deduction_11x11', label:'Cascading 11x11', desc:'25 hazards'},
{id:'safe_zone_identification_9x9', label:'Safe Zone ID 9x9', desc:'Find safe cells'},
];
function initScenarios() {
const grid = document.getElementById('scenario-grid');
SCENARIOS.forEach(s => {
const btn = document.createElement('button');
btn.className = 'scenario-btn';
btn.innerHTML = `${s.label}<br><span style="color:var(--text-dim);font-size:9px">${s.desc}</span>`;
btn.dataset.id = s.id;
btn.onclick = () => loadScenario(s.id);
grid.appendChild(btn);
});
}
function setMode(mode) {
clickMode = mode;
document.getElementById('mode-reveal').className = 'mode-btn' + (mode === 'reveal' ? ' active-reveal' : '');
document.getElementById('mode-flag').className = 'mode-btn' + (mode === 'flag' ? ' active-flag' : '');
document.getElementById('mode-help').textContent = mode === 'reveal'
? 'Click any dark (hidden) cell on the board to reveal it.'
: 'Click any dark (hidden) cell to flag it as a hazard.';
}
function log(msg, cls) {
const el = document.getElementById('log');
const div = document.createElement('div');
div.className = 'log-entry ' + (cls || '');
div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
el.prepend(div);
}
function updateBoard(svgText) {
if (!svgText) return;
const container = document.getElementById('board-container');
const wrap = document.createElement('div');
wrap.className = 'board-wrap';
wrap.innerHTML = svgText;
const overlay = document.createElement('div');
overlay.className = 'click-overlay';
for (let r = 0; r < boardHeight; r++) {
for (let c = 0; c < boardWidth; c++) {
const cell = document.createElement('div');
cell.className = 'click-cell';
cell.setAttribute('role', 'button');
cell.setAttribute('aria-label', `cell ${r} ${c}`);
cell.setAttribute('tabindex', '0');
cell.style.left = (PADDING + c * CELL_SIZE) + 'px';
cell.style.top = (PADDING + r * CELL_SIZE) + 'px';
cell.style.width = CELL_SIZE + 'px';
cell.style.height = CELL_SIZE + 'px';
cell.dataset.row = r;
cell.dataset.col = c;
cell.title = `(${r}, ${c})`;
cell.onclick = () => onCellClick(r, c);
overlay.appendChild(cell);
}
}
wrap.appendChild(overlay);
container.innerHTML = '';
container.appendChild(wrap);
document.getElementById('legend').style.display = 'flex';
}
function updateStats(status) {
if (!status) return;
document.getElementById('stat-steps').textContent = status.step_count ?? '—';
document.getElementById('stat-max').textContent = status.max_steps ?? '—';
document.getElementById('stat-flags').textContent = status.flags_remaining ?? '—';
document.getElementById('stat-revealed').textContent = status.cells_revealed ?? '—';
gameOver = !!status.game_over;
}
function updateFlaggedList() {
const el = document.getElementById('flagged-list');
document.getElementById('flag-count').textContent = `(${flaggedCells.length})`;
if (flaggedCells.length === 0) { el.textContent = 'No cells flagged yet'; return; }
el.textContent = flaggedCells.map(c => `[${c[0]},${c[1]}]`).join(' ');
}
function showGameOver(won, msg) {
document.getElementById('game-over-slot').innerHTML =
`<div class="game-over-banner ${won ? 'win' : 'lose'}">${msg}</div>`;
}
async function api(method, path, body) {
try {
const opts = { method, headers: {'Content-Type': 'application/json'} };
if (body !== undefined) opts.body = JSON.stringify(body);
const resp = await fetch(API + path, opts);
return await resp.json();
} catch (e) {
log('Connection error: ' + e.message + ' — is play_server.py running on port 8001?', 'log-danger');
return { error: e.message };
}
}
function processResponse(data) {
if (data.error) { log(data.error, 'log-danger'); return; }
if (data.board && data.board.svg) {
updateBoard(data.board.svg);
if (data.board.metadata) {
document.getElementById('stat-revealed').textContent = data.board.metadata.cell_counts?.revealed ?? '—';
}
}
if (data.status) updateStats(data.status);
return data.action_result;
}
async function loadScenario(id) {
document.querySelectorAll('.scenario-btn').forEach(b => { b.classList.remove('active'); b.disabled = true; });
const btn = document.querySelector(`[data-id='${id}']`);
if (btn) btn.classList.add('active');
document.getElementById('game-over-slot').innerHTML = '';
flaggedCells = [];
updateFlaggedList();
document.getElementById('log').innerHTML = '';
gameOver = false;
log(`Loading scenario: ${id}...`, 'log-info');
const data = await api('POST', '/load', { scenario_id: id });
document.querySelectorAll('.scenario-btn').forEach(b => b.disabled = false);
if (data.error) { log(data.error, 'log-danger'); return; }
boardWidth = data.status?.board_size ? parseInt(data.status.board_size.split('x')[0]) : 0;
boardHeight = data.status?.board_size ? parseInt(data.status.board_size.split('x')[1]) : 0;
processResponse(data);
const winLabels = {
'flag_all_hazards': 'Flag all hazards, then submit.',
'collect_keys': 'Find and reveal all keys to win.',
'identify_safe_cells': 'Identify all safe (non-hazard) cells, then submit.',
'reach_goal': 'Reach the goal cell to win.',
};
const wc = data.status?.win_condition || '';
const goalText = winLabels[wc] || `Win condition: ${wc}`;
const howTo = data.how_to_play || '';
const infoEl = document.getElementById('scenario-info');
if (howTo) {
document.getElementById('info-goal').textContent = goalText;
document.getElementById('info-text').textContent = howTo;
infoEl.style.display = 'block';
} else {
infoEl.style.display = 'none';
}
log(`Loaded ${boardWidth}x${boardHeight} board | Type: ${data.status?.scenario_type} | Win: ${data.status?.win_condition} | Max steps: ${data.status?.max_steps}`, 'log-success');
}
async function onCellClick(row, col) {
if (gameOver) { log('Game is over. Load a new scenario to play again.', 'log-warn'); return; }
if (boardWidth === 0) { log('Load a scenario first.', 'log-warn'); return; }
if (clickMode === 'reveal') {
log(`Revealing (${row}, ${col})...`);
const data = await api('POST', '/reveal', { row, col });
const result = processResponse(data);
if (!result) return;
if (result.error) { log(result.error, 'log-danger'); return; }
const t = result.type || '';
if (t === 'hazard') log(`HAZARD at (${row},${col})!${result.game_over ? ' GAME OVER!' : ''}`, 'log-danger');
else if (t === 'key') log(`KEY found at (${row},${col})!`, 'log-success');
else if (t === 'signal') log(`Signal at (${row},${col}): ${JSON.stringify(result.value)}`, 'log-info');
else if (t === 'decoy') log(`Decoy at (${row},${col})`, 'log-warn');
else if (t === 'goal') log(`GOAL reached at (${row},${col})!`, 'log-success');
else if (t === 'empty') log(`Empty cell at (${row},${col})`, '');
else log(`Cell (${row},${col}): ${JSON.stringify(result).slice(0,120)}`, 'log-info');
if (result.game_over && result.message) {
showGameOver(!!result.message?.includes('win') || !!result.message?.includes('Win'), result.message);
}
} else {
const alreadyFlagged = flaggedCells.some(f => f[0] === row && f[1] === col);
if (alreadyFlagged) {
log(`Unflagging (${row}, ${col})...`);
const data = await api('POST', '/unflag', { row, col });
const result = processResponse(data);
if (!result) return;
if (result.error) { log(result.error, 'log-danger'); return; }
flaggedCells = flaggedCells.filter(f => !(f[0] === row && f[1] === col));
updateFlaggedList();
log(`Unflagged (${row},${col})`, 'log-warn');
} else {
log(`Flagging (${row}, ${col})...`);
const data = await api('POST', '/flag', { row, col });
const result = processResponse(data);
if (!result) return;
if (result.error) { log(result.error, 'log-danger'); return; }
flaggedCells.push([row, col]);
updateFlaggedList();
log(`Flagged (${row},${col}) as hazard | ${result.flags_remaining} flags left`, 'log-warn');
if (result.game_over && result.message) {
showGameOver(true, result.message);
}
}
}
}
async function doInspect() {
const row = parseInt(prompt('Center row:', '0'));
const col = parseInt(prompt('Center col:', '0'));
const radius = parseInt(prompt('Radius (1-3):', '1'));
if (isNaN(row) || isNaN(col)) return;
log(`Inspecting region around (${row},${col}) r=${radius}...`, 'log-info');
const data = await api('POST', '/inspect', { center_row: row, center_col: col, radius: radius || 1 });
const result = processResponse(data);
if (!result) return;
if (result.error) { log(result.error, 'log-danger'); return; }
const cells = result.cells || [];
log(`Inspected ${cells.length} cells`, 'log-info');
cells.forEach(c => {
if (c.state !== 'hidden' && c.state !== 'fog') {
log(` (${c.row},${c.col}): ${c.state} ${c.content ? JSON.stringify(c.content) : ''}`, 'log-info');
}
});
}
async function doMoveViewport() {
const row = parseInt(prompt('Viewport center row:', '0'));
const col = parseInt(prompt('Viewport center col:', '0'));
if (isNaN(row) || isNaN(col)) return;
log(`Moving viewport to (${row},${col})...`, 'log-info');
const data = await api('POST', '/move_viewport', { row, col });
const result = processResponse(data);
if (result && result.error) log(result.error, 'log-danger');
else log(`Viewport moved to (${row},${col})`, 'log-info');
}
async function doRecall() {
const data = await api('GET', '/recall');
if (data.error) { log(data.error, 'log-danger'); return; }
const sigs = data.discovered_signals || [];
const mems = data.memory_events || [];
log(`Recall: ${sigs.length} signals, ${mems.length} memory events`, 'log-info');
sigs.forEach(s => log(` Signal (${s.row},${s.col}): ${s.type} = ${JSON.stringify(s.value)}`, 'log-info'));
mems.slice(-5).forEach(m => log(` Memory: step ${m.step} ${m.event} (${m.row},${m.col})`, 'log-info'));
}
async function doGetStatus() {
const data = await api('GET', '/status');
if (data.error) { log(data.error, 'log-danger'); return; }
updateStats(data);
log(`Step ${data.step_count}/${data.max_steps} | Flags: ${data.flags_placed}/${data.flags_remaining + data.flags_placed} | Hits: ${data.hazard_hits} | ${data.game_over ? 'GAME OVER' : 'Active'} | Win: ${data.win_condition}`, 'log-info');
}
async function doSubmit() {
if (!confirm(`Submit solution with ${flaggedCells.length} flagged cells?`)) return;
log('Submitting solution...', 'log-warn');
const data = await api('POST', '/submit', {
flagged_positions: flaggedCells,
safe_positions: [],
});
const result = processResponse(data);
if (!result) return;
if (result.error) { log(result.error, 'log-danger'); return; }
const won = result.correct === true;
const prec = ((result.precision || 0) * 100).toFixed(1);
const rec = ((result.recall || 0) * 100).toFixed(1);
log(`${won ? 'CORRECT!' : 'INCORRECT'} | Precision: ${prec}% | Recall: ${rec}% | Found: ${result.hazards_found||0}/${result.hazards_total||'?'}`, won ? 'log-success' : 'log-danger');
showGameOver(won, won ? 'YOU WIN!' : `INCORRECT — ${prec}% precision, ${rec}% recall`);
}
initScenarios();
</script>
</body>
</html>