diff --git a/README.md b/README.md index 046a593..94d97a3 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,12 @@ kept alive. Edits to connection/bootstrap files are reported as deferred until 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, read-only inventory/component -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. +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. +The default server is `127.0.0.1`; set `SERVER` explicitly only when you +intentionally want another server. ## Usage @@ -74,7 +74,7 @@ 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 +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 legacy bot; other inventory actions, arbitrary block interaction, and the other farming/trading commands are not yet available on 26.3. Common item diff --git a/mosfet/bot.py b/mosfet/bot.py index 4ae7eaf..15e6b60 100644 --- a/mosfet/bot.py +++ b/mosfet/bot.py @@ -116,7 +116,8 @@ def tick(global_state): 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' + status = ('draining_inventory' if g.get('draining') else + '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 @@ -394,6 +395,12 @@ def start_automation(global_state, initialize=True): g.players = g.get('players') or {} g.breaking = None g.break_time = 0.0 + g.draining = False + g.drain_slots = [] + g.drain_waiting = None + g.drain_dropped = 0 + g.drain_total = 0 + g.drain_error = None 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) @@ -408,6 +415,15 @@ def start_automation(global_state, initialize=True): g.game = Game(g) g.job = JobStates(g) + # Preserve active work during hot reloads, but give the worker all fields + # introduced by the currently migrated inventory controls. + g.setdefault('draining', False) + g.setdefault('drain_slots', []) + g.setdefault('drain_waiting', None) + g.setdefault('drain_dropped', 0) + g.setdefault('drain_total', 0) + g.setdefault('drain_error', None) + old_stop = g.get('movement_stop_event') old_worker = g.get('movement_thread') if old_stop is not None: @@ -444,7 +460,8 @@ def start_automation(global_state, initialize=True): # 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) + has_work = (bool(g.path) or job_active or bool(g.breaking) or + bool(g.draining)) if g.get('movement_paused'): set_status('paused') elif has_work and not movement_ready: diff --git a/mosfet/commands.py b/mosfet/commands.py index a93b234..771357e 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -57,7 +57,7 @@ class Commands: unsupported = { 'gather', 'loiter', 'trade', 'gapple', 'cache', 'fill', - 'drop', 'dump', 'drain', 'break', 'open', 'click', 'use', + 'drop', 'dump', 'break', 'open', 'click', 'use', 'interact', 'respawn', } if (command in unsupported or @@ -473,8 +473,8 @@ class Commands: ## 1drain - drops all items in inventory if command == 'drain': - self.g.draining = True - reply = 'ok' + count = self.g.game.start_drain() + reply = 'ok' if count else 'empty' if command == 'gapple': self.g.job.state = self.g.job.find_gapple diff --git a/mosfet/game.py b/mosfet/game.py index 5031093..36a8cbd 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -5,7 +5,7 @@ from itertools import count from munch import Munch from minecraft.networking.packets import Packet, clientbound, serverbound -from minecraft.networking.types import BlockFace +from minecraft.networking.types import BlockFace, Byte, Short, VarInt from minecraft.networking.modern import ( ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket, SetCarriedItemPacket, UseItemOnBlockPacket, @@ -33,6 +33,21 @@ from mosfet.info import items from mosfet.info import mcdata from mosfet.info import mobs +class ContainerClickDropPacket(Packet): + """Drop the full stack in one player-inventory slot (protocol 777).""" + id = 0x12 + packet_name = 'container click drop' + + 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(1, packet_buffer) # drop the whole stack + VarInt.send(4, packet_buffer) # ContainerInput.THROW + VarInt.send(0, packet_buffer) # no changed-slot predictions + VarInt.send(0, packet_buffer) # empty carried item stack + + class LegacyGame: def __init__(self, global_state): self.g = global_state @@ -644,7 +659,68 @@ class Game(LegacyGame): self.break_deadline = time.monotonic() + max(0.05, hardness_time) return True + def start_drain(self): + slots = [slot for slot, item in sorted(self.g.inv.items()) + if 9 <= slot <= 45 and getattr(item, 'present', False)] + self.g.drain_slots = slots + self.g.drain_waiting = None + self.g.drain_dropped = 0 + self.g.drain_total = len(slots) + self.g.drain_error = None + self.g.draining = bool(slots) + return len(slots) + + def _tick_drain(self): + waiting = self.g.get('drain_waiting') + if waiting: + slot = waiting['slot'] + state_id = self.g.inventory_state_ids.get(0, 0) + if state_id != waiting['state_id']: + item = self.g.inv.get(slot) + if not item or not getattr(item, 'present', False): + self.g.drain_dropped += 1 + print('[drain] dropped stack from slot {}; {}/{} complete'.format( + slot, self.g.drain_dropped, self.g.drain_total), flush=True) + self.g.drain_waiting = None + else: + self.g.drain_error = 'server did not clear inventory slot {}'.format(slot) + self.g.draining = False + self.g.drain_waiting = None + print('[drain] stopped: ' + self.g.drain_error, flush=True) + return + elif time.monotonic() - waiting['sent_at'] > 3.0: + self.g.drain_error = 'timed out waiting for inventory update on slot {}'.format(slot) + self.g.draining = False + self.g.drain_waiting = None + print('[drain] stopped: ' + self.g.drain_error, flush=True) + return + else: + return + + while self.g.drain_slots: + slot = self.g.drain_slots.pop(0) + item = self.g.inv.get(slot) + if not item or not getattr(item, 'present', False): + continue + state_id = self.g.inventory_state_ids.get(0, 0) + packet = ContainerClickDropPacket( + container_id=0, state_id=state_id, slot=slot) + self.g.connection.write_packet(packet, force=True) + self.g.connection.write_packet(ClientTickEnd(), force=True) + self.g.drain_waiting = { + 'slot': slot, 'state_id': state_id, + 'item_count': item.item_count, 'sent_at': time.monotonic(), + } + return + + self.g.draining = False + self.g.drain_waiting = None + print('[drain] complete: dropped {} stacks'.format( + self.g.drain_dropped), flush=True) + def tick(self): + if self.g.get('draining'): + self._tick_drain() if not self.g.get('breaking') or self.break_deadline is None: return if time.monotonic() < self.break_deadline: diff --git a/tests/test_inventory_drain.py b/tests/test_inventory_drain.py new file mode 100644 index 0000000..ab7e9d6 --- /dev/null +++ b/tests/test_inventory_drain.py @@ -0,0 +1,59 @@ +import unittest + +from munch import Munch +from minecraft.networking.modern import ClientTickEnd +from minecraft.networking.packets import PacketBuffer + +from mosfet.game import ContainerClickDropPacket, Game +from mosfet.protocol.types import Slot + + +class FakeConnection: + def __init__(self): + self.packets = [] + + def write_packet(self, packet, force=False): + self.packets.append((packet, force)) + + +class TestInventoryDrain(unittest.TestCase): + def test_drop_packet_encodes_throw_whole_stack(self): + packet = ContainerClickDropPacket( + container_id=0, state_id=7, slot=45) + payload = PacketBuffer() + packet.write_fields(payload) + + self.assertEqual(packet.id, 0x12) + self.assertEqual(payload.get_writable(), bytes.fromhex( + '0007002d01040000')) + + def test_drain_waits_for_authoritative_slot_update(self): + connection = FakeConnection() + game = Game.__new__(Game) + game.g = Munch( + inv={36: Slot(True, 62, 3)}, + inventory_state_ids={0: 5}, + connection=connection, + draining=False, + ) + + self.assertEqual(game.start_drain(), 1) + game._tick_drain() + self.assertTrue(game.g.draining) + self.assertEqual(game.g.drain_waiting['slot'], 36) + self.assertIsInstance(connection.packets[0][0], + ContainerClickDropPacket) + self.assertEqual(connection.packets[0][0].state_id, 5) + self.assertIsInstance(connection.packets[1][0], ClientTickEnd) + + # InventoryManager updates the state id and slot from server packets. + game.g.inventory_state_ids[0] = 6 + game.g.inv[36] = Slot(False) + game._tick_drain() + self.assertFalse(game.g.draining) + self.assertEqual(game.g.drain_dropped, 1) + self.assertIsNone(game.g.drain_error) + + +if __name__ == '__main__': + unittest.main()