Port loiter and harden 26.3 bed updates

This commit is contained in:
Tanner
2026-11-05 01:05:52 +00:00
parent b9d43c7c37
commit efa2140a91
12 changed files with 664 additions and 65 deletions
+8 -7
View File
@@ -5,9 +5,9 @@ play. The connection path targets protocol 777; older Minecraft protocol
versions are not supported. versions are not supported.
The original bot included wood/sand/crop farming, villager trading, eating, The original bot included wood/sand/crop farming, villager trading, eating,
sleeping, and threat avoidance. On the current 26.3 runtime, path following and sleeping, and threat avoidance. On the current 26.3 runtime, path following,
crop harvest/replant are migrated; other legacy jobs remain unavailable until crop harvest/replant, and loiter/threat avoidance are migrated; other legacy
ported to protocol 777. jobs remain unavailable until ported to protocol 777.
## Requirements and setup ## Requirements and setup
@@ -52,9 +52,9 @@ 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), food use (`1eat`, also run as needed by 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 crop farming), and the crop harvest/replant loop (`1farm crops`), and death-screen respawning (`1respawn`). Other
inventory/window actions and farming/trading jobs remain disabled until their inventory/window actions and resource-farming/trading jobs remain disabled
26.3 packets are migrated. until their 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
intentionally want another server. intentionally want another server.
@@ -79,7 +79,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`, `1eat`, `!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 `1respawn`, `1sleep`, `1loiter`, 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
+2 -2
View File
@@ -66,9 +66,9 @@ class Commands:
command = 'nosquarebrackets' command = 'nosquarebrackets'
unsupported = { unsupported = {
'gather', 'loiter', 'trade', 'gapple', 'fill', 'gather', 'trade', 'gapple', 'fill',
'drop', 'dump', 'break', 'open', 'click', 'use', 'drop', 'dump', 'break', 'open', 'click', 'use',
'interact', 'respawn', 'interact',
} }
if (command in unsupported or if (command in unsupported or
(command == 'farm' and not data.lower().startswith('crop'))): (command == 'farm' and not data.lower().startswith('crop'))):
+11 -3
View File
@@ -24,7 +24,7 @@ from mosfet.protocol.packets import (
from mosfet.protocol.types import Slot from mosfet.protocol.types import Slot
from mosfet.protocol.modern import ( from mosfet.protocol.modern import (
PlayerCommandPacket, UpdateTimePacket, UseItemPacket, ClientCommandPacket, PlayerCommandPacket, UpdateTimePacket, UseItemPacket,
) )
from mosfet import utils from mosfet import utils
@@ -711,6 +711,12 @@ class Game(LegacyGame):
self.break_deadline = time.monotonic() + max(0.05, hardness_time) self.break_deadline = time.monotonic() + max(0.05, hardness_time)
return True return True
def respawn(self):
self.g.respawn_requested_at = time.monotonic()
self.g.connection.write_packet(
ClientCommandPacket(action_id=0), force=True)
self.g.connection.write_packet(ClientTickEnd(), force=True)
def leave_bed(self): def leave_bed(self):
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))
@@ -854,11 +860,13 @@ class Game(LegacyGame):
self.g.connection.write_packet(ClientTickEnd(), force=True) self.g.connection.write_packet(ClientTickEnd(), force=True)
return True return True
def place_block(self, location, face, hand=0): def place_block(self, location, face, hand=0, cursor_y=None):
location = tuple(int(value) for value in location) location = tuple(int(value) for value in location)
if cursor_y is None:
cursor_y = 0.99 if int(face) == 1 else 0.5
packet = UseItemOnBlockPacket( packet = UseItemOnBlockPacket(
hand=hand, location=location, face=int(face), hand=hand, location=location, face=int(face),
cursor_x=0.5, cursor_y=0.99 if int(face) == 1 else 0.5, cursor_x=0.5, cursor_y=cursor_y,
cursor_z=0.5, inside_block=False, world_border_hit=False, cursor_z=0.5, inside_block=False, world_border_hit=False,
sequence=self._next_sequence()) sequence=self._next_sequence())
self._send_action(packet) self._send_action(packet)
+11 -1
View File
@@ -265,7 +265,7 @@ class LegacyJobStates:
class JobStates(LegacyJobStates): class JobStates(LegacyJobStates):
"""26.3 job facade; crop gathering is the currently migrated job.""" """26.3 facade for crop, inventory, sleeping, and loiter workflows."""
def __init__(self, global_state): def __init__(self, global_state):
self.g = global_state self.g = global_state
self.gather_crop_states = gather_crop.GatherCropStates(global_state) self.gather_crop_states = gather_crop.GatherCropStates(global_state)
@@ -273,6 +273,8 @@ class JobStates(LegacyJobStates):
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.eat_food_states = eat_food.EatFoodStates(global_state)
self.check_threats_states = check_threats.CheckThreatsStates(
global_state)
self.state = self.idle self.state = self.idle
def farm_crop(self): def farm_crop(self):
@@ -295,6 +297,11 @@ class JobStates(LegacyJobStates):
self.sleep_with_bed_states.silent = False self.sleep_with_bed_states.silent = False
return [self.sleep_with_bed_states] return [self.sleep_with_bed_states]
def loiter(self):
self.sleep_with_bed_states.silent = True
return [self.check_threats_states, self.sleep_with_bed_states,
self.eat_food_states]
def stop(self): def stop(self):
if self.g.get('breaking'): if self.g.get('breaking'):
self.g.game.cancel_break() self.g.game.cancel_break()
@@ -309,5 +316,8 @@ class JobStates(LegacyJobStates):
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.eat_food_states.state = self.eat_food_states.idle
self.check_threats_states.state = self.check_threats_states.idle
self.check_threats_states.safety = None
self.check_threats_states.external_safety = False
self.gather_crop_states.active_section_anchor = None self.gather_crop_states.active_section_anchor = None
self.state = self.idle self.state = self.idle
+46 -28
View File
@@ -17,6 +17,10 @@ from mosfet.info import mcdata
from mosfet.info import mobs from mosfet.info import mobs
class CheckThreatsStates: class CheckThreatsStates:
THREAT_DISTANCE = 20
CLEAR_DISTANCE = 30
CHECK_INTERVAL = 2.0
def idle(self): def idle(self):
return None return None
@@ -28,51 +32,63 @@ class CheckThreatsStates:
w = self.g.world w = self.g.world
p = utils.pint(self.g.pos) p = utils.pint(self.g.pos)
threats = w.find_threats(p, 40) threats = w.find_threats(p, self.THREAT_DISTANCE)
if threats: if threats:
print('Found', len(threats), 'threats, fleeing:') print('Found', len(threats), 'threats, seeking safety:')
print(threats) print(threats)
self.threats = threats
self.state = self.find_safety self.state = self.find_safety
else: else:
print('Aborting, no threats') self.wait_time = self.CHECK_INTERVAL
self.state = self.cleanup self.state = self.wait
def find_safety(self): def find_safety(self):
w = self.g.world w = self.g.world
p = utils.pint(self.g.pos) p = utils.pint(self.g.pos)
safety = w.find_blocks_indexed(p, [blocks.EMERALD_BLOCK]) safety_blocks = w.find_blocks_indexed(
p, [blocks.EMERALD_BLOCK], self.THREAT_DISTANCE * 4)
if not safety: for block in safety_blocks:
print('No emerald blocks found, aborting') spot = utils.padd(block, path.BLOCK_ABOVE)
self.state = self.cleanup if w.find_threats(spot, self.CLEAR_DISTANCE):
return continue
navpath = w.path_to_place(p, spot)
safety.sort(key=lambda s: utils.phyp(p, s)) if navpath is not None:
print('Found emerald blocks:', safety) self.safety = spot
self.external_safety = True
for s in safety:
s = utils.padd(s, path.BLOCK_ABOVE)
navpath = w.path_to_place(p, s)
if navpath:
self.g.path = navpath self.g.path = navpath
self.state = self.going_to_safety self.state = self.going_to_safety
self.safety = s print('Going to emerald safety point', self.safety)
print('Going to safety', self.safety)
return return
else:
print('Cant get to safety', self.safety)
time.sleep(0.1)
print('Cant get to safety, aborting') # No configured emerald safe point was reachable. Retreat to a clear,
self.state = self.cleanup # supported location that the modern navigator can actually reach.
for spot in w.find_bed_areas(p, self.CLEAR_DISTANCE * 2):
if w.find_threats(spot, self.CLEAR_DISTANCE):
continue
navpath = w.path_to_place(p, spot)
if navpath is not None:
self.safety = spot
self.external_safety = False
self.g.path = navpath
self.state = self.going_to_safety
print('Fleeing to clear area', self.safety)
return
print('No reachable safe area found')
self.wait_time = self.CHECK_INTERVAL
self.state = self.wait
def going_to_safety(self): def going_to_safety(self):
if utils.pint(self.g.pos) == self.safety: if utils.pint(self.g.pos) == self.safety:
print('At safety spot, waiting to be moved') if self.external_safety:
self.state = self.wait_for_move print('At emerald safety point, waiting for server move')
self.state = self.wait_for_move
else:
print('At clear retreat spot')
self.wait_time = 3
self.state = self.wait
def wait_for_move(self): def wait_for_move(self):
# wait for the server to move the bot when it's safe # wait for the server to move the bot when it's safe
@@ -103,6 +119,8 @@ class CheckThreatsStates:
self.safety = None self.safety = None
self.wait_time = 0 self.wait_time = 0
self.threats = []
self.external_safety = False
def run(self): def run(self):
self.state() self.state()
+106 -15
View File
@@ -24,7 +24,11 @@ class SleepWithBedStates:
self.my_bed = False self.my_bed = False
self.area = None self.area = None
self.opening = None self.opening = None
self.bed_head = None
self.beds = [] self.beds = []
self.sleep_use_attempts = 0
self.waiting_for_night = False
self.waiting_for_time_update = False
current_time = self.g.get('time') current_time = self.g.get('time')
if current_time is None or current_time < 12000: if current_time is None or current_time < 12000:
@@ -39,16 +43,55 @@ class SleepWithBedStates:
self.state = self.find_beds self.state = self.find_beds
@staticmethod
def _bed_facing(state_id):
props = blocks.PROPS.get(state_id, {})
return {
'north': (0, 0, -1), 'south': (0, 0, 1),
'east': (1, 0, 0), 'west': (-1, 0, 0),
}.get(props.get('facing'))
def _bed_foot(self, position):
w = self.g.world
state_id = w.block_at(*position)
props = blocks.PROPS.get(state_id, {})
if props.get('part') != 'head':
return position
facing = self._bed_facing(state_id)
if facing is None:
return position
foot = utils.psub(position, facing)
foot_state = w.block_at(*foot)
if (foot_state in blocks.BED_IDS and
blocks.PROPS.get(foot_state, {}).get('part') == 'foot'):
return foot
return position
def _bed_head(self, foot):
state_id = self.g.world.block_at(*foot)
facing = self._bed_facing(state_id)
return utils.padd(foot, facing) if facing is not None else foot
def find_beds(self): def find_beds(self):
print('Finding beds...') print('Finding beds...')
w = self.g.world w = self.g.world
p = utils.pint(self.g.pos) p = utils.pint(self.g.pos)
result = ([] if self.force_new_bed else
w.find_blocks_indexed(p, blocks.BED_IDS, 80))
if self.force_new_bed: if self.force_new_bed:
result = []
print('Skipping existing beds; placing a new bed as requested') print('Skipping existing beds; placing a new bed as requested')
else:
indexed = w.find_blocks_indexed(p, blocks.BED_IDS, 80)
# Keep a tiny direct scan as a guard against stale/missing chunk
# index entries, especially around respawn and chunk-cache changes.
nearby = list(w.find_blocks_3d(
p, blocks.BED_IDS, distance=8, y_limit=8))
result = list(dict.fromkeys(nearby + indexed))
result.sort(key=lambda bed: utils.phyp(p, bed))
if nearby and not indexed:
print('Found beds by local scan; chunk index had none')
result = list(dict.fromkeys(self._bed_foot(bed) for bed in result))
self.beds = [] self.beds = []
for bed in result: for bed in result:
if bed in self.bad_beds: if bed in self.bad_beds:
@@ -78,8 +121,11 @@ class SleepWithBedStates:
continue continue
self.area = bed self.area = bed
self.bed_head = self._bed_head(bed)
self.opening = navpath[-2] if len(navpath) > 1 else p self.opening = navpath[-2] if len(navpath) > 1 else p
self.g.path = navpath[:-1] self.g.path = navpath[:-1]
self.opening_deadline = time.monotonic() + max(
8.0, len(navpath) * 0.75)
self.g.look_at = self.area self.g.look_at = self.area
self.state = self.going_to_bed self.state = self.going_to_bed
return return
@@ -87,13 +133,23 @@ class SleepWithBedStates:
print('No reachable beds; trying to place one') print('No reachable beds; trying to place one')
self.state = self.select_bed self.state = self.select_bed
def _at_opening(self):
position = utils.pint(self.g.pos)
return utils.phyp(position, self.opening) <= 1.25
def going_to_bed(self): def going_to_bed(self):
if utils.pint(self.g.pos) == self.opening: if self._at_opening():
print('At existing bed') print('At existing bed')
self.g.path = []
self.my_bed = False self.my_bed = False
self.g.look_at = self.area self.g.look_at = self.area
self.wait_time = 0.5 self.wait_time = 0.5
self.state = self.turn_to_bed self.state = self.turn_to_bed
elif time.monotonic() >= self.opening_deadline:
print('Could not reach bed opening', self.opening,
'; trying another bed')
self.bad_beds.append(self.area)
self.state = self.choose_bed
def turn_to_bed(self): def turn_to_bed(self):
self.g.look_at = self.area self.g.look_at = self.area
@@ -151,15 +207,22 @@ class SleepWithBedStates:
return return
self.g.path = navpath self.g.path = navpath
self.opening_deadline = time.monotonic() + max(
8.0, len(navpath) * 0.75)
self.g.look_at = self.area self.g.look_at = self.area
self.state = self.going_to_area self.state = self.going_to_area
def going_to_area(self): def going_to_area(self):
if utils.pint(self.g.pos) == self.opening: if self._at_opening():
print('At bed area') print('At bed area')
self.g.path = []
self.g.look_at = self.area self.g.look_at = self.area
self.wait_time = 0.5 self.wait_time = 0.5
self.state = self.turn_to_bed_area self.state = self.turn_to_bed_area
elif time.monotonic() >= self.opening_deadline:
print('Could not reach bed placement opening', self.opening)
self.bad_areas.append(self.area)
self.state = self.cleanup
def turn_to_bed_area(self): def turn_to_bed_area(self):
self.g.look_at = self.area self.g.look_at = self.area
@@ -174,28 +237,38 @@ class SleepWithBedStates:
support, BlockFace.TOP, support, BlockFace.TOP,
hand=self.g.get('holding_hand', 0)) hand=self.g.get('holding_hand', 0))
self.my_bed = True self.my_bed = True
self.wait_time = 0.5 self.bed_place_deadline = time.monotonic() + 5.0
self.state = self.wait_use self.state = self.wait_use
def wait_use(self): def wait_use(self):
if self.wait_time > 0: # Block updates can arrive after the use packet; poll authoritative
self.wait_time -= utils.TICK # chunk state for several seconds instead of giving up after 0.5s.
return if self.g.world.block_at(*self.area) in blocks.BED_IDS:
self.bed_head = self._bed_head(self.area)
if self.g.world.block_at(*self.area) not in blocks.BED_IDS: self.state = self.use_bed
elif time.monotonic() >= self.bed_place_deadline:
print('Bed placement was not confirmed at', self.area) print('Bed placement was not confirmed at', self.area)
self.bad_areas.append(self.area) self.bad_areas.append(self.area)
self.my_bed = False self.my_bed = False
self.state = self.cleanup self.state = self.cleanup
return
self.state = self.use_bed
def use_bed(self): def use_bed(self):
w = self.g.world w = self.g.world
current_time = self.g.get('time') current_time = self.g.get('time')
if current_time is None or current_time < 12550: if current_time is None:
if not getattr(self, 'waiting_for_time_update', False):
print('Waiting for world time update before using bed')
self.waiting_for_time_update = True
return return
if current_time < 12550:
if not getattr(self, 'waiting_for_night', False):
print('Bed reached; waiting until sleep time (time={})'.format(
current_time))
self.waiting_for_night = True
return
self.waiting_for_night = False
self.waiting_for_time_update = False
if w.block_at(*self.area) not in blocks.BED_IDS: if w.block_at(*self.area) not in blocks.BED_IDS:
print('Bed disappeared before use:', self.area) print('Bed disappeared before use:', self.area)
@@ -208,8 +281,13 @@ class SleepWithBedStates:
return return
print('Using bed') print('Using bed')
self.g.game.place_block(self.area, BlockFace.TOP) # Beds are only 9/16 block tall. Aim inside their collision shape;
# the near-top cursor used for placing a new block misses them.
self.g.game.place_block(
self.area, BlockFace.TOP, cursor_y=0.5)
self.sleep_deadline = time.monotonic() + 5.0 self.sleep_deadline = time.monotonic() + 5.0
self.sleep_retry_at = time.monotonic() + 1.5
self.sleep_use_attempts = 1
self.sleep_confirmed = False self.sleep_confirmed = False
if not self.silent: if not self.silent:
self.g.chat.send('zzz') self.g.chat.send('zzz')
@@ -236,9 +314,19 @@ class SleepWithBedStates:
elif self.g.get('correction_count', 0) and self.sleep_confirmed: elif self.g.get('correction_count', 0) and self.sleep_confirmed:
print('Woke up by movement') print('Woke up by movement')
self.state = self.break_bed self.state = self.break_bed
elif (not self.sleep_confirmed and
self.sleep_use_attempts < 2 and
time.monotonic() >= self.sleep_retry_at):
print('Bed use not confirmed on foot; trying head block',
self.bed_head)
self.g.game.place_block(
self.bed_head, BlockFace.TOP, cursor_y=0.5)
self.sleep_use_attempts += 1
self.sleep_retry_at = time.monotonic() + 1.5
elif (not self.sleep_confirmed and elif (not self.sleep_confirmed and
time.monotonic() >= self.sleep_deadline): time.monotonic() >= self.sleep_deadline):
print('Bed use was not confirmed') print('Bed use was not confirmed; blacklisting', self.area)
self.bad_beds.append(self.area)
self.state = self.cleanup self.state = self.cleanup
def break_bed(self): def break_bed(self):
@@ -299,8 +387,11 @@ class SleepWithBedStates:
self.bad_beds = [] self.bad_beds = []
self.area = None self.area = None
self.opening = None self.opening = None
self.bed_head = None
self.bad_areas = [] self.bad_areas = []
self.wait_time = 0 self.wait_time = 0
self.sleep_use_attempts = 0
self.sleep_retry_at = 0
self.force_new_bed = False self.force_new_bed = False
self.sleep_confirmed = False self.sleep_confirmed = False
self.sleep_deadline = 0 self.sleep_deadline = 0
+7
View File
@@ -48,6 +48,13 @@ class UseItemPacket(Packet):
] ]
class ClientCommandPacket(Packet):
"""Client command; action 0 asks the server to respawn this player."""
id = 0x0C
packet_name = 'client command'
definition = [{'action_id': VarInt}]
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 = (
+8 -3
View File
@@ -13,6 +13,7 @@ from mosfet.info import mobs
class World: class World:
def __init__(self, global_state): def __init__(self, global_state):
self.g = global_state self.g = global_state
self._bed_occupancy_states = {}
def block_at(self, x, y, z): def block_at(self, x, y, z):
return self.g.chunks.get_block_at(x, y, z) return self.g.chunks.get_block_at(x, y, z)
@@ -316,11 +317,15 @@ class World:
return result return result
def check_bed_occupied(self, bed): def check_bed_occupied(self, bed):
"""Return whether a loaded bed block currently has occupied=true.""" """Return occupancy, logging only the first observation or a change."""
bed = tuple(bed)
bid = self.g.chunks.get_block_at(*bed) bid = self.g.chunks.get_block_at(*bed)
occupied = blocks.PROPS.get(bid, {}).get('occupied') == 'true' occupied = blocks.PROPS.get(bid, {}).get('occupied') == 'true'
print('Checking bed occupancy:', bed, '->', previous = self._bed_occupancy_states.get(bed)
'occupied' if occupied else 'free') if previous is None or previous != occupied:
print('Checking bed occupancy:', bed, '->',
'occupied' if occupied else 'free')
self._bed_occupancy_states[bed] = occupied
return occupied return occupied
def find_cache_openings(self, area): def find_cache_openings(self, area):
+67
View File
@@ -0,0 +1,67 @@
import unittest
from munch import Munch
from minecraft.networking.packets import PacketBuffer
from minecraft.networking.modern import ClientTickEnd
from mosfet.commands import Commands
from mosfet.game import Game
from mosfet.protocol.modern import ClientCommandPacket
class FakeConnection:
def __init__(self):
self.packets = []
def write_packet(self, packet, force=False):
self.packets.append((packet, force))
class FakeChat:
def __init__(self):
self.handler = None
self.messages = []
def set_handler(self, handler):
self.handler = handler
def send(self, message):
self.messages.append(message)
class TestRespawn(unittest.TestCase):
def test_client_command_encodes_perform_respawn(self):
packet = ClientCommandPacket(action_id=0)
buffer = PacketBuffer()
packet.write_fields(buffer)
self.assertEqual(packet.id, 0x0C)
self.assertEqual(buffer.get_writable(), b'\x00')
def test_game_respawn_sends_modern_client_command_and_tick_end(self):
connection = FakeConnection()
game = Game.__new__(Game)
game.g = Munch(connection=connection)
game.respawn()
packet, forced = connection.packets[0]
self.assertIsInstance(packet, ClientCommandPacket)
self.assertEqual(packet.action_id, 0)
self.assertTrue(forced)
self.assertIsInstance(connection.packets[1][0], ClientTickEnd)
self.assertIn('respawn_requested_at', game.g)
def test_respawn_command_is_enabled(self):
chat = FakeChat()
calls = []
game = Munch(respawn=lambda: calls.append('respawn'))
commands = Commands(Munch(name='mosfet1', chat=chat, game=game))
commands.handle_chat(('SYSTEM', None, 'server: 1respawn'))
self.assertEqual(calls, ['respawn'])
self.assertEqual(chat.messages, ['ok'])
if __name__ == '__main__':
unittest.main()
+221 -6
View File
@@ -1,5 +1,6 @@
import io import io
import unittest import unittest
from contextlib import redirect_stdout
from munch import Munch from munch import Munch
from minecraft.networking.packets import PacketBuffer from minecraft.networking.packets import PacketBuffer
@@ -9,8 +10,9 @@ from mosfet.game import Game
from mosfet.info import blocks, items from mosfet.info import blocks, items
from mosfet.protocol.types import Slot from mosfet.protocol.types import Slot
from mosfet.jobs.sleep_with_bed import SleepWithBedStates from mosfet.jobs.sleep_with_bed import SleepWithBedStates
from mosfet.world import World
from mosfet.protocol.modern import PlayerCommandPacket, UpdateTimePacket from mosfet.protocol.modern import PlayerCommandPacket, UpdateTimePacket
from minecraft.networking.modern import ClientTickEnd from minecraft.networking.modern import ClientTickEnd, UseItemOnBlockPacket
class FakeConnection: class FakeConnection:
@@ -21,6 +23,14 @@ class FakeConnection:
self.packets.append((packet, force)) self.packets.append((packet, force))
class FakeChunks:
def __init__(self, block):
self.block = block
def get_block_at(self, *position):
return self.block
class FakeWorld: class FakeWorld:
def __init__(self, block=blocks.AIR): def __init__(self, block=blocks.AIR):
self.block = block self.block = block
@@ -28,16 +38,46 @@ class FakeWorld:
def block_at(self, *position): def block_at(self, *position):
return self.block return self.block
def check_bed_occupied(self, position):
return False
class FakeGame: class FakeGame:
def __init__(self): def __init__(self):
self.placements = [] self.placements = []
def place_block(self, location, face, hand=0): def place_block(self, location, face, hand=0, cursor_y=None):
self.placements.append((location, face, hand)) self.placements.append((location, face, hand, cursor_y))
class TestSleepWithBed(unittest.TestCase): class TestSleepWithBed(unittest.TestCase):
def test_bed_occupancy_only_logs_first_observation_and_changes(self):
bed_states = [
state for state in blocks.BED_IDS
if blocks.BLOCKS[state] == 'white_bed' and
blocks.PROPS[state].get('part') == 'foot' and
blocks.PROPS[state].get('facing') == 'north'
]
free = next(state for state in bed_states
if blocks.PROPS[state].get('occupied') == 'false')
occupied = next(state for state in bed_states
if blocks.PROPS[state].get('occupied') == 'true')
chunks = FakeChunks(free)
world = World(Munch(chunks=chunks))
output = io.StringIO()
with redirect_stdout(output):
self.assertFalse(world.check_bed_occupied((1, 64, 2)))
self.assertFalse(world.check_bed_occupied((1, 64, 2)))
chunks.block = occupied
self.assertTrue(world.check_bed_occupied((1, 64, 2)))
self.assertTrue(world.check_bed_occupied((1, 64, 2)))
lines = output.getvalue().splitlines()
self.assertEqual(len(lines), 2)
self.assertIn('-> free', lines[0])
self.assertIn('-> occupied', lines[1])
def test_update_time_packet_decodes_clock_updates(self): def test_update_time_packet_decodes_clock_updates(self):
payload = PacketBuffer() payload = PacketBuffer()
Long.send(123456, payload) Long.send(123456, payload)
@@ -78,6 +118,20 @@ class TestSleepWithBed(unittest.TestCase):
self.assertTrue(force) self.assertTrue(force)
self.assertIsInstance(connection.packets[1][0], ClientTickEnd) self.assertIsInstance(connection.packets[1][0], ClientTickEnd)
def test_existing_bed_use_aims_inside_the_short_bed_collision_shape(self):
connection = FakeConnection()
game = Game.__new__(Game)
game.g = Munch(connection=connection)
game.sequence = 0
game.place_block((-175, 70, -312), 1, cursor_y=0.5)
packet = connection.packets[0][0]
self.assertIsInstance(packet, UseItemOnBlockPacket)
self.assertEqual(packet.location, (-175, 70, -312))
self.assertEqual(packet.cursor_y, 0.5)
self.assertIsInstance(connection.packets[1][0], ClientTickEnd)
def test_bed_in_offhand_is_selectable_without_swapping_inventory(self): def test_bed_in_offhand_is_selectable_without_swapping_inventory(self):
connection = FakeConnection() connection = FakeConnection()
game = Game.__new__(Game) game = Game.__new__(Game)
@@ -91,6 +145,147 @@ class TestSleepWithBed(unittest.TestCase):
self.assertEqual(game.g.holding_hand, 1) self.assertEqual(game.g.holding_hand, 1)
self.assertIsInstance(connection.packets[0][0], ClientTickEnd) self.assertIsInstance(connection.packets[0][0], ClientTickEnd)
def test_bed_head_position_is_normalized_to_foot(self):
bed_name = 'white_bed'
foot_state = next(
state for state in blocks.BED_IDS
if blocks.BLOCKS[state] == bed_name and
blocks.PROPS[state].get('part') == 'foot' and
blocks.PROPS[state].get('facing') == 'west')
head_state = next(
state for state in blocks.BED_IDS
if blocks.BLOCKS[state] == bed_name and
blocks.PROPS[state].get('part') == 'head' and
blocks.PROPS[state].get('facing') == 'west')
foot, head = (-175, 70, -312), (-176, 70, -312)
class BedWorld:
def block_at(self, *position):
return {foot: foot_state, head: head_state}.get(tuple(position), blocks.AIR)
state = SleepWithBedStates(Munch(world=BedWorld()))
self.assertEqual(state._bed_foot(head), foot)
self.assertEqual(state._bed_foot(foot), foot)
def test_bed_search_falls_back_to_loaded_blocks_if_index_is_empty(self):
bed = (1, 64, 0)
bed_state = next(
state for state in blocks.BED_IDS
if blocks.PROPS[state].get('part') == 'foot')
class IndexedWorld:
def find_blocks_indexed(self, center, block_ids, distance=0):
return []
def find_blocks_3d(self, center, block_ids, distance, y_limit):
self.assert_search = (center, block_ids, distance, y_limit)
return iter([bed])
def block_at(self, *position):
return bed_state if tuple(position) == bed else blocks.AIR
def check_bed_occupied(self, position):
return False
world = IndexedWorld()
state = SleepWithBedStates(Munch(
world=world, pos=(0, 64, 0), time=13000,
dimension='overworld'))
state.find_beds()
self.assertEqual(state.beds, [bed])
self.assertEqual(state.state, state.choose_bed)
def test_force_new_bed_skips_existing_bed_scan(self):
class IndexedWorld:
def find_blocks_indexed(self, *args, **kwargs):
raise AssertionError('forced placement must skip bed search')
def find_blocks_3d(self, *args, **kwargs):
raise AssertionError('forced placement must skip local scan')
state = SleepWithBedStates(Munch(
world=IndexedWorld(), pos=(0, 64, 0), time=13000,
dimension='overworld'))
state.force_new_bed = True
state.find_beds()
self.assertEqual(state.beds, [])
self.assertEqual(state.state, state.choose_bed)
def test_arrival_accepts_completed_path_with_small_position_error(self):
state = SleepWithBedStates(Munch(
pos=(0, 64, 0), path=[(1, 64, 0)], look_at=None,
game=FakeGame(), world=FakeWorld()))
state.opening = (1, 64, 0)
state.opening_deadline = 9999999999
state.going_to_bed()
self.assertEqual(state.state, state.turn_to_bed)
self.assertEqual(state.g.path, [])
def test_unconfirmed_sleep_retries_using_head_block(self):
foot = (0, 64, 0)
head = (0, 64, -1)
bed_id = next(
state for state in blocks.BED_IDS
if blocks.PROPS[state].get('part') == 'foot')
class ThreatWorld(FakeWorld):
def find_threats(self, center, distance):
return []
game = FakeGame()
state = SleepWithBedStates(Munch(
time=13000, pos=(1, 64, 0), world=ThreatWorld(block=bed_id),
game=game, path=[], look_at=None, correction_count=0))
state.area = foot
state.bed_head = head
state.sleep_use_attempts = 1
state.sleep_retry_at = 0
state.sleep_deadline = 9999999999
state.sleep_bed()
self.assertEqual(game.placements, [(head, 1, 0, 0.5)])
self.assertEqual(state.sleep_use_attempts, 2)
def test_failed_existing_bed_use_is_blacklisted(self):
bed_id = next(iter(blocks.BED_IDS))
class ThreatWorld(FakeWorld):
def find_threats(self, center, distance):
return []
state = SleepWithBedStates(Munch(
time=13000, pos=(1, 64, 0), world=ThreatWorld(block=bed_id),
game=FakeGame(), path=[], look_at=None, correction_count=0))
state.area = (0, 64, 0)
state.sleep_use_attempts = 2
state.sleep_retry_at = 9999999999
state.sleep_deadline = 0
state.sleep_bed()
self.assertIn(state.area, state.bad_beds)
self.assertEqual(state.state, state.cleanup)
def test_bed_waits_with_status_until_sleep_time(self):
bed_id = next(iter(blocks.BED_IDS))
state = SleepWithBedStates(Munch(
time=12400, world=FakeWorld(block=bed_id), game=FakeGame(),
path=[], look_at=None))
state.area = (0, 64, 0)
state.state = state.use_bed
state.use_bed()
self.assertEqual(state.state, state.use_bed)
self.assertTrue(state.waiting_for_night)
def test_daytime_sleep_aborts_cleanly(self): def test_daytime_sleep_aborts_cleanly(self):
state = SleepWithBedStates(Munch(time=1000, dimension='overworld')) state = SleepWithBedStates(Munch(time=1000, dimension='overworld'))
state.init() state.init()
@@ -106,20 +301,40 @@ class TestSleepWithBed(unittest.TestCase):
state.place_bed() state.place_bed()
self.assertEqual(len(game.placements), 1) self.assertEqual(len(game.placements), 1)
location, face, hand = game.placements[0] location, face, hand, cursor_y = game.placements[0]
self.assertEqual(location, (4, 69, 5)) self.assertEqual(location, (4, 69, 5))
self.assertEqual(int(face), 1) self.assertEqual(int(face), 1)
self.assertEqual(hand, 1) self.assertEqual(hand, 1)
self.assertIsNone(cursor_y)
self.assertTrue(state.my_bed) self.assertTrue(state.my_bed)
self.assertEqual(state.state, state.wait_use) self.assertEqual(state.state, state.wait_use)
def test_wait_use_aborts_if_server_rejects_bed_placement(self): def test_wait_use_waits_for_delayed_chunk_update(self):
bed_id = next(iter(blocks.BED_IDS))
world = FakeWorld(block=blocks.AIR)
state = SleepWithBedStates(Munch(
game=FakeGame(), world=world, path=[], look_at=None))
state.area = (4, 70, 5)
state.my_bed = True
state.bed_place_deadline = 9999999999
state.state = state.wait_use
state.wait_use()
self.assertEqual(state.state, state.wait_use)
self.assertTrue(state.my_bed)
world.block = bed_id
state.wait_use()
self.assertEqual(state.state, state.use_bed)
self.assertIsNotNone(state.bed_head)
def test_wait_use_aborts_after_update_timeout(self):
state = SleepWithBedStates(Munch( state = SleepWithBedStates(Munch(
game=FakeGame(), world=FakeWorld(block=blocks.AIR), game=FakeGame(), world=FakeWorld(block=blocks.AIR),
path=[], look_at=None)) path=[], look_at=None))
state.area = (4, 70, 5) state.area = (4, 70, 5)
state.my_bed = True state.my_bed = True
state.wait_time = 0 state.bed_place_deadline = 0
state.wait_use() state.wait_use()
+111
View File
@@ -0,0 +1,111 @@
import unittest
from munch import Munch
from mosfet.commands import Commands
from mosfet.info import blocks, mobs
from mosfet.job import JobStates
from mosfet.jobs.check_threats import CheckThreatsStates
from mosfet.world import World
class FakeChunks:
def __init__(self, solid=()):
self.solid = set(solid)
def get_block_at(self, x, y, z):
return blocks.STONE if (x, y, z) in self.solid else blocks.AIR
class FakeChat:
def __init__(self):
self.handler = None
self.messages = []
def set_handler(self, handler):
self.handler = handler
def send(self, message):
self.messages.append(message)
class TestThreatDetection(unittest.TestCase):
def test_find_threats_filters_passive_and_distant_entities(self):
zombie = mobs.MOBS['minecraft:zombie']['protocol_id']
villager = mobs.MOBS['minecraft:villager']['protocol_id']
world = World(Munch(mobs={
1: Munch(entity_id=1, type=zombie, x=3.2, y=64, z=0),
2: Munch(entity_id=2, type=villager, x=2, y=64, z=0),
3: Munch(entity_id=3, type=zombie, x=30, y=64, z=0),
}))
world.check_air_column = lambda pos, distance: True
result = world.find_threats((0, 64, 0), 12)
self.assertEqual([threat.entity_id for threat in result], [1])
def test_air_column_excludes_mobs_under_a_solid_roof(self):
world = World(Munch(chunks=FakeChunks(solid={(0, 67, 0)})))
self.assertFalse(world.check_air_column((0, 64, 0), 5))
self.assertTrue(world.check_air_column((2, 64, 0), 5))
def test_threat_state_falls_back_to_reachable_clear_area(self):
spot = (8, 64, 0)
class SafeWorld:
def find_blocks_indexed(self, center, block_ids, distance=0):
return []
def find_bed_areas(self, center, distance):
return [spot]
def find_threats(self, center, distance):
return []
def path_to_place(self, start, target):
return [start, target]
g = Munch(world=SafeWorld(), pos=(0, 64, 0), path=[], look_at=None)
state = CheckThreatsStates(g)
state.state = state.find_safety
state.threats = [Munch(entity_id=7)]
state.find_safety()
self.assertEqual(state.state, state.going_to_safety)
self.assertEqual(state.safety, spot)
self.assertFalse(state.external_safety)
self.assertEqual(g.path[-1], spot)
g.pos = spot
state.going_to_safety()
self.assertEqual(state.state, state.wait)
self.assertEqual(state.wait_time, 3)
def test_loiter_is_enabled_as_a_job(self):
check = object()
sleep = Munch(silent=False)
eat = object()
job = JobStates.__new__(JobStates)
job.check_threats_states = check
job.sleep_with_bed_states = sleep
job.eat_food_states = eat
self.assertEqual(JobStates.loiter(job), [check, sleep, eat])
self.assertTrue(sleep.silent)
def test_loiter_command_is_no_longer_rejected(self):
def loiter():
return []
chat = FakeChat()
job = Munch(loiter=loiter, state=None)
commands = Commands(Munch(name='mosfet1', chat=chat, job=job))
commands.handle_chat(('CHAT', 'player-uuid', 'tanner: 1loiter'))
self.assertIs(job.state, loiter)
self.assertEqual(chat.messages, ['ok'])
if __name__ == '__main__':
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
import io
import unittest
from minecraft.networking.modern import (
PaletteSection, SectionBlocksUpdatePacket,
)
from minecraft.networking.packets import PacketBuffer
from minecraft.networking.types import Long, VarInt, VarLong
from mosfet.info import blocks
from mosfet.protocol.managers import ChunkColumn, WorldState
def encode_section_update(section_x, section_y, section_z, changes):
"""Encode Prismarine's section-position and packed x:z:y records."""
buffer = PacketBuffer()
packed_section = (
((section_x & 0x3FFFFF) << 42) |
((section_z & 0x3FFFFF) << 20) |
(section_y & 0xFFFFF))
if packed_section >= (1 << 63):
packed_section -= 1 << 64
Long.send(packed_section, buffer)
VarInt.send(len(changes), buffer)
for x, y, z, block_id in changes:
local = ((x & 0xF) << 8) | ((z & 0xF) << 4) | (y & 0xF)
VarLong.send((block_id << 12) | local, buffer)
packet = SectionBlocksUpdatePacket()
packet.read(io.BytesIO(buffer.get_writable()))
return packet
class TestSectionBlockUpdates(unittest.TestCase):
def test_negative_section_coordinates_and_local_yz_decode(self):
bed_id = next(iter(blocks.BED_IDS))
packet = encode_section_update(
-11, 4, -20, [(3, 6, 9, bed_id)])
self.assertEqual(packet.id, 0x56)
self.assertEqual(packet.changes, [(-173, 70, -311, bed_id)])
def test_section_update_replaces_old_block_and_index_entry(self):
section_x, section_y, section_z = -11, 4, -20
x, y, z = -173, 70, -311
bed_id = next(iter(blocks.BED_IDS))
world = WorldState(min_y=section_y * 16)
world.chunks[(section_x, section_z)] = ChunkColumn(
section_x, section_z,
[{'blocks': PaletteSection(0, [blocks.AIR], [], 4096)}],
section_y)
placed = encode_section_update(
section_x, section_y, section_z, [(x, y, z, bed_id)])
world.handle_section_update(placed)
self.assertEqual(world.get_block_at(x, y, z), bed_id)
self.assertIn((x, y, z), world.index[bed_id])
broken = encode_section_update(
section_x, section_y, section_z, [(x, y, z, blocks.AIR)])
world.handle_section_update(broken)
self.assertEqual(world.get_block_at(x, y, z), blocks.AIR)
self.assertNotIn((x, y, z), world.index.get(bed_id, set()))
if __name__ == '__main__':
unittest.main()