From 7d4f04510091e5b6ad66c1733bec2068921f1744 Mon Sep 17 00:00:00 2001 From: Tanner Date: Wed, 4 Nov 2026 20:06:02 +0000 Subject: [PATCH] Implement 26.3 food consumption --- README.md | 8 ++-- mosfet/commands.py | 5 ++ mosfet/game.py | 10 +++- mosfet/job.py | 7 ++- mosfet/jobs/eat_food.py | 81 +++++++++++++++++++++++++------- mosfet/protocol/modern.py | 10 ++++ tests/test_crop_cache.py | 5 +- tests/test_eat_food.py | 99 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 tests/test_eat_food.py diff --git a/README.md b/README.md index 4805f49..269b964 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,8 @@ modern block/item/entity data, entity tracking, inventory/component sync, path-following movement (`1goto x y z`), inventory draining (`1drain`), normal/trapped-chest caching (`1cache`, also run by crop farming at the inventory threshold), bed sleeping (`1sleep`, or `1sleep new` to place a bed; -also run by crop farming at night), and the crop harvest/replant loop -(`1farm crops`). Other +also run by crop farming at night), food use (`1eat`, also run as needed by +crop farming), 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 @@ -78,8 +78,8 @@ and all bots will run the command. 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`, `1sleep`, -addressed path movement such as `1goto x y z`, and `1farm crops`. The command catalog below is retained from +`!info x y z`, `!inv` (basic slot/count data), `1cache`, `1drain`, `1eat`, +`1sleep`, 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/commands.py b/mosfet/commands.py index 19780a2..45006d8 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -404,6 +404,11 @@ class Commands: self.g.job.state = self.g.job.sleep reply = 'ok' + ## 1eat - eat one food item when hunger/health requires it + if command == 'eat': + self.g.job.state = self.g.job.eat + reply = 'ok' + ## 1gather wood - gathers wood from the world ## 1gather sand - gathers sand from the world if command == 'gather' and data: diff --git a/mosfet/game.py b/mosfet/game.py index d5ca57f..28e5f63 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -23,7 +23,9 @@ from mosfet.protocol.packets import ( ) from mosfet.protocol.types import Slot -from mosfet.protocol.modern import PlayerCommandPacket, UpdateTimePacket +from mosfet.protocol.modern import ( + PlayerCommandPacket, UpdateTimePacket, UseItemPacket, +) from mosfet import utils from mosfet import path @@ -713,6 +715,12 @@ class Game(LegacyGame): self._send_action(PlayerCommandPacket( entity_id=self.g.eid, action_id=0, jump_boost=0)) + def use_item(self, hand=0): + self._send_action(UseItemPacket( + hand=hand, sequence=self._next_sequence(), + yaw=float(self.g.get('yaw', 0.0)), + pitch=float(self.g.get('pitch', 0.0)))) + def start_drain(self): slots = [slot for slot, item in sorted(self.g.inv.items()) if 9 <= slot <= 45 and getattr(item, 'present', False)] diff --git a/mosfet/job.py b/mosfet/job.py index cedb147..a4b3fec 100644 --- a/mosfet/job.py +++ b/mosfet/job.py @@ -272,6 +272,7 @@ class JobStates(LegacyJobStates): self.cache_items_states = cache_items.CacheItemsStates(global_state) self.sleep_with_bed_states = sleep_with_bed.SleepWithBedStates( global_state) + self.eat_food_states = eat_food.EatFoodStates(global_state) self.state = self.idle def farm_crop(self): @@ -285,7 +286,10 @@ class JobStates(LegacyJobStates): items.BEETROOT_SEEDS_ID, }) return [self.gather_crop_states, self.sleep_with_bed_states, - self.cache_items_states] + self.eat_food_states, self.cache_items_states] + + def eat(self): + return [self.eat_food_states] def sleep(self): self.sleep_with_bed_states.silent = False @@ -304,5 +308,6 @@ class JobStates(LegacyJobStates): self.cache_items_states.state = self.cache_items_states.idle self.sleep_with_bed_states.state = self.sleep_with_bed_states.idle self.sleep_with_bed_states.force_new_bed = False + self.eat_food_states.state = self.eat_food_states.idle self.gather_crop_states.active_section_anchor = None self.state = self.idle diff --git a/mosfet/jobs/eat_food.py b/mosfet/jobs/eat_food.py index bfae10e..2f8d9b9 100644 --- a/mosfet/jobs/eat_food.py +++ b/mosfet/jobs/eat_food.py @@ -21,41 +21,86 @@ class EatFoodStates: return None def init(self): - if self.g.food < 12: + food = self.g.get('food') + health = self.g.get('health') + if food is None: + print('No authoritative hunger status yet; not eating') + self.state = self.cleanup + return + + if food < 12: print('Hungry, eating') - self.state = self.select_food - return - - if self.g.health < 20 and self.g.food < 18: + elif health is not None and health < 20 and food < 18: print('Low health, eating') - self.state = self.select_food + else: + print('Don\'t need to eat, aborting') + self.state = self.cleanup return - print('Don\'t need to eat, aborting') - self.state = self.cleanup + self.food_before = food + self.saturation_before = self.g.get('food_saturation', 0.0) or 0.0 + self.health_before = health + self.selected_item_id = None + self.selected_slot = None + self.selected_count = 0 + self.state = self.select_food def select_food(self): - if self.g.game.select_item(items.FOOD_IDS): - self.state = self.eat_food - else: + if not self.g.game.select_item(items.FOOD_IDS): print('No food, aborting') self.state = self.cleanup + return + + self.selected_item_id = self.g.holding + self.selected_slot = ( + 45 if self.g.get('holding_hand', 0) == 1 else + 36 + self.g.get('holding_slot', 0)) + self.select_deadline = time.monotonic() + 2.0 + self.state = self.wait_for_selection + + def wait_for_selection(self): + selected = self.g.inv.get(self.selected_slot) + if (selected and selected.present and + selected.item_id == self.selected_item_id): + self.selected_count = selected.item_count + self.state = self.eat_food + elif time.monotonic() >= self.select_deadline: + print('Food did not reach the selected hand; aborting') + self.state = self.cleanup def eat_food(self): - self.g.game.use_item(0) - - print('Eating food') - self.wait_time = 3 + hand = self.g.get('holding_hand', 0) + print('Eating food from {} hand'.format( + 'off' if hand else 'main')) + self.g.game.use_item(hand) + self.eat_deadline = time.monotonic() + 6.0 self.state = self.wait def wait(self): - if self.wait_time > 0: - self.wait_time -= utils.TICK - else: + selected = self.g.inv.get(self.selected_slot) + consumed = ( + not selected or not selected.present or + selected.item_id != self.selected_item_id or + selected.item_count < self.selected_count) + stats_improved = ( + (self.g.get('food') is not None and + self.g.food > self.food_before) or + (self.g.get('food_saturation') is not None and + self.g.food_saturation > self.saturation_before) or + (self.health_before is not None and self.g.get('health') is not None + and self.g.health > self.health_before)) + if consumed or stats_improved: + print('Eating confirmed by server state') + self.state = self.cleanup + elif time.monotonic() >= self.eat_deadline: + print('Food use was not confirmed by server state') self.state = self.cleanup def cleanup(self): self.state = self.done + if (self.g.get('job') and + getattr(self.g.job.state, '__name__', None) == 'eat'): + self.g.job.state = self.g.job.idle def done(self): # never gets ran, placeholder diff --git a/mosfet/protocol/modern.py b/mosfet/protocol/modern.py index 7744435..8141aab 100644 --- a/mosfet/protocol/modern.py +++ b/mosfet/protocol/modern.py @@ -38,6 +38,16 @@ class PlayerCommandPacket(Packet): ] +class UseItemPacket(Packet): + """Use the selected hand, including the 26.3 interaction sequence.""" + id = 0x43 + packet_name = 'use item' + definition = [ + {'hand': VarInt}, {'sequence': VarInt}, + {'yaw': Float}, {'pitch': Float}, + ] + + def parse_incoming_private_message(message): """Return (sender_name, body) for common Minecraft incoming-DM forms.""" patterns = ( diff --git a/tests/test_crop_cache.py b/tests/test_crop_cache.py index 32a9656..1721b67 100644 --- a/tests/test_crop_cache.py +++ b/tests/test_crop_cache.py @@ -12,14 +12,17 @@ class TestCropCaching(unittest.TestCase): crop_state = object() cache_state = Munch(silent=False) sleep_state = Munch(silent=False) + eat_state = object() jobs = JobStates.__new__(JobStates) jobs.gather_crop_states = crop_state jobs.cache_items_states = cache_state jobs.sleep_with_bed_states = sleep_state + jobs.eat_food_states = eat_state try: machines = JobStates.farm_crop(jobs) - self.assertEqual(machines, [crop_state, sleep_state, cache_state]) + self.assertEqual( + machines, [crop_state, sleep_state, eat_state, cache_state]) self.assertTrue(cache_state.silent) self.assertTrue(sleep_state.silent) self.assertTrue({ diff --git a/tests/test_eat_food.py b/tests/test_eat_food.py new file mode 100644 index 0000000..7bf7e0b --- /dev/null +++ b/tests/test_eat_food.py @@ -0,0 +1,99 @@ +import unittest + +from munch import Munch +from minecraft.networking.modern import ClientTickEnd +from minecraft.networking.packets import PacketBuffer + +from mosfet.game import Game +from mosfet.info import items +from mosfet.jobs.eat_food import EatFoodStates +from mosfet.protocol.modern import UseItemPacket +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 FakeGame: + def __init__(self, state): + self.state = state + self.used_hands = [] + + def select_item(self, item_ids): + for slot, item in self.state.g.inv.items(): + if item.present and item.item_id in item_ids: + self.state.g.holding = item.item_id + self.state.g.holding_hand = 0 + self.state.g.holding_slot = slot - 36 + return True + return False + + def use_item(self, hand=0): + self.used_hands.append(hand) + + +class TestEatFood(unittest.TestCase): + def test_use_item_packet_encodes_hand_sequence_and_rotation(self): + packet = UseItemPacket(hand=1, sequence=300, yaw=90.0, pitch=-30.0) + buffer = PacketBuffer() + packet.write_fields(buffer) + self.assertEqual(packet.id, 0x43) + self.assertEqual(buffer.get_writable(), bytes.fromhex( + '01ac0242b40000c1f00000')) + + def test_game_use_item_sends_packet_and_tick_end(self): + connection = FakeConnection() + game = Game.__new__(Game) + game.g = Munch(connection=connection, yaw=45.0, pitch=-5.0) + game.sequence = 0 + + game.use_item(1) + + packet, forced = connection.packets[0] + self.assertIsInstance(packet, UseItemPacket) + self.assertEqual((packet.hand, packet.sequence, + packet.yaw, packet.pitch), (1, 1, 45.0, -5.0)) + self.assertTrue(forced) + self.assertIsInstance(connection.packets[1][0], ClientTickEnd) + + def test_eat_uses_authoritative_hunger_and_waits_for_confirmation(self): + food_id = next(iter(items.FOOD_IDS)) + state = EatFoodStates(Munch( + food=8, health=20.0, food_saturation=0.0, + inv={36: Slot(True, food_id, 3)}, holding_hand=0, + holding_slot=0, job=None)) + fake_game = FakeGame(state) + state.g.game = fake_game + + state.init() + self.assertEqual(state.state, state.select_food) + state.select_food() + self.assertEqual(state.state, state.wait_for_selection) + state.wait_for_selection() + self.assertEqual(state.state, state.eat_food) + state.eat_food() + self.assertEqual(fake_game.used_hands, [0]) + self.assertEqual(state.state, state.wait) + + # Don't declare success until the server sends a hunger/saturation + # update or confirms that it consumed the selected stack. + state.wait() + self.assertEqual(state.state, state.wait) + state.g.food = 16 + state.g.food_saturation = 4.0 + state.wait() + self.assertEqual(state.state, state.cleanup) + + def test_no_hunger_packet_does_not_eat(self): + state = EatFoodStates(Munch(food=None, health=None)) + state.init() + self.assertEqual(state.state, state.cleanup) + + +if __name__ == '__main__': + unittest.main()