From b71774b9aa613c3770570b33ff8c2caaca1a26e5 Mon Sep 17 00:00:00 2001 From: Tanner Date: Thu, 5 Nov 2026 15:26:55 +0000 Subject: [PATCH] Port wood gathering and fix 26.3 swing animation --- mosfet/commands.py | 5 ++- mosfet/connect.py | 11 ++--- mosfet/game.py | 23 ++++++++-- mosfet/info/blocks.py | 2 +- mosfet/job.py | 4 +- mosfet/jobs/gather_wood.py | 13 +----- mosfet/world.py | 23 +++++----- tests/test_break_animation.py | 78 +++++++++++++++++++++++++++++++++ tests/test_gather_wood.py | 82 +++++++++++++++++++++++++++++++++++ 9 files changed, 208 insertions(+), 33 deletions(-) create mode 100644 tests/test_break_animation.py create mode 100644 tests/test_gather_wood.py diff --git a/mosfet/commands.py b/mosfet/commands.py index b721386..e279125 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -66,10 +66,11 @@ class Commands: command = 'nosquarebrackets' unsupported = { - 'gather', 'trade', 'gapple', 'fill', + 'trade', 'gapple', 'fill', 'break', 'open', 'click', 'use', 'interact', } - if (command in unsupported or + if ((command == 'gather' and data.lower().strip() != 'wood') or + command in unsupported or (command == 'farm' and not data.lower().startswith('crop'))): reply = 'not migrated to Minecraft 26.3 yet' print('Reply:', reply) diff --git a/mosfet/connect.py b/mosfet/connect.py index 8065367..79faca1 100644 --- a/mosfet/connect.py +++ b/mosfet/connect.py @@ -47,11 +47,12 @@ def component_text(value): return str(value) -def main(global_state=None): - server = os.environ.get('SERVER', '127.0.0.1') - port = int(os.environ.get('PORT', '25565')) - username = os.environ.get('PLAYER_NAME', 'mosfet1') - password = os.environ.get('GAME_PASSWORD', 'mosfetbot') +def main(global_state=None, *, server=None, port=None, username=None, + password=None): + server = server or os.environ.get('SERVER', '127.0.0.1') + port = int(port or os.environ.get('PORT', '25565')) + username = username or os.environ.get('PLAYER_NAME', 'mosfet1') + password = password or os.environ.get('GAME_PASSWORD', 'mosfetbot') connection = Connection(server, port, username=username, allowed_versions=['26.3'], diff --git a/mosfet/game.py b/mosfet/game.py index 4802b20..c35d74d 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -8,7 +8,8 @@ from minecraft.networking.packets import Packet, clientbound, serverbound from minecraft.networking.types import BlockFace, Byte, Short, VarInt from minecraft.networking.modern import ( ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket, - SetCarriedItemPacket, SetHeldSlotPacket, UseItemOnBlockPacket, + PunchPacket, SetCarriedItemPacket, SetHeldSlotPacket, + UseItemOnBlockPacket, ) from mosfet.protocol.packets import ( @@ -651,11 +652,14 @@ class LegacyGame: # Active game facade. The constructor registers no legacy packet listeners; # migrated actions use the 26.3 packet classes imported above. class Game(LegacyGame): + ARM_SWING_INTERVAL = 0.1 + def __init__(self, global_state): self.g = global_state self.sequence = 0 self.break_deadline = None self.breaking_state = None + self.next_swing_at = 0.0 from mosfet.protocol.packets import SetHealthPacket # Health is a protocol-777 packet, not the legacy UpdateHealthPacket. @@ -708,6 +712,10 @@ class Game(LegacyGame): self.sequence += 1 return self.sequence + def animate(self): + """Ask the 26.3 server to broadcast a main-hand punch animation.""" + self._send_action(PunchPacket()) + def break_block(self, location): location = tuple(int(value) for value in location) block_id = self.g.chunks.get_block_at(*location) @@ -720,7 +728,10 @@ class Game(LegacyGame): self.g.breaking = location self.breaking_state = block_id hardness_time = utils.break_time(block_id, self.g.get('holding', 0)) - self.break_deadline = time.monotonic() + max(0.05, hardness_time) + now = time.monotonic() + self.break_deadline = now + max(0.05, hardness_time) + self.animate() + self.next_swing_at = now + self.ARM_SWING_INTERVAL return True def respawn(self): @@ -922,7 +933,11 @@ class Game(LegacyGame): self._tick_drain() if not self.g.get('breaking') or self.break_deadline is None: return - if time.monotonic() < self.break_deadline: + now = time.monotonic() + if now < self.break_deadline: + if now >= self.next_swing_at: + self.animate() + self.next_swing_at = now + self.ARM_SWING_INTERVAL return packet = PlayerActionPacket( action=3, location=self.g.breaking, face=1, @@ -937,6 +952,7 @@ class Game(LegacyGame): self.g.breaking = None self.break_deadline = None self.breaking_state = None + self.next_swing_at = 0.0 def cancel_break(self): if not self.g.get('breaking'): @@ -948,6 +964,7 @@ class Game(LegacyGame): self.g.breaking = None self.break_deadline = None self.breaking_state = None + self.next_swing_at = 0.0 def select_item(self, item_ids): item_ids = set(item_ids) diff --git a/mosfet/info/blocks.py b/mosfet/info/blocks.py index 3770830..8694ef3 100644 --- a/mosfet/info/blocks.py +++ b/mosfet/info/blocks.py @@ -79,7 +79,7 @@ SAPLINGS = sorted(name for name in MCD_BLOCKS BEDS = sorted(name for name in MCD_BLOCKS if name.endswith('_bed')) INDEXED = ['chest', 'trapped_chest', 'emerald_block', 'barrel'] + BEDS + [ 'wheat', 'potatoes', 'carrots', 'beetroots', -] +] + LOGS LOG_IDS = get_set(LOGS) LEAF_IDS = get_set(LEAVES) diff --git a/mosfet/job.py b/mosfet/job.py index 15eaf20..ca24588 100644 --- a/mosfet/job.py +++ b/mosfet/job.py @@ -265,9 +265,10 @@ class LegacyJobStates: class JobStates(LegacyJobStates): - """26.3 facade for crop, inventory, sleeping, and loiter workflows.""" + """26.3 facade for migrated gather-wood and crop workflows.""" def __init__(self, global_state): self.g = global_state + self.gather_wood_states = gather_wood.GatherWoodStates(global_state) self.gather_crop_states = gather_crop.GatherCropStates(global_state) self.cache_items_states = cache_items.CacheItemsStates(global_state) self.sleep_with_bed_states = sleep_with_bed.SleepWithBedStates( @@ -311,6 +312,7 @@ class JobStates(LegacyJobStates): self.g.look_at = None if self.sleep_with_bed_states.state == self.sleep_with_bed_states.sleep_bed: self.g.game.leave_bed() + self.gather_wood_states.state = self.gather_wood_states.idle self.gather_crop_states.state = self.gather_crop_states.idle self.cache_items_states.state = self.cache_items_states.idle self.sleep_with_bed_states.state = self.sleep_with_bed_states.idle diff --git a/mosfet/jobs/gather_wood.py b/mosfet/jobs/gather_wood.py index 100969e..2f29d50 100644 --- a/mosfet/jobs/gather_wood.py +++ b/mosfet/jobs/gather_wood.py @@ -1,20 +1,11 @@ -import re +"""Gather naturally generated trees using the active 26.3 world/game APIs.""" import time -import importlib -import random -from itertools import count -from math import floor - -from minecraft.networking.types import BlockFace - -from mosfet.protocol.managers import ChunkNotLoadedException from mosfet import utils from mosfet import path from mosfet.info import blocks from mosfet.info import items -from mosfet.info import mcdata -from mosfet.info import mobs + class GatherWoodStates: def bair(self, p): diff --git a/mosfet/world.py b/mosfet/world.py index df82d49..21be7a8 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -81,9 +81,14 @@ class World: return result def find_trees(self, center, distance): - found_trees = [] - for log in self.find_blocks_3d(center, blocks.LOG_IDS, distance, 15): - # crawl to the bottom log + found_trees = set() + # 26.3 block-state caches keep an index of tree logs. Searching a + # three-dimensional radius voxel-by-voxel is prohibitively expensive + # and can walk through unloaded space, so use loaded indexed states. + for candidate in self.find_blocks_indexed( + center, blocks.LOG_IDS, distance): + # Crawl to the bottom log of this trunk. + log = candidate while self.block_at(*utils.padd(log, path.BLOCK_BELOW)) in blocks.LOG_IDS: log = utils.padd(log, path.BLOCK_BELOW) base = log @@ -91,27 +96,25 @@ class World: if base in found_trees: continue - # make sure we are on the ground - if self.block_at(*utils.padd(base, path.BLOCK_BELOW)) in blocks.NON_SOLID_IDS: + # Only classify trees when their supporting block is known. + support = self.block_at(*utils.padd(base, path.BLOCK_BELOW)) + if support is None or support in blocks.NON_SOLID_IDS: continue - # crawl to the top log to count and check leaves + # Crawl to the top log to count trunk blocks and check for leaves. log_count = 1 good_leaves = False while self.block_at(*utils.padd(log, path.BLOCK_ABOVE)) in blocks.LOG_IDS: log = utils.padd(log, path.BLOCK_ABOVE) log_count += 1 - for offset in path.CHECK_DIRECTIONS: if self.block_at(*utils.padd(log, offset)) in blocks.LEAF_IDS: good_leaves = True - # make sure it's a good tree if not good_leaves or log_count < 3: continue - found_trees.append(base) - + found_trees.add(base) yield base def find_tree_openings(self, tree): diff --git a/tests/test_break_animation.py b/tests/test_break_animation.py new file mode 100644 index 0000000..af3af6b --- /dev/null +++ b/tests/test_break_animation.py @@ -0,0 +1,78 @@ +import io +import time +import unittest +from munch import Munch + +from minecraft.networking.modern import ( + ClientTickEnd, EntityAnimationPacket, EntitySwingAnimationPacket, + PunchPacket, +) +from mosfet.game import Game + + +class RecordingConnection: + def __init__(self): + self.packets = [] + + def write_packet(self, packet, force=False): + self.packets.append((packet, force)) + + +class BlockBreakAnimationTests(unittest.TestCase): + def make_game(self): + connection = RecordingConnection() + game = object.__new__(Game) + game.g = Munch(connection=connection, inv={}, inventory_state_ids={}) + game.sequence = 0 + game.break_deadline = None + game.breaking_state = None + game.next_swing_at = 0.0 + return game, connection + + def test_26_3_punch_packet_is_the_empty_serverbound_packet(self): + self.assertEqual(PunchPacket.id, 0x2E) + self.assertEqual(PunchPacket.definition, []) + + def test_observer_packet_decodes_entity_id_and_swing_kind(self): + packet = EntityAnimationPacket() + packet.read(io.BytesIO(bytes((0x85, 0x01, 0)))) + self.assertEqual(packet.entity_id, 133) + self.assertEqual(packet.animation, 0) + + def test_26_3_observer_swing_packet_decodes_animation_details(self): + packet = EntitySwingAnimationPacket() + packet.read(io.BytesIO(bytes((0x85, 0x01, 0, 1, 6)))) + self.assertEqual(EntitySwingAnimationPacket.id, 0x7B) + self.assertEqual((packet.entity_id, packet.hand, + packet.animation_type, packet.duration), + (133, 0, 1, 6)) + + def test_animate_sends_punch_before_client_tick_end(self): + game, connection = self.make_game() + game.animate() + self.assertEqual([type(packet) for packet, _ in connection.packets], + [PunchPacket, ClientTickEnd]) + self.assertTrue(all(force for _, force in connection.packets)) + + def test_breaking_sends_initial_and_periodic_punches(self): + game, connection = self.make_game() + game.g.breaking = (1, 2, 3) + game.break_deadline = time.monotonic() + 5 + game.breaking_state = 10 + game.next_swing_at = 0 + + game.tick() + self.assertEqual(sum(isinstance(packet, PunchPacket) + for packet, _ in connection.packets), 1) + sent = len(connection.packets) + game.tick() + self.assertEqual(len(connection.packets), sent) + + game.next_swing_at = 0 + game.tick() + self.assertEqual(sum(isinstance(packet, PunchPacket) + for packet, _ in connection.packets), 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_gather_wood.py b/tests/test_gather_wood.py new file mode 100644 index 0000000..4efaee0 --- /dev/null +++ b/tests/test_gather_wood.py @@ -0,0 +1,82 @@ +import unittest +from munch import Munch + +from mosfet.commands import Commands +from mosfet.info import blocks, items +from mosfet.job import JobStates +from mosfet.world import World + + +class FakeChunks: + def __init__(self, states): + self.states = states + self.index = {} + + def get_block_at(self, x, y, z): + return self.states.get((x, y, z), blocks.AIR) + + +class GatherWoodPortTests(unittest.TestCase): + def test_log_states_are_indexed_for_modern_chunk_search(self): + self.assertTrue(blocks.LOG_IDS) + self.assertTrue(blocks.LOG_IDS.issubset(blocks.INDEXED_IDS)) + + def test_tree_search_finds_indexed_26_3_log_states(self): + oak_log = blocks.MCD_BLOCKS['oak_log']['minStateId'] + oak_leaves = blocks.MCD_BLOCKS['oak_leaves']['minStateId'] + stone = blocks.MCD_BLOCKS['stone']['minStateId'] + states = { + (0, 63, 0): stone, + (0, 64, 0): oak_log, + (0, 65, 0): oak_log, + (0, 66, 0): oak_log, + (1, 66, 0): oak_leaves, + } + chunks = FakeChunks(states) + chunks.index[oak_log] = { + (0, 64, 0), (0, 65, 0), (0, 66, 0), + } + world = World(Munch(chunks=chunks)) + self.assertEqual(list(world.find_trees((0, 64, 0), 10)), + [(0, 64, 0)]) + + def test_gather_wood_job_keeps_existing_machine_sequence(self): + job = JobStates(Munch()) + self.assertIsNotNone(job.gather_wood_states) + self.assertEqual(job.gather_wood(), [ + job.gather_wood_states, + job.sleep_with_bed_states, + job.eat_food_states, + job.cache_items_states, + ]) + job.gather_wood_states.state = job.gather_wood_states.find_new_tree + job.stop() + self.assertIs(job.gather_wood_states.state.__func__, + job.gather_wood_states.idle.__func__) + + def test_gather_wood_command_is_enabled_but_farm_wood_is_not(self): + class FakeChat: + def __init__(self): + self.sent = [] + + def set_handler(self, handler): + self.handler = handler + + def send(self, message): + self.sent.append(message) + + job = JobStates(Munch()) + chat = FakeChat() + g = Munch(name='mosfet1', chat=chat, job=job, + inv={0: Munch(item_id=next(iter(items.BED_IDS)))}) + Commands(g).handle_chat(('CHAT', 'tester', 'tester: 1gather wood')) + self.assertEqual(chat.sent, ['ok']) + self.assertIs(job.state.__func__, job.gather_wood.__func__) + + chat.sent.clear() + Commands(g).handle_chat(('CHAT', 'tester', 'tester: 1farm wood')) + self.assertEqual(chat.sent, ['not migrated to Minecraft 26.3 yet']) + + +if __name__ == '__main__': + unittest.main()