208 lines
9.2 KiB
JavaScript
208 lines
9.2 KiB
JavaScript
(() => {
|
||
'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) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
})[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 = Number(state.health);
|
||
const food = Number(state.food);
|
||
$('health-value').textContent = Number.isFinite(health) ? String(health) : '—';
|
||
$('food-value').textContent = Number.isFinite(food) ? String(food) : '—';
|
||
$('health-meter').style.width = `${Math.max(0, Math.min(100, health * 5 || 0))}%`;
|
||
$('food-meter').style.width = `${Math.max(0, Math.min(100, food * 5 || 0))}%`;
|
||
|
||
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);
|
||
})();
|