Implement 26.3 food consumption

This commit is contained in:
Tanner
2026-11-04 20:06:02 +00:00
parent 1ecec547ae
commit 7d4f045100
8 changed files with 200 additions and 25 deletions
+4 -4
View File
@@ -51,8 +51,8 @@ modern block/item/entity data, entity tracking, inventory/component sync,
path-following movement (`1goto x y z`), inventory draining (`1drain`), path-following movement (`1goto x y z`), inventory draining (`1drain`),
normal/trapped-chest caching (`1cache`, also run by crop farming at the normal/trapped-chest caching (`1cache`, also run by crop farming at the
inventory threshold), bed sleeping (`1sleep`, or `1sleep new` to place a bed; 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 also run by crop farming at night), food use (`1eat`, also run as needed by
(`1farm crops`). Other crop farming), and the crop harvest/replant loop (`1farm crops`). Other
inventory/window actions and farming/trading jobs remain disabled until their inventory/window actions and farming/trading jobs remain disabled until their
26.3 packets are migrated. 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
@@ -78,8 +78,8 @@ and all bots will run the command.
The current 26.3 command path handles public chat and direct messages, replying The current 26.3 command path handles public chat and direct messages, replying
in the same channel. Verified commands are `!ping`, `!pos`, `!loaded`, in the same channel. Verified commands are `!ping`, `!pos`, `!loaded`,
`!info x y z`, `!inv` (basic slot/count data), `1cache`, `1drain`, `1sleep`, `!info x y z`, `!inv` (basic slot/count data), `1cache`, `1drain`, `1eat`,
addressed path movement such as `1goto x y z`, and `1farm crops`. The command catalog below is retained from `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 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
+5
View File
@@ -404,6 +404,11 @@ class Commands:
self.g.job.state = self.g.job.sleep self.g.job.state = self.g.job.sleep
reply = 'ok' 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 wood - gathers wood from the world
## 1gather sand - gathers sand from the world ## 1gather sand - gathers sand from the world
if command == 'gather' and data: if command == 'gather' and data:
+9 -1
View File
@@ -23,7 +23,9 @@ from mosfet.protocol.packets import (
) )
from mosfet.protocol.types import Slot 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 utils
from mosfet import path from mosfet import path
@@ -713,6 +715,12 @@ class Game(LegacyGame):
self._send_action(PlayerCommandPacket( self._send_action(PlayerCommandPacket(
entity_id=self.g.eid, action_id=0, jump_boost=0)) 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): def start_drain(self):
slots = [slot for slot, item in sorted(self.g.inv.items()) slots = [slot for slot, item in sorted(self.g.inv.items())
if 9 <= slot <= 45 and getattr(item, 'present', False)] if 9 <= slot <= 45 and getattr(item, 'present', False)]
+6 -1
View File
@@ -272,6 +272,7 @@ class JobStates(LegacyJobStates):
self.cache_items_states = cache_items.CacheItemsStates(global_state) self.cache_items_states = cache_items.CacheItemsStates(global_state)
self.sleep_with_bed_states = sleep_with_bed.SleepWithBedStates( self.sleep_with_bed_states = sleep_with_bed.SleepWithBedStates(
global_state) global_state)
self.eat_food_states = eat_food.EatFoodStates(global_state)
self.state = self.idle self.state = self.idle
def farm_crop(self): def farm_crop(self):
@@ -285,7 +286,10 @@ class JobStates(LegacyJobStates):
items.BEETROOT_SEEDS_ID, items.BEETROOT_SEEDS_ID,
}) })
return [self.gather_crop_states, self.sleep_with_bed_states, 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): def sleep(self):
self.sleep_with_bed_states.silent = False 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.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.state = self.sleep_with_bed_states.idle
self.sleep_with_bed_states.force_new_bed = False 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.gather_crop_states.active_section_anchor = None
self.state = self.idle self.state = self.idle
+63 -18
View File
@@ -21,41 +21,86 @@ class EatFoodStates:
return None return None
def init(self): 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') print('Hungry, eating')
self.state = self.select_food elif health is not None and health < 20 and food < 18:
return
if self.g.health < 20 and self.g.food < 18:
print('Low health, eating') print('Low health, eating')
self.state = self.select_food else:
print('Don\'t need to eat, aborting')
self.state = self.cleanup
return return
print('Don\'t need to eat, aborting') self.food_before = food
self.state = self.cleanup 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): def select_food(self):
if self.g.game.select_item(items.FOOD_IDS): if not self.g.game.select_item(items.FOOD_IDS):
self.state = self.eat_food
else:
print('No food, aborting') print('No food, aborting')
self.state = self.cleanup 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): def eat_food(self):
self.g.game.use_item(0) hand = self.g.get('holding_hand', 0)
print('Eating food from {} hand'.format(
print('Eating food') 'off' if hand else 'main'))
self.wait_time = 3 self.g.game.use_item(hand)
self.eat_deadline = time.monotonic() + 6.0
self.state = self.wait self.state = self.wait
def wait(self): def wait(self):
if self.wait_time > 0: selected = self.g.inv.get(self.selected_slot)
self.wait_time -= utils.TICK consumed = (
else: 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 self.state = self.cleanup
def cleanup(self): def cleanup(self):
self.state = self.done 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): def done(self):
# never gets ran, placeholder # never gets ran, placeholder
+10
View File
@@ -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): def parse_incoming_private_message(message):
"""Return (sender_name, body) for common Minecraft incoming-DM forms.""" """Return (sender_name, body) for common Minecraft incoming-DM forms."""
patterns = ( patterns = (
+4 -1
View File
@@ -12,14 +12,17 @@ class TestCropCaching(unittest.TestCase):
crop_state = object() crop_state = object()
cache_state = Munch(silent=False) cache_state = Munch(silent=False)
sleep_state = Munch(silent=False) sleep_state = Munch(silent=False)
eat_state = object()
jobs = JobStates.__new__(JobStates) jobs = JobStates.__new__(JobStates)
jobs.gather_crop_states = crop_state jobs.gather_crop_states = crop_state
jobs.cache_items_states = cache_state jobs.cache_items_states = cache_state
jobs.sleep_with_bed_states = sleep_state jobs.sleep_with_bed_states = sleep_state
jobs.eat_food_states = eat_state
try: try:
machines = JobStates.farm_crop(jobs) 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(cache_state.silent)
self.assertTrue(sleep_state.silent) self.assertTrue(sleep_state.silent)
self.assertTrue({ self.assertTrue({
+99
View File
@@ -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()