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

828 lines
34 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 MINIMAP_POLL_MS = 2000;
const MINIMAP_MIN_RADIUS = 4;
const MINIMAP_MAX_RADIUS = 24;
const MINIMAP_ZOOM_STEP = 4;
const $ = (id) => document.getElementById(id);
let itemNames = {};
let lastInventorySignature = '';
let lastState = null;
let lastInputMethod = 'pointer';
let selectedInventoryItem = null;
let menuAnchor = null;
let toastTimer = null;
let minimapRadius = 12;
let minimapNorthLocked = false;
let minimapLarge = false;
let minimapRequestPending = false;
let lastMinimapData = 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 componentText(value) {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (Array.isArray(value)) return value.map(componentText).filter(Boolean).join('');
if (typeof value !== 'object') return '';
if ('raw' in value) return componentText(value.raw);
if ('text' in value) return componentText(value.text) + componentText(value.extra);
if ('translate' in value) {
const args = Array.isArray(value.with) ? value.with.map(componentText).join(' ') : '';
return `${prettyName(value.translate)}${args ? ` ${args}` : ''}${componentText(value.extra)}`;
}
if ('value' in value) return componentText(value.value);
return '';
}
function itemDisplayName(item, itemName) {
const components = item?.components || {};
return componentText(components.custom_name || components.item_name) || prettyName(itemName);
}
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 formatWorldClock(ticks) {
if (ticks == null) return '—';
const time = Number(ticks);
if (!Number.isFinite(time)) return '—';
const dayTicks = ((Math.floor(time) % 24000) + 24000) % 24000;
const minutes = Math.floor((((dayTicks + 6000) % 24000) / 1000) * 60);
const hour24 = Math.floor(minutes / 60);
const hour12 = hour24 % 12 || 12;
const minute = minutes % 60;
const period = hour24 < 12 ? 'AM' : 'PM';
return `${String(hour12).padStart(2, '0')}:${String(minute).padStart(2, '0')} ${period}`;
}
function minimapBlockColor(name) {
const block = String(name || '').replace(/^minecraft:/, '').toLowerCase();
if (/water|bubble_column/.test(block)) return '#3979a5';
if (/lava|magma|fire/.test(block)) return '#bd5d32';
if (/leaves|moss|grass_block|fern|azalea|flower|crop/.test(block)) return '#557c42';
if (/dirt|mud|podzol|mycelium|farmland|rooted_dirt/.test(block)) return '#806044';
if (/sand|snow|ice|clay|calcite|diorite|quartz/.test(block)) return '#c0b58b';
if (/log|wood|planks|stem|hyphae|bamboo/.test(block)) return '#896842';
if (/stone|deepslate|cobble|ore|tuff|andesite|granite|bedrock/.test(block)) return '#777d7d';
if (/netherrack|blackstone|basalt|nether/.test(block)) return '#76515a';
return '#7a806f';
}
function shadeMinimapColor(color, amount) {
const value = Number.parseInt(color.slice(1), 16);
const channels = [value >> 16, (value >> 8) & 0xff, value & 0xff]
.map((channel) => Math.max(0, Math.min(255, Math.round(channel * amount))));
return `rgb(${channels[0]} ${channels[1]} ${channels[2]})`;
}
function drawMinimap(data = lastMinimapData) {
const canvas = $('minimap-canvas');
if (!canvas) return;
const context = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const centerX = width / 2;
const centerY = height / 2;
context.clearRect(0, 0, width, height);
context.fillStyle = '#111411';
context.fillRect(0, 0, width, height);
if (!data?.ready || !Array.isArray(data.cells)) {
$('minimap-caption').textContent = 'Waiting for loaded terrain';
updateMinimapControls();
return;
}
const radiusZ = Number(data.radius_z || data.radius || minimapRadius);
const radiusX = Number(data.radius_x || data.radius || minimapRadius);
const rows = radiusZ * 2 + 1;
const columns = radiusX * 2 + 1;
const cellWidth = width / columns;
const cellHeight = height / rows;
const yaw = Number(data.yaw || 0);
const mapRotation = minimapNorthLocked ? 0 : (180 - yaw) * Math.PI / 180;
context.save();
context.beginPath();
context.rect(0, 0, width, height);
context.clip();
context.translate(centerX, centerY);
context.rotate(mapRotation);
for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
const row = data.cells[rowIndex] || [];
for (let columnIndex = 0; columnIndex < columns; columnIndex++) {
const surface = row[columnIndex];
const x = -centerX + columnIndex * cellWidth;
const y = -centerY + rowIndex * cellHeight;
if (!surface) {
context.fillStyle = '#171b17';
} else {
const heightDelta = Number(surface.y) - Number(data.center?.y || 0);
const shade = Math.max(0.72, Math.min(1.28, 1 + heightDelta * 0.018));
context.fillStyle = shadeMinimapColor(
minimapBlockColor(surface.block), shade);
}
context.fillRect(x, y, Math.ceil(cellWidth) + 0.2,
Math.ceil(cellHeight) + 0.2);
}
}
context.restore();
context.save();
context.translate(centerX, centerY);
if (minimapNorthLocked) context.rotate((yaw + 180) * Math.PI / 180);
context.beginPath();
context.moveTo(0, -8);
context.lineTo(-5, 6);
context.lineTo(0, 3);
context.lineTo(5, 6);
context.closePath();
context.fillStyle = '#f1f3ef';
context.strokeStyle = '#151815';
context.lineWidth = 2;
context.fill();
context.stroke();
context.restore();
context.strokeStyle = '#566252';
context.lineWidth = 2;
context.strokeRect(1, 1, width - 2, height - 2);
$('minimap-caption').textContent =
`X ${data.center.x} · Z ${data.center.z} · ${columns}×${rows}`;
updateMinimapControls();
}
function minimapRadii() {
const canvas = $('minimap-canvas');
return {
x: Math.max(4, Math.min(48,
Math.round(minimapRadius * canvas.width / canvas.height))),
z: minimapRadius,
};
}
function updateMinimapControls() {
$('minimap-zoom-out').disabled = minimapRadius >= MINIMAP_MAX_RADIUS;
$('minimap-zoom-in').disabled = minimapRadius <= MINIMAP_MIN_RADIUS;
const northButton = $('minimap-north-lock');
northButton.setAttribute('aria-pressed', String(minimapNorthLocked));
northButton.title = minimapNorthLocked ? 'Rotate with the bot' : 'Lock north to the top';
northButton.setAttribute('aria-label', northButton.title);
const sizeButton = $('minimap-size');
sizeButton.setAttribute('aria-pressed', String(minimapLarge));
sizeButton.title = minimapLarge ? 'Reduce minimap size' : 'Enlarge minimap';
sizeButton.setAttribute('aria-label', sizeButton.title);
sizeButton.textContent = minimapLarge ? '⤡' : '⤢';
}
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,
item && item.components
])]);
}
function selectedHotbarSlot(state) {
if (!state.connected) return null;
const selected = Number(state.holding_slot);
return Number.isInteger(selected) && selected >= 0 && selected <= 8
? selected + 36 : null;
}
function updateSelectedSlot(slot) {
const grid = $('inventory-grid');
grid.querySelectorAll('.selected-slot').forEach((element) => {
element.classList.remove('selected-slot');
element.removeAttribute('aria-current');
});
if (slot == null) return;
const selected = grid.querySelector(`.inventory-slot[data-slot="${slot}"]`);
if (selected) {
selected.classList.add('selected-slot');
selected.setAttribute('aria-current', 'true');
}
}
function renderInventory(state) {
const inv = state.inv || {};
const signature = inventorySignature(inv, state.inventory_partial);
const selectedSlot = selectedHotbarSlot(state);
if (signature === lastInventorySignature) {
updateSelectedSlot(selectedSlot);
return;
}
lastInventorySignature = signature;
hideItemTooltip();
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});
}
const bySlot = new Map(items.map((entry) => [entry.slot, entry.item]));
const slotHTML = (slot, layoutClass = '') => {
const item = bySlot.get(slot);
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 displayName = present ? itemDisplayName(item, name) : `Empty slot ${slot}`;
const label = present ? `${displayName}, ${count} item${count === 1 ? '' : 's'}, slot ${slot}` : displayName;
return `<div class="inventory-slot${layoutClass ? ` ${layoutClass}` : ''}${present ? ' occupied interactive' : ''}${slot >= 36 && slot <= 44 ? ' hotbar-slot' : ''}${slot === selectedSlot ? ' selected-slot' : ''}"
${present ? `data-slot="${slot}" role="button" tabindex="0" aria-haspopup="menu" aria-expanded="false"` : ''}
aria-label="${escapeHTML(label)}"${slot === selectedSlot ? ' aria-current="true"' : ''}>
<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>`;
};
const slotsHTML = (first, last) => {
let html = '';
for (let slot = first; slot <= last; slot++) html += slotHTML(slot);
return html;
};
grid.innerHTML = `<div class="inventory-upper">
<div class="upper-spacer" aria-hidden="true"></div>
<section class="inventory-group crafting-group" aria-label="Crafting">
<div class="crafting-labels">
<div class="inventory-group-label crafting-label-offhand">OFFHAND</div>
<div class="inventory-group-label crafting-label-grid">CRAFTING</div>
<div class="inventory-group-label crafting-label-output">OUTPUT</div>
</div>
<div id="crafting-grid" class="crafting-layout">
${slotHTML(45, 'crafting-offhand')}
${slotHTML(1, 'crafting-input crafting-input-1')}
${slotHTML(2, 'crafting-input crafting-input-2')}
${slotHTML(3, 'crafting-input crafting-input-3')}
${slotHTML(4, 'crafting-input crafting-input-4')}
<span class="crafting-arrow" aria-label="Crafting result">→</span>
${slotHTML(0, 'crafting-result')}
</div>
</section>
</div>
<div class="inventory-lower-headings">
<div class="inventory-group-label">ARMOUR</div>
<div class="inventory-group-label">INVENTORY</div>
</div>
<div class="inventory-lower-grid">
<div id="armor-slots" class="armor-slots">${slotsHTML(5, 8)}</div>
<div class="inventory-body">
<div id="main-inventory" class="main-inventory">${slotsHTML(9, 35)}</div>
<div class="hotbar-label">HOTBAR</div>
<div id="hotbar-inventory" class="hotbar-inventory">${slotsHTML(36, 44)}</div>
</div>
</div>`;
reanchorInventoryMenu(inv);
items.forEach(({slot, item}) => {
if (!item || !item.present) return;
const element = grid.querySelector(`.inventory-slot[data-slot="${slot}"]`);
const icon = element?.querySelector('img');
const fallback = element?.querySelector('.icon-fallback');
if (icon && fallback) renderItemIcon(icon, fallback, itemNames[String(item.item_id)] || `item_${item.item_id}`);
});
updateSelectedSlot(selectedSlot);
$('inventory-count').textContent = `${totalCount} items`;
$('inventory-slots').textContent = `${stackCount} / 46 slots`;
$('inventory-warning').hidden = !state.inventory_partial;
}
function romanNumeral(number) {
let value = Math.max(0, Math.floor(Number(number) || 0));
const numerals = [[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'], [10, 'X'],
[9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
let result = '';
for (const [amount, glyph] of numerals) {
while (value >= amount) { result += glyph; value -= amount; }
}
return result;
}
function summarizeProperty(value) {
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'string' || typeof value === 'number') return String(value);
if (Array.isArray(value)) return `${value.length} entries`;
if (value && typeof value === 'object') {
const text = componentText(value);
return text || `${Object.keys(value).length} fields`;
}
return '';
}
function tooltipHTML(item, itemName, slot) {
const components = item.components || {};
const displayName = itemDisplayName(item, itemName);
const rarity = String(components.rarity || 'common').toLowerCase();
const titleClass = /^[a-z]+$/.test(rarity) ? ` rarity-${rarity}` : '';
const rows = [];
const add = (text, className = '') => {
if (text) rows.push(`<div class="tooltip-property${className ? ` ${className}` : ''}">${escapeHTML(text)}</div>`);
};
for (const key of ['enchantments', 'stored_enchantments']) {
const enchantments = components[key];
if (!enchantments || typeof enchantments !== 'object') continue;
for (const [name, level] of Object.entries(enchantments)) {
add(`${prettyName(name)} ${romanNumeral(level)}`, 'tooltip-enchantment');
}
}
const damage = Number(components.damage);
const maxDamage = Number(components.max_damage);
if (Number.isFinite(damage) && Number.isFinite(maxDamage) && maxDamage > 0) {
const remaining = Math.max(0, maxDamage - damage);
const percent = Math.max(0, Math.min(100, remaining / maxDamage * 100));
rows.push(`<div class="tooltip-durability"><span>Durability</span><b>${remaining} / ${maxDamage}</b><i><em style="width:${percent}%"></em></i></div>`);
} else if (damage > 0) {
add(`Damage: ${damage}`);
}
if (components.unbreakable) add('Unbreakable', 'tooltip-enchantment');
if (components.max_stack_size != null) add(`Maximum stack size: ${components.max_stack_size}`);
const lore = Array.isArray(components.lore) ? components.lore : [];
for (const line of lore) add(componentText(line), 'tooltip-lore');
const container = components.container;
if (Array.isArray(container)) {
const contents = container.filter((entry) => entry && entry.present).length;
add(`${contents} item${contents === 1 ? '' : 's'} inside`);
}
const book = components.written_book_content;
if (book) {
const title = componentText(book.title) || 'Untitled';
add(`Book: ${title} (${(book.pages || []).length} pages)`);
}
if (components.dyed_color != null) {
add(`Dyed color: #${(Number(components.dyed_color) & 0xffffff).toString(16).padStart(6, '0').toUpperCase()}`);
}
const shown = new Set([
'custom_name', 'item_name', 'rarity', 'enchantments', 'stored_enchantments',
'damage', 'max_damage', 'unbreakable', 'max_stack_size', 'lore',
'container', 'written_book_content', 'writable_book_content', 'dyed_color',
'custom_data', 'custom_model_data', 'tooltip_display',
]);
for (const [key, value] of Object.entries(components)) {
if (shown.has(key) || value == null || value === false) continue;
add(`${prettyName(key)}: ${summarizeProperty(value)}`);
}
if (item.components_unknown) add('Some item properties could not be decoded.', 'tooltip-muted');
if (!rows.length) add('No additional properties', 'tooltip-muted');
return `<div class="tooltip-name${titleClass}">${escapeHTML(displayName)}</div>
<div class="tooltip-meta">Stack ${Number(item.item_count || 0)} · Slot ${slot} · ID ${item.item_id}</div>
<div class="tooltip-divider"></div>${rows.join('')}`;
}
function positionFloating(element, x, y) {
element.style.left = '0px';
element.style.top = '0px';
const bounds = element.getBoundingClientRect();
const margin = 8;
const left = Math.max(margin, Math.min(x, window.innerWidth - bounds.width - margin));
const top = y + bounds.height + margin <= window.innerHeight
? y : Math.max(margin, y - bounds.height - margin);
element.style.left = `${left}px`;
element.style.top = `${top}px`;
}
function hideItemTooltip() {
const tooltip = $('item-tooltip');
if (tooltip) tooltip.hidden = true;
}
function showItemTooltip(slotElement, point) {
if (selectedInventoryItem) return;
const slot = Number(slotElement.dataset.slot);
const item = lastState?.inv?.[String(slot)] || lastState?.inv?.[slot];
if (!item?.present) return hideItemTooltip();
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
const tooltip = $('item-tooltip');
tooltip.innerHTML = tooltipHTML(item, name, slot);
tooltip.hidden = false;
positionFloating(tooltip, point.x + 14, point.y + 14);
}
function closeInventoryMenu() {
const menu = $('inventory-menu');
if (menu) menu.hidden = true;
if (menuAnchor) menuAnchor.setAttribute('aria-expanded', 'false');
menuAnchor = null;
selectedInventoryItem = null;
}
function openInventoryMenu(slotElement) {
const slot = Number(slotElement.dataset.slot);
const item = lastState?.inv?.[String(slot)] || lastState?.inv?.[slot];
if (!item?.present) return closeInventoryMenu();
hideItemTooltip();
closeInventoryMenu();
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
selectedInventoryItem = {slot, itemId: item.item_id, count: Number(item.item_count || 0)};
menuAnchor = slotElement;
menuAnchor.setAttribute('aria-expanded', 'true');
$('inventory-menu-title').textContent = itemDisplayName(item, name);
$('inventory-menu-meta').textContent = `Slot ${slot} · ${selectedInventoryItem.count} item${selectedInventoryItem.count === 1 ? '' : 's'}`;
$('inventory-menu-status').classList.remove('is-error');
$('inventory-menu-status').textContent = slot < 5
? 'Crafting slots cannot be dropped from the dashboard.' : '';
$('inventory-menu-status').hidden = slot >= 5;
const unavailable = !lastState?.connected || slot < 5;
$('inventory-menu').querySelectorAll('[data-inventory-action]:not([data-inventory-action="cancel"])')
.forEach((button) => { button.disabled = unavailable; });
const rect = slotElement.getBoundingClientRect();
const menu = $('inventory-menu');
menu.hidden = false;
positionFloating(menu, rect.left, rect.bottom + 4);
}
function reanchorInventoryMenu(inv) {
if (!selectedInventoryItem) return;
const {slot, itemId} = selectedInventoryItem;
const item = inv[String(slot)] || inv[slot];
if (!item?.present || item.item_id !== itemId) {
closeInventoryMenu();
return;
}
selectedInventoryItem.count = Number(item.item_count || 0);
const anchor = $('inventory-grid').querySelector(
`.inventory-slot[data-slot="${slot}"]`);
if (!anchor) {
closeInventoryMenu();
return;
}
menuAnchor = anchor;
anchor.setAttribute('aria-expanded', 'true');
const name = itemNames[String(item.item_id)] || `item_${item.item_id}`;
$('inventory-menu-title').textContent = itemDisplayName(item, name);
$('inventory-menu-meta').textContent = `Slot ${slot} · ${selectedInventoryItem.count} item${selectedInventoryItem.count === 1 ? '' : 's'}`;
const rect = anchor.getBoundingClientRect();
const menu = $('inventory-menu');
menu.hidden = false;
positionFloating(menu, rect.left, rect.bottom + 4);
}
function showToast(message, isError = false) {
const toast = $('toast');
toast.textContent = message;
toast.classList.toggle('is-error', isError);
toast.classList.add('visible');
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => toast.classList.remove('visible'), 2400);
}
async function runInventoryAction(action) {
if (!selectedInventoryItem) return;
const selected = {...selectedInventoryItem};
const buttons = $('inventory-menu').querySelectorAll('button');
buttons.forEach((button) => { button.disabled = true; });
$('inventory-menu-status').hidden = false;
$('inventory-menu-status').textContent = 'Sending action…';
try {
const response = await fetch('/api/inventory/drop', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({slot: selected.slot, action}),
});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || `API ${response.status}`);
closeInventoryMenu();
const message = action === 'matching'
? `Dropping ${result.stacks} matching stack${result.stacks === 1 ? '' : 's'}`
: action === 'one' ? 'Dropped one item' : 'Dropped the stack';
showToast(message);
lastInventorySignature = '';
await poll();
} catch (error) {
$('inventory-menu-status').textContent = error.message;
$('inventory-menu-status').classList.add('is-error');
buttons.forEach((button) => {
const cancel = button.dataset.inventoryAction === 'cancel';
button.disabled = !cancel && (!lastState?.connected || selected.slot < 5);
});
}
}
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);
const worldTime = online && state.time_base != null && state.time != null
? Number(state.time) : NaN;
$('world-time').textContent = Number.isFinite(worldTime)
? `${Math.floor(worldTime).toLocaleString()} t · ${formatWorldClock(worldTime)}`
: '—';
if (lastMinimapData?.ready) {
lastMinimapData.yaw = Number(state.yaw || 0);
drawMinimap(lastMinimapData);
}
$('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 pollMinimap() {
if (!lastState?.connected || lastState.connection_status !== 'in_game') {
lastMinimapData = null;
drawMinimap(null);
return;
}
if (minimapRequestPending) return;
minimapRequestPending = true;
const requestedRadius = minimapRadius;
const requestedRadii = minimapRadii();
const requestKey = `${requestedRadii.x}:${requestedRadii.z}`;
try {
const response = await fetch(
`/api/minimap?radius=${requestedRadius}&radius_x=${requestedRadii.x}&radius_z=${requestedRadii.z}`,
{cache: 'no-store'},
);
if (!response.ok) throw new Error(`API ${response.status}`);
const data = await response.json();
const currentRadii = minimapRadii();
if (requestedRadius !== minimapRadius ||
requestKey !== `${currentRadii.x}:${currentRadii.z}` ||
!lastState?.connected) return;
lastMinimapData = data;
drawMinimap(data);
} catch (_) {
const currentRadii = minimapRadii();
if (requestedRadius === minimapRadius &&
requestKey === `${currentRadii.x}:${currentRadii.z}`) {
lastMinimapData = null;
drawMinimap(null);
$('minimap-caption').textContent = 'Map unavailable';
}
} finally {
minimapRequestPending = false;
}
}
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}`;
}
}
const inventoryGrid = $('inventory-grid');
inventoryGrid.addEventListener('pointerover', (event) => {
const slot = event.target.closest('.inventory-slot.interactive');
if (!slot || selectedInventoryItem) return;
showItemTooltip(slot, {x: event.clientX, y: event.clientY});
});
inventoryGrid.addEventListener('pointermove', (event) => {
const tooltip = $('item-tooltip');
if (!tooltip.hidden && !selectedInventoryItem) {
positionFloating(tooltip, event.clientX + 14, event.clientY + 14);
}
});
inventoryGrid.addEventListener('pointerout', (event) => {
const slot = event.target.closest('.inventory-slot.interactive');
if (slot && !slot.contains(event.relatedTarget)) hideItemTooltip();
});
inventoryGrid.addEventListener('focusin', (event) => {
const slot = event.target.closest('.inventory-slot.interactive');
if (!slot || lastInputMethod !== 'keyboard') return;
const rect = slot.getBoundingClientRect();
showItemTooltip(slot, {x: rect.right, y: rect.top});
});
inventoryGrid.addEventListener('focusout', (event) => {
if (!event.relatedTarget?.closest('.inventory-slot.interactive')) hideItemTooltip();
});
inventoryGrid.addEventListener('click', (event) => {
const slot = event.target.closest('.inventory-slot');
if (!slot) return;
if (slot.classList.contains('interactive')) openInventoryMenu(slot);
else closeInventoryMenu();
});
inventoryGrid.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const slot = event.target.closest('.inventory-slot.interactive');
if (!slot) return;
event.preventDefault();
openInventoryMenu(slot);
});
document.addEventListener('pointerdown', () => {
lastInputMethod = 'pointer';
}, true);
document.addEventListener('keydown', () => {
lastInputMethod = 'keyboard';
}, true);
$('inventory-menu').addEventListener('click', (event) => {
const button = event.target.closest('button[data-inventory-action]');
if (!button || button.disabled) return;
const action = button.dataset.inventoryAction;
if (action === 'cancel') closeInventoryMenu();
else runInventoryAction(action);
});
document.addEventListener('click', (event) => {
if ($('inventory-menu').hidden) return;
if (event.target.closest('#inventory-menu') ||
event.target.closest('.inventory-slot.interactive')) return;
closeInventoryMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
hideItemTooltip();
closeInventoryMenu();
}
});
window.addEventListener('resize', () => {
if (!selectedInventoryItem || !menuAnchor?.isConnected) return;
const rect = menuAnchor.getBoundingClientRect();
positionFloating($('inventory-menu'), rect.left, rect.bottom + 4);
});
$('minimap-zoom-out').addEventListener('click', () => {
minimapRadius = Math.min(MINIMAP_MAX_RADIUS,
minimapRadius + MINIMAP_ZOOM_STEP);
lastMinimapData = null;
drawMinimap();
pollMinimap();
});
$('minimap-zoom-in').addEventListener('click', () => {
minimapRadius = Math.max(MINIMAP_MIN_RADIUS,
minimapRadius - MINIMAP_ZOOM_STEP);
lastMinimapData = null;
drawMinimap();
pollMinimap();
});
$('minimap-north-lock').addEventListener('click', () => {
minimapNorthLocked = !minimapNorthLocked;
updateMinimapControls();
drawMinimap();
});
$('minimap-size').addEventListener('click', () => {
minimapLarge = !minimapLarge;
const panel = $('minimap-panel');
const canvas = $('minimap-canvas');
const contentGrid = document.querySelector('.content-grid');
const sideColumn = document.querySelector('.side-column');
if (minimapLarge) {
canvas.width = 640;
canvas.height = 320;
contentGrid.insertBefore(panel, contentGrid.firstElementChild);
} else {
canvas.width = 320;
canvas.height = 240;
sideColumn.insertBefore(panel, sideColumn.firstElementChild);
}
panel.classList.toggle('is-large', minimapLarge);
lastMinimapData = null;
updateMinimapControls();
drawMinimap();
pollMinimap();
});
loadItems();
drawMinimap(null);
poll();
pollMinimap();
window.setInterval(poll, POLL_MS);
window.setInterval(pollMinimap, MINIMAP_POLL_MS);
})();