Reimplement !drain command

This commit is contained in:
Tanner
2026-11-04 17:29:52 +00:00
parent 6dbc61284a
commit 23ecfa4446
5 changed files with 165 additions and 13 deletions
+7 -7
View File
@@ -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 next manual start, rather than triggering an automatic reconnect.
The 26.3 client now handles AuthMe, chat commands, chunk/world-state caching, The 26.3 client now handles AuthMe, chat commands, chunk/world-state caching,
modern block/item/entity data, entity tracking, read-only inventory/component modern block/item/entity data, entity tracking, inventory/component sync,
sync, path-following movement (`1goto x y z`), and the crop harvest/replant loop path-following movement (`1goto x y z`), inventory draining (`1drain`), and the
(`1farm crops`). General inventory/window actions and the other farming/trading crop harvest/replant loop (`1farm crops`). Other inventory/window actions and
jobs remain disabled until their 26.3 packets are migrated. The default server is the farming/trading jobs remain disabled until their 26.3 packets are migrated.
`127.0.0.1`; set `SERVER` explicitly only when you intentionally want another The default server is `127.0.0.1`; set `SERVER` explicitly only when you
server. intentionally want another server.
## Usage ## 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 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 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 `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 the legacy bot; other inventory actions, arbitrary block interaction, and the
other farming/trading commands are not yet available on 26.3. Common item other farming/trading commands are not yet available on 26.3. Common item
+19 -2
View File
@@ -116,7 +116,8 @@ def tick(global_state):
print('Chunks loaded in', round(time.time() - g.chunks.loading, 2), 's') print('Chunks loaded in', round(time.time() - g.chunks.loading, 2), 's')
g.chunks.loading = False g.chunks.loading = False
g.chunk_wait_report_at = 0 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: if g.get('movement_status') != status:
print('[movement] status={}'.format(status), flush=True) print('[movement] status={}'.format(status), flush=True)
g.movement_status = status g.movement_status = status
@@ -394,6 +395,12 @@ def start_automation(global_state, initialize=True):
g.players = g.get('players') or {} g.players = g.get('players') or {}
g.breaking = None g.breaking = None
g.break_time = 0.0 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.correction_count = g.get('correction_count', 0)
g.yaw = float(g.get('yaw', 0.0) or 0.0) g.yaw = float(g.get('yaw', 0.0) or 0.0)
g.pitch = float(g.get('pitch', 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.game = Game(g)
g.job = JobStates(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_stop = g.get('movement_stop_event')
old_worker = g.get('movement_thread') old_worker = g.get('movement_thread')
if old_stop is not None: 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. # of gating itself forever on a momentary missing column.
movement_ready = initial_world_ready movement_ready = initial_world_ready
job_active = (g.job is not None and g.job.state != g.job.idle) 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'): if g.get('movement_paused'):
set_status('paused') set_status('paused')
elif has_work and not movement_ready: elif has_work and not movement_ready:
+3 -3
View File
@@ -57,7 +57,7 @@ class Commands:
unsupported = { unsupported = {
'gather', 'loiter', 'trade', 'gapple', 'cache', 'fill', 'gather', 'loiter', 'trade', 'gapple', 'cache', 'fill',
'drop', 'dump', 'drain', 'break', 'open', 'click', 'use', 'drop', 'dump', 'break', 'open', 'click', 'use',
'interact', 'respawn', 'interact', 'respawn',
} }
if (command in unsupported or if (command in unsupported or
@@ -473,8 +473,8 @@ class Commands:
## 1drain - drops all items in inventory ## 1drain - drops all items in inventory
if command == 'drain': if command == 'drain':
self.g.draining = True count = self.g.game.start_drain()
reply = 'ok' reply = 'ok' if count else 'empty'
if command == 'gapple': if command == 'gapple':
self.g.job.state = self.g.job.find_gapple self.g.job.state = self.g.job.find_gapple
+77 -1
View File
@@ -5,7 +5,7 @@ from itertools import count
from munch import Munch from munch import Munch
from minecraft.networking.packets import Packet, clientbound, serverbound 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 ( from minecraft.networking.modern import (
ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket, ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket,
SetCarriedItemPacket, UseItemOnBlockPacket, SetCarriedItemPacket, UseItemOnBlockPacket,
@@ -33,6 +33,21 @@ from mosfet.info import items
from mosfet.info import mcdata from mosfet.info import mcdata
from mosfet.info import mobs 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: class LegacyGame:
def __init__(self, global_state): def __init__(self, global_state):
self.g = global_state self.g = global_state
@@ -644,7 +659,68 @@ class Game(LegacyGame):
self.break_deadline = time.monotonic() + max(0.05, hardness_time) self.break_deadline = time.monotonic() + max(0.05, hardness_time)
return True 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): def tick(self):
if self.g.get('draining'):
self._tick_drain()
if not self.g.get('breaking') or self.break_deadline is None: if not self.g.get('breaking') or self.break_deadline is None:
return return
if time.monotonic() < self.break_deadline: if time.monotonic() < self.break_deadline:
+59
View File
@@ -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()