diff --git a/mosfet.py b/mosfet.py index e200fe6..120eda2 100644 --- a/mosfet.py +++ b/mosfet.py @@ -48,8 +48,14 @@ g.job = None g.correction_count = 0 g.holding = 0 g.afk = False -g.health = 20 -g.food = 20 +g.health = None +g.food = None +g.hunger = None +g.food_saturation = None +g.saturation = None +g.health_update_received = False +g.health_updated_at = None +g.dead = False g.sand_origin = None g.reload_lock = threading.RLock() _item_icon_cache = Path(__file__).resolve().parent / 'web_interface' / 'item_cache' diff --git a/mosfet/commands.py b/mosfet/commands.py index d2bf4b5..a93b234 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -107,6 +107,14 @@ class Commands: if command == 'pos': reply = str(utils.pint(self.g.pos))[1:-1] + ', ' + self.g.dimension + ## !health - replies with health, hunger, and saturation + if command == 'health': + if not self.g.get('health_update_received'): + reply = 'health status not received yet' + else: + reply = 'health {:.1f}, hunger {}/20, saturation {:.2f}'.format( + self.g.health, self.g.food, self.g.food_saturation) + ## !afk - goes AFK with /afk if command == 'afk': if not self.g.afk: diff --git a/mosfet/game.py b/mosfet/game.py index 875c0fb..5031093 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -583,6 +583,44 @@ class Game(LegacyGame): self.break_deadline = None self.breaking_state = None + from mosfet.protocol.packets import SetHealthPacket + # Health is a protocol-777 packet, not the legacy UpdateHealthPacket. + # Register its decoder on an already-running reactor as well, so a + # hot reload can enable health tracking without reconnecting. Rebuild + # the base map first to remove any stale decoder for an earlier ID. + reactor = self.g.connection.reactor + if hasattr(reactor, 'clientbound_packets'): + context = self.g.connection.context + reactor.clientbound_packets = { + packet.get_id(context): packet + for packet in reactor.__class__.get_clientbound_packets(context) + } + reactor.clientbound_packets[SetHealthPacket.id] = SetHealthPacket + self.g.connection.register_packet_listener( + self.handle_set_health, SetHealthPacket) + + if not self.g.get('health_update_received', False): + self.g.health = None + self.g.food = None + self.g.hunger = None + self.g.food_saturation = None + self.g.saturation = None + self.g.health_update_received = False + self.g.health_updated_at = None + self.g.dead = False + + def handle_set_health(self, packet): + self.g.health = float(packet.health) + self.g.food = int(packet.food) + self.g.hunger = self.g.food + self.g.food_saturation = float(packet.food_saturation) + self.g.saturation = self.g.food_saturation + self.g.health_update_received = True + self.g.health_updated_at = time.time() + self.g.dead = self.g.health <= 0 + print('[status] health={:.1f} hunger={} saturation={:.2f}'.format( + self.g.health, self.g.food, self.g.food_saturation), flush=True) + def _send_action(self, packet): self.g.connection.write_packet(packet, force=True) self.g.connection.write_packet(ClientTickEnd(), force=True) diff --git a/mosfet/protocol/packets.py b/mosfet/protocol/packets.py index 8886c8d..66df3e8 100644 --- a/mosfet/protocol/packets.py +++ b/mosfet/protocol/packets.py @@ -13,6 +13,17 @@ from .types import Nbt, Slot, Entry, Trade from mosfet.info import blocks +class SetHealthPacket(Packet): + """Minecraft 26.3 (protocol 777) clientbound health/food update.""" + id = 0x6A + packet_name = 'set health' + definition = [ + {'health': Float}, + {'food': VarInt}, + {'food_saturation': Float}, + ] + + class ChunkDataPacket(Packet): id = 0x20 packet_name = 'chunk data' diff --git a/tests/test_health_packet.py b/tests/test_health_packet.py new file mode 100644 index 0000000..0b8bd59 --- /dev/null +++ b/tests/test_health_packet.py @@ -0,0 +1,48 @@ +import io +import struct +import unittest + +from munch import Munch + +from mosfet.game import Game +from mosfet.protocol.packets import SetHealthPacket + + +class TestSetHealthPacket(unittest.TestCase): + def test_protocol_777_packet_decodes_all_status_values(self): + payload = struct.pack('>f', 13.5) + bytes([14]) + struct.pack('>f', 5.25) + packet = SetHealthPacket() + packet.read(io.BytesIO(payload)) + + self.assertEqual(packet.id, 0x6A) + self.assertEqual(packet.health, 13.5) + self.assertEqual(packet.food, 14) + self.assertEqual(packet.food_saturation, 5.25) + + def test_game_state_tracks_health_hunger_and_saturation(self): + game = Game.__new__(Game) + game.g = Munch() + game.handle_set_health(SetHealthPacket( + health=7.5, food=9, food_saturation=2.25)) + + self.assertEqual(game.g.health, 7.5) + self.assertEqual(game.g.food, 9) + self.assertEqual(game.g.hunger, 9) + self.assertEqual(game.g.food_saturation, 2.25) + self.assertEqual(game.g.saturation, 2.25) + self.assertTrue(game.g.health_update_received) + self.assertFalse(game.g.dead) + + def test_zero_health_marks_bot_dead_without_faking_values(self): + game = Game.__new__(Game) + game.g = Munch() + game.handle_set_health(SetHealthPacket( + health=0.0, food=0, food_saturation=0.0)) + + self.assertEqual(game.g.health, 0.0) + self.assertEqual(game.g.food, 0) + self.assertTrue(game.g.dead) + + +if __name__ == '__main__': + unittest.main() diff --git a/web_interface/build/index.html b/web_interface/build/index.html index 3e31d07..da1d87a 100644 --- a/web_interface/build/index.html +++ b/web_interface/build/index.html @@ -37,6 +37,7 @@
HUNGER◆
—/ 20
+
Saturation —
MOVEMENT
diff --git a/web_interface/public/index.html b/web_interface/public/index.html index 3e31d07..da1d87a 100644 --- a/web_interface/public/index.html +++ b/web_interface/public/index.html @@ -37,6 +37,7 @@
HUNGER◆
—/ 20
+
Saturation —
MOVEMENT
diff --git a/web_interface/src/dashboard.js b/web_interface/src/dashboard.js index 6a30fb9..df1816d 100644 --- a/web_interface/src/dashboard.js +++ b/web_interface/src/dashboard.js @@ -147,12 +147,17 @@ $('position').textContent = vectorText(state.pos || state.player_position); $('dimension').textContent = dimensionName(state.dimension); - const health = Number(state.health); - const food = Number(state.food); - $('health-value').textContent = Number.isFinite(health) ? String(health) : '—'; + const health = state.health == null ? NaN : Number(state.health); + const food = state.food == null ? NaN : Number(state.food); + const saturation = state.food_saturation == null + ? NaN : Number(state.food_saturation); + $('health-value').textContent = Number.isFinite(health) + ? Number(health.toFixed(1)).toString() : '—'; $('food-value').textContent = Number.isFinite(food) ? String(food) : '—'; - $('health-meter').style.width = `${Math.max(0, Math.min(100, health * 5 || 0))}%`; - $('food-meter').style.width = `${Math.max(0, Math.min(100, food * 5 || 0))}%`; + $('food-saturation').textContent = Number.isFinite(saturation) + ? `Saturation ${saturation.toFixed(2)}` : 'Saturation —'; + $('health-meter').style.width = `${Math.max(0, Math.min(100, (health || 0) * 5))}%`; + $('food-meter').style.width = `${Math.max(0, Math.min(100, (food || 0) * 5))}%`; const movement = state.movement_status || (state.path?.length ? 'following_path' : 'idle'); $('movement-value').textContent = String(movement).replaceAll('_', ' ').toUpperCase(); diff --git a/web_interface/src/index.html b/web_interface/src/index.html index 3e31d07..da1d87a 100644 --- a/web_interface/src/index.html +++ b/web_interface/src/index.html @@ -37,6 +37,7 @@
HUNGER◆
—/ 20
+
Saturation —
MOVEMENT