828 lines
34 KiB
JavaScript
828 lines
34 KiB
JavaScript
(() => {
|
||
'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) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
})[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);
|
||
})();
|