5b28002001
Major changes from this session: Training: - 0.6.0 training running: 9B on steel141 3090 Ti, 27B on rented H100 NVL - 7,256 merged training examples (up from 3,183) - New training data: failure modes (85), midloop messaging (27), prompt injection defense (29), personality (32), gold from quarantine bank (232), new tool examples (30), claude's own experience (10) - All training data RCON-validated at 100% pass rate - Bake-off: gemma3:27b 66%, qwen3.5:27b 61%, translategemma:27b 56% Oracle Bot (Mind's Eye): - Invisible spectator bot (mineflayer) streams world state via WebSocket - HTML5 Canvas frontend at mind.mortdec.ai - Real-time tool trace visualization with expandable entries - Streaming model tokens during inference - Gateway integration: fire-and-forget POST /trace on every tool call Reinforcement Learning: - Gymnasium environment wrapping mineflayer bot (minecraft_env.py) - PPO training via Stable Baselines3 (10K param policy network) - Behavioral cloning pretraining (97.5% accuracy on expert policy) - Infinite training loop with auto-restart and checkpoint resume - Bot learns combat, survival, navigation from raw experience Bot Army: - 8-soldier marching formation with autonomous combat - Combat bots using mineflayer-pvp, pathfinder, armor-manager - Multilingual prayer bots via translategemma:27b (18 languages) - Frame-based AI architecture: LLM planner + reactive micro-scripts Infrastructure: - Fixed mattpc.sethpc.xyz billing gateway (API key + player list parser) - Billing gateway now tracks all LAN traffic (LAN auto-auth) - Gateway fallback for empty god-mode responses - Updated mortdec.ai landing page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1062 lines
28 KiB
HTML
1062 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>MORTDECAI — MIND'S EYE</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #1a1a2e;
|
|
--bg-panel: #16162a;
|
|
--bg-darker: #111122;
|
|
--orange: #D35400;
|
|
--blue: #2196F3;
|
|
--green: #4CAF50;
|
|
--red: #E74C3C;
|
|
--text: #E0E0E0;
|
|
--text-dim: #888;
|
|
--border: #2a2a4a;
|
|
--purple: #9C27B0;
|
|
}
|
|
|
|
html, body {
|
|
width: 100%; height: 100%;
|
|
overflow: hidden;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: 'Rajdhani', sans-serif;
|
|
font-weight: 500;
|
|
}
|
|
|
|
#app {
|
|
display: grid;
|
|
grid-template-columns: 1fr 380px;
|
|
grid-template-rows: 1fr 56px;
|
|
width: 100vw; height: 100vh;
|
|
}
|
|
|
|
/* ── World Map Panel ── */
|
|
#map-panel {
|
|
grid-column: 1; grid-row: 1;
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: var(--bg-darker);
|
|
border-right: 1px solid var(--border);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
#map-panel canvas {
|
|
display: block;
|
|
width: 100%; height: 100%;
|
|
}
|
|
|
|
#map-title {
|
|
position: absolute;
|
|
top: 12px; left: 16px;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
letter-spacing: 2px;
|
|
text-transform: uppercase;
|
|
color: var(--text-dim);
|
|
pointer-events: none;
|
|
user-select: none;
|
|
}
|
|
|
|
#header-bar {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(180deg, rgba(26,26,46,0.95) 0%, rgba(26,26,46,0) 100%);
|
|
pointer-events: none;
|
|
z-index: 5;
|
|
}
|
|
|
|
#header-bar h1 {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
letter-spacing: 4px;
|
|
color: var(--orange);
|
|
text-shadow: 0 0 20px rgba(211,84,0,0.3);
|
|
}
|
|
|
|
/* ── Tool Trace Panel ── */
|
|
#trace-panel {
|
|
grid-column: 2; grid-row: 1 / 3;
|
|
background: var(--bg-panel);
|
|
border-left: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#trace-header {
|
|
padding: 14px 16px 10px;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
letter-spacing: 2px;
|
|
text-transform: uppercase;
|
|
color: var(--text-dim);
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#stream-box {
|
|
padding: 10px 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
background: rgba(211,84,0,0.05);
|
|
max-height: 180px;
|
|
overflow-y: auto;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#stream-box.hidden { display: none; }
|
|
|
|
#stream-box.sudo { background: rgba(33,150,243,0.05); }
|
|
|
|
#stream-label {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 1px;
|
|
color: var(--orange);
|
|
margin-bottom: 6px;
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
#stream-box.sudo #stream-label { color: var(--blue); }
|
|
|
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
|
|
#stream-text {
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
color: var(--text);
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
|
|
#trace-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px 0;
|
|
}
|
|
|
|
#trace-list::-webkit-scrollbar { width: 4px; }
|
|
#trace-list::-webkit-scrollbar-track { background: transparent; }
|
|
#trace-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
.trace-entry {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 10px 16px;
|
|
font-size: 13px;
|
|
border-bottom: 1px solid rgba(42,42,74,0.5);
|
|
animation: traceIn 0.3s ease-out;
|
|
}
|
|
|
|
.trace-header-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.trace-details {
|
|
display: none;
|
|
padding-left: 18px;
|
|
padding-top: 4px;
|
|
}
|
|
|
|
.trace-entry.expanded .trace-details {
|
|
display: block;
|
|
}
|
|
|
|
.trace-header-row {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.trace-header-row:hover .trace-tool {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.trace-expand {
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
margin-left: auto;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.trace-entry.expanded .trace-expand {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.trace-preview {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
padding-left: 18px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.trace-detail {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
word-break: break-all;
|
|
line-height: 1.5;
|
|
padding: 2px 0;
|
|
}
|
|
|
|
.trace-detail.command {
|
|
color: var(--orange);
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.trace-detail.result {
|
|
color: var(--green);
|
|
font-family: monospace;
|
|
}
|
|
|
|
.trace-detail.result.fail {
|
|
color: var(--red);
|
|
}
|
|
|
|
.trace-detail.message {
|
|
color: #FFD54F;
|
|
font-style: italic;
|
|
}
|
|
|
|
@keyframes traceIn {
|
|
from { opacity: 0; transform: translateX(20px); }
|
|
to { opacity: 1; transform: translateX(0); }
|
|
}
|
|
|
|
.trace-dot {
|
|
width: 8px; height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.trace-dot.ok { background: var(--green); box-shadow: 0 0 6px rgba(76,175,80,0.5); }
|
|
.trace-dot.fail { background: var(--red); box-shadow: 0 0 6px rgba(231,76,60,0.5); }
|
|
|
|
.trace-tool {
|
|
flex: 1;
|
|
font-weight: 600;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.trace-tool.rcon { color: var(--orange); }
|
|
.trace-tool.world { color: var(--blue); }
|
|
.trace-tool.journal { color: var(--purple); }
|
|
.trace-tool.script { color: var(--green); }
|
|
.trace-tool.memory { color: #FF9800; }
|
|
.trace-tool.other { color: var(--text-dim); }
|
|
|
|
.trace-step {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.trace-time {
|
|
font-size: 10px;
|
|
color: rgba(136,136,136,0.6);
|
|
flex-shrink: 0;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
#trace-footer {
|
|
padding: 10px 16px;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
border-top: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
text-align: center;
|
|
}
|
|
|
|
/* ── Status Bar ── */
|
|
#status-bar {
|
|
grid-column: 1; grid-row: 2;
|
|
background: var(--bg-panel);
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 20px;
|
|
gap: 24px;
|
|
font-size: 13px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.status-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.status-label {
|
|
color: var(--text-dim);
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 11px;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 8px; height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.status-dot.idle { background: #555; }
|
|
.status-dot.god { background: var(--orange); box-shadow: 0 0 8px rgba(211,84,0,0.6); }
|
|
.status-dot.sudo { background: var(--blue); box-shadow: 0 0 8px rgba(33,150,243,0.6); }
|
|
.status-dot.connected { background: var(--green); box-shadow: 0 0 6px rgba(76,175,80,0.5); }
|
|
.status-dot.disconnected { background: var(--red); box-shadow: 0 0 6px rgba(231,76,60,0.5); }
|
|
|
|
#status-mode { font-weight: 700; }
|
|
#status-player { color: var(--text); font-weight: 600; }
|
|
#status-pos { color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
|
#status-players-count { color: var(--text); }
|
|
|
|
/* ── Reconnect Overlay ── */
|
|
#reconnect-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(17,17,34,0.92);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
z-index: 100;
|
|
}
|
|
|
|
#reconnect-overlay.visible { display: flex; }
|
|
|
|
#reconnect-overlay .spinner {
|
|
width: 40px; height: 40px;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--orange);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
#reconnect-overlay .label {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
letter-spacing: 2px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
/* ── Idle pulse ── */
|
|
@keyframes idlePulse {
|
|
0%, 100% { opacity: 0.4; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
|
|
#idle-eye {
|
|
position: absolute;
|
|
top: 50%; left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
pointer-events: none;
|
|
z-index: 2;
|
|
text-align: center;
|
|
animation: idlePulse 4s ease-in-out infinite;
|
|
transition: opacity 0.6s;
|
|
}
|
|
|
|
#idle-eye.hidden { opacity: 0; pointer-events: none; }
|
|
|
|
#idle-eye .eye-icon {
|
|
font-size: 64px;
|
|
color: var(--text-dim);
|
|
line-height: 1;
|
|
}
|
|
|
|
#idle-eye .eye-label {
|
|
font-size: 12px;
|
|
letter-spacing: 3px;
|
|
color: var(--text-dim);
|
|
margin-top: 8px;
|
|
}
|
|
|
|
/* ── Responsive ── */
|
|
@media (max-width: 700px) {
|
|
#app {
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: 1fr 200px 48px;
|
|
}
|
|
#trace-panel { grid-column: 1; grid-row: 2; border-left: none; border-top: 1px solid var(--border); }
|
|
#status-bar { grid-column: 1; grid-row: 3; gap: 12px; padding: 0 12px; font-size: 11px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="app">
|
|
<!-- World Map -->
|
|
<div id="map-panel">
|
|
<div id="header-bar"><h1>MORTDECAI — MIND'S EYE</h1></div>
|
|
<canvas id="world-canvas"></canvas>
|
|
<div id="idle-eye">
|
|
<div class="eye-icon">◎</div>
|
|
<div class="eye-label">AWAITING VISION</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tool Trace -->
|
|
<div id="trace-panel">
|
|
<div id="trace-header">TOOL TRACE</div>
|
|
<div id="stream-box" class="hidden">
|
|
<div id="stream-label">MODEL THINKING...</div>
|
|
<div id="stream-text"></div>
|
|
</div>
|
|
<div id="trace-list"></div>
|
|
<div id="trace-footer">No active session</div>
|
|
</div>
|
|
|
|
<!-- Status Bar -->
|
|
<div id="status-bar">
|
|
<div class="status-item">
|
|
<span class="status-dot idle" id="mode-dot"></span>
|
|
<span class="status-label">Mode</span>
|
|
<span id="status-mode">IDLE</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="status-label">Player</span>
|
|
<span id="status-player">—</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="status-label">Pos</span>
|
|
<span id="status-pos">—</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="status-dot disconnected" id="conn-dot"></span>
|
|
<span id="status-conn">Offline</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span class="status-label">Players</span>
|
|
<span id="status-players-count">0</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reconnect Overlay -->
|
|
<div id="reconnect-overlay">
|
|
<div class="spinner"></div>
|
|
<div class="label">RECONNECTING...</div>
|
|
</div>
|
|
|
|
<script>
|
|
'use strict';
|
|
|
|
// ── Block color map ──
|
|
const BLOCK_COLORS = {
|
|
stone: '#808080', cobblestone: '#6B6B6B', deepslate: '#4A4A4A',
|
|
dirt: '#8B6914', grass_block: '#4CAF50', sand: '#F4E4A0',
|
|
gravel: '#A0A0A0', oak_planks: '#BC8F4F', oak_log: '#6B4226',
|
|
spruce_planks: '#5C3A1E', birch_planks: '#D4C78F',
|
|
water: '#2196F3', lava: '#FF5722',
|
|
redstone_wire: '#FF0000', redstone_torch: '#FF4444',
|
|
diamond_ore: '#4FC3F7', iron_ore: '#D4A574', gold_ore: '#FFD54F',
|
|
coal_ore: '#333333', bedrock: '#1A1A1A', obsidian: '#1A0A2E',
|
|
glass: 'rgba(224,247,250,0.3)', torch: '#FFEB3B',
|
|
chest: '#8D6E3F', crafting_table: '#B8860B', furnace: '#888888',
|
|
_default: '#9E9E9E',
|
|
};
|
|
|
|
// ── Entity colors ──
|
|
const HOSTILE_MOBS = new Set([
|
|
'zombie', 'skeleton', 'creeper', 'spider', 'enderman', 'witch',
|
|
'blaze', 'ghast', 'slime', 'phantom', 'drowned', 'husk', 'stray',
|
|
'pillager', 'vindicator', 'evoker', 'ravager', 'warden', 'wither',
|
|
]);
|
|
const PASSIVE_MOBS = new Set([
|
|
'cow', 'pig', 'sheep', 'chicken', 'horse', 'donkey', 'mule',
|
|
'cat', 'dog', 'wolf', 'rabbit', 'villager', 'iron_golem',
|
|
'bee', 'parrot', 'turtle', 'axolotl', 'frog', 'sniffer',
|
|
]);
|
|
|
|
// ── State ──
|
|
const state = {
|
|
mode: 'idle',
|
|
activePlayer: null,
|
|
connected: false,
|
|
players: [],
|
|
world: { time: 0, isRaining: false },
|
|
blocks: [],
|
|
entities: [],
|
|
center: { x: 0, y: 0, z: 0 },
|
|
traces: [],
|
|
traceStep: null,
|
|
traceTotal: null,
|
|
sessionId: null,
|
|
dirty: true,
|
|
hasWorldData: false,
|
|
scanHighlights: [], // {x, z, age}
|
|
lastHeartbeat: 0,
|
|
};
|
|
|
|
const TILE = 8;
|
|
const MAX_TRACES = 50;
|
|
|
|
// ── DOM refs ──
|
|
const canvas = document.getElementById('world-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const traceList = document.getElementById('trace-list');
|
|
const traceFooter = document.getElementById('trace-footer');
|
|
const modeDot = document.getElementById('mode-dot');
|
|
const connDot = document.getElementById('conn-dot');
|
|
const statusMode = document.getElementById('status-mode');
|
|
const statusPlayer = document.getElementById('status-player');
|
|
const statusPos = document.getElementById('status-pos');
|
|
const statusConn = document.getElementById('status-conn');
|
|
const statusPlayersCount = document.getElementById('status-players-count');
|
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
const idleEye = document.getElementById('idle-eye');
|
|
|
|
// ── Canvas resize ──
|
|
function resizeCanvas() {
|
|
const panel = document.getElementById('map-panel');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
canvas.width = panel.clientWidth * dpr;
|
|
canvas.height = panel.clientHeight * dpr;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
state.dirty = true;
|
|
}
|
|
window.addEventListener('resize', resizeCanvas);
|
|
resizeCanvas();
|
|
|
|
// ── Mode colors ──
|
|
function getModeAccent() {
|
|
if (state.mode === 'god') return '#D35400';
|
|
if (state.mode === 'sudo') return '#2196F3';
|
|
return '#555';
|
|
}
|
|
|
|
function getModeGlow() {
|
|
if (state.mode === 'god') return 'rgba(211,84,0,0.15)';
|
|
if (state.mode === 'sudo') return 'rgba(33,150,243,0.1)';
|
|
return 'transparent';
|
|
}
|
|
|
|
// ── Render world ──
|
|
function renderWorld() {
|
|
const w = canvas.width / (window.devicePixelRatio || 1);
|
|
const h = canvas.height / (window.devicePixelRatio || 1);
|
|
|
|
// Background
|
|
ctx.fillStyle = '#111122';
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
if (!state.hasWorldData) return;
|
|
|
|
// Center offset
|
|
const cx = Math.floor(w / 2);
|
|
const cz = Math.floor(h / 2);
|
|
const refX = state.center.x;
|
|
const refZ = state.center.z;
|
|
|
|
// Mode glow overlay
|
|
const glowColor = getModeGlow();
|
|
if (glowColor !== 'transparent') {
|
|
const grad = ctx.createRadialGradient(cx, cz, 0, cx, cz, Math.max(w, h) * 0.5);
|
|
grad.addColorStop(0, glowColor);
|
|
grad.addColorStop(1, 'transparent');
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, w, h);
|
|
}
|
|
|
|
// Grid lines (sudo mode only)
|
|
if (state.mode === 'sudo') {
|
|
ctx.strokeStyle = 'rgba(33,150,243,0.06)';
|
|
ctx.lineWidth = 0.5;
|
|
const offsetX = cx % TILE;
|
|
const offsetZ = cz % TILE;
|
|
for (let x = offsetX; x < w; x += TILE) {
|
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
|
}
|
|
for (let z = offsetZ; z < h; z += TILE) {
|
|
ctx.beginPath(); ctx.moveTo(0, z); ctx.lineTo(w, z); ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Blocks
|
|
for (const block of state.blocks) {
|
|
const sx = cx + (block.x - refX) * TILE;
|
|
const sz = cz + (block.z - refZ) * TILE;
|
|
if (sx < -TILE || sx > w + TILE || sz < -TILE || sz > h + TILE) continue;
|
|
|
|
const btype = block.type ? block.type.replace('minecraft:', '') : '_default';
|
|
ctx.fillStyle = BLOCK_COLORS[btype] || BLOCK_COLORS._default;
|
|
ctx.fillRect(sx - TILE / 2, sz - TILE / 2, TILE, TILE);
|
|
}
|
|
|
|
// Scan highlights (pulse)
|
|
const now = performance.now();
|
|
for (let i = state.scanHighlights.length - 1; i >= 0; i--) {
|
|
const sh = state.scanHighlights[i];
|
|
const age = now - sh.born;
|
|
if (age > 3000) { state.scanHighlights.splice(i, 1); continue; }
|
|
const alpha = 0.3 * (1 - age / 3000);
|
|
const accent = state.mode === 'sudo' ? `rgba(33,150,243,${alpha})` : `rgba(211,84,0,${alpha})`;
|
|
const sx = cx + (sh.x - refX) * TILE;
|
|
const sz = cz + (sh.z - refZ) * TILE;
|
|
ctx.fillStyle = accent;
|
|
ctx.fillRect(sx - TILE / 2, sz - TILE / 2, TILE, TILE);
|
|
}
|
|
|
|
// Entities
|
|
for (const ent of state.entities) {
|
|
const sx = cx + (ent.x - refX) * TILE;
|
|
const sz = cz + (ent.z - refZ) * TILE;
|
|
if (sx < -6 || sx > w + 6 || sz < -6 || sz > h + 6) continue;
|
|
|
|
const etype = (ent.type || '').replace('minecraft:', '');
|
|
let color = '#FFD54F'; // default yellow
|
|
if (HOSTILE_MOBS.has(etype)) color = '#E74C3C';
|
|
else if (PASSIVE_MOBS.has(etype)) color = '#4CAF50';
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(sx, sz, 3, 0, Math.PI * 2);
|
|
ctx.fillStyle = color;
|
|
ctx.fill();
|
|
|
|
// Entity type label (small)
|
|
if (etype) {
|
|
ctx.font = '9px Rajdhani';
|
|
ctx.fillStyle = 'rgba(224,224,224,0.5)';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(etype, sx, sz - 6);
|
|
}
|
|
}
|
|
|
|
// Players
|
|
for (const p of state.players) {
|
|
const sx = cx + (p.x - refX) * TILE;
|
|
const sz = cz + (p.z - refZ) * TILE;
|
|
|
|
const isActive = p.name === state.activePlayer;
|
|
|
|
// Glow for active player
|
|
if (isActive) {
|
|
ctx.beginPath();
|
|
ctx.arc(sx, sz, 10, 0, Math.PI * 2);
|
|
const accent = getModeAccent();
|
|
ctx.fillStyle = accent.replace(')', ',0.2)').replace('rgb', 'rgba');
|
|
ctx.fill();
|
|
}
|
|
|
|
// Dot
|
|
ctx.beginPath();
|
|
ctx.arc(sx, sz, isActive ? 5 : 4, 0, Math.PI * 2);
|
|
ctx.fillStyle = isActive ? '#FFFFFF' : '#CCCCCC';
|
|
ctx.fill();
|
|
ctx.strokeStyle = isActive ? getModeAccent() : '#666';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.stroke();
|
|
|
|
// Name label
|
|
ctx.font = isActive ? 'bold 12px Rajdhani' : '11px Rajdhani';
|
|
ctx.fillStyle = isActive ? '#FFFFFF' : '#AAAAAA';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(p.name, sx, sz - 10);
|
|
}
|
|
|
|
// Compass indicator
|
|
ctx.font = 'bold 11px Rajdhani';
|
|
ctx.fillStyle = 'rgba(224,224,224,0.3)';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('N', cx, 16);
|
|
ctx.fillText('S', cx, h - 8);
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText('W', 8, cz + 4);
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText('E', w - 8, cz + 4);
|
|
|
|
// Coordinate display
|
|
ctx.font = '10px Rajdhani';
|
|
ctx.fillStyle = 'rgba(224,224,224,0.25)';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(`Center: ${refX}, ${state.center.y}, ${refZ}`, 12, h - 10);
|
|
}
|
|
|
|
// ── Render loop ──
|
|
function renderLoop() {
|
|
if (state.dirty || state.scanHighlights.length > 0) {
|
|
renderWorld();
|
|
state.dirty = false;
|
|
}
|
|
requestAnimationFrame(renderLoop);
|
|
}
|
|
requestAnimationFrame(renderLoop);
|
|
|
|
// ── Update status bar ──
|
|
function updateStatus() {
|
|
// Mode
|
|
modeDot.className = 'status-dot ' + state.mode;
|
|
statusMode.textContent = state.mode.toUpperCase();
|
|
statusMode.style.color = getModeAccent();
|
|
|
|
// Player
|
|
statusPlayer.textContent = state.activePlayer || '\u2014';
|
|
|
|
// Position
|
|
const ap = state.players.find(p => p.name === state.activePlayer);
|
|
if (ap) {
|
|
statusPos.textContent = `(${Math.round(ap.x)}, ${Math.round(ap.y)}, ${Math.round(ap.z)})`;
|
|
} else {
|
|
statusPos.textContent = '\u2014';
|
|
}
|
|
|
|
// Connection
|
|
connDot.className = 'status-dot ' + (state.connected ? 'connected' : 'disconnected');
|
|
statusConn.textContent = state.connected ? 'Online' : 'Offline';
|
|
|
|
// Player count
|
|
statusPlayersCount.textContent = state.players.length;
|
|
|
|
// Idle eye
|
|
if (state.mode === 'idle' && !state.hasWorldData) {
|
|
idleEye.classList.remove('hidden');
|
|
} else {
|
|
idleEye.classList.add('hidden');
|
|
}
|
|
|
|
// Title accent
|
|
const titleH1 = document.querySelector('#header-bar h1');
|
|
if (state.mode === 'sudo') {
|
|
titleH1.style.color = '#2196F3';
|
|
titleH1.style.textShadow = '0 0 20px rgba(33,150,243,0.3)';
|
|
} else {
|
|
titleH1.style.color = '#D35400';
|
|
titleH1.style.textShadow = '0 0 20px rgba(211,84,0,0.3)';
|
|
}
|
|
}
|
|
|
|
// ── Trace panel ──
|
|
function getToolClass(tool) {
|
|
if (!tool) return 'other';
|
|
if (tool.startsWith('rcon')) return 'rcon';
|
|
if (tool.startsWith('world')) return 'world';
|
|
if (tool.startsWith('journal')) return 'journal';
|
|
if (tool.startsWith('script')) return 'script';
|
|
if (tool.startsWith('memory')) return 'memory';
|
|
return 'other';
|
|
}
|
|
|
|
function addTrace(data) {
|
|
state.traces.unshift(data);
|
|
if (state.traces.length > MAX_TRACES) state.traces.pop();
|
|
|
|
const entry = document.createElement('div');
|
|
entry.className = 'trace-entry';
|
|
|
|
const input = data.input || {};
|
|
|
|
// Header row: dot + tool + step + time + expand arrow
|
|
const headerRow = document.createElement('div');
|
|
headerRow.className = 'trace-header-row';
|
|
|
|
const dot = document.createElement('span');
|
|
dot.className = 'trace-dot ' + (data.ok !== false ? 'ok' : 'fail');
|
|
|
|
const tool = document.createElement('span');
|
|
tool.className = 'trace-tool ' + getToolClass(data.tool);
|
|
tool.textContent = data.tool || 'unknown';
|
|
|
|
const step = document.createElement('span');
|
|
step.className = 'trace-step';
|
|
step.textContent = data.step != null ? `#${data.step}` : '';
|
|
|
|
const time = document.createElement('span');
|
|
time.className = 'trace-time';
|
|
const d = data.ts ? new Date(data.ts) : new Date();
|
|
time.textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
|
|
const expand = document.createElement('span');
|
|
expand.className = 'trace-expand';
|
|
expand.textContent = '▶';
|
|
|
|
headerRow.appendChild(dot);
|
|
headerRow.appendChild(tool);
|
|
headerRow.appendChild(step);
|
|
headerRow.appendChild(time);
|
|
headerRow.appendChild(expand);
|
|
entry.appendChild(headerRow);
|
|
|
|
// Preview line (always visible, one-line summary)
|
|
const preview = document.createElement('div');
|
|
preview.className = 'trace-preview';
|
|
if (data.tool === 'rcon.execute' && input.command) {
|
|
preview.textContent = '> ' + input.command;
|
|
} else if (data.tool === '_direct_response') {
|
|
const cmds = Array.isArray(input.commands) ? input.commands : [];
|
|
preview.textContent = cmds.length + ' cmds' + (input.message ? ' + message' : '');
|
|
} else if (data.tool === '_session_end') {
|
|
const cmds = Array.isArray(input.commands) ? input.commands : [];
|
|
preview.textContent = 'Session end: ' + cmds.length + ' cmds' + (input.message ? ' + message' : '');
|
|
} else {
|
|
const inputStr = Object.entries(input).map(([k,v]) => `${k}=${v}`).join(', ');
|
|
preview.textContent = inputStr || (data.player || '');
|
|
}
|
|
entry.appendChild(preview);
|
|
|
|
// Expandable details container
|
|
const details = document.createElement('div');
|
|
details.className = 'trace-details';
|
|
|
|
// Player + mode
|
|
if (data.player) {
|
|
const d1 = document.createElement('div');
|
|
d1.className = 'trace-detail';
|
|
d1.textContent = 'Player: ' + data.player + (data.mode ? ' [' + data.mode.toUpperCase() + ']' : '');
|
|
details.appendChild(d1);
|
|
}
|
|
|
|
// RCON command + result
|
|
if (data.tool === 'rcon.execute' && input.command) {
|
|
const d2 = document.createElement('div');
|
|
d2.className = 'trace-detail command';
|
|
d2.textContent = '> ' + input.command;
|
|
details.appendChild(d2);
|
|
if (data.result) {
|
|
const d3 = document.createElement('div');
|
|
d3.className = 'trace-detail result' + (data.ok === false ? ' fail' : '');
|
|
d3.textContent = '← ' + data.result;
|
|
details.appendChild(d3);
|
|
}
|
|
}
|
|
|
|
// Other tool inputs
|
|
if (data.tool !== 'rcon.execute' && data.tool !== '_direct_response' && data.tool !== '_session_end') {
|
|
for (const [k, v] of Object.entries(input)) {
|
|
const d4 = document.createElement('div');
|
|
d4.className = 'trace-detail';
|
|
d4.textContent = k + ': ' + v;
|
|
details.appendChild(d4);
|
|
}
|
|
}
|
|
|
|
// Direct response: all commands + message
|
|
if (data.tool === '_direct_response') {
|
|
const cmds = Array.isArray(input.commands) ? input.commands : [];
|
|
for (const cmd of cmds) {
|
|
const d5 = document.createElement('div');
|
|
d5.className = 'trace-detail command';
|
|
d5.textContent = '> ' + cmd;
|
|
details.appendChild(d5);
|
|
}
|
|
if (input.message) {
|
|
const d6 = document.createElement('div');
|
|
d6.className = 'trace-detail message';
|
|
d6.textContent = '"' + String(input.message) + '"';
|
|
details.appendChild(d6);
|
|
}
|
|
}
|
|
|
|
// Session end: commands + message
|
|
if (data.tool === '_session_end') {
|
|
const cmds = Array.isArray(input.commands) ? input.commands : [];
|
|
for (const cmd of cmds) {
|
|
const d7 = document.createElement('div');
|
|
d7.className = 'trace-detail command';
|
|
d7.textContent = '> ' + cmd;
|
|
details.appendChild(d7);
|
|
}
|
|
if (input.message) {
|
|
const d8 = document.createElement('div');
|
|
d8.className = 'trace-detail message';
|
|
d8.textContent = '"' + String(input.message) + '"';
|
|
details.appendChild(d8);
|
|
}
|
|
}
|
|
|
|
// Session ID
|
|
if (data.session_id) {
|
|
const d9 = document.createElement('div');
|
|
d9.className = 'trace-detail';
|
|
d9.textContent = 'Session: ' + data.session_id;
|
|
details.appendChild(d9);
|
|
}
|
|
|
|
entry.appendChild(details);
|
|
|
|
// Click to expand/collapse
|
|
headerRow.addEventListener('click', () => {
|
|
entry.classList.toggle('expanded');
|
|
});
|
|
|
|
traceList.insertBefore(entry, traceList.firstChild);
|
|
|
|
// Trim DOM
|
|
while (traceList.children.length > MAX_TRACES) {
|
|
traceList.removeChild(traceList.lastChild);
|
|
}
|
|
|
|
// Update footer
|
|
if (data.session_id) state.sessionId = data.session_id;
|
|
const modeLabel = data.mode ? data.mode.toUpperCase() : state.mode.toUpperCase();
|
|
const playerLabel = data.player || state.activePlayer || '?';
|
|
traceFooter.textContent = `${modeLabel} | ${playerLabel} | step ${data.step || '?'}`;
|
|
}
|
|
|
|
// ── Streaming display ──
|
|
const streamBox = document.getElementById('stream-box');
|
|
const streamText = document.getElementById('stream-text');
|
|
const streamLabel = document.getElementById('stream-label');
|
|
let streamTimeout = null;
|
|
|
|
function handleStream(msg) {
|
|
// Show the stream box
|
|
streamBox.classList.remove('hidden');
|
|
streamBox.classList.toggle('sudo', msg.mode === 'sudo');
|
|
|
|
// Update the streaming text with accumulated content
|
|
if (msg.accumulated) {
|
|
streamText.textContent = msg.accumulated;
|
|
// Auto-scroll to bottom
|
|
streamBox.scrollTop = streamBox.scrollHeight;
|
|
}
|
|
|
|
// Reset the hide timeout (hide 3s after last token)
|
|
clearTimeout(streamTimeout);
|
|
streamTimeout = setTimeout(hideStream, 3000);
|
|
}
|
|
|
|
function hideStream() {
|
|
streamBox.classList.add('hidden');
|
|
streamText.textContent = '';
|
|
clearTimeout(streamTimeout);
|
|
}
|
|
|
|
// ── WebSocket ──
|
|
let ws = null;
|
|
let reconnectDelay = 1000;
|
|
const MAX_RECONNECT_DELAY = 30000;
|
|
|
|
function connectWS() {
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
|
|
|
ws.onopen = function() {
|
|
state.connected = true;
|
|
reconnectDelay = 1000;
|
|
reconnectOverlay.classList.remove('visible');
|
|
updateStatus();
|
|
};
|
|
|
|
ws.onclose = function() {
|
|
state.connected = false;
|
|
updateStatus();
|
|
reconnectOverlay.classList.add('visible');
|
|
setTimeout(connectWS, reconnectDelay);
|
|
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
};
|
|
|
|
ws.onerror = function() {
|
|
ws.close();
|
|
};
|
|
|
|
ws.onmessage = function(evt) {
|
|
let msg;
|
|
try { msg = JSON.parse(evt.data); } catch { return; }
|
|
|
|
switch (msg.type) {
|
|
case 'heartbeat':
|
|
state.mode = msg.mode || 'idle';
|
|
state.activePlayer = msg.activePlayer || null;
|
|
state.players = msg.players || [];
|
|
if (msg.world) {
|
|
state.world = msg.world;
|
|
}
|
|
state.lastHeartbeat = Date.now();
|
|
state.dirty = true;
|
|
updateStatus();
|
|
break;
|
|
|
|
case 'world':
|
|
state.mode = msg.mode || state.mode;
|
|
state.activePlayer = msg.activePlayer || state.activePlayer;
|
|
state.center = msg.center || state.center;
|
|
state.blocks = msg.blocks || [];
|
|
state.entities = msg.entities || [];
|
|
state.players = msg.players || state.players;
|
|
state.hasWorldData = true;
|
|
state.dirty = true;
|
|
|
|
// Add scan highlight on all new block positions
|
|
const born = performance.now();
|
|
const seen = new Set();
|
|
for (const b of state.blocks) {
|
|
const key = b.x + ',' + b.z;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
state.scanHighlights.push({ x: b.x, z: b.z, born });
|
|
}
|
|
}
|
|
|
|
updateStatus();
|
|
break;
|
|
|
|
case 'trace':
|
|
addTrace(msg);
|
|
if (msg.mode) state.mode = msg.mode;
|
|
if (msg.player) state.activePlayer = msg.player;
|
|
state.dirty = true;
|
|
updateStatus();
|
|
break;
|
|
|
|
case 'stream':
|
|
handleStream(msg);
|
|
break;
|
|
|
|
case 'mode':
|
|
state.mode = msg.mode || 'idle';
|
|
if (msg.player) state.activePlayer = msg.player;
|
|
if (msg.mode === 'idle') hideStream();
|
|
state.dirty = true;
|
|
updateStatus();
|
|
break;
|
|
|
|
case 'status':
|
|
if (msg.connected !== undefined) {
|
|
state.connected = msg.connected;
|
|
updateStatus();
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
}
|
|
|
|
connectWS();
|
|
|
|
// ── Idle heartbeat timeout → revert to idle ──
|
|
setInterval(function() {
|
|
if (state.lastHeartbeat && Date.now() - state.lastHeartbeat > 15000) {
|
|
if (state.mode !== 'idle') {
|
|
state.mode = 'idle';
|
|
state.dirty = true;
|
|
updateStatus();
|
|
}
|
|
}
|
|
}, 5000);
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|