import importlib import json import queue import re import urllib.error import urllib.request import threading import time import traceback import sys from pathlib import Path import logging log = logging.getLogger('werkzeug') log.setLevel(logging.ERROR) from flask import Flask, abort, 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_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__ == 'Slot' 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} 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(snapshot, default=_api_json_default), status=200, mimetype='application/json' ) return response @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') 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()