diff --git a/README.md b/README.md index 58cce07..323ad75 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # Mosfet Minecraft Bot -A general-purpose Minecraft 1.16 bot written in Python that uses an actual -Minecraft account to play. +A Minecraft 26.3 bot written in Python that uses an offline-mode account to +play. The connection path targets protocol 777; older Minecraft protocol +versions are not supported. -Mosfet is able to farm wood by cutting trees, farm crops, gather sand, farm -netherwart, and trade with villagers to get emeralds. He can eat, sleep, and -flee from threats. +The original bot included wood/sand/crop farming, villager trading, eating, +sleeping, and threat avoidance. On the current 26.3 runtime, path following and +crop harvest/replant are migrated; other legacy jobs remain unavailable until +ported to protocol 777. ## Requirements and setup @@ -35,9 +37,9 @@ through environment variables as needed. Microsoft authentication is not used. The 26.3 client now handles AuthMe, chat commands, chunk/world-state caching, modern block/item/entity data, entity tracking, read-only inventory/component -sync, and path-following movement (`1goto x y z`). Inventory actions, block -breaking/placing, and farming/trading jobs remain disabled until their 26.3 -packets are migrated. The default server is +sync, path-following movement (`1goto x y z`), and the crop harvest/replant loop +(`1farm crops`). General inventory/window actions and the other farming/trading +jobs remain disabled until their 26.3 packets are migrated. The default server is `127.0.0.1`; set `SERVER` explicitly only when you intentionally want another server. @@ -62,9 +64,10 @@ and all bots will run the command. The current 26.3 command path handles public chat. Private-message routing is not migrated yet. Verified commands are `!ping`, `!pos`, `!loaded`, `!info x y z`, `!inv` (basic slot/count data), and addressed path movement such as -`1goto x y z`. The command catalog below is retained from the legacy bot; -inventory actions, block interaction, and farming/trading commands are not yet -available on 26.3. Common item components (names, damage, enchantments, book +`1goto x y z`, and `1farm crops`. The command catalog below is retained from +the legacy bot; other inventory actions, arbitrary block interaction, and the +other farming/trading commands are not yet available on 26.3. Common item +components (names, damage, enchantments, book contents, and shulker contents) are decoded; unrecognized component types are marked partial rather than silently discarded. diff --git a/mosfet.py b/mosfet.py index 4173cd0..5c0a1c1 100644 --- a/mosfet.py +++ b/mosfet.py @@ -3,6 +3,7 @@ import json import threading import time import traceback +import sys import logging log = logging.getLogger('werkzeug') @@ -64,13 +65,22 @@ def root(): def main(): """Connect with offline/AuthMe and enable only the migrated 26.3 features. - Current gameplay support is read-only world/entity/inventory state plus - path following; inventory actions, block interactions, and legacy job - packet handlers remain disabled until migrated. + Current gameplay support includes path following and a crop-only harvest / + replant job. General inventory actions, other block interactions, and + legacy jobs remain disabled until migrated. """ reload_timeout = time.time() reload_requested = False + def reload_modules(): + manager_module = sys.modules.get('mosfet.protocol.managers') + if manager_module is not None: + importlib.reload(manager_module) + bot_module = sys.modules.get('mosfet.bot') + if bot_module is not None: + importlib.reload(bot_module) + importlib.reload(connect) + def reload_bot(event): nonlocal reload_timeout, reload_requested if time.time() - reload_timeout > 2.0: @@ -94,7 +104,7 @@ def main(): if not reload_requested: break reload_requested = False - importlib.reload(connect) + reload_modules() except KeyboardInterrupt: break except BaseException: @@ -104,7 +114,7 @@ def main(): while g.running: time.sleep(1) reload_requested = False - importlib.reload(connect) + reload_modules() finally: g.running = False observer.stop() diff --git a/mosfet/automation_26_3.py b/mosfet/automation_26_3.py deleted file mode 100644 index 6ad5781..0000000 --- a/mosfet/automation_26_3.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Safe, movement-only bridge from legacy navigation to protocol 777.""" -import threading -import time -import traceback - - -def start(global_state): - """Start the legacy path-following physics, without legacy job/game packets.""" - g = global_state - g.protocol_version = 777 - g.time = 0.0 - g.path = [] - g.look_at = None - g.y_v = 0.0 - g.y_a = 0.0 - g.crawling = False - g.afk_timeout = 0.0 - g.objects = g.get('objects') or {} - g.players = g.get('players') or {} - g.breaking = None - g.break_time = 0.0 - g.correction_count = g.get('correction_count', 0) - g.yaw = float(g.get('yaw', 0.0) or 0.0) - g.pitch = float(g.get('pitch', 0.0) or 0.0) - g.chunks.loading = False - # Local navigation only needs nearby chunks; waiting for the entire view - # radius can stall on servers that stream chunks gradually. - g.physics_render_distance = 2 - g.movement_paused = False - g.movement_status = 'idle' - g.movement_error = None - # Inventory, interaction, and job packets remain disabled until migrated. - g.job = None - g.game = None - - def run(): - from mosfet import bot, utils - loaded_since = None - - while (g.get('running', True) and g.get('connection') and - g.connection.connected): - started = time.monotonic() - now = time.monotonic() - if g.chunks.check_loaded(g.physics_render_distance): - if loaded_since is None: - loaded_since = now - else: - loaded_since = None - # Let the initial position correction and first chunk batch settle - # before allowing any client-originated movement. - movement_ready = loaded_since is not None and now - loaded_since >= 0.5 - if g.get('movement_paused'): - g.movement_status = 'paused' - elif g.path and not movement_ready: - g.movement_status = 'waiting_for_chunks' - elif g.path and movement_ready: - g.movement_status = 'following_path' - try: - bot.tick(g) - except Exception: - traceback.print_exc() - g.movement_error = traceback.format_exc() - g.path = [] - g.movement_status = 'error' - print('[26.3 movement] stopped after navigation error', - flush=True) - return - elif not g.path and not g.get('movement_paused'): - g.movement_status = 'idle' - delay = max(0.0, utils.TICK - (time.monotonic() - started)) - time.sleep(delay) - - worker = threading.Thread(target=run, name='mosfet-26.3-movement', - daemon=True) - g.movement_thread = worker - worker.start() - return worker diff --git a/mosfet/bot.py b/mosfet/bot.py index 7826b10..9e03cdf 100644 --- a/mosfet/bot.py +++ b/mosfet/bot.py @@ -6,6 +6,8 @@ import os import sys import time import importlib +import threading +import traceback from math import floor, ceil from copy import copy @@ -68,26 +70,51 @@ def tick(global_state): required_distance = getattr(g, 'physics_render_distance', g.info.render_distance) if not g.chunks.check_loaded(required_distance): + if g.get('movement_status') != 'waiting_for_chunks': + print('[movement] status=waiting_for_chunks near {}'.format( + utils.pint(p)), flush=True) + g.movement_status = 'waiting_for_chunks' if not g.chunks.loading: print('Loading chunks', end='', flush=True) g.chunks.loading = time.time() - if getattr(g, 'protocol_version', 0) == 777: - from minecraft.networking.modern import PlayerPositionRotation - packet = PlayerPositionRotation( - x=p.x, y=p.y, z=p.z, pitch=0, yaw=0, on_ground=True) - else: - packet = serverbound.play.PositionAndLookPacket( - x=p.x, feet_y=p.y, z=p.z, pitch=0, yaw=0, on_ground=True) + now = time.time() + if now - g.get('chunk_wait_report_at', 0) >= 5: + center = g.chunks.center + if center is None: + missing = 'cache center not received' + else: + cx, cz = center + missing = [ + (x, z) + for x in range(cx - required_distance, + cx + required_distance + 1) + for z in range(cz - required_distance, + cz + required_distance + 1) + if (x, z) not in g.chunks.chunks + ] + print('\n[movement] chunk wait: pos={}, center={}, ' + 'radius={}, loaded={}, missing={}'.format( + utils.pint(p), center, g.chunks.radius, + len(g.chunks.chunks), missing), flush=True) + g.chunk_wait_report_at = now + from minecraft.networking.modern import ( + ClientTickEnd, PlayerPositionRotation, + ) + packet = PlayerPositionRotation( + x=p.x, y=p.y, z=p.z, pitch=0, yaw=0, on_ground=True) g.connection.write_packet(packet, force=True) - if getattr(g, 'protocol_version', 0) == 777: - from minecraft.networking.modern import ClientTickEnd - g.connection.write_packet(ClientTickEnd(), force=True) + g.connection.write_packet(ClientTickEnd(), force=True) return else: if g.chunks.loading: print() print('Chunks loaded in', round(time.time() - g.chunks.loading, 2), 's') g.chunks.loading = False + g.chunk_wait_report_at = 0 + status = 'following_path' if g.path else 'running_job' + if g.get('movement_status') != status: + print('[movement] status={}'.format(status), flush=True) + g.movement_status = status ########## object physics ########## # note: it's possible the chunk data is out of date when this runs @@ -208,23 +235,15 @@ def tick(global_state): target_pitch_d = 0 - g.pitch g.pitch += utils.cap(target_pitch_d, 10) - if getattr(g, 'protocol_version', 0) == 777: - from minecraft.networking.modern import PlayerPositionRotation - packet = PlayerPositionRotation( - x=p.x, y=p.y, z=p.z, pitch=g.pitch, yaw=g.yaw, - on_ground=(not in_air)) - else: - packet = serverbound.play.PositionAndLookPacket( - x=p.x, feet_y=p.y, z=p.z, pitch=g.pitch, yaw=g.yaw, - on_ground=(not in_air)) - if getattr(g, 'protocol_version', 0) == 777: - from minecraft.networking.modern import ClientTickEnd - # Keep the client-tick boundary after its movement packet on the wire; - # force=True on only the latter could overtake the queued position. - g.connection.write_packet(packet, force=True) - g.connection.write_packet(ClientTickEnd(), force=True) - else: - g.connection.write_packet(packet) + from minecraft.networking.modern import ( + ClientTickEnd, PlayerPositionRotation, + ) + packet = PlayerPositionRotation( + x=p.x, y=p.y, z=p.z, pitch=g.pitch, yaw=g.yaw, + on_ground=(not in_air)) + # Keep the client-tick boundary after its movement packet on the wire. + g.connection.write_packet(packet, force=True) + g.connection.write_packet(ClientTickEnd(), force=True) if g.job: g.job.tick() @@ -352,3 +371,90 @@ def bot(global_state): g.connection.early_outgoing_packet_listeners = [] print('Bot module loaded.') + + +def start_automation(global_state): + """Start migrated path-following and crop automation only.""" + g = global_state + g.protocol_version = 777 + g.time = 0.0 + g.path = [] + g.look_at = None + g.y_v = 0.0 + g.y_a = 0.0 + g.crawling = False + g.afk_timeout = 0.0 + g.objects = g.get('objects') or {} + g.players = g.get('players') or {} + g.breaking = None + g.break_time = 0.0 + g.correction_count = g.get('correction_count', 0) + g.yaw = float(g.get('yaw', 0.0) or 0.0) + g.pitch = float(g.get('pitch', 0.0) or 0.0) + g.chunks.loading = False + # Local navigation only needs nearby chunks; waiting for the entire view + # radius can stall on servers that stream chunks gradually. + g.physics_render_distance = 2 + g.movement_paused = False + g.movement_status = 'idle' + g.movement_error = None + # Keep the port constrained to crop harvesting; other legacy jobs and + # general inventory/window mutations are not exposed through this adapter. + from mosfet.game import Game + from mosfet.job import JobStates + g.game = Game(g) + g.job = JobStates(g) + + def run(): + from mosfet import bot, utils + loaded_since = None + initial_world_ready = False + + def set_status(status): + if g.movement_status != status: + print('[movement] status={}'.format(status), flush=True) + g.movement_status = status + + while (g.get('running', True) and g.get('connection') and + g.connection.connected): + started = time.monotonic() + now = time.monotonic() + if not initial_world_ready: + if g.chunks.check_loaded(g.physics_render_distance): + if loaded_since is None: + loaded_since = now + elif now - loaded_since >= 0.5: + initial_world_ready = True + else: + loaded_since = None + # Wait once for the initial position/chunk batch. After that, + # bot.tick() keeps requesting chunks at cache boundaries instead + # of gating itself forever on a momentary missing column. + movement_ready = initial_world_ready + job_active = (g.job is not None and g.job.state != g.job.idle) + has_work = bool(g.path) or job_active or bool(g.breaking) + if g.get('movement_paused'): + set_status('paused') + elif has_work and not movement_ready: + set_status('waiting_for_chunks') + elif has_work and movement_ready: + try: + bot.tick(g) + except Exception: + traceback.print_exc() + g.movement_error = traceback.format_exc() + g.path = [] + g.movement_status = 'error' + print('[movement] stopped after navigation error', + flush=True) + return + elif not has_work and not g.get('movement_paused'): + set_status('idle') + delay = max(0.0, utils.TICK - (time.monotonic() - started)) + time.sleep(delay) + + worker = threading.Thread(target=run, name='mosfet-movement', + daemon=True) + g.movement_thread = worker + worker.start() + return worker diff --git a/mosfet/commands.py b/mosfet/commands.py index 4ec4e2d..d2bf4b5 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -10,7 +10,6 @@ from mosfet.protocol.types import Slot from mosfet import print_help from mosfet import utils from mosfet import path -from mosfet import bot from mosfet.info import blocks from mosfet.info import items from mosfet.info import mobs @@ -56,6 +55,20 @@ class Commands: if data.startswith('[') and data.endswith(']'): command = 'nosquarebrackets' + unsupported = { + 'gather', 'loiter', 'trade', 'gapple', 'cache', 'fill', + 'drop', 'dump', 'drain', 'break', 'open', 'click', 'use', + 'interact', 'respawn', + } + if (command in unsupported or + (command == 'farm' and not data.lower().startswith('crop'))): + reply = 'not migrated to Minecraft 26.3 yet' + print('Reply:', reply) + if private: + self.g.chat.send('/r ' + reply) + else: + self.g.chat.send(reply) + return try: @@ -406,15 +419,12 @@ class Commands: self.g.job.state = self.g.job.farm_wart reply = 'ok' elif data.startswith('crop'): - self.g.job.state = self.g.job.farm_crop - reply = 'ok' - - if reply and self.g.dimension == 'overworld': - for i in self.g.inv.values(): - if i.item_id in items.BED_IDS: - break + mode = self.g.get('game_mode') + if mode is not None and mode != 0: + reply = 'crop farming needs Survival mode' else: - reply += ', I need a bed' + self.g.job.state = self.g.job.farm_crop + reply = 'ok' ## 1loiter - stands still but eats, sleeps, and flees if command == 'loiter': @@ -428,8 +438,8 @@ class Commands: ## 1stop - stops the current job and resets bot if command == 'stop': + self.g.job.stop() self.g.game.close_window() - bot.init(self.g) reply = 'ok' ## 1drop - drops the current stack its holding @@ -515,14 +525,12 @@ class Commands: navpath = self.g.world.path_to_place(pos, goal) if navpath: - self.g.path = navpath if self.g.job: self.g.job.stop() + self.g.path = navpath print(len(navpath)) print(navpath) print(round(time.time() - start, 3), 'seconds') - if self.g.job: - self.g.job.stop() self.g.look_at = None reply = 'ok' else: @@ -543,14 +551,12 @@ class Commands: navpath = self.g.world.path_to_place(pos, goal) if navpath: - self.g.path = navpath if self.g.job: self.g.job.stop() + self.g.path = navpath print(len(navpath)) print(navpath) print(round(time.time() - start, 3), 'seconds') - if self.g.job: - self.g.job.stop() self.g.look_at = None reply = 'ok' else: diff --git a/mosfet/connect.py b/mosfet/connect.py index 47eb13d..acc18fd 100644 --- a/mosfet/connect.py +++ b/mosfet/connect.py @@ -6,10 +6,10 @@ import os import threading from minecraft.networking.connection import Connection from minecraft.networking.modern import ( - CancelPostjoinDialog, ChatCommand, PlayShowDialog, + CancelPostjoinDialog, ChatCommand, PlayGameEventPacket, PlayShowDialog, PlayLogin, SubmitPostjoinLoginDialog, SystemChat, TeleportPosition, ) -from mosfet.world_26_3 import WorldState +from mosfet.protocol.managers import WorldState from mosfet.vector import Point3D @@ -71,6 +71,7 @@ def main(global_state=None): global_state.connected = True global_state.connection_status = 'in_game' global_state.player_id = packet.player_id + global_state.game_mode = packet.game_mode global_state.dimension = packet.dimension.removeprefix('minecraft:') packet.render_distance = packet.chunk_radius global_state.info = packet @@ -79,15 +80,14 @@ def main(global_state=None): from mosfet.world import World from mosfet.commands import Commands from mosfet.protocol.modern import ModernChatManager - from mosfet.protocol.entities_26_3 import ModernEntityManager - from mosfet.protocol.inventory_26_3 import ModernInventoryManager - from mosfet.automation_26_3 import start as start_modern_movement + from mosfet.protocol.managers import EntityManager, InventoryManager + from mosfet.bot import start_automation global_state.world = World(global_state) global_state.chat = ModernChatManager(global_state) - global_state.entities = ModernEntityManager(global_state) - global_state.inventory = ModernInventoryManager(global_state) + global_state.entities = EntityManager(global_state) + global_state.inventory = InventoryManager(global_state) global_state.commands = Commands(global_state) - start_modern_movement(global_state) + start_automation(global_state) def send_auth_command(action): @@ -100,6 +100,11 @@ def main(global_state=None): connection.write_packet(ChatCommand(command=command.lstrip('/')), force=True) + @connection.listener(PlayGameEventPacket) + def on_game_event(packet): + if global_state is not None and packet.event == 3: + global_state.game_mode = int(packet.value) + @connection.listener(TeleportPosition) def on_spawn(packet): if global_state is not None: diff --git a/mosfet/game.py b/mosfet/game.py index 0f863a5..875c0fb 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -6,6 +6,10 @@ from munch import Munch from minecraft.networking.packets import Packet, clientbound, serverbound from minecraft.networking.types import BlockFace +from minecraft.networking.modern import ( + ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket, + SetCarriedItemPacket, UseItemOnBlockPacket, +) from mosfet.protocol.packets import ( SetSlotPacket, PlayerDiggingPacket, @@ -29,7 +33,7 @@ from mosfet.info import items from mosfet.info import mcdata from mosfet.info import mobs -class Game: +class LegacyGame: def __init__(self, global_state): self.g = global_state @@ -568,3 +572,115 @@ class Game: if not self.g.path: self.g.correction_count = 0 + + +# Active game facade. The constructor registers no legacy packet listeners; +# migrated actions use the 26.3 packet classes imported above. +class Game(LegacyGame): + def __init__(self, global_state): + self.g = global_state + self.sequence = 0 + self.break_deadline = None + self.breaking_state = None + + def _send_action(self, packet): + self.g.connection.write_packet(packet, force=True) + self.g.connection.write_packet(ClientTickEnd(), force=True) + + def _next_sequence(self): + self.sequence += 1 + return self.sequence + + def break_block(self, location): + location = tuple(int(value) for value in location) + block_id = self.g.chunks.get_block_at(*location) + if block_id is None or block_id == blocks.AIR: + return False + packet = PlayerActionPacket( + action=0, location=location, face=1, + sequence=self._next_sequence()) + self._send_action(packet) + self.g.breaking = location + self.breaking_state = block_id + hardness_time = utils.break_time(block_id, self.g.get('holding', 0)) + self.break_deadline = time.monotonic() + max(0.05, hardness_time) + return True + + def tick(self): + if not self.g.get('breaking') or self.break_deadline is None: + return + if time.monotonic() < self.break_deadline: + return + packet = PlayerActionPacket( + action=3, location=self.g.breaking, face=1, + sequence=self._next_sequence()) + self._send_action(packet) + location = self.g.breaking + if self.g.chunks.get_block_at(*location) == self.breaking_state: + # Predict the normal client-side block removal. The server's later + # block update is authoritative and will correct the cache if the + # action was denied or the block changed differently. + self.g.chunks.set_block_at(*location, blocks.AIR) + self.g.breaking = None + self.break_deadline = None + self.breaking_state = None + + def cancel_break(self): + if not self.g.get('breaking'): + return + packet = PlayerActionPacket( + action=2, location=self.g.breaking, face=1, + sequence=self._next_sequence()) + self._send_action(packet) + self.g.breaking = None + self.break_deadline = None + self.breaking_state = None + + def select_item(self, item_ids): + item_ids = set(item_ids) + matches = [(slot, item) for slot, item in self.g.inv.items() + if item.present and item.item_id in item_ids] + if not matches: + return False + + # Prefer an already-hotbar stack; otherwise swap the requested stack + # into an empty hotbar slot using the modern container SWAP input. + slot, item = next( + ((slot, item) for slot, item in matches if 36 <= slot <= 44), + matches[0]) + if 36 <= slot <= 44: + hotbar_slot = slot - 36 + elif 9 <= slot <= 35: + empty_hotbar = next( + (candidate - 36 for candidate in range(36, 45) + if candidate not in self.g.inv or + not self.g.inv[candidate].present), None) + hotbar_slot = (empty_hotbar if empty_hotbar is not None else + self.g.get('holding_slot', 0) or 0) + self.g.connection.write_packet(ContainerClickSwapPacket( + container_id=0, + state_id=self.g.inventory_state_ids.get(0, 0), + slot=slot, button=hotbar_slot), force=True) + else: + return False + + if self.g.get('holding_slot') != hotbar_slot: + self.g.connection.write_packet(SetCarriedItemPacket( + slot=hotbar_slot), force=True) + self.g.holding_slot = hotbar_slot + self.g.holding = item.item_id + self.g.connection.write_packet(ClientTickEnd(), force=True) + return True + + def place_block(self, location, face): + location = tuple(int(value) for value in location) + packet = UseItemOnBlockPacket( + hand=0, location=location, face=int(face), + cursor_x=0.5, cursor_y=0.99 if int(face) == 1 else 0.5, + cursor_z=0.5, inside_block=False, world_border_hit=False, + sequence=self._next_sequence()) + self._send_action(packet) + + def close_window(self): + # Container open/close packet migration is not part of the crop path. + return None diff --git a/mosfet/info/blocks.py b/mosfet/info/blocks.py index 3c02bb4..3770830 100644 --- a/mosfet/info/blocks.py +++ b/mosfet/info/blocks.py @@ -77,7 +77,9 @@ LEAVES = sorted(name for name in MCD_BLOCKS if name.endswith('_leaves')) SAPLINGS = sorted(name for name in MCD_BLOCKS if name.endswith('_sapling') or name.endswith('_propagule')) BEDS = sorted(name for name in MCD_BLOCKS if name.endswith('_bed')) -INDEXED = ['chest', 'trapped_chest', 'emerald_block', 'barrel'] + BEDS +INDEXED = ['chest', 'trapped_chest', 'emerald_block', 'barrel'] + BEDS + [ + 'wheat', 'potatoes', 'carrots', 'beetroots', +] LOG_IDS = get_set(LOGS) LEAF_IDS = get_set(LEAVES) diff --git a/mosfet/job.py b/mosfet/job.py index 3ed6bcb..e3f9dab 100644 --- a/mosfet/job.py +++ b/mosfet/job.py @@ -55,7 +55,7 @@ for module in [ importlib.reload(module) -class JobStates: +class LegacyJobStates: def idle(self): return [] @@ -262,3 +262,22 @@ class JobStates: def tick(self): self.run_machines(self.state()) + + +class JobStates(LegacyJobStates): + """26.3 job facade; crop gathering is the currently migrated job.""" + def __init__(self, global_state): + self.g = global_state + self.gather_crop_states = gather_crop.GatherCropStates(global_state) + self.state = self.idle + + def farm_crop(self): + return [self.gather_crop_states] + + def stop(self): + if self.g.get('breaking'): + self.g.game.cancel_break() + self.g.path = [] + self.g.look_at = None + self.gather_crop_states.state = self.gather_crop_states.idle + self.state = self.idle diff --git a/mosfet/jobs/gather_crop.py b/mosfet/jobs/gather_crop.py index 99cdbf1..1c61a4c 100644 --- a/mosfet/jobs/gather_crop.py +++ b/mosfet/jobs/gather_crop.py @@ -35,12 +35,15 @@ class GatherCropStates: blocks.MATURE_BEETROOT_ID, ] - for crop in w.find_blocks_3d(p, mature_crops, 50, 20, True): + candidates = w.find_blocks_indexed(p, mature_crops, 50) + + for crop in candidates: if crop not in self.bad_crops: break else: # for - print('No good crops left, aborting.') - self.state = self.cleanup + print('No good crops left; retrying after a short wait.') + self.retry_after = time.monotonic() + 15.0 + self.state = self.wait_for_crops return print('Found crop:', crop) @@ -48,6 +51,10 @@ class GatherCropStates: self.type_id = w.block_at(*crop) self.state = self.nav_to_crop + def wait_for_crops(self): + if time.monotonic() >= self.retry_after: + self.state = self.find_new_crop + def nav_to_crop(self): w = self.g.world p = utils.pint(self.g.pos) @@ -71,8 +78,34 @@ class GatherCropStates: self.state = self.break_crop def break_crop(self): - self.g.game.break_block(self.crop) - self.state = self.select_seed + print('Requesting crop break at {}: block={}, mode={}, held={}'.format( + self.crop, self.g.world.block_at(*self.crop), + self.g.get('game_mode'), self.g.get('holding'))) + if self.g.game.break_block(self.crop): + print('Started breaking crop {} (state {})'.format( + self.crop, self.type_id)) + self.break_deadline = time.time() + 5.0 + self.state = self.wait_break + else: + print('Break request skipped; current block is', + self.g.world.block_at(*self.crop)) + self.bad_crops.append(self.crop) + self.state = self.find_new_crop + + def wait_break(self): + current_state = self.g.world.block_at(*self.crop) + if current_state != self.type_id: + print('Crop break confirmed at {}: {} -> {}'.format( + self.crop, self.type_id, current_state)) + self.seed_deadline = time.monotonic() + 8.0 + self.seed_wait_logged = False + self.state = self.select_seed + elif time.time() > self.break_deadline: + print('Crop break timed out at {}: expected state {}, server still has {}'.format( + self.crop, self.type_id, current_state)) + print('Crop did not break; blacklisting', self.crop) + self.bad_crops.append(self.crop) + self.state = self.find_new_crop def select_seed(self): p = utils.pint(self.g.pos) @@ -86,9 +119,19 @@ class GatherCropStates: self.target_seed = crop_seeds[self.type_id] if self.g.game.select_item([self.target_seed]): + self.seed_deadline = None self.state = self.wait_select else: - print('Havent picked up seed yet') + if (self.seed_deadline is not None and + time.monotonic() > self.seed_deadline): + print('No seed collected for crop; skipping', self.crop) + self.bad_crops.append(self.crop) + self.seed_deadline = None + self.state = self.find_new_crop + elif not getattr(self, 'seed_wait_logged', False): + print('Waiting for crop seed {} in inventory/hotbar'.format( + self.target_seed)) + self.seed_wait_logged = True return def wait_select(self): @@ -98,17 +141,25 @@ class GatherCropStates: self.state = self.place_crop def place_crop(self): - p = utils.pint(self.g.pos) + p = utils.padd(self.crop, path.BLOCK_BELOW) + self.place_deadline = time.time() + 3.0 self.g.game.place_block(p, BlockFace.TOP) print('Placed crop') self.state = self.wait_place def wait_place(self): w = self.g.world - if w.block_at(*self.crop) == blocks.AIR: - return - else: + current_state = w.block_at(*self.crop) + crop_name = blocks.BLOCKS.get(self.type_id) + replanted = (current_state not in (None, blocks.AIR) and + blocks.BLOCKS.get(current_state) == crop_name and + current_state != self.type_id) + if replanted: self.state = self.cleanup + elif time.time() > getattr(self, 'place_deadline', 0): + print('Crop did not replant; blacklisting', self.crop) + self.bad_crops.append(self.crop) + self.state = self.find_new_crop def cleanup(self): self.g.look_at = None @@ -125,6 +176,9 @@ class GatherCropStates: self.crop = None self.type_id = None self.target_seed = None + self.seed_deadline = None + self.seed_wait_logged = False + self.retry_after = 0.0 self.bad_crops = [] def run(self): diff --git a/mosfet/protocol/entities_26_3.py b/mosfet/protocol/entities_26_3.py deleted file mode 100644 index 552830d..0000000 --- a/mosfet/protocol/entities_26_3.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Entity and player-list updates for the 26.3 play protocol.""" -from munch import Munch - -from minecraft.networking.modern import ( - AddEntityPacket, EntityPositionSyncPacket, MoveEntityPositionPacket, - MoveEntityPositionRotationPacket, MoveEntityRotationPacket, - PlayerInfoRemovePacket, PlayerInfoUpdatePacket, RemoveEntitiesPacket, -) -from mosfet.info import mobs - - -class ModernEntityManager: - def __init__(self, global_state): - self.g = global_state - self.g.mobs = self.g.get('mobs') or {} - self.g.players = self.g.get('players') or {} - self.g.objects = self.g.get('objects') or {} - self.g.player_names = self.g.get('player_names') or {} - register = self.g.connection.register_packet_listener - for packet_type, handler in ( - (AddEntityPacket, self.handle_add_entity), - (RemoveEntitiesPacket, self.handle_remove_entities), - (MoveEntityPositionPacket, self.handle_move), - (MoveEntityPositionRotationPacket, self.handle_move), - (MoveEntityRotationPacket, self.handle_rotation), - (EntityPositionSyncPacket, self.handle_position_sync), - (PlayerInfoUpdatePacket, self.handle_player_info), - (PlayerInfoRemovePacket, self.handle_player_info_remove), - ): - register(handler, packet_type) - - def handle_add_entity(self, packet): - name = mobs.MOB_NAMES.get(packet.entity_type_id, 'unknown') - common = dict(entity_id=packet.entity_id, x=packet.x, - y=packet.y, z=packet.z) - if name == 'player': - player_uuid = str(packet.entity_uuid) - self.g.players[packet.entity_id] = Munch( - **common, player_uuid=player_uuid, - yaw=packet.yaw, pitch=packet.pitch) - elif name == 'item': - self.g.objects[packet.entity_id] = Munch( - **common, entity_uuid=str(packet.entity_uuid), - velocity_x=packet.velocity_x * 8000, - velocity_y=packet.velocity_y * 8000, - velocity_z=packet.velocity_z * 8000) - else: - self.g.mobs[packet.entity_id] = Munch( - **common, entity_uuid=str(packet.entity_uuid), - type=packet.entity_type_id, yaw=packet.yaw, - pitch=packet.pitch) - - def _entity(self, entity_id): - for collection in (self.g.players, self.g.mobs, self.g.objects): - entity = collection.get(entity_id) - if entity is not None: - return entity - return None - - def handle_move(self, packet): - entity = self._entity(packet.entity_id) - if entity is None: - return - entity.x += packet.delta_x - entity.y += packet.delta_y - entity.z += packet.delta_z - if hasattr(packet, 'yaw'): - entity.yaw = packet.yaw - entity.pitch = packet.pitch - - def handle_rotation(self, packet): - entity = self._entity(packet.entity_id) - if entity is not None: - entity.yaw = packet.yaw - entity.pitch = packet.pitch - - def handle_position_sync(self, packet): - entity = self._entity(packet.entity_id) - if entity is not None: - entity.x, entity.y, entity.z = packet.x, packet.y, packet.z - entity.yaw = packet.yaw - entity.pitch = packet.pitch - - def handle_remove_entities(self, packet): - for entity_id in packet.entity_ids: - self.g.players.pop(entity_id, None) - self.g.mobs.pop(entity_id, None) - self.g.objects.pop(entity_id, None) - - def handle_player_info(self, packet): - for profile_uuid, name in packet.entries: - if name: - uuid_text = str(profile_uuid) - self.g.player_names[uuid_text] = name - self.g.player_names[name] = profile_uuid - self.g.player_names[name.lower()] = profile_uuid - - def handle_player_info_remove(self, packet): - removed = {str(uuid) for uuid in packet.uuids} - for key, value in list(self.g.player_names.items()): - if str(value) in removed or str(key) in removed: - del self.g.player_names[key] diff --git a/mosfet/protocol/inventory_26_3.py b/mosfet/protocol/inventory_26_3.py deleted file mode 100644 index 5301fc3..0000000 --- a/mosfet/protocol/inventory_26_3.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Minimal 26.3 inventory synchronization for legacy read-only commands.""" -from mosfet.protocol.types import Slot -from minecraft.networking.modern import ( - ContainerSetContentPacket, ContainerSetSlotPacket, SetCursorItemPacket, -) - - -class ModernInventoryManager: - def __init__(self, global_state): - self.g = global_state - self.g.inv = self.g.get('inv') or {} - self.g.inventory_state_ids = self.g.get('inventory_state_ids') or {} - self.g.inventory_partial = False - self.g.cursor = Slot(present=False) - register = self.g.connection.register_packet_listener - register(self.handle_content, ContainerSetContentPacket) - register(self.handle_slot, ContainerSetSlotPacket) - register(self.handle_cursor, SetCursorItemPacket) - - @staticmethod - def to_slot(item): - return Slot(item.present, item.item_id, item.item_count, - components=item.components, - component_ids=item.component_ids, - removed_components=item.removed_components, - components_unknown=item.components_unknown) - - def handle_content(self, packet): - slots = {index: self.to_slot(item) - for index, item in enumerate(packet.items)} - self.g.inventory_state_ids[packet.container_id] = packet.state_id - if packet.container_id == 0: - self.g.inv = slots - self.g.inventory_partial = packet.partial - self.g.cursor = self.to_slot(packet.carried_item) - elif self.g.get('window'): - self.g.window.contents = slots - self.g.cursor = self.to_slot(packet.carried_item) - - def handle_slot(self, packet): - self.g.inventory_state_ids[packet.container_id] = packet.state_id - item = self.to_slot(packet.item) - if packet.item.components_unknown: - self.g.inventory_partial = True - if packet.container_id == 0: - self.g.inv[packet.slot] = item - elif packet.container_id == -1 and packet.slot == -1: - self.g.cursor = item - elif self.g.get('window'): - self.g.window.contents[packet.slot] = item - - def handle_cursor(self, packet): - if packet.item.components_unknown: - self.g.inventory_partial = True - self.g.cursor = self.to_slot(packet.item) diff --git a/mosfet/protocol/managers.py b/mosfet/protocol/managers.py index c45bc38..39447ce 100644 --- a/mosfet/protocol/managers.py +++ b/mosfet/protocol/managers.py @@ -1,8 +1,18 @@ import json from math import floor +from munch import Munch from minecraft.networking.packets import clientbound, serverbound +from minecraft.networking.modern import ( + AddEntityPacket, ContainerSetContentPacket, ContainerSetSlotPacket, + PaletteSection, + EntityPositionSyncPacket, MoveEntityPositionPacket, + MoveEntityPositionRotationPacket, MoveEntityRotationPacket, + PlayerInfoRemovePacket, PlayerInfoUpdatePacket, RemoveEntitiesPacket, + SetCursorItemPacket, +) from mosfet.protocol import packets +from mosfet.protocol.types import Slot from mosfet.info import blocks, mcdata, mobs @@ -172,3 +182,298 @@ class ChatManager: packet = serverbound.play.ChatPacket() packet.message = text self.g.connection.write_packet(packet) + + +class ChunkColumn: + def __init__(self, x, z, sections, min_section): + self.x = x + self.z = z + self.sections = { + min_section + offset: section + for offset, section in enumerate(sections) + } + + def get_block_at(self, x, y, z): + section = self.sections.get(y // 16) + if section is None: + return None + index = ((y & 15) * 16 + (z & 15)) * 16 + (x & 15) + return section['blocks'].get(index) + + def set_block_at(self, x, y, z, block_state_id): + section = self.sections.get(y // 16) + if section is None: + return None + index = ((y & 15) * 16 + (z & 15)) * 16 + (x & 15) + previous = section['blocks'].get(index) + section['blocks'].set(index, block_state_id) + return previous + + +class WorldState: + """Sparse 26.3 chunk/block-state cache used by world and navigation.""" + def __init__(self, min_y=-64): + self.min_y = min_y + self.min_section = min_y // 16 + self.chunks = {} + self.index = {} + from mosfet.info import blocks + self.indexed_ids = blocks.INDEXED_IDS + self.center = None + self.radius = None + self.dimension = 'minecraft:overworld' + self.player_id = None + + def set_dimension(self, dimension, dimension_type=None): + if dimension: + self.dimension = dimension + if dimension_type: + min_y = dimension_type.get('min_y', self.min_y) + if isinstance(min_y, int): + self.min_y = min_y + self.min_section = min_y // 16 + + def load_chunk(self, packet): + # Refresh selectors in case block metadata was reloaded before login. + from mosfet.info import blocks + self.indexed_ids = blocks.INDEXED_IDS + self.unload_chunk(packet.x, packet.z) + chunk = ChunkColumn(packet.x, packet.z, packet.sections, + self.min_section) + self.chunks[(packet.x, packet.z)] = chunk + for section_y, section in chunk.sections.items(): + palette = section['blocks'] + if not self.indexed_ids.intersection( + palette.palette or [palette.get(i) for i in range(palette.size)]): + continue + for index in range(palette.size): + state_id = palette.get(index) + if state_id not in self.indexed_ids: + continue + local_x = index & 0xF + local_z = (index >> 4) & 0xF + local_y = (index >> 8) & 0xF + pos = (packet.x * 16 + local_x, + section_y * 16 + local_y, + packet.z * 16 + local_z) + self.index.setdefault(state_id, set()).add(pos) + + def handle_block_update(self, packet): + self.set_block_at(packet.x, packet.y, packet.z, packet.block_state_id) + + def handle_section_update(self, packet): + for x, y, z, state_id in packet.changes: + self.set_block_at(x, y, z, state_id) + + def get_chunk(self, x, z): + return self.chunks.get((x, z)) + + def get_block_at(self, x, y, z): + chunk = self.get_chunk(floor(x / 16), floor(z / 16)) + if chunk is None: + return None + return chunk.get_block_at(floor(x), floor(y), floor(z)) + + def set_block_at(self, x, y, z, block_state_id): + x, y, z = floor(x), floor(y), floor(z) + chunk = self.get_chunk(floor(x / 16), floor(z / 16)) + if chunk is None: + return False + previous = chunk.set_block_at(x, y, z, block_state_id) + pos = (x, y, z) + if previous in self.indexed_ids: + self.index.get(previous, set()).discard(pos) + if block_state_id in self.indexed_ids: + self.index.setdefault(block_state_id, set()).add(pos) + return True + + def unload_chunk(self, x, z): + chunk = self.chunks.pop((x, z), None) + if chunk is None: + return + x0, z0 = x * 16, z * 16 + for state_id, locations in list(self.index.items()): + self.index[state_id] = { + pos for pos in locations + if not (x0 <= pos[0] < x0 + 16 and z0 <= pos[2] < z0 + 16) + } + if not self.index[state_id]: + del self.index[state_id] + + def unload_all_chunks(self): + self.chunks.clear() + self.index.clear() + + def get_loaded_area(self, ignore_empty=False): + if not self.chunks: + return None + xs = [pos[0] for pos in self.chunks] + zs = [pos[1] for pos in self.chunks] + ys = [section_y for chunk in self.chunks.values() + for section_y in chunk.sections] + return ((min(xs), min(ys), min(zs)), + (max(xs), max(ys), max(zs))) + + def check_loaded(self, chunk_distance=None): + if self.center is None: + return bool(self.chunks) + radius = self.radius if chunk_distance is None else chunk_distance + if radius is None: + return bool(self.chunks) + cx, cz = self.center + return all((x, z) in self.chunks + for x in range(cx - radius, cx + radius + 1) + for z in range(cz - radius, cz + radius + 1)) + + @property + def loaded_chunk_count(self): + return len(self.chunks) + + def __repr__(self): + return ('WorldState(dimension={!r}, chunks={}, min_y={}, center={!r}, ' + 'radius={!r})').format( + self.dimension, len(self.chunks), self.min_y, + self.center, self.radius) + + +class EntityManager: + def __init__(self, global_state): + self.g = global_state + self.g.mobs = self.g.get('mobs') or {} + self.g.players = self.g.get('players') or {} + self.g.objects = self.g.get('objects') or {} + self.g.player_names = self.g.get('player_names') or {} + register = self.g.connection.register_packet_listener + for packet_type, handler in ( + (AddEntityPacket, self.handle_add_entity), + (RemoveEntitiesPacket, self.handle_remove_entities), + (MoveEntityPositionPacket, self.handle_move), + (MoveEntityPositionRotationPacket, self.handle_move), + (MoveEntityRotationPacket, self.handle_rotation), + (EntityPositionSyncPacket, self.handle_position_sync), + (PlayerInfoUpdatePacket, self.handle_player_info), + (PlayerInfoRemovePacket, self.handle_player_info_remove), + ): + register(handler, packet_type) + + def handle_add_entity(self, packet): + name = mobs.MOB_NAMES.get(packet.entity_type_id, 'unknown') + common = dict(entity_id=packet.entity_id, x=packet.x, + y=packet.y, z=packet.z) + if name == 'player': + player_uuid = str(packet.entity_uuid) + self.g.players[packet.entity_id] = Munch( + **common, player_uuid=player_uuid, + yaw=packet.yaw, pitch=packet.pitch) + elif name == 'item': + self.g.objects[packet.entity_id] = Munch( + **common, entity_uuid=str(packet.entity_uuid), + velocity_x=packet.velocity_x * 8000, + velocity_y=packet.velocity_y * 8000, + velocity_z=packet.velocity_z * 8000) + else: + self.g.mobs[packet.entity_id] = Munch( + **common, entity_uuid=str(packet.entity_uuid), + type=packet.entity_type_id, yaw=packet.yaw, + pitch=packet.pitch) + + def _entity(self, entity_id): + for collection in (self.g.players, self.g.mobs, self.g.objects): + entity = collection.get(entity_id) + if entity is not None: + return entity + return None + + def handle_move(self, packet): + entity = self._entity(packet.entity_id) + if entity is None: + return + entity.x += packet.delta_x + entity.y += packet.delta_y + entity.z += packet.delta_z + if hasattr(packet, 'yaw'): + entity.yaw = packet.yaw + entity.pitch = packet.pitch + + def handle_rotation(self, packet): + entity = self._entity(packet.entity_id) + if entity is not None: + entity.yaw = packet.yaw + entity.pitch = packet.pitch + + def handle_position_sync(self, packet): + entity = self._entity(packet.entity_id) + if entity is not None: + entity.x, entity.y, entity.z = packet.x, packet.y, packet.z + entity.yaw = packet.yaw + entity.pitch = packet.pitch + + def handle_remove_entities(self, packet): + for entity_id in packet.entity_ids: + self.g.players.pop(entity_id, None) + self.g.mobs.pop(entity_id, None) + self.g.objects.pop(entity_id, None) + + def handle_player_info(self, packet): + for profile_uuid, name in packet.entries: + if name: + uuid_text = str(profile_uuid) + self.g.player_names[uuid_text] = name + self.g.player_names[name] = profile_uuid + self.g.player_names[name.lower()] = profile_uuid + + def handle_player_info_remove(self, packet): + removed = {str(uuid) for uuid in packet.uuids} + for key, value in list(self.g.player_names.items()): + if str(value) in removed or str(key) in removed: + del self.g.player_names[key] + + +class InventoryManager: + def __init__(self, global_state): + self.g = global_state + self.g.inv = self.g.get('inv') or {} + self.g.inventory_state_ids = self.g.get('inventory_state_ids') or {} + self.g.inventory_partial = False + self.g.cursor = Slot(present=False) + register = self.g.connection.register_packet_listener + register(self.handle_content, ContainerSetContentPacket) + register(self.handle_slot, ContainerSetSlotPacket) + register(self.handle_cursor, SetCursorItemPacket) + + @staticmethod + def to_slot(item): + return Slot(item.present, item.item_id, item.item_count, + components=item.components, + component_ids=item.component_ids, + removed_components=item.removed_components, + components_unknown=item.components_unknown) + + def handle_content(self, packet): + slots = {index: self.to_slot(item) + for index, item in enumerate(packet.items)} + self.g.inventory_state_ids[packet.container_id] = packet.state_id + if packet.container_id == 0: + self.g.inv = slots + self.g.inventory_partial = packet.partial + self.g.cursor = self.to_slot(packet.carried_item) + elif self.g.get('window'): + self.g.window.contents = slots + self.g.cursor = self.to_slot(packet.carried_item) + + def handle_slot(self, packet): + self.g.inventory_state_ids[packet.container_id] = packet.state_id + item = self.to_slot(packet.item) + if packet.item.components_unknown: + self.g.inventory_partial = True + if packet.container_id == 0: + self.g.inv[packet.slot] = item + elif packet.container_id == -1 and packet.slot == -1: + self.g.cursor = item + elif self.g.get('window'): + self.g.window.contents[packet.slot] = item + + def handle_cursor(self, packet): + if packet.item.components_unknown: + self.g.inventory_partial = True + self.g.cursor = self.to_slot(packet.item) diff --git a/mosfet/world.py b/mosfet/world.py index 71ce48b..55d93db 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -49,13 +49,10 @@ class World: yield check def find_blocks_indexed(self, center, block_ids, distance=0): - print('finding', block_ids) index = [] for bid in block_ids: index.extend(self.g.chunks.index.get(bid, [])) - print('index', index) - result = [] for block in index: if self.block_at(*block) not in block_ids: diff --git a/mosfet/world_26_3.py b/mosfet/world_26_3.py deleted file mode 100644 index 88dd9b1..0000000 --- a/mosfet/world_26_3.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Small 26.3 world cache used while the legacy automation is being ported. - -It retains numeric block-state IDs from chunk palettes and block updates. It -intentionally does not assign 1.16 block meanings to modern state IDs. -""" -from math import floor - - -class PaletteSection: - def __init__(self, bits, palette, words, size): - self.bits = bits - self.palette = palette - self.words = words - self.size = size - self.overrides = {} - self.mask = (1 << bits) - 1 if bits else 0 - self.per_word = 64 // bits if bits else 0 - - def get(self, index): - if index in self.overrides: - return self.overrides[index] - if self.bits == 0: - return self.palette[0] - word = self.words[index // self.per_word] - palette_index = (word >> ((index % self.per_word) * self.bits)) & self.mask - return self.palette[palette_index] if self.palette is not None else palette_index - - def set(self, index, block_state_id): - self.overrides[index] = block_state_id - - -class ChunkColumn: - def __init__(self, x, z, sections, min_section): - self.x = x - self.z = z - self.sections = { - min_section + offset: section - for offset, section in enumerate(sections) - } - - def get_block_at(self, x, y, z): - section = self.sections.get(y // 16) - if section is None: - return None - index = ((y & 15) * 16 + (z & 15)) * 16 + (x & 15) - return section['blocks'].get(index) - - def set_block_at(self, x, y, z, block_state_id): - section = self.sections.get(y // 16) - if section is None: - return None - index = ((y & 15) * 16 + (z & 15)) * 16 + (x & 15) - previous = section['blocks'].get(index) - section['blocks'].set(index, block_state_id) - return previous - - -class WorldState: - """Sparse loaded-chunk/block-state cache with the old manager's read API.""" - def __init__(self, min_y=-64): - self.min_y = min_y - self.min_section = min_y // 16 - self.chunks = {} - self.index = {} - from mosfet.info import blocks - self.indexed_ids = blocks.INDEXED_IDS - self.center = None - self.radius = None - self.dimension = 'minecraft:overworld' - self.player_id = None - - def set_dimension(self, dimension, dimension_type=None): - if dimension: - self.dimension = dimension - if dimension_type: - min_y = dimension_type.get('min_y', self.min_y) - if isinstance(min_y, int): - self.min_y = min_y - self.min_section = min_y // 16 - - def load_chunk(self, packet): - self.unload_chunk(packet.x, packet.z) - chunk = ChunkColumn(packet.x, packet.z, packet.sections, - self.min_section) - self.chunks[(packet.x, packet.z)] = chunk - for section_y, section in chunk.sections.items(): - palette = section['blocks'] - if not self.indexed_ids.intersection( - palette.palette or [palette.get(i) for i in range(palette.size)]): - continue - for index in range(palette.size): - state_id = palette.get(index) - if state_id not in self.indexed_ids: - continue - local_x = index & 0xF - local_z = (index >> 4) & 0xF - local_y = (index >> 8) & 0xF - pos = (packet.x * 16 + local_x, - section_y * 16 + local_y, - packet.z * 16 + local_z) - self.index.setdefault(state_id, set()).add(pos) - - def handle_block_update(self, packet): - self.set_block_at(packet.x, packet.y, packet.z, packet.block_state_id) - - def handle_section_update(self, packet): - for x, y, z, state_id in packet.changes: - self.set_block_at(x, y, z, state_id) - - def get_chunk(self, x, z): - return self.chunks.get((x, z)) - - def get_block_at(self, x, y, z): - chunk = self.get_chunk(floor(x / 16), floor(z / 16)) - if chunk is None: - return None - return chunk.get_block_at(floor(x), floor(y), floor(z)) - - def set_block_at(self, x, y, z, block_state_id): - x, y, z = floor(x), floor(y), floor(z) - chunk = self.get_chunk(floor(x / 16), floor(z / 16)) - if chunk is None: - return False - previous = chunk.set_block_at(x, y, z, block_state_id) - pos = (x, y, z) - if previous in self.indexed_ids: - self.index.get(previous, set()).discard(pos) - if block_state_id in self.indexed_ids: - self.index.setdefault(block_state_id, set()).add(pos) - return True - - def unload_chunk(self, x, z): - chunk = self.chunks.pop((x, z), None) - if chunk is None: - return - x0, z0 = x * 16, z * 16 - for state_id, locations in list(self.index.items()): - self.index[state_id] = { - pos for pos in locations - if not (x0 <= pos[0] < x0 + 16 and z0 <= pos[2] < z0 + 16) - } - if not self.index[state_id]: - del self.index[state_id] - - def unload_all_chunks(self): - self.chunks.clear() - self.index.clear() - - def get_loaded_area(self, ignore_empty=False): - if not self.chunks: - return None - xs = [pos[0] for pos in self.chunks] - zs = [pos[1] for pos in self.chunks] - ys = [section_y for chunk in self.chunks.values() - for section_y in chunk.sections] - return ((min(xs), min(ys), min(zs)), - (max(xs), max(ys), max(zs))) - - def check_loaded(self, chunk_distance=None): - if self.center is None: - return bool(self.chunks) - radius = self.radius if chunk_distance is None else chunk_distance - if radius is None: - return bool(self.chunks) - cx, cz = self.center - return all((x, z) in self.chunks - for x in range(cx - radius, cx + radius + 1) - for z in range(cz - radius, cz + radius + 1)) - - @property - def loaded_chunk_count(self): - return len(self.chunks) - - def __repr__(self): - return ('WorldState(dimension={!r}, chunks={}, min_y={}, center={!r}, ' - 'radius={!r})').format( - self.dimension, len(self.chunks), self.min_y, - self.center, self.radius) - - -def read_palette_container(stream, *, size, local_bits_max): - from minecraft.networking.types import Byte, UnsignedLong, VarInt - - bits = Byte.read(stream) - if bits == 0: - palette = [VarInt.read(stream)] - elif bits <= local_bits_max: - palette_size = VarInt.read(stream) - palette = [VarInt.read(stream) for _ in range(palette_size)] - else: - palette = None - - word_count = ((size + (64 // bits) - 1) // (64 // bits)) if bits else 0 - words = [UnsignedLong.read(stream) for _ in range(word_count)] - return PaletteSection(bits, palette, words, size) - - -def decode_chunk_sections(data): - from io import BytesIO - from minecraft.networking.types import Short - - stream = BytesIO(data) - sections = [] - while stream.tell() < len(data): - start = stream.tell() - if len(data) - start < 4: - raise ValueError('truncated chunk section header at byte {}'.format(start)) - non_empty_blocks = Short.read(stream) - fluid_count = Short.read(stream) - blocks = read_palette_container(stream, size=4096, local_bits_max=8) - biomes = read_palette_container(stream, size=64, local_bits_max=3) - sections.append({ - 'non_empty_blocks': non_empty_blocks, - 'fluid_count': fluid_count, - 'blocks': blocks, - 'biomes': biomes, - }) - if stream.tell() <= start: - raise ValueError('chunk section decoder made no progress') - return sections