530 lines
19 KiB
Python
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()
|