Files
minecraft-bot/web_interface/src/dashboard.js
T

213 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'use strict';
const POLL_MS = 1000;
const $ = (id) => document.getElementById(id);
let itemNames = {};
let lastInventorySignature = '';
let lastState = null;
const escapeHTML = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[ch]);
function prettyName(name) {
return String(name || 'unknown item').replace(/^minecraft:/, '')
.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function vectorText(pos) {
if (!pos) return '—';
if (typeof pos === 'object' && ['x', 'y', 'z'].every((key) => key in pos)) {
return `${Number(pos.x).toFixed(1)}, ${Number(pos.y).toFixed(1)}, ${Number(pos.z).toFixed(1)}`;
}
const match = String(pos).match(/-?\d+(?:\.\d+)?/g);
return match && match.length >= 3 ? match.slice(0, 3).join(', ') : String(pos);
}
function dimensionName(name) {
return String(name || 'unknown').replace(/^minecraft:/, '').replaceAll('_', ' ').toUpperCase();
}
function formatMode(mode) {
const modes = {0: 'Survival', 1: 'Creative', 2: 'Adventure', 3: 'Spectator'};
return modes[mode] || (mode == null ? '—' : String(mode));
}
function textureCandidates(name) {
const id = String(name || '').replace(/^minecraft:/, '').toLowerCase();
if (!/^[a-z0-9_]+$/.test(id) || id === 'air') return [];
// Prefer bundled icons, then the same-origin asset proxy. The external
// sources remain a last resort for uncached items on older bot processes.
const local = `/static/item-icons/${encodeURIComponent(id)}.png`;
const proxy = `/api/item-icon/${encodeURIComponent(id)}`;
const root = 'https://assets.mcasset.cloud/26.3/assets/minecraft/textures';
const paths = [`item/${id}.png`, `block/${id}.png`];
const simplified = id.replace(/_(slab|stairs|wall|fence_gate|fence|door|trapdoor|button|pressure_plate|sign|hanging_sign|carpet|banner|bed|candle)$/, '');
if (simplified !== id) paths.push(`block/${simplified}.png`, `item/${simplified}.png`);
return [local, proxy, ...[...new Set(paths)].map((path) => `${root}/${path}`)];
}
function renderItemIcon(img, fallback, itemName) {
const candidates = textureCandidates(itemName);
let index = 0;
const tryNext = () => {
if (index >= candidates.length) {
img.remove();
fallback.hidden = false;
fallback.textContent = prettyName(itemName).slice(0, 2).toUpperCase();
return;
}
img.src = candidates[index++];
};
img.onerror = tryNext;
img.onload = () => { fallback.hidden = true; };
tryNext();
}
function inventorySignature(inv, partial) {
return JSON.stringify([Boolean(partial), Object.entries(inv || {}).map(([slot, item]) => [
slot, item && item.present, item && item.item_id, item && item.item_count
])]);
}
function renderInventory(state) {
const inv = state.inv || {};
const signature = inventorySignature(inv, state.inventory_partial);
if (signature === lastInventorySignature) return;
lastInventorySignature = signature;
const grid = $('inventory-grid');
const items = [];
let stackCount = 0;
let totalCount = 0;
for (let slot = 0; slot < 46; slot++) {
const item = inv[String(slot)] || inv[slot];
if (item && item.present) {
stackCount++;
totalCount += Number(item.item_count || 0);
}
items.push({slot, item});
}
grid.innerHTML = items.map(({slot, item}) => {
const present = Boolean(item && item.present);
const itemId = present ? item.item_id : null;
const name = present ? (itemNames[String(itemId)] || `item_${itemId}`) : '';
const count = present ? Number(item.item_count || 0) : 0;
const title = present ? `${prettyName(name)} × ${count} (id ${itemId}, slot ${slot})` : `Empty slot ${slot}`;
return `<div class="inventory-slot${present ? ' occupied' : ''}${slot >= 36 && slot <= 44 ? ' hotbar-slot' : ''}"
title="${escapeHTML(title)}" aria-label="${escapeHTML(title)}">
<span class="slot-number">${slot}</span>
${present ? `<span class="item-art"><img alt="" loading="lazy"><span class="icon-fallback" hidden></span></span>
<span class="stack-count">${count > 1 ? count : ''}</span>` : ''}
</div>`;
}).join('');
items.forEach(({slot, item}) => {
if (!item || !item.present) return;
const icon = grid.children[slot]?.querySelector('img');
const fallback = grid.children[slot]?.querySelector('.icon-fallback');
if (icon && fallback) renderItemIcon(icon, fallback, itemNames[String(item.item_id)] || `item_${item.item_id}`);
});
$('inventory-count').textContent = `${totalCount} items`;
$('inventory-slots').textContent = `${stackCount} / 46 slots`;
$('inventory-warning').hidden = !state.inventory_partial;
}
function renderPlayers(state) {
const players = Object.values(state.players || {});
$('player-count').textContent = String(players.length);
if (!players.length) {
$('player-list').innerHTML = '<div class="empty-state">No player data in range</div>';
return;
}
$('player-list').innerHTML = players.map((player) => {
const uuid = String(player.player_uuid || '');
const name = state.player_names?.[uuid] || state.player_names?.[uuid.toLowerCase()] || `Player ${player.entity_id}`;
const pos = [player.x, player.y, player.z].map((n) => Number(n).toFixed(0)).join(', ');
return `<div class="player-row"><span class="player-avatar">${escapeHTML(name.slice(0, 1).toUpperCase())}</span>
<span class="player-name">${escapeHTML(name)}</span><span class="player-pos">${escapeHTML(pos)}</span></div>`;
}).join('');
}
function render(state) {
lastState = state;
const online = Boolean(state.connected) && state.connection_status === 'in_game';
const pill = $('connection-pill');
pill.classList.toggle('is-online', online);
pill.classList.toggle('is-offline', !online);
pill.querySelector('span').textContent = online ? 'ONLINE' : (state.connection_status || 'OFFLINE').toUpperCase();
$('bot-name').textContent = state.name || 'Mosfet';
$('session-subtitle').textContent = online
? `${formatMode(state.game_mode)} mode · protocol 777 · AuthMe session active`
: `Connection state: ${state.connection_status || 'disconnected'}`;
$('position').textContent = vectorText(state.pos || state.player_position);
$('dimension').textContent = dimensionName(state.dimension);
const health = state.health == null ? NaN : Number(state.health);
const food = state.food == null ? NaN : Number(state.food);
const saturation = state.food_saturation == null
? NaN : Number(state.food_saturation);
$('health-value').textContent = Number.isFinite(health)
? Number(health.toFixed(1)).toString() : '—';
$('food-value').textContent = Number.isFinite(food) ? String(food) : '—';
$('food-saturation').textContent = Number.isFinite(saturation)
? `Saturation ${saturation.toFixed(2)}` : 'Saturation —';
$('health-meter').style.width = `${Math.max(0, Math.min(100, (health || 0) * 5))}%`;
$('food-meter').style.width = `${Math.max(0, Math.min(100, (food || 0) * 5))}%`;
const movement = state.movement_status || (state.path?.length ? 'following_path' : 'idle');
$('movement-value').textContent = String(movement).replaceAll('_', ' ').toUpperCase();
$('movement-detail').textContent = state.path?.length ? `${state.path.length} nodes remaining` : 'No active path';
$('chunk-count').textContent = state.chunk_state?.loaded ?? '—';
const center = state.chunk_state?.center;
$('chunk-detail').textContent = center ? `Center ${center[0]}, ${center[1]} · radius ${state.chunk_state.radius ?? '—'}` : 'Waiting for world data';
$('game-mode').textContent = formatMode(state.game_mode);
$('job-state').textContent = prettyName(state.job_state || 'idle').toUpperCase();
$('path-length').textContent = String(state.path?.length || 0);
$('entity-count').textContent = `${Object.keys(state.mobs || {}).length} mobs · ${Object.keys(state.objects || {}).length} drops`;
$('afk-state').textContent = state.afk ? 'YES' : 'NO';
$('movement-error').hidden = !state.movement_error;
$('movement-error').textContent = state.movement_error ? `Movement error: ${state.movement_error}` : '';
renderInventory(state);
renderPlayers(state);
$('last-update').textContent = `Updated ${new Date().toLocaleTimeString()}`;
}
async function loadItems() {
try {
const response = await fetch('/api/items', {cache: 'force-cache'});
if (response.ok) itemNames = await response.json();
if (lastState) {
lastInventorySignature = '';
renderInventory(lastState);
}
} catch (_) {
// Inventory still renders with numeric IDs if the name endpoint is absent.
}
}
async function poll() {
try {
const response = await fetch('/api/global', {cache: 'no-store'});
if (!response.ok) throw new Error(`API ${response.status}`);
render(await response.json());
} catch (error) {
const pill = $('connection-pill');
pill.classList.add('is-offline');
pill.classList.remove('is-online');
pill.querySelector('span').textContent = 'API OFFLINE';
$('session-subtitle').textContent = 'Cannot reach the bot web API';
$('last-update').textContent = `Last update failed · ${error.message}`;
}
}
loadItems();
poll();
window.setInterval(poll, POLL_MS);
})();