Reimplement !drain command
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user