From 2d5bf2df2272b89efe98c830c8d62fd65867375b Mon Sep 17 00:00:00 2001 From: Tanner Date: Sun, 8 Nov 2026 23:46:52 +0000 Subject: [PATCH] Add terrain minimap to dashboard --- mosfet.py | 79 +++++++++++ tests/test_web_inventory_api.py | 41 +++++- web_interface/build/index.html | 18 ++- web_interface/public/index.html | 18 ++- web_interface/src/dashboard.css | 10 ++ web_interface/src/dashboard.js | 242 +++++++++++++++++++++++++++++++- web_interface/src/index.html | 18 ++- 7 files changed, 418 insertions(+), 8 deletions(-) diff --git a/mosfet.py b/mosfet.py index 952a770..8b25e10 100644 --- a/mosfet.py +++ b/mosfet.py @@ -8,6 +8,7 @@ import threading import time import traceback import sys +from math import floor from pathlib import Path import logging @@ -158,6 +159,84 @@ def api_global(): return response +def _minimap_surface_cell(chunks, x, z): + """Return the uppermost solid/water block in a loaded world column.""" + from mosfet.info import blocks + + chunk_x, chunk_z = floor(x / 16), floor(z / 16) + chunk = chunks.chunks.get((chunk_x, chunk_z)) + if chunk is None: + return None + + local_x, local_z = x - chunk_x * 16, z - chunk_z * 16 + min_y = getattr(chunks, 'min_y', -64) + for section_y in sorted(chunk.sections, reverse=True): + if section_y * 16 + 15 < min_y: + continue + section = chunk.sections[section_y] + if section.get('non_empty_blocks') == 0: + continue + for y in range(section_y * 16 + 15, max(min_y, section_y * 16) - 1, -1): + state_id = chunk.get_block_at(local_x, y, local_z) + if state_id is None or state_id == blocks.AIR: + continue + if (state_id in blocks.NON_SOLID_IDS and + state_id not in blocks.WATER_IDS): + continue + return {'block': blocks.BLOCKS.get(state_id, 'unknown'), 'y': y} + return None + + +@app.route('/api/minimap') +def api_minimap(): + position = g.get('pos') or g.get('player_position') + chunks = g.get('chunks') + if (not g.get('connected') or position is None or position is False or + chunks is None or not hasattr(chunks, 'chunks')): + return jsonify(ready=False, cells=[]) + + try: + radius = int(request.args.get('radius', 12)) + except (TypeError, ValueError): + radius = 12 + radius = max(4, min(32, radius)) + try: + radius_x = int(request.args.get('radius_x', radius)) + except (TypeError, ValueError): + radius_x = radius + try: + radius_z = int(request.args.get('radius_z', radius)) + except (TypeError, ValueError): + radius_z = radius + radius_x = max(4, min(48, radius_x)) + radius_z = max(4, min(32, radius_z)) + center_x, center_z = floor(position.x), floor(position.z) + center_y = floor(position.y) + lock = getattr(chunks, 'lock', None) + if lock is not None: + lock.acquire() + try: + cells = [ + [_minimap_surface_cell(chunks, center_x + dx, center_z + dz) + for dx in range(-radius_x, radius_x + 1)] + for dz in range(-radius_z, radius_z + 1) + ] + finally: + if lock is not None: + lock.release() + + ready = any(cell is not None for row in cells for cell in row) + return jsonify( + ready=ready, + center={'x': center_x, 'y': center_y, 'z': center_z}, + yaw=float(g.get('yaw', 0.0) or 0.0), + radius=radius, + radius_x=radius_x, + radius_z=radius_z, + cells=cells if ready else [], + ) + + @app.route('/api/items') def api_items(): from mosfet.info.items import ITEM_NAMES diff --git a/tests/test_web_inventory_api.py b/tests/test_web_inventory_api.py index 6d420b3..793360e 100644 --- a/tests/test_web_inventory_api.py +++ b/tests/test_web_inventory_api.py @@ -2,7 +2,9 @@ import importlib.util from pathlib import Path import threading import unittest +from munch import Munch +from mosfet.info import blocks from mosfet.protocol.types import Slot @@ -32,7 +34,8 @@ class TestWebInventoryAPI(unittest.TestCase): self.g = mosfet_web.g self.original = { key: self.g.get(key) for key in - ('connected', 'game', 'inv', 'chest_viewers', 'time', 'time_base') + ('connected', 'game', 'inv', 'chest_viewers', 'time', 'time_base', + 'chunks', 'pos', 'yaw') } self.game = FakeGame() self.g.connected = True @@ -54,6 +57,42 @@ class TestWebInventoryAPI(unittest.TestCase): item = response.get_json()['inv']['9'] self.assertEqual(item['components']['custom_name']['text'], 'Test Paper') + def test_minimap_api_returns_surface_blocks_centered_on_bot(self): + grass = blocks.MCD_BLOCKS['grass_block']['minStateId'] + + class FakeChunk: + sections = { + 0: {'non_empty_blocks': 1}, + 1: {'non_empty_blocks': 0}, + } + + @staticmethod + def get_block_at(x, y, z): + return grass if (x, y, z) == (1, 5, 1) else blocks.AIR + + class FakeChunks: + min_y = 0 + chunks = {(0, 0): FakeChunk()} + + self.g.connected = True + self.g.chunks = FakeChunks() + self.g.pos = Munch(x=1.5, y=6.0, z=1.5) + self.g.yaw = 90 + + response = self.client.get( + '/api/minimap?radius=4&radius_x=6&radius_z=4') + + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertTrue(data['ready']) + self.assertEqual(data['center'], {'x': 1, 'y': 6, 'z': 1}) + self.assertEqual(data['yaw'], 90) + self.assertEqual(data['radius_x'], 6) + self.assertEqual(data['radius_z'], 4) + self.assertEqual(len(data['cells']), 9) + self.assertEqual(len(data['cells'][0]), 13) + self.assertEqual(data['cells'][4][6], {'block': 'grass_block', 'y': 5}) + def test_global_api_exposes_world_time_ticks(self): self.g.time = 12345.5 self.g.time_base = 12345.5 diff --git a/web_interface/build/index.html b/web_interface/build/index.html index c3e9ef4..839586b 100644 --- a/web_interface/build/index.html +++ b/web_interface/build/index.html @@ -6,8 +6,8 @@ Mosfet · Minecraft Bot - - + +
@@ -72,6 +72,20 @@
`; + reanchorInventoryMenu(inv); items.forEach(({slot, item}) => { if (!item || !item.present) return; @@ -374,6 +502,33 @@ 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; @@ -470,6 +625,10 @@ $('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`; @@ -495,6 +654,43 @@ } } + 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'}); @@ -581,7 +777,51 @@ 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); })(); diff --git a/web_interface/src/index.html b/web_interface/src/index.html index c3e9ef4..839586b 100644 --- a/web_interface/src/index.html +++ b/web_interface/src/index.html @@ -6,8 +6,8 @@ Mosfet · Minecraft Bot - - + +
@@ -72,6 +72,20 @@