Files

530 lines
19 KiB
Python

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/<name>')
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()