Implement item cachine for 26.3

This commit is contained in:
Tanner
2026-11-04 18:54:04 +00:00
parent 459e288407
commit 9fd565c3c9
10 changed files with 228 additions and 37 deletions
+9 -7
View File
@@ -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, The 26.3 client now handles AuthMe, chat commands, chunk/world-state caching,
modern block/item/entity data, entity tracking, inventory/component sync, modern block/item/entity data, entity tracking, inventory/component sync,
path-following movement (`1goto x y z`), inventory draining (`1drain`), and the path-following movement (`1goto x y z`), inventory draining (`1drain`),
crop harvest/replant loop (`1farm crops`). Other inventory/window actions and normal/trapped-chest caching (`1cache`, also run by crop farming at the
the farming/trading jobs remain disabled until their 26.3 packets are migrated. 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 The default server is `127.0.0.1`; set `SERVER` explicitly only when you
intentionally want another server. 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 `!` The exception are the below public commands, they can optionally be prefixed with `!`
and all bots will run the command. 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 and direct messages, replying
not migrated yet. Verified commands are `!ping`, `!pos`, `!loaded`, `!info x y in the same channel. Verified commands are `!ping`, `!pos`, `!loaded`,
z`, `!inv` (basic slot/count data), `1drain`, addressed path movement such as `!info x y z`, `!inv` (basic slot/count data), `1cache`, `1drain`, addressed
`1goto x y z`, and `1farm crops`. The command catalog below is retained from 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 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
components (names, damage, enchantments, book components (names, damage, enchantments, book
+6
View File
@@ -395,6 +395,8 @@ 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.item_lock = False
g.minimum_cache_slots = g.get('minimum_cache_slots', 27)
g.draining = False g.draining = False
g.drain_slots = [] g.drain_slots = []
g.drain_waiting = None g.drain_waiting = None
@@ -404,6 +406,7 @@ def start_automation(global_state, initialize=True):
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)
g.holding_slot = g.get('holding_slot', 0)
g.chunks.loading = False g.chunks.loading = False
# Readiness below is based on nearby chunks, not unrelated view holes. # Readiness below is based on nearby chunks, not unrelated view holes.
g.movement_paused = False g.movement_paused = False
@@ -423,6 +426,9 @@ def start_automation(global_state, initialize=True):
g.setdefault('drain_dropped', 0) g.setdefault('drain_dropped', 0)
g.setdefault('drain_total', 0) g.setdefault('drain_total', 0)
g.setdefault('drain_error', None) 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_stop = g.get('movement_stop_event')
old_worker = g.get('movement_thread') old_worker = g.get('movement_thread')
+1 -1
View File
@@ -66,7 +66,7 @@ class Commands:
command = 'nosquarebrackets' command = 'nosquarebrackets'
unsupported = { unsupported = {
'gather', 'loiter', 'trade', 'gapple', 'cache', 'fill', 'gather', 'loiter', 'trade', 'gapple', 'fill',
'drop', 'dump', 'break', 'open', 'click', 'use', 'drop', 'dump', 'break', 'open', 'click', 'use',
'interact', 'respawn', 'interact', 'respawn',
} }
+50 -2
View File
@@ -48,6 +48,27 @@ class ContainerClickDropPacket(Packet):
VarInt.send(0, packet_buffer) # empty carried item stack 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: class LegacyGame:
def __init__(self, global_state): def __init__(self, global_state):
self.g = global_state self.g = global_state
@@ -795,6 +816,33 @@ class Game(LegacyGame):
sequence=self._next_sequence()) sequence=self._next_sequence())
self._send_action(packet) 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): def close_window(self):
# Container open/close packet migration is not part of the crop path. window = self.g.get('window')
return None if not window:
return None
self.g.connection.write_packet(ContainerClosePacket(
container_id=window.window_id), force=True)
self.g.window = None
+12 -1
View File
@@ -269,16 +269,27 @@ class JobStates(LegacyJobStates):
def __init__(self, global_state): def __init__(self, global_state):
self.g = global_state self.g = global_state
self.gather_crop_states = gather_crop.GatherCropStates(global_state) self.gather_crop_states = gather_crop.GatherCropStates(global_state)
self.cache_items_states = cache_items.CacheItemsStates(global_state)
self.state = self.idle self.state = self.idle
def farm_crop(self): 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): def stop(self):
if self.g.get('breaking'): if self.g.get('breaking'):
self.g.game.cancel_break() self.g.game.cancel_break()
if self.g.get('window'):
self.g.game.close_window()
self.g.path = [] self.g.path = []
self.g.look_at = None self.g.look_at = None
self.gather_crop_states.state = self.gather_crop_states.idle 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.gather_crop_states.active_section_anchor = None
self.state = self.idle self.state = self.idle
+46 -20
View File
@@ -13,7 +13,6 @@ from mosfet import utils
from mosfet import path from mosfet import path
from mosfet.info import blocks from mosfet.info import blocks
from mosfet.info import items from mosfet.info import items
from mosfet.info import mcdata
from mosfet.info import mobs from mosfet.info import mobs
class CacheItemsStates: class CacheItemsStates:
@@ -25,8 +24,12 @@ class CacheItemsStates:
self.skip_items = [] self.skip_items = []
num_stacks = self.g.game.count_inventory_slots() num_stacks = self.g.game.count_inventory_slots()
minimum = getattr(
self, 'minimum', self.g.get('minimum_cache_slots', 27))
print('Inventory amount:', num_stacks) 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 self.state = self.find_trapped_chests
else: else:
print('Aborting caching, not full') print('Aborting caching, not full')
@@ -53,23 +56,30 @@ class CacheItemsStates:
return return
chest = self.trapped_chests[0] chest = self.trapped_chests[0]
self.area = chest
navpath = w.path_to_place_faked(p, chest) for opening in w.find_cache_openings(chest):
print('navpath:', navpath) 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.trapped_chests.pop(0)
self.g.path = navpath[:-1] time.sleep(0.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)
def going_to_trapped_chest(self): def going_to_trapped_chest(self):
if utils.pint(self.g.pos) == self.opening: if utils.pint(self.g.pos) == self.opening:
self.g.look_at = self.area 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 self.state = self.open_chest
def select_chest(self): def select_chest(self):
@@ -84,7 +94,7 @@ class CacheItemsStates:
w = self.g.world w = self.g.world
p = utils.pint(self.g.pos) 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) print('Found area:', area)
if area not in self.bad_areas: if area not in self.bad_areas:
break break
@@ -112,10 +122,18 @@ class CacheItemsStates:
def going_to_area(self): def going_to_area(self):
if utils.pint(self.g.pos) == self.opening: if utils.pint(self.g.pos) == self.opening:
self.g.look_at = self.area 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 self.state = self.place_chest
def place_chest(self): 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 self.state = self.open_chest
def open_chest(self): def open_chest(self):
@@ -139,14 +157,19 @@ class CacheItemsStates:
print('Didnt get a window, aborting') print('Didnt get a window, aborting')
self.state = self.cleanup self.state = self.cleanup
return return
if w.data.window_type != mcdata.SINGLE_CHEST: if not w.container_slots:
print('Got wrong window, aborting') 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 self.state = self.cleanup
return return
w_info = mcdata.WINDOWS[w.data.window_type] w_container_slots = range(w.container_slots)
w_inventory_slots = w_info.inventory w_inventory_slots = range(w.container_slots, len(w.contents))
w_container_slots = w_info.container
used_slots = self.g.game.count_window_slots() used_slots = self.g.game.count_window_slots()
print('used:', used_slots, 'total:', len(w_container_slots)) print('used:', used_slots, 'total:', len(w_container_slots))
@@ -204,6 +227,9 @@ class CacheItemsStates:
def cleanup(self): def cleanup(self):
self.g.look_at = None self.g.look_at = None
self.state = self.done 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): def done(self):
# never gets ran, placeholder # never gets ran, placeholder
+31 -3
View File
@@ -4,7 +4,8 @@ from munch import Munch
from minecraft.networking.packets import clientbound, serverbound from minecraft.networking.packets import clientbound, serverbound
from minecraft.networking.modern import ( from minecraft.networking.modern import (
AddEntityPacket, ContainerSetContentPacket, ContainerSetSlotPacket, AddEntityPacket, ContainerOpenPacket, ContainerSetContentPacket,
ContainerSetSlotPacket,
PaletteSection, PaletteSection,
EntityPositionSyncPacket, MoveEntityPositionPacket, EntityPositionSyncPacket, MoveEntityPositionPacket,
MoveEntityPositionRotationPacket, MoveEntityRotationPacket, MoveEntityPositionRotationPacket, MoveEntityRotationPacket,
@@ -433,10 +434,13 @@ class InventoryManager:
def __init__(self, global_state): def __init__(self, global_state):
self.g = global_state self.g = global_state
self.g.inv = self.g.get('inv') or {} 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_state_ids = self.g.get('inventory_state_ids') or {}
self.g.inventory_partial = False self.g.inventory_partial = False
self.g.cursor = Slot(present=False) self.g.cursor = Slot(present=False)
register = self.g.connection.register_packet_listener register = self.g.connection.register_packet_listener
register(self.handle_open, ContainerOpenPacket)
register(self.handle_content, ContainerSetContentPacket) register(self.handle_content, ContainerSetContentPacket)
register(self.handle_slot, ContainerSetSlotPacket) register(self.handle_slot, ContainerSetSlotPacket)
register(self.handle_cursor, SetCursorItemPacket) register(self.handle_cursor, SetCursorItemPacket)
@@ -449,6 +453,16 @@ class InventoryManager:
removed_components=item.removed_components, removed_components=item.removed_components,
components_unknown=item.components_unknown) 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): def handle_content(self, packet):
slots = {index: self.to_slot(item) slots = {index: self.to_slot(item)
for index, item in enumerate(packet.items)} for index, item in enumerate(packet.items)}
@@ -457,9 +471,14 @@ class InventoryManager:
self.g.inv = slots self.g.inv = slots
self.g.inventory_partial = packet.partial self.g.inventory_partial = packet.partial
self.g.cursor = self.to_slot(packet.carried_item) 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 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.cursor = self.to_slot(packet.carried_item)
self.g.item_lock = False
def handle_slot(self, packet): def handle_slot(self, packet):
self.g.inventory_state_ids[packet.container_id] = packet.state_id self.g.inventory_state_ids[packet.container_id] = packet.state_id
@@ -468,10 +487,19 @@ class InventoryManager:
self.g.inventory_partial = True self.g.inventory_partial = True
if packet.container_id == 0: if packet.container_id == 0:
self.g.inv[packet.slot] = item self.g.inv[packet.slot] = item
self.g.item_lock = False
elif packet.container_id == -1 and packet.slot == -1: elif packet.container_id == -1 and packet.slot == -1:
self.g.cursor = item 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.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): def handle_cursor(self, packet):
if packet.item.components_unknown: if packet.item.components_unknown:
+19 -1
View File
@@ -182,7 +182,25 @@ class World:
yield a yield a
def find_cache_areas(self, center, distance): 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): def sand_adjacent_safe(self, sand):
for direction in path.CHECK_DIRECTIONS: for direction in path.CHECK_DIRECTIONS:
+31
View File
@@ -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()
+23 -2
View File
@@ -1,10 +1,13 @@
import io
import unittest import unittest
from munch import Munch from munch import Munch
from minecraft.networking.modern import ClientTickEnd from minecraft.networking.modern import ClientTickEnd, ContainerOpenPacket
from minecraft.networking.packets import PacketBuffer 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 from mosfet.protocol.types import Slot
@@ -27,6 +30,24 @@ class TestInventoryDrain(unittest.TestCase):
self.assertEqual(payload.get_writable(), bytes.fromhex( self.assertEqual(payload.get_writable(), bytes.fromhex(
'0007002d01040000')) '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): def test_drain_waits_for_authoritative_slot_update(self):
connection = FakeConnection() connection = FakeConnection()
game = Game.__new__(Game) game = Game.__new__(Game)