Port wood gathering and fix 26.3 swing animation

This commit is contained in:
Tanner
2026-11-05 15:26:55 +00:00
parent 659fa49ea1
commit b71774b9aa
9 changed files with 208 additions and 33 deletions
+3 -2
View File
@@ -66,10 +66,11 @@ class Commands:
command = 'nosquarebrackets' command = 'nosquarebrackets'
unsupported = { unsupported = {
'gather', 'trade', 'gapple', 'fill', 'trade', 'gapple', 'fill',
'break', 'open', 'click', 'use', 'interact', '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'))): (command == 'farm' and not data.lower().startswith('crop'))):
reply = 'not migrated to Minecraft 26.3 yet' reply = 'not migrated to Minecraft 26.3 yet'
print('Reply:', reply) print('Reply:', reply)
+6 -5
View File
@@ -47,11 +47,12 @@ def component_text(value):
return str(value) return str(value)
def main(global_state=None): def main(global_state=None, *, server=None, port=None, username=None,
server = os.environ.get('SERVER', '127.0.0.1') password=None):
port = int(os.environ.get('PORT', '25565')) server = server or os.environ.get('SERVER', '127.0.0.1')
username = os.environ.get('PLAYER_NAME', 'mosfet1') port = int(port or os.environ.get('PORT', '25565'))
password = os.environ.get('GAME_PASSWORD', 'mosfetbot') username = username or os.environ.get('PLAYER_NAME', 'mosfet1')
password = password or os.environ.get('GAME_PASSWORD', 'mosfetbot')
connection = Connection(server, port, username=username, connection = Connection(server, port, username=username,
allowed_versions=['26.3'], allowed_versions=['26.3'],
+20 -3
View File
@@ -8,7 +8,8 @@ from minecraft.networking.packets import Packet, clientbound, serverbound
from minecraft.networking.types import BlockFace, Byte, Short, VarInt from minecraft.networking.types import BlockFace, Byte, Short, VarInt
from minecraft.networking.modern import ( from minecraft.networking.modern import (
ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket, ClientTickEnd, ContainerClickSwapPacket, PlayerActionPacket,
SetCarriedItemPacket, SetHeldSlotPacket, UseItemOnBlockPacket, PunchPacket, SetCarriedItemPacket, SetHeldSlotPacket,
UseItemOnBlockPacket,
) )
from mosfet.protocol.packets import ( from mosfet.protocol.packets import (
@@ -651,11 +652,14 @@ class LegacyGame:
# Active game facade. The constructor registers no legacy packet listeners; # Active game facade. The constructor registers no legacy packet listeners;
# migrated actions use the 26.3 packet classes imported above. # migrated actions use the 26.3 packet classes imported above.
class Game(LegacyGame): class Game(LegacyGame):
ARM_SWING_INTERVAL = 0.1
def __init__(self, global_state): def __init__(self, global_state):
self.g = global_state self.g = global_state
self.sequence = 0 self.sequence = 0
self.break_deadline = None self.break_deadline = None
self.breaking_state = None self.breaking_state = None
self.next_swing_at = 0.0
from mosfet.protocol.packets import SetHealthPacket from mosfet.protocol.packets import SetHealthPacket
# Health is a protocol-777 packet, not the legacy UpdateHealthPacket. # Health is a protocol-777 packet, not the legacy UpdateHealthPacket.
@@ -708,6 +712,10 @@ class Game(LegacyGame):
self.sequence += 1 self.sequence += 1
return self.sequence 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): def break_block(self, location):
location = tuple(int(value) for value in location) location = tuple(int(value) for value in location)
block_id = self.g.chunks.get_block_at(*location) block_id = self.g.chunks.get_block_at(*location)
@@ -720,7 +728,10 @@ class Game(LegacyGame):
self.g.breaking = location self.g.breaking = location
self.breaking_state = block_id self.breaking_state = block_id
hardness_time = utils.break_time(block_id, self.g.get('holding', 0)) 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 return True
def respawn(self): def respawn(self):
@@ -922,7 +933,11 @@ class Game(LegacyGame):
self._tick_drain() self._tick_drain()
if not self.g.get('breaking') or self.break_deadline is None: if not self.g.get('breaking') or self.break_deadline is None:
return 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 return
packet = PlayerActionPacket( packet = PlayerActionPacket(
action=3, location=self.g.breaking, face=1, action=3, location=self.g.breaking, face=1,
@@ -937,6 +952,7 @@ class Game(LegacyGame):
self.g.breaking = None self.g.breaking = None
self.break_deadline = None self.break_deadline = None
self.breaking_state = None self.breaking_state = None
self.next_swing_at = 0.0
def cancel_break(self): def cancel_break(self):
if not self.g.get('breaking'): if not self.g.get('breaking'):
@@ -948,6 +964,7 @@ class Game(LegacyGame):
self.g.breaking = None self.g.breaking = None
self.break_deadline = None self.break_deadline = None
self.breaking_state = None self.breaking_state = None
self.next_swing_at = 0.0
def select_item(self, item_ids): def select_item(self, item_ids):
item_ids = set(item_ids) item_ids = set(item_ids)
+1 -1
View File
@@ -79,7 +79,7 @@ SAPLINGS = sorted(name for name in MCD_BLOCKS
BEDS = sorted(name for name in MCD_BLOCKS if name.endswith('_bed')) BEDS = sorted(name for name in MCD_BLOCKS if name.endswith('_bed'))
INDEXED = ['chest', 'trapped_chest', 'emerald_block', 'barrel'] + BEDS + [ INDEXED = ['chest', 'trapped_chest', 'emerald_block', 'barrel'] + BEDS + [
'wheat', 'potatoes', 'carrots', 'beetroots', 'wheat', 'potatoes', 'carrots', 'beetroots',
] ] + LOGS
LOG_IDS = get_set(LOGS) LOG_IDS = get_set(LOGS)
LEAF_IDS = get_set(LEAVES) LEAF_IDS = get_set(LEAVES)
+3 -1
View File
@@ -265,9 +265,10 @@ class LegacyJobStates:
class JobStates(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): def __init__(self, global_state):
self.g = 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.gather_crop_states = gather_crop.GatherCropStates(global_state)
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(
@@ -311,6 +312,7 @@ class JobStates(LegacyJobStates):
self.g.look_at = None self.g.look_at = None
if self.sleep_with_bed_states.state == self.sleep_with_bed_states.sleep_bed: if self.sleep_with_bed_states.state == self.sleep_with_bed_states.sleep_bed:
self.g.game.leave_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.gather_crop_states.state = self.gather_crop_states.idle
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
+2 -11
View File
@@ -1,20 +1,11 @@
import re """Gather naturally generated trees using the active 26.3 world/game APIs."""
import time 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 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
class GatherWoodStates: class GatherWoodStates:
def bair(self, p): def bair(self, p):
+13 -10
View File
@@ -81,9 +81,14 @@ class World:
return result return result
def find_trees(self, center, distance): def find_trees(self, center, distance):
found_trees = [] found_trees = set()
for log in self.find_blocks_3d(center, blocks.LOG_IDS, distance, 15): # 26.3 block-state caches keep an index of tree logs. Searching a
# crawl to the bottom log # 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: while self.block_at(*utils.padd(log, path.BLOCK_BELOW)) in blocks.LOG_IDS:
log = utils.padd(log, path.BLOCK_BELOW) log = utils.padd(log, path.BLOCK_BELOW)
base = log base = log
@@ -91,27 +96,25 @@ class World:
if base in found_trees: if base in found_trees:
continue continue
# make sure we are on the ground # Only classify trees when their supporting block is known.
if self.block_at(*utils.padd(base, path.BLOCK_BELOW)) in blocks.NON_SOLID_IDS: support = self.block_at(*utils.padd(base, path.BLOCK_BELOW))
if support is None or support in blocks.NON_SOLID_IDS:
continue 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 log_count = 1
good_leaves = False good_leaves = False
while self.block_at(*utils.padd(log, path.BLOCK_ABOVE)) in blocks.LOG_IDS: while self.block_at(*utils.padd(log, path.BLOCK_ABOVE)) in blocks.LOG_IDS:
log = utils.padd(log, path.BLOCK_ABOVE) log = utils.padd(log, path.BLOCK_ABOVE)
log_count += 1 log_count += 1
for offset in path.CHECK_DIRECTIONS: for offset in path.CHECK_DIRECTIONS:
if self.block_at(*utils.padd(log, offset)) in blocks.LEAF_IDS: if self.block_at(*utils.padd(log, offset)) in blocks.LEAF_IDS:
good_leaves = True good_leaves = True
# make sure it's a good tree
if not good_leaves or log_count < 3: if not good_leaves or log_count < 3:
continue continue
found_trees.append(base) found_trees.add(base)
yield base yield base
def find_tree_openings(self, tree): def find_tree_openings(self, tree):
+78
View File
@@ -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()
+82
View File
@@ -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()