From 9fd565c3c9fe09b61ee844893041e04a5b1176c7 Mon Sep 17 00:00:00 2001 From: Tanner Date: Wed, 4 Nov 2026 18:54:04 +0000 Subject: [PATCH] Implement item cachine for 26.3 --- README.md | 16 +++++---- mosfet/bot.py | 6 ++++ mosfet/commands.py | 2 +- mosfet/game.py | 52 +++++++++++++++++++++++++-- mosfet/job.py | 13 ++++++- mosfet/jobs/cache_items.py | 66 ++++++++++++++++++++++++----------- mosfet/protocol/managers.py | 34 ++++++++++++++++-- mosfet/world.py | 20 ++++++++++- tests/test_crop_cache.py | 31 ++++++++++++++++ tests/test_inventory_drain.py | 25 +++++++++++-- 10 files changed, 228 insertions(+), 37 deletions(-) create mode 100644 tests/test_crop_cache.py diff --git a/README.md b/README.md index 94d97a3..fbb45fb 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,11 @@ the next manual start, rather than triggering an automatic reconnect. The 26.3 client now handles AuthMe, chat commands, chunk/world-state caching, modern block/item/entity data, entity tracking, inventory/component sync, -path-following movement (`1goto x y z`), inventory draining (`1drain`), and the -crop harvest/replant loop (`1farm crops`). Other inventory/window actions and -the farming/trading jobs remain disabled until their 26.3 packets are migrated. +path-following movement (`1goto x y z`), inventory draining (`1drain`), +normal/trapped-chest caching (`1cache`, also run by crop farming at the +inventory threshold), and the crop harvest/replant loop (`1farm crops`). Other +inventory/window actions and 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. @@ -72,10 +74,10 @@ would be ran like `1farm wood` or `1pos`. The exception are the below public commands, they can optionally be prefixed with `!` 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), `1drain`, addressed path movement such as -`1goto x y z`, and `1farm crops`. The command catalog below is retained from +The current 26.3 command path handles public chat and direct messages, replying +in the same channel. Verified commands are `!ping`, `!pos`, `!loaded`, +`!info x y z`, `!inv` (basic slot/count data), `1cache`, `1drain`, addressed +path movement such as `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 diff --git a/mosfet/bot.py b/mosfet/bot.py index 15e6b60..bbe1dba 100644 --- a/mosfet/bot.py +++ b/mosfet/bot.py @@ -395,6 +395,8 @@ def start_automation(global_state, initialize=True): g.players = g.get('players') or {} g.breaking = None g.break_time = 0.0 + g.item_lock = False + g.minimum_cache_slots = g.get('minimum_cache_slots', 27) g.draining = False g.drain_slots = [] g.drain_waiting = None @@ -404,6 +406,7 @@ def start_automation(global_state, initialize=True): 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.holding_slot = g.get('holding_slot', 0) g.chunks.loading = False # Readiness below is based on nearby chunks, not unrelated view holes. g.movement_paused = False @@ -423,6 +426,9 @@ def start_automation(global_state, initialize=True): g.setdefault('drain_dropped', 0) g.setdefault('drain_total', 0) g.setdefault('drain_error', None) + g.setdefault('item_lock', False) + g.setdefault('minimum_cache_slots', 27) + g.setdefault('holding_slot', 0) old_stop = g.get('movement_stop_event') old_worker = g.get('movement_thread') diff --git a/mosfet/commands.py b/mosfet/commands.py index c9027c3..8fbece8 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -66,7 +66,7 @@ class Commands: command = 'nosquarebrackets' unsupported = { - 'gather', 'loiter', 'trade', 'gapple', 'cache', 'fill', + 'gather', 'loiter', 'trade', 'gapple', 'fill', 'drop', 'dump', 'break', 'open', 'click', 'use', 'interact', 'respawn', } diff --git a/mosfet/game.py b/mosfet/game.py index 36a8cbd..2c67b0c 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -48,6 +48,27 @@ class ContainerClickDropPacket(Packet): VarInt.send(0, packet_buffer) # empty carried item stack +class ContainerClickQuickMovePacket(Packet): + """Shift-click an inventory slot into the open container (protocol 777).""" + id = 0x12 + packet_name = 'container click quick move' + + def write_fields(self, packet_buffer): + VarInt.send(self.container_id, packet_buffer) + VarInt.send(self.state_id, packet_buffer) + Short.send(self.slot, packet_buffer) + Byte.send(0, packet_buffer) # mouse button + VarInt.send(1, packet_buffer) # ContainerInput.QUICK_MOVE + VarInt.send(0, packet_buffer) # no changed-slot predictions + VarInt.send(0, packet_buffer) # empty carried item stack + + +class ContainerClosePacket(Packet): + id = 0x13 + packet_name = 'container close' + definition = [{'container_id': VarInt}] + + class LegacyGame: def __init__(self, global_state): self.g = global_state @@ -795,6 +816,33 @@ class Game(LegacyGame): sequence=self._next_sequence()) self._send_action(packet) + def count_window_slots(self): + window = self.g.get('window') + if not window: + return 0 + return sum( + item.present + for slot, item in window.contents.items() + if slot < window.container_slots) + + def click_window(self, slot, button=0, mode=1, item=None): + window = self.g.get('window') + if not window: + return False + packet = ContainerClickQuickMovePacket( + container_id=window.window_id, + state_id=self.g.inventory_state_ids.get( + window.window_id, window.state_id), + slot=slot, + ) + self.g.connection.write_packet(packet, force=True) + self.g.connection.write_packet(ClientTickEnd(), force=True) + return True + def close_window(self): - # Container open/close packet migration is not part of the crop path. - return None + window = self.g.get('window') + if not window: + return None + self.g.connection.write_packet(ContainerClosePacket( + container_id=window.window_id), force=True) + self.g.window = None diff --git a/mosfet/job.py b/mosfet/job.py index dde2870..c425995 100644 --- a/mosfet/job.py +++ b/mosfet/job.py @@ -269,16 +269,27 @@ class JobStates(LegacyJobStates): def __init__(self, global_state): self.g = global_state self.gather_crop_states = gather_crop.GatherCropStates(global_state) + self.cache_items_states = cache_items.CacheItemsStates(global_state) self.state = self.idle def farm_crop(self): - return [self.gather_crop_states] + self.cache_items_states.silent = True + items.set_wanted({ + items.CARROT_ID, + items.POTATO_ID, + items.WHEAT_SEEDS_ID, + items.BEETROOT_SEEDS_ID, + }) + return [self.gather_crop_states, self.cache_items_states] def stop(self): if self.g.get('breaking'): self.g.game.cancel_break() + if self.g.get('window'): + self.g.game.close_window() self.g.path = [] self.g.look_at = None self.gather_crop_states.state = self.gather_crop_states.idle + self.cache_items_states.state = self.cache_items_states.idle self.gather_crop_states.active_section_anchor = None self.state = self.idle diff --git a/mosfet/jobs/cache_items.py b/mosfet/jobs/cache_items.py index b0b8c8d..7e1e9c6 100644 --- a/mosfet/jobs/cache_items.py +++ b/mosfet/jobs/cache_items.py @@ -13,7 +13,6 @@ from mosfet import utils from mosfet import path from mosfet.info import blocks from mosfet.info import items -from mosfet.info import mcdata from mosfet.info import mobs class CacheItemsStates: @@ -25,8 +24,12 @@ class CacheItemsStates: self.skip_items = [] num_stacks = self.g.game.count_inventory_slots() + minimum = getattr( + self, 'minimum', self.g.get('minimum_cache_slots', 27)) print('Inventory amount:', num_stacks) - if num_stacks >= self.g.minimum_cache_slots: + if num_stacks == 0: + self.state = self.cleanup + elif num_stacks >= minimum: self.state = self.find_trapped_chests else: print('Aborting caching, not full') @@ -53,23 +56,30 @@ class CacheItemsStates: return chest = self.trapped_chests[0] + self.area = chest - navpath = w.path_to_place_faked(p, chest) - print('navpath:', navpath) + for opening in w.find_cache_openings(chest): + navpath = w.path_to_place(p, opening) + if navpath: + print('navpath to trapped chest:', navpath) + self.opening = opening + self.g.path = navpath + self.state = self.going_to_trapped_chest + return - if navpath: - self.g.path = navpath[:-1] - self.opening = self.g.path[-1] - self.area = chest - self.state = self.going_to_trapped_chest - return - else: - self.trapped_chests.pop(0) - time.sleep(0.1) + self.trapped_chests.pop(0) + time.sleep(0.1) def going_to_trapped_chest(self): if utils.pint(self.g.pos) == self.opening: self.g.look_at = self.area + self.wait_time = 0.5 + self.state = self.turn_to_trapped_chest + + def turn_to_trapped_chest(self): + self.g.look_at = self.area + self.wait_time -= utils.TICK + if self.wait_time <= 0: self.state = self.open_chest def select_chest(self): @@ -84,7 +94,7 @@ class CacheItemsStates: w = self.g.world p = utils.pint(self.g.pos) - for area in w.find_cache_areas(p, 100): + for area in w.find_cache_areas(p, 16): print('Found area:', area) if area not in self.bad_areas: break @@ -112,10 +122,18 @@ class CacheItemsStates: def going_to_area(self): if utils.pint(self.g.pos) == self.opening: self.g.look_at = self.area + self.wait_time = 0.5 + self.state = self.turn_to_cache_spot + + def turn_to_cache_spot(self): + self.g.look_at = self.area + self.wait_time -= utils.TICK + if self.wait_time <= 0: self.state = self.place_chest def place_chest(self): - self.g.game.place_block(self.area, BlockFace.TOP) + support = utils.padd(self.area, path.BLOCK_BELOW) + self.g.game.place_block(support, BlockFace.TOP) self.state = self.open_chest def open_chest(self): @@ -139,14 +157,19 @@ class CacheItemsStates: print('Didnt get a window, aborting') self.state = self.cleanup return - if w.data.window_type != mcdata.SINGLE_CHEST: - print('Got wrong window, aborting') + if not w.container_slots: + print('Chest contents have not arrived, aborting') + self.g.game.close_window() + self.state = self.cleanup + return + if w.container_slots not in (27, 54): + print('Got non-chest window, aborting') + self.g.game.close_window() self.state = self.cleanup return - w_info = mcdata.WINDOWS[w.data.window_type] - w_inventory_slots = w_info.inventory - w_container_slots = w_info.container + w_container_slots = range(w.container_slots) + w_inventory_slots = range(w.container_slots, len(w.contents)) used_slots = self.g.game.count_window_slots() print('used:', used_slots, 'total:', len(w_container_slots)) @@ -204,6 +227,9 @@ class CacheItemsStates: def cleanup(self): self.g.look_at = None self.state = self.done + if (self.g.get('job') and + getattr(self.g.job.state, '__name__', None) == 'cache_items'): + self.g.job.state = self.g.job.idle def done(self): # never gets ran, placeholder diff --git a/mosfet/protocol/managers.py b/mosfet/protocol/managers.py index 39447ce..fc3326b 100644 --- a/mosfet/protocol/managers.py +++ b/mosfet/protocol/managers.py @@ -4,7 +4,8 @@ from munch import Munch from minecraft.networking.packets import clientbound, serverbound from minecraft.networking.modern import ( - AddEntityPacket, ContainerSetContentPacket, ContainerSetSlotPacket, + AddEntityPacket, ContainerOpenPacket, ContainerSetContentPacket, + ContainerSetSlotPacket, PaletteSection, EntityPositionSyncPacket, MoveEntityPositionPacket, MoveEntityPositionRotationPacket, MoveEntityRotationPacket, @@ -433,10 +434,13 @@ class InventoryManager: def __init__(self, global_state): self.g = global_state self.g.inv = self.g.get('inv') or {} + self.g.window = self.g.get('window') + self.g.item_lock = self.g.get('item_lock', False) 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_open, ContainerOpenPacket) register(self.handle_content, ContainerSetContentPacket) register(self.handle_slot, ContainerSetSlotPacket) register(self.handle_cursor, SetCursorItemPacket) @@ -449,6 +453,16 @@ class InventoryManager: removed_components=item.removed_components, components_unknown=item.components_unknown) + def handle_open(self, packet): + self.g.window = Munch( + window_id=packet.window_id, + inventory_type=packet.inventory_type, + title=packet.title, + contents={}, + container_slots=0, + state_id=self.g.inventory_state_ids.get(packet.window_id, 0), + ) + def handle_content(self, packet): slots = {index: self.to_slot(item) for index, item in enumerate(packet.items)} @@ -457,9 +471,14 @@ class InventoryManager: self.g.inv = slots self.g.inventory_partial = packet.partial self.g.cursor = self.to_slot(packet.carried_item) - elif self.g.get('window'): + elif (self.g.get('window') and + packet.container_id == self.g.window.window_id): self.g.window.contents = slots + # Player inventory contributes 36 slots to chest menus. + self.g.window.container_slots = max(0, len(slots) - 36) + self.g.window.state_id = packet.state_id self.g.cursor = self.to_slot(packet.carried_item) + self.g.item_lock = False def handle_slot(self, packet): self.g.inventory_state_ids[packet.container_id] = packet.state_id @@ -468,10 +487,19 @@ class InventoryManager: self.g.inventory_partial = True if packet.container_id == 0: self.g.inv[packet.slot] = item + self.g.item_lock = False elif packet.container_id == -1 and packet.slot == -1: self.g.cursor = item - elif self.g.get('window'): + elif (self.g.get('window') and + packet.container_id == self.g.window.window_id): self.g.window.contents[packet.slot] = item + self.g.window.state_id = packet.state_id + offset = packet.slot - self.g.window.container_slots + if 0 <= offset < 27: + self.g.inv[9 + offset] = item + elif 27 <= offset < 36: + self.g.inv[36 + offset - 27] = item + self.g.item_lock = False def handle_cursor(self, packet): if packet.item.components_unknown: diff --git a/mosfet/world.py b/mosfet/world.py index 55d93db..33a0ab4 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -182,7 +182,25 @@ class World: yield a def find_cache_areas(self, center, distance): - return self.find_bed_areas(center, distance) + """Yield nearby, supported air blocks suitable for placing a chest.""" + center = tuple(center) + for offset in utils.search_2d(distance): + if utils.hypot(offset[0], offset[2]) > distance: + continue + for dy in (0, 1, -1, 2, -2): + area = (center[0] + offset[0], center[1] + dy, + center[2] + offset[2]) + if area == center: + continue + if self.block_at(*area) != blocks.AIR: + continue + if self.block_at(*utils.padd(area, path.BLOCK_ABOVE)) != blocks.AIR: + continue + support = self.block_at(*utils.padd(area, path.BLOCK_BELOW)) + if (support is None or support in blocks.NON_SOLID_IDS or + support in blocks.AVOID_IDS): + continue + yield area def sand_adjacent_safe(self, sand): for direction in path.CHECK_DIRECTIONS: diff --git a/tests/test_crop_cache.py b/tests/test_crop_cache.py new file mode 100644 index 0000000..328315c --- /dev/null +++ b/tests/test_crop_cache.py @@ -0,0 +1,31 @@ +import unittest + +from munch import Munch + +from mosfet.info import items +from mosfet.job import JobStates + + +class TestCropCaching(unittest.TestCase): + def test_crop_farm_runs_silent_cache_machine_and_keeps_seeds(self): + old_wanted = items.WANTED_ITEMS + crop_state = object() + cache_state = Munch(silent=False) + jobs = JobStates.__new__(JobStates) + jobs.gather_crop_states = crop_state + jobs.cache_items_states = cache_state + + try: + machines = JobStates.farm_crop(jobs) + self.assertEqual(machines, [crop_state, cache_state]) + self.assertTrue(cache_state.silent) + self.assertTrue({ + items.CARROT_ID, items.POTATO_ID, + items.WHEAT_SEEDS_ID, items.BEETROOT_SEEDS_ID, + }.issubset(items.WANTED_ITEMS)) + finally: + items.WANTED_ITEMS = old_wanted + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_inventory_drain.py b/tests/test_inventory_drain.py index ab7e9d6..27d1560 100644 --- a/tests/test_inventory_drain.py +++ b/tests/test_inventory_drain.py @@ -1,10 +1,13 @@ +import io import unittest from munch import Munch -from minecraft.networking.modern import ClientTickEnd +from minecraft.networking.modern import ClientTickEnd, ContainerOpenPacket from minecraft.networking.packets import PacketBuffer -from mosfet.game import ContainerClickDropPacket, Game +from mosfet.game import ( + ContainerClickDropPacket, ContainerClickQuickMovePacket, Game, +) from mosfet.protocol.types import Slot @@ -27,6 +30,24 @@ class TestInventoryDrain(unittest.TestCase): self.assertEqual(payload.get_writable(), bytes.fromhex( '0007002d01040000')) + def test_quick_move_packet_encodes_shift_click(self): + packet = ContainerClickQuickMovePacket( + container_id=3, state_id=7, slot=42) + payload = PacketBuffer() + packet.write_fields(payload) + + self.assertEqual(packet.id, 0x12) + self.assertEqual(payload.get_writable(), bytes.fromhex( + '0307002a00010000')) + + def test_open_window_packet_decodes_container_metadata(self): + # window id, menu type, empty anonymous-NBT title + packet = ContainerOpenPacket() + packet.read(io.BytesIO(bytes.fromhex('010200'))) + self.assertEqual(packet.window_id, 1) + self.assertEqual(packet.inventory_type, 2) + self.assertIsNone(packet.title) + def test_drain_waits_for_authoritative_slot_update(self): connection = FakeConnection() game = Game.__new__(Game)