diff --git a/README.md b/README.md index 269b964..d1f244f 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ play. The connection path targets protocol 777; older Minecraft protocol versions are not supported. The original bot included wood/sand/crop farming, villager trading, eating, -sleeping, and threat avoidance. On the current 26.3 runtime, path following and -crop harvest/replant are migrated; other legacy jobs remain unavailable until -ported to protocol 777. +sleeping, and threat avoidance. On the current 26.3 runtime, path following, +crop harvest/replant, and loiter/threat avoidance are migrated; other legacy +jobs remain unavailable until ported to protocol 777. ## 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 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 -crop farming), and the crop harvest/replant loop (`1farm crops`). Other -inventory/window actions and farming/trading jobs remain disabled until their -26.3 packets are migrated. +crop farming), and the crop harvest/replant loop (`1farm crops`), and death-screen respawning (`1respawn`). Other +inventory/window actions and resource-farming/trading jobs remain disabled +until their 26.3 packets are migrated. The default server is `127.0.0.1`; set `SERVER` explicitly only when you 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 in the same channel. Verified commands are `!ping`, `!pos`, `!loaded`, `!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 other farming/trading commands are not yet available on 26.3. Common item components (names, damage, enchantments, book diff --git a/mosfet/commands.py b/mosfet/commands.py index 45006d8..bfc48f6 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -66,9 +66,9 @@ class Commands: command = 'nosquarebrackets' unsupported = { - 'gather', 'loiter', 'trade', 'gapple', 'fill', + 'gather', 'trade', 'gapple', 'fill', 'drop', 'dump', 'break', 'open', 'click', 'use', - 'interact', 'respawn', + 'interact', } if (command in unsupported or (command == 'farm' and not data.lower().startswith('crop'))): diff --git a/mosfet/game.py b/mosfet/game.py index 28e5f63..fcbd569 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -24,7 +24,7 @@ from mosfet.protocol.packets import ( from mosfet.protocol.types import Slot from mosfet.protocol.modern import ( - PlayerCommandPacket, UpdateTimePacket, UseItemPacket, + ClientCommandPacket, PlayerCommandPacket, UpdateTimePacket, UseItemPacket, ) from mosfet import utils @@ -711,6 +711,12 @@ class Game(LegacyGame): self.break_deadline = time.monotonic() + max(0.05, hardness_time) 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): self._send_action(PlayerCommandPacket( 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) 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) + if cursor_y is None: + cursor_y = 0.99 if int(face) == 1 else 0.5 packet = UseItemOnBlockPacket( 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, sequence=self._next_sequence()) self._send_action(packet) diff --git a/mosfet/job.py b/mosfet/job.py index a4b3fec..15eaf20 100644 --- a/mosfet/job.py +++ b/mosfet/job.py @@ -265,7 +265,7 @@ class 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): self.g = 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( global_state) self.eat_food_states = eat_food.EatFoodStates(global_state) + self.check_threats_states = check_threats.CheckThreatsStates( + global_state) self.state = self.idle def farm_crop(self): @@ -295,6 +297,11 @@ class JobStates(LegacyJobStates): self.sleep_with_bed_states.silent = False 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): if self.g.get('breaking'): 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.force_new_bed = False 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.state = self.idle diff --git a/mosfet/jobs/check_threats.py b/mosfet/jobs/check_threats.py index b150632..56f9f91 100644 --- a/mosfet/jobs/check_threats.py +++ b/mosfet/jobs/check_threats.py @@ -17,6 +17,10 @@ from mosfet.info import mcdata from mosfet.info import mobs class CheckThreatsStates: + THREAT_DISTANCE = 20 + CLEAR_DISTANCE = 30 + CHECK_INTERVAL = 2.0 + def idle(self): return None @@ -28,51 +32,63 @@ class CheckThreatsStates: w = self.g.world p = utils.pint(self.g.pos) - threats = w.find_threats(p, 40) + threats = w.find_threats(p, self.THREAT_DISTANCE) if threats: - print('Found', len(threats), 'threats, fleeing:') + print('Found', len(threats), 'threats, seeking safety:') print(threats) + self.threats = threats self.state = self.find_safety else: - print('Aborting, no threats') - self.state = self.cleanup + self.wait_time = self.CHECK_INTERVAL + self.state = self.wait def find_safety(self): w = self.g.world p = utils.pint(self.g.pos) - safety = w.find_blocks_indexed(p, [blocks.EMERALD_BLOCK]) - - if not safety: - print('No emerald blocks found, aborting') - self.state = self.cleanup - return - - safety.sort(key=lambda s: utils.phyp(p, s)) - print('Found emerald blocks:', safety) - - for s in safety: - s = utils.padd(s, path.BLOCK_ABOVE) - navpath = w.path_to_place(p, s) - - if navpath: + safety_blocks = w.find_blocks_indexed( + p, [blocks.EMERALD_BLOCK], self.THREAT_DISTANCE * 4) + for block in safety_blocks: + spot = utils.padd(block, path.BLOCK_ABOVE) + 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 = True self.g.path = navpath self.state = self.going_to_safety - self.safety = s - print('Going to safety', self.safety) + print('Going to emerald safety point', self.safety) return - else: - print('Cant get to safety', self.safety) - time.sleep(0.1) - print('Cant get to safety, aborting') - self.state = self.cleanup + # No configured emerald safe point was reachable. Retreat to a clear, + # 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): if utils.pint(self.g.pos) == self.safety: - print('At safety spot, waiting to be moved') - self.state = self.wait_for_move + if self.external_safety: + 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): # wait for the server to move the bot when it's safe @@ -103,6 +119,8 @@ class CheckThreatsStates: self.safety = None self.wait_time = 0 + self.threats = [] + self.external_safety = False def run(self): self.state() diff --git a/mosfet/jobs/sleep_with_bed.py b/mosfet/jobs/sleep_with_bed.py index 52a359a..5aa5a37 100644 --- a/mosfet/jobs/sleep_with_bed.py +++ b/mosfet/jobs/sleep_with_bed.py @@ -24,7 +24,11 @@ class SleepWithBedStates: self.my_bed = False self.area = None self.opening = None + self.bed_head = None self.beds = [] + self.sleep_use_attempts = 0 + self.waiting_for_night = False + self.waiting_for_time_update = False current_time = self.g.get('time') if current_time is None or current_time < 12000: @@ -39,16 +43,55 @@ class SleepWithBedStates: 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): print('Finding beds...') w = self.g.world 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: + result = [] 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 = [] for bed in result: if bed in self.bad_beds: @@ -78,8 +121,11 @@ class SleepWithBedStates: continue self.area = bed + self.bed_head = self._bed_head(bed) self.opening = navpath[-2] if len(navpath) > 1 else p self.g.path = navpath[:-1] + self.opening_deadline = time.monotonic() + max( + 8.0, len(navpath) * 0.75) self.g.look_at = self.area self.state = self.going_to_bed return @@ -87,13 +133,23 @@ class SleepWithBedStates: print('No reachable beds; trying to place one') 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): - if utils.pint(self.g.pos) == self.opening: + if self._at_opening(): print('At existing bed') + self.g.path = [] self.my_bed = False self.g.look_at = self.area self.wait_time = 0.5 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): self.g.look_at = self.area @@ -151,15 +207,22 @@ class SleepWithBedStates: return self.g.path = navpath + self.opening_deadline = time.monotonic() + max( + 8.0, len(navpath) * 0.75) self.g.look_at = self.area self.state = self.going_to_area def going_to_area(self): - if utils.pint(self.g.pos) == self.opening: + if self._at_opening(): print('At bed area') + self.g.path = [] self.g.look_at = self.area self.wait_time = 0.5 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): self.g.look_at = self.area @@ -174,28 +237,38 @@ class SleepWithBedStates: support, BlockFace.TOP, hand=self.g.get('holding_hand', 0)) self.my_bed = True - self.wait_time = 0.5 + self.bed_place_deadline = time.monotonic() + 5.0 self.state = self.wait_use def wait_use(self): - if self.wait_time > 0: - self.wait_time -= utils.TICK - return - - if self.g.world.block_at(*self.area) not in blocks.BED_IDS: + # Block updates can arrive after the use packet; poll authoritative + # chunk state for several seconds instead of giving up after 0.5s. + if self.g.world.block_at(*self.area) in blocks.BED_IDS: + self.bed_head = self._bed_head(self.area) + self.state = self.use_bed + elif time.monotonic() >= self.bed_place_deadline: print('Bed placement was not confirmed at', self.area) self.bad_areas.append(self.area) self.my_bed = False self.state = self.cleanup - return - self.state = self.use_bed def use_bed(self): w = self.g.world 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 + 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: print('Bed disappeared before use:', self.area) @@ -208,8 +281,13 @@ class SleepWithBedStates: return 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_retry_at = time.monotonic() + 1.5 + self.sleep_use_attempts = 1 self.sleep_confirmed = False if not self.silent: self.g.chat.send('zzz') @@ -236,9 +314,19 @@ class SleepWithBedStates: elif self.g.get('correction_count', 0) and self.sleep_confirmed: print('Woke up by movement') 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 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 def break_bed(self): @@ -299,8 +387,11 @@ class SleepWithBedStates: self.bad_beds = [] self.area = None self.opening = None + self.bed_head = None self.bad_areas = [] self.wait_time = 0 + self.sleep_use_attempts = 0 + self.sleep_retry_at = 0 self.force_new_bed = False self.sleep_confirmed = False self.sleep_deadline = 0 diff --git a/mosfet/protocol/modern.py b/mosfet/protocol/modern.py index 8141aab..6aaaef0 100644 --- a/mosfet/protocol/modern.py +++ b/mosfet/protocol/modern.py @@ -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): """Return (sender_name, body) for common Minecraft incoming-DM forms.""" patterns = ( diff --git a/mosfet/world.py b/mosfet/world.py index dadb04b..df82d49 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -13,6 +13,7 @@ from mosfet.info import mobs class World: def __init__(self, global_state): self.g = global_state + self._bed_occupancy_states = {} def block_at(self, x, y, z): return self.g.chunks.get_block_at(x, y, z) @@ -316,11 +317,15 @@ class World: return result 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) occupied = blocks.PROPS.get(bid, {}).get('occupied') == 'true' - print('Checking bed occupancy:', bed, '->', - 'occupied' if occupied else 'free') + previous = self._bed_occupancy_states.get(bed) + 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 def find_cache_openings(self, area): diff --git a/tests/test_respawn.py b/tests/test_respawn.py new file mode 100644 index 0000000..884a0f4 --- /dev/null +++ b/tests/test_respawn.py @@ -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() diff --git a/tests/test_sleep_with_bed.py b/tests/test_sleep_with_bed.py index be516e6..c935e53 100644 --- a/tests/test_sleep_with_bed.py +++ b/tests/test_sleep_with_bed.py @@ -1,5 +1,6 @@ import io import unittest +from contextlib import redirect_stdout from munch import Munch from minecraft.networking.packets import PacketBuffer @@ -9,8 +10,9 @@ from mosfet.game import Game from mosfet.info import blocks, items from mosfet.protocol.types import Slot from mosfet.jobs.sleep_with_bed import SleepWithBedStates +from mosfet.world import World from mosfet.protocol.modern import PlayerCommandPacket, UpdateTimePacket -from minecraft.networking.modern import ClientTickEnd +from minecraft.networking.modern import ClientTickEnd, UseItemOnBlockPacket class FakeConnection: @@ -21,6 +23,14 @@ class FakeConnection: 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: def __init__(self, block=blocks.AIR): self.block = block @@ -28,16 +38,46 @@ class FakeWorld: def block_at(self, *position): return self.block + def check_bed_occupied(self, position): + return False + class FakeGame: def __init__(self): self.placements = [] - def place_block(self, location, face, hand=0): - self.placements.append((location, face, hand)) + def place_block(self, location, face, hand=0, cursor_y=None): + self.placements.append((location, face, hand, cursor_y)) 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): payload = PacketBuffer() Long.send(123456, payload) @@ -78,6 +118,20 @@ class TestSleepWithBed(unittest.TestCase): self.assertTrue(force) 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): connection = FakeConnection() game = Game.__new__(Game) @@ -91,6 +145,147 @@ class TestSleepWithBed(unittest.TestCase): self.assertEqual(game.g.holding_hand, 1) 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): state = SleepWithBedStates(Munch(time=1000, dimension='overworld')) state.init() @@ -106,20 +301,40 @@ class TestSleepWithBed(unittest.TestCase): state.place_bed() 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(int(face), 1) self.assertEqual(hand, 1) + self.assertIsNone(cursor_y) self.assertTrue(state.my_bed) 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( game=FakeGame(), world=FakeWorld(block=blocks.AIR), path=[], look_at=None)) state.area = (4, 70, 5) state.my_bed = True - state.wait_time = 0 + state.bed_place_deadline = 0 state.wait_use() diff --git a/tests/test_threat_detection.py b/tests/test_threat_detection.py new file mode 100644 index 0000000..c76958e --- /dev/null +++ b/tests/test_threat_detection.py @@ -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() diff --git a/tests/test_world_updates.py b/tests/test_world_updates.py new file mode 100644 index 0000000..a11b73f --- /dev/null +++ b/tests/test_world_updates.py @@ -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()