import importlib import json import queue import re import urllib.error import urllib.request import threading import time import traceback import sys from math import floor from pathlib import Path import logging log = logging.getLogger('werkzeug') log.setLevel(logging.ERROR) from flask import Flask, abort, jsonify, request, send_file from flask_cors import CORS build_folder = 'web_interface/build' app = Flask(__name__, template_folder=build_folder, static_folder=build_folder, static_url_path='') CORS(app) from munch import Munch from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from mosfet import connect global_state = Munch() g = global_state g.connection = False g.connected = False g.connection_status = 'disconnected' g.player_position = None g.last_chat = None g.name = None g.mcdata = False g.pos = False g.dimension = None g.item_lock = False g.inv = {} g.objects = {} g.mobs = {} g.players = {} g.player_names = {} g.window = None g.job = None g.correction_count = 0 g.holding = 0 g.afk = False g.health = None g.food = None g.hunger = None g.food_saturation = None g.saturation = None g.health_update_received = False g.health_updated_at = None g.dead = False g.sand_origin = None g.reload_lock = threading.RLock() _item_icon_cache = Path(__file__).resolve().parent / 'web_interface' / 'item_cache' _item_icon_locks = {} _item_icon_locks_guard = threading.Lock() def _api_plain(value, depth=0): """Convert item component values into bounded JSON-safe structures.""" if depth > 12: return '[nested data omitted]' if value is None or isinstance(value, (str, int, float, bool)): return value if isinstance(value, dict): return {str(key): _api_plain(item, depth + 1) for key, item in value.items()} if isinstance(value, (list, tuple, set)): return [_api_plain(item, depth + 1) for item in value] if (type(value).__name__ in ('Slot', 'ItemStackData') and all(hasattr(value, key) for key in ('present', 'item_id', 'item_count'))): return { 'present': value.present, 'item_id': value.item_id, 'item_count': value.item_count, 'components': _api_plain(getattr(value, 'components', {}), depth + 1), 'components_unknown': bool(getattr(value, 'components_unknown', False)), } if hasattr(value, 'value'): return _api_plain(value.value, depth + 1) if hasattr(value, 'items'): try: return {str(key): _api_plain(item, depth + 1) for key, item in value.items()} except Exception: pass return str(value) def _api_json_safe(value, depth=0): """Normalize snapshots for JSON, including non-string dictionary keys.""" if depth > 12: return '[nested data omitted]' if value is None or isinstance(value, (str, int, float, bool)): return value if isinstance(value, dict): return {str(key): _api_json_safe(item, depth + 1) for key, item in value.items()} if isinstance(value, (list, tuple, set)): return [_api_json_safe(item, depth + 1) for item in value] converted = _api_json_default(value) if converted is value: return str(value) return _api_json_safe(converted, depth + 1) def _api_json_default(value): """Serialize live protocol objects without invoking fragile __repr__ code.""" from minecraft.networking.packets import Packet if isinstance(value, Packet): fields = {key: item for key, item in vars(value).items() if key != 'context'} return {'__type__': type(value).__name__, 'packet_name': getattr(value, 'packet_name', None), 'fields': fields} if (type(value).__name__ in ('Slot', 'ItemStackData') and all(hasattr(value, key) for key in ('present', 'item_id', 'item_count'))): return _api_plain(value) if all(hasattr(value, key) for key in ('x', 'y', 'z')): return {'x': value.x, 'y': value.y, 'z': value.z} if isinstance(value, (bytes, bytearray)): return value.hex() try: return str(value) except Exception: return object.__repr__(value) @app.route('/api/global') def api_global(): snapshot = dict(g) job = g.get('job') job_state = getattr(job, 'state', None) if job else None snapshot['job_state'] = getattr(job_state, '__name__', 'idle') chunks = g.get('chunks') if chunks is not None and hasattr(chunks, 'chunks'): snapshot['chunk_state'] = { 'loaded': len(chunks.chunks), 'center': chunks.center, 'radius': chunks.radius, 'dimension': chunks.dimension, } response = app.response_class( response=json.dumps(_api_json_safe(snapshot)), status=200, mimetype='application/json' ) 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 data = {str(item_id): name for item_id, name in ITEM_NAMES.items()} return app.response_class( response=json.dumps(data), status=200, mimetype='application/json') @app.route('/api/inventory/drop', methods=['POST']) def api_inventory_drop(): origin = request.headers.get('Origin') if origin: from urllib.parse import urlparse if urlparse(origin).netloc.lower() != request.host.lower(): return jsonify(ok=False, error='cross-origin inventory actions are blocked'), 403 payload = request.get_json(silent=True) or {} if not isinstance(payload, dict): return jsonify(ok=False, error='JSON body must be an object'), 400 try: slot = int(payload.get('slot')) except (TypeError, ValueError): return jsonify(ok=False, error='slot must be an integer'), 400 action = payload.get('action') if action not in ('one', 'stack', 'matching'): return jsonify(ok=False, error='unknown inventory action'), 400 if not g.get('connected') or not g.get('game'): return jsonify(ok=False, error='bot is not connected'), 503 with g.reload_lock: item = g.inv.get(slot) if slot < 5 or slot > 45 or not item or not getattr(item, 'present', False): return jsonify(ok=False, error='that inventory slot is empty or protected'), 409 if action == 'matching': count = g.game.start_dump(item.item_id) if count is None: return jsonify(ok=False, error='another inventory action is running'), 409 if not count: return jsonify(ok=False, error='no matching stacks to drop'), 409 return jsonify(ok=True, action=action, stacks=count) if not g.game.drop_inventory_slot( slot, whole_stack=(action == 'stack')): return jsonify(ok=False, error='another inventory action is running or the menu is open'), 409 return jsonify(ok=True, action=action, slot=slot) def _item_texture_urls(name): root = 'https://assets.mcasset.cloud/26.3/assets/minecraft/textures' yield root + '/item/{}.png'.format(name) yield root + '/block/{}.png'.format(name) simplified = re.sub( r'_(slab|stairs|wall|fence_gate|fence|door|trapdoor|button|' r'pressure_plate|sign|hanging_sign|carpet|banner|bed|candle)$', '', name) if simplified != name: yield root + '/block/{}.png'.format(simplified) yield root + '/item/{}.png'.format(simplified) def _fetch_item_texture(name): for url in _item_texture_urls(name): request = urllib.request.Request( url, headers={'User-Agent': 'curl/8.0 Mosfet/26.3'}) try: with urllib.request.urlopen(request, timeout=4) as response: image = response.read(2 * 1024 * 1024 + 1) if len(image) <= 2 * 1024 * 1024 and image.startswith(b'\x89PNG\r\n\x1a\n'): return image except (urllib.error.URLError, TimeoutError, OSError): continue return None @app.route('/api/item-icon/') def api_item_icon(name): from mosfet.info.items import ITEM_NAMES if not re.fullmatch(r'[a-z0-9_]+', name): abort(404) if name not in set(ITEM_NAMES.values()): abort(404) with _item_icon_locks_guard: lock = _item_icon_locks.setdefault(name, threading.Lock()) _item_icon_cache.mkdir(parents=True, exist_ok=True) cached = _item_icon_cache / (name + '.png') with lock: if not cached.is_file(): image = _fetch_item_texture(name) if image is None: abort(404) temporary = cached.with_suffix('.tmp') temporary.write_bytes(image) temporary.replace(cached) return send_file(cached, mimetype='image/png', max_age=86400) @app.route('/') def root(): return app.send_static_file('index.html') def main(): """Run the 26.3 client with in-place development hot reload. Application modules are refreshed while the current socket and player session remain alive. Connection/bootstrap and packet-schema edits are reported as deferred because their listeners cannot safely be replaced without reconnecting. """ g.running = True changes = queue.Queue() observer = Observer() source_root = Path(__file__).resolve().parent / 'mosfet' def enqueue_change(event): if event.is_directory: return path = getattr(event, 'dest_path', None) if event.event_type == 'moved' else event.src_path if not path or Path(path).suffix != '.py' or '__pycache__' in path: return changes.put(str(Path(path).resolve())) def module_name(path): source = Path(path) try: relative = source.relative_to(source_root) except ValueError: return None parts = list(relative.with_suffix('').parts) if parts and parts[-1] == '__init__': parts.pop() return '.'.join(('mosfet', *parts)) def reload_changed(paths): paths = sorted(set(path for path in paths if Path(path).is_file())) if not paths: return # Don't execute a half-written editor save. If any file is syntactically # invalid, keep the currently running code untouched. try: for path in paths: source = Path(path) compile(source.read_text(encoding='utf-8'), str(source), 'exec') except (OSError, SyntaxError, UnicodeError): print('[hot reload] waiting for a valid save', flush=True) return deferred = [] modules = [] for path in paths: name = module_name(path) if not name: continue if name in ('mosfet.connect', 'mosfet') or name == 'mosfet.monkey_patch': deferred.append(name) else: modules.append(name) if deferred: print('[hot reload] {} changed; connection/bootstrap changes are ' 'deferred until the next start (no disconnect performed)'.format( ', '.join(sorted(set(deferred)))), flush=True) if not modules: return if not g.get('connected') or not g.get('commands'): deadline = time.monotonic() + 15.0 while (g.running and time.monotonic() < deadline and (not g.get('connected') or not g.get('commands'))): time.sleep(0.1) if not g.get('connected') or not g.get('commands'): print('[hot reload] runtime not ready; change left for next save', flush=True) return managers_changed = bool( {'mosfet.protocol.managers', 'mosfet.protocol.types'} & set(modules)) core_modules = { 'mosfet.bot', 'mosfet.commands', 'mosfet.game', 'mosfet.job', 'mosfet.path', 'mosfet.print_help', 'mosfet.utils', 'mosfet.vector', 'mosfet.world', 'mosfet.info.blocks', 'mosfet.info.items', 'mosfet.info.mcdata', 'mosfet.info.mobs', } # bot.py reloads the legacy gameplay modules and job state classes as # one coherent group. Reload protocol adapters first so rebinding sees # their latest definitions. direct_modules = [name for name in modules if name not in core_modules and not name.startswith('mosfet.jobs.')] if managers_changed and 'mosfet.protocol.managers' not in direct_modules: direct_modules.append('mosfet.protocol.managers') priority = { 'mosfet.protocol.types': 0, 'mosfet.protocol.packets': 1, 'mosfet.protocol.managers': 2, 'mosfet.protocol.modern': 3, } direct_modules.sort(key=lambda name: (priority.get(name, 10), name)) connection = g.connection packet_lock = getattr(connection, 'packet_listener_lock', None) try: if packet_lock is None: with g.reload_lock: apply_reload(direct_modules, managers_changed) else: # Match packet dispatch's lock order (listener lock, then the # runtime lock) so callbacks cannot race listener replacement. with packet_lock: with g.reload_lock: apply_reload(direct_modules, managers_changed) except Exception: traceback.print_exc() print('[hot reload] failed; connection left running', flush=True) def apply_reload(direct_modules, managers_changed): for name in direct_modules: module = sys.modules.get(name) if module is not None: importlib.reload(module) bot_module = sys.modules.get('mosfet.bot') if bot_module is None: bot_module = importlib.import_module('mosfet.bot') bot_module = importlib.reload(bot_module) bot_module.refresh_runtime(global_state, reload_managers=managers_changed) print('[hot reload] refreshed {} (session preserved)'.format( ', '.join(direct_modules) if direct_modules else 'bot runtime'), flush=True) def reload_worker(): while True: path = changes.get() if path is None: return batch = {path} stopping = False # Coalesce the burst of modified/moved events from editor saves. while True: try: candidate = changes.get(timeout=0.4) except queue.Empty: break if candidate is None: stopping = True break batch.add(candidate) time.sleep(0.15) reload_changed(batch) if stopping: return event_handler = FileSystemEventHandler() event_handler.on_modified = enqueue_change event_handler.on_created = enqueue_change event_handler.on_moved = enqueue_change observer.schedule(event_handler, str(source_root), recursive=True) observer.start() worker = threading.Thread(target=reload_worker, name='mosfet-hot-reloader', daemon=True) worker.start() try: connect.main(global_state) except KeyboardInterrupt: pass except BaseException: traceback.print_exc() finally: g.running = False changes.put(None) observer.stop() observer.join() worker.join(timeout=2.0) def run_api(): host = '0.0.0.0' port = 3300 while True: print('Trying to run web interface on port:', port) print('If it works, go to http://localhost:' + str(port)) try: app.run(host=host, port=port) except OSError: print() print('Error: Port already taken.') port += 1 if __name__ == '__main__': threading.Thread(target=run_api, daemon=True).start() main()