diff --git a/README.md b/README.md index fbb45fb..4805f49 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,9 @@ The 26.3 client now handles AuthMe, chat commands, chunk/world-state caching, modern block/item/entity data, entity tracking, inventory/component sync, path-following movement (`1goto x y z`), inventory draining (`1drain`), normal/trapped-chest caching (`1cache`, also run by crop farming at the -inventory threshold), and the crop harvest/replant loop (`1farm crops`). Other +inventory threshold), bed sleeping (`1sleep`, or `1sleep new` to place a bed; +also run by crop farming at night), 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. The default server is `127.0.0.1`; set `SERVER` explicitly only when you @@ -76,8 +78,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`, addressed -path movement such as `1goto x y z`, and `1farm crops`. The command catalog below is retained from +`!info x y z`, `!inv` (basic slot/count data), `1cache`, `1drain`, `1sleep`, +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/bot.py b/mosfet/bot.py index bbe1dba..2652e92 100644 --- a/mosfet/bot.py +++ b/mosfet/bot.py @@ -407,6 +407,7 @@ def start_automation(global_state, initialize=True): g.yaw = float(g.get('yaw', 0.0) or 0.0) g.pitch = float(g.get('pitch', 0.0) or 0.0) g.holding_slot = g.get('holding_slot', 0) + g.holding_hand = 0 g.chunks.loading = False # Readiness below is based on nearby chunks, not unrelated view holes. g.movement_paused = False @@ -429,6 +430,8 @@ def start_automation(global_state, initialize=True): g.setdefault('item_lock', False) g.setdefault('minimum_cache_slots', 27) g.setdefault('holding_slot', 0) + g.setdefault('holding_hand', 0) + g.setdefault('time', 0.0) old_stop = g.get('movement_stop_event') old_worker = g.get('movement_thread') diff --git a/mosfet/commands.py b/mosfet/commands.py index 8fbece8..19780a2 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -397,6 +397,13 @@ class Commands: self.g.game.respawn() reply = 'ok' + ## 1sleep [new] - use a bed; `new` forces bed placement + if command == 'sleep': + self.g.job.sleep_with_bed_states.force_new_bed = ( + data.lower() in ('new', 'place')) + self.g.job.state = self.g.job.sleep + reply = 'ok' + ## 1gather wood - gathers wood from the world ## 1gather sand - gathers sand from the world if command == 'gather' and data: diff --git a/mosfet/game.py b/mosfet/game.py index 2c67b0c..d5ca57f 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -23,6 +23,7 @@ from mosfet.protocol.packets import ( ) from mosfet.protocol.types import Slot +from mosfet.protocol.modern import PlayerCommandPacket, UpdateTimePacket from mosfet import utils from mosfet import path @@ -164,7 +165,32 @@ class LegacyGame: self.g.path = new_path def handle_time_update(self, packet): - self.g.time = packet.time_of_day % 24000 + if hasattr(packet, 'time_of_day'): + self.g.time = packet.time_of_day % 24000 + return + + updates = getattr(packet, 'clock_updates', ()) + if not updates: + return + + registries = getattr(self.g.connection, 'registry_ids', {}) + clock_names = { + index: name + for name, index in registries.get( + 'minecraft:world_clock', {}).items() + } + dimension = self.g.get('dimension', 'overworld') + preferred = [ + update for update in updates + if clock_names.get(update['clock_id'], '').removeprefix( + 'minecraft:') in (dimension, 'overworld') + ] + update = preferred[0] if preferred else updates[0] + self.g.time = update['total_ticks'] % 24000 + self.g.clock_id = update['clock_id'] + self.g.clock_name = clock_names.get(update['clock_id']) + self.g.clock_rate = update['rate'] + self.g.world_age = packet.age def handle_set_slot(self, packet): g = self.g @@ -632,8 +658,11 @@ class Game(LegacyGame): for packet in reactor.__class__.get_clientbound_packets(context) } reactor.clientbound_packets[SetHealthPacket.id] = SetHealthPacket + reactor.clientbound_packets[UpdateTimePacket.id] = UpdateTimePacket self.g.connection.register_packet_listener( self.handle_set_health, SetHealthPacket) + self.g.connection.register_packet_listener( + self.handle_time_update, UpdateTimePacket) if not self.g.get('health_update_received', False): self.g.health = None @@ -680,6 +709,10 @@ class Game(LegacyGame): self.break_deadline = time.monotonic() + max(0.05, hardness_time) return True + def leave_bed(self): + self._send_action(PlayerCommandPacket( + entity_id=self.g.eid, action_id=0, jump_boost=0)) + def start_drain(self): slots = [slot for slot, item in sorted(self.g.inv.items()) if 9 <= slot <= 45 and getattr(item, 'present', False)] @@ -783,6 +816,12 @@ class Game(LegacyGame): slot, item = next( ((slot, item) for slot, item in matches if 36 <= slot <= 44), matches[0]) + if slot == 45: + self.g.holding = item.item_id + self.g.holding_hand = 1 + self.g.connection.write_packet(ClientTickEnd(), force=True) + return True + self.g.holding_hand = 0 if 36 <= slot <= 44: hotbar_slot = slot - 36 elif 9 <= slot <= 35: @@ -807,10 +846,10 @@ class Game(LegacyGame): self.g.connection.write_packet(ClientTickEnd(), force=True) return True - def place_block(self, location, face): + def place_block(self, location, face, hand=0): location = tuple(int(value) for value in location) packet = UseItemOnBlockPacket( - hand=0, 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_z=0.5, inside_block=False, world_border_hit=False, sequence=self._next_sequence()) diff --git a/mosfet/job.py b/mosfet/job.py index c425995..cedb147 100644 --- a/mosfet/job.py +++ b/mosfet/job.py @@ -270,17 +270,26 @@ class JobStates(LegacyJobStates): self.g = 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( + global_state) self.state = self.idle def farm_crop(self): self.cache_items_states.silent = True + self.sleep_with_bed_states.silent = True + self.sleep_with_bed_states.force_new_bed = False items.set_wanted({ items.CARROT_ID, items.POTATO_ID, items.WHEAT_SEEDS_ID, items.BEETROOT_SEEDS_ID, }) - return [self.gather_crop_states, self.cache_items_states] + return [self.gather_crop_states, self.sleep_with_bed_states, + self.cache_items_states] + + def sleep(self): + self.sleep_with_bed_states.silent = False + return [self.sleep_with_bed_states] def stop(self): if self.g.get('breaking'): @@ -289,7 +298,11 @@ class JobStates(LegacyJobStates): self.g.game.close_window() self.g.path = [] 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_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 + self.sleep_with_bed_states.force_new_bed = False self.gather_crop_states.active_section_anchor = None self.state = self.idle diff --git a/mosfet/jobs/sleep_with_bed.py b/mosfet/jobs/sleep_with_bed.py index 8d4437d..52a359a 100644 --- a/mosfet/jobs/sleep_with_bed.py +++ b/mosfet/jobs/sleep_with_bed.py @@ -21,12 +21,18 @@ class SleepWithBedStates: return None def init(self): - if self.g.time < 12000: + self.my_bed = False + self.area = None + self.opening = None + self.beds = [] + + current_time = self.g.get('time') + if current_time is None or current_time < 12000: print('Aborting sleep, not night') self.state = self.cleanup return - if self.g.dimension != 'overworld': + if self.g.get('dimension') != 'overworld': print('Aborting sleep, not in overworld') self.state = self.cleanup return @@ -38,7 +44,10 @@ class SleepWithBedStates: w = self.g.world p = utils.pint(self.g.pos) - result = w.find_blocks_indexed(p, blocks.BED_IDS, 80) + result = ([] if self.force_new_bed else + w.find_blocks_indexed(p, blocks.BED_IDS, 80)) + if self.force_new_bed: + print('Skipping existing beds; placing a new bed as requested') self.beds = [] for bed in result: @@ -57,38 +66,39 @@ class SleepWithBedStates: print('Choosing a bed...') w = self.g.world p = utils.pint(self.g.pos) - c = self.g.chunks - if not len(self.beds): - print('No beds') - self.state = self.select_bed - return + while self.beds: + bed = self.beds.pop(0) + print('Chose:', bed) + navpath = w.path_to_place_faked(p, bed) + print('navpath:', navpath) + if navpath is None: + self.bad_beds.append(bed) + print('Cant get to bed, blacklisting') + continue - bed = self.beds[0] - print('Chose:', bed) - - navpath = w.path_to_place_faked(p, bed) - print('navpath:', navpath) - - if navpath: - self.g.path = navpath[:-1] - self.opening = self.g.path[-1] - self.g.look_at = self.opening self.area = bed + self.opening = navpath[-2] if len(navpath) > 1 else p + self.g.path = navpath[:-1] + self.g.look_at = self.area self.state = self.going_to_bed return - else: - self.beds.pop(0) - self.bad_beds.append(bed) - print('Cant get to bed, blacklisting') - time.sleep(0.1) - self.state = self.select_bed + + print('No reachable beds; trying to place one') + self.state = self.select_bed def going_to_bed(self): if utils.pint(self.g.pos) == self.opening: print('At existing bed') self.my_bed = False - self.g.look_at = utils.padd(self.area, path.BLOCK_BELOW) + self.g.look_at = self.area + self.wait_time = 0.5 + self.state = self.turn_to_bed + + def turn_to_bed(self): + self.g.look_at = self.area + self.wait_time -= utils.TICK + if self.wait_time <= 0: self.state = self.use_bed def select_bed(self): @@ -116,9 +126,24 @@ class SleepWithBedStates: openings = w.find_bed_openings(self.area) for o in openings: + toward_player = (o[0] - self.area[0], 0, + o[2] - self.area[2]) + # The bed head points away from the player when placing from this + # opening, so require that footprint and its support to be clear. + head = (self.area[0] - toward_player[0], self.area[1], + self.area[2] - toward_player[2]) + if (w.block_at(*head) != blocks.AIR or + w.block_at(*utils.padd(head, path.BLOCK_ABOVE)) != blocks.AIR): + continue + head_support = w.block_at(*utils.padd(head, path.BLOCK_BELOW)) + if (head_support is None or + head_support in blocks.NON_SOLID_IDS or + head_support in blocks.AVOID_IDS): + continue navpath = w.path_to_place(p, o) self.opening = o - if navpath: break + if navpath is not None: + break else: # for print('Unable to get to bed area', self.area) self.bad_areas.append(self.area) @@ -126,35 +151,57 @@ class SleepWithBedStates: return self.g.path = navpath - self.g.look_at = self.opening + 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: print('At bed area') - self.g.look_at = utils.padd(self.area, path.BLOCK_BELOW) + self.g.look_at = self.area + self.wait_time = 0.5 + self.state = self.turn_to_bed_area + + def turn_to_bed_area(self): + self.g.look_at = self.area + self.wait_time -= utils.TICK + if self.wait_time <= 0: self.state = self.place_bed def place_bed(self): - print('Placing bed') - self.g.game.place_block(self.area, BlockFace.TOP) + support = utils.padd(self.area, path.BLOCK_BELOW) + print('Placing bed at', self.area, 'on', support) + self.g.game.place_block( + support, BlockFace.TOP, + hand=self.g.get('holding_hand', 0)) self.my_bed = True self.wait_time = 0.5 self.state = self.wait_use def wait_use(self): - # wait to use the bed if self.wait_time > 0: self.wait_time -= utils.TICK - else: - self.state = self.use_bed + return + + if self.g.world.block_at(*self.area) not in blocks.BED_IDS: + 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 self.g.time < 12550: + if current_time is None or current_time < 12550: return + if w.block_at(*self.area) not in blocks.BED_IDS: + print('Bed disappeared before use:', self.area) + self.my_bed = False + self.state = self.cleanup + return if w.check_bed_occupied(self.area): print('Bed occupied, aborting.') self.state = self.cleanup @@ -162,6 +209,8 @@ class SleepWithBedStates: print('Using bed') self.g.game.place_block(self.area, BlockFace.TOP) + self.sleep_deadline = time.monotonic() + 5.0 + self.sleep_confirmed = False if not self.silent: self.g.chat.send('zzz') self.state = self.sleep_bed @@ -169,43 +218,71 @@ class SleepWithBedStates: def sleep_bed(self): w = self.g.world p = utils.pint(self.g.pos) + current_time = self.g.get('time') threats = w.find_threats(p, 10) + if w.check_bed_occupied(self.area): + self.sleep_confirmed = True + if threats: print('Waking up due to threats:') print(threats) - self.g.game.leave_bed() + if self.sleep_confirmed: + self.g.game.leave_bed() self.state = self.cleanup - elif self.g.time < 100: + elif current_time is not None and current_time < 100: print('Woke up time') self.state = self.break_bed - elif self.g.correction_count: + 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 + time.monotonic() >= self.sleep_deadline): + print('Bed use was not confirmed') + self.state = self.cleanup def break_bed(self): if self.my_bed: - self.g.game.break_block(self.area) - self.state = self.collect_bed + if self.g.game.break_block(self.area): + self.state = self.collect_bed + else: + print('Could not break the placed bed at', self.area) + self.my_bed = False + self.state = self.cleanup else: self.state = self.cleanup def collect_bed(self): if not self.g.breaking: - self.g.path = [utils.padd(self.area, utils.spiral(n)) for n in range(9)] + self.g.path = [utils.padd(self.area, utils.spiral(n)) + for n in range(9)] self.wait_time = 2 + self.collect_deadline = time.monotonic() + 8 self.state = self.wait def wait(self): - # wait to pick up bed + # Finish the short pickup sweep, but don't let it block forever. + if (self.g.path and + time.monotonic() < self.collect_deadline): + return if self.wait_time > 0: self.wait_time -= utils.TICK else: self.state = self.cleanup def cleanup(self): + if (self.my_bed and self.area and + self.g.world.block_at(*self.area) in blocks.BED_IDS): + self.state = self.break_bed + return self.g.look_at = None + self.g.path = [] + self.my_bed = False + self.force_new_bed = False self.state = self.done + if (self.g.get('job') and + getattr(self.g.job.state, '__name__', None) == 'sleep'): + self.g.job.state = self.g.job.idle def done(self): # never gets ran, placeholder @@ -224,6 +301,9 @@ class SleepWithBedStates: self.opening = None self.bad_areas = [] self.wait_time = 0 + self.force_new_bed = False + self.sleep_confirmed = False + self.sleep_deadline = 0 def run(self): self.state() diff --git a/mosfet/protocol/modern.py b/mosfet/protocol/modern.py index bc7acf3..7744435 100644 --- a/mosfet/protocol/modern.py +++ b/mosfet/protocol/modern.py @@ -3,11 +3,41 @@ import re import time from contextlib import nullcontext +from minecraft.networking.packets import Packet +from minecraft.networking.types import Float, Long, VarInt, VarLong from minecraft.networking.modern import ( ChatCommand, ClientboundPlayerChatPacket, ServerboundChatPacket, SystemChat, ) +class UpdateTimePacket(Packet): + """26.3 update-time packet with registry-indexed world clocks.""" + # The 26.1 Prismarine schema defines this payload; protocol 777's 26.3 + # clientbound table places it at 0x73 (0x71 is a different short packet). + id = 0x73 + packet_name = 'update time' + + def read(self, file_object): + self.age = Long.read(file_object) + self.clock_updates = [] + for _ in range(VarInt.read(file_object)): + self.clock_updates.append({ + 'clock_id': VarInt.read(file_object), + 'total_ticks': VarLong.read(file_object), + 'partial_tick': Float.read(file_object), + 'rate': Float.read(file_object), + }) + + +class PlayerCommandPacket(Packet): + """Client player-command packet; action 0 stops sleeping (protocol 777).""" + id = 0x2A + packet_name = 'player command' + definition = [ + {'entity_id': VarInt}, {'action_id': VarInt}, {'jump_boost': 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 33a0ab4..dadb04b 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -158,28 +158,50 @@ class World: return navpath def find_bed_areas(self, center, distance): - bed_clearance = 9 # 5x5 area - clear_distance = 2 - - for a in self.find_blocks_3d(center, [0], distance, 50): - # check for air around the area - if len(self.find_blocks(a, clear_distance, [0], bed_clearance)) < bed_clearance: + """Yield nearby two-block bed footprints on solid, safe ground.""" + # Only search loaded-neighborhood distance. The old 3-D flood fill + # explored millions of air blocks and used overly broad 5x5 clearance. + distance = min(distance, 32) + headroom = path.BLOCK_ABOVE + support = path.BLOCK_BELOW + for offset in utils.search_2d(distance): + if utils.hypot(offset[0], offset[2]) > distance: continue + for dy in (0, 1, -1, 2, -2): + area = (center[0] + offset[0], center[1] + dy, + center[2] + offset[2]) + if area == center: + continue + if self.block_at(*area) != blocks.AIR: + continue + if self.block_at(*utils.padd(area, headroom)) != blocks.AIR: + continue + support_block = self.block_at(*utils.padd(area, support)) + if (support_block is None or + support_block in blocks.NON_SOLID_IDS or + support_block in blocks.AVOID_IDS): + continue - # check for ground around the area - if len(self.find_blocks(utils.padd(a, path.BLOCK_BELOW), clear_distance, blocks.NON_SOLID_IDS, bed_clearance)): - continue + footprint_found = False + for direction in path.CHECK_DIRECTIONS: + head = utils.padd(area, direction) + if (self.block_at(*head) != blocks.AIR or + self.block_at(*utils.padd(head, headroom)) != blocks.AIR): + continue + head_support = self.block_at(*utils.padd(head, support)) + if (head_support is None or + head_support in blocks.NON_SOLID_IDS or + head_support in blocks.AVOID_IDS): + continue + footprint_found = True + break + if not footprint_found: + continue - # check for air above the area - if len(self.find_blocks(utils.padd(a, path.BLOCK_ABOVE), clear_distance, [0], bed_clearance)) < bed_clearance: - continue - - # ensure there's no monsters within 20 blocks - # can't sleep if they are within 10, good to have a buffer - if self.find_monsters(a, 20): - continue - - yield a + # Avoid placing a bed within range of nearby monsters. + if self.find_monsters(area, 20): + continue + yield area def find_cache_areas(self, center, distance): """Yield nearby, supported air blocks suitable for placing a chest.""" @@ -294,14 +316,12 @@ class World: return result def check_bed_occupied(self, bed): - # returns true if the bed is occupied by a player + """Return whether a loaded bed block currently has occupied=true.""" bid = self.g.chunks.get_block_at(*bed) - if blocks.PROPS[bid]['occupied'] == 'true': - print('Checking bed occupancy:', bed, '-> occupied') - return True - else: - print('Checking bed occupancy:', bed, '-> free') - return False + occupied = blocks.PROPS.get(bid, {}).get('occupied') == 'true' + print('Checking bed occupancy:', bed, '->', + 'occupied' if occupied else 'free') + return occupied def find_cache_openings(self, area): return self.find_bed_openings(area) diff --git a/tests/test_crop_cache.py b/tests/test_crop_cache.py index 328315c..32a9656 100644 --- a/tests/test_crop_cache.py +++ b/tests/test_crop_cache.py @@ -11,14 +11,17 @@ class TestCropCaching(unittest.TestCase): old_wanted = items.WANTED_ITEMS crop_state = object() cache_state = Munch(silent=False) + sleep_state = Munch(silent=False) jobs = JobStates.__new__(JobStates) jobs.gather_crop_states = crop_state jobs.cache_items_states = cache_state + jobs.sleep_with_bed_states = sleep_state try: machines = JobStates.farm_crop(jobs) - self.assertEqual(machines, [crop_state, cache_state]) + self.assertEqual(machines, [crop_state, sleep_state, cache_state]) self.assertTrue(cache_state.silent) + self.assertTrue(sleep_state.silent) self.assertTrue({ items.CARROT_ID, items.POTATO_ID, items.WHEAT_SEEDS_ID, items.BEETROOT_SEEDS_ID, diff --git a/tests/test_sleep_with_bed.py b/tests/test_sleep_with_bed.py new file mode 100644 index 0000000..be516e6 --- /dev/null +++ b/tests/test_sleep_with_bed.py @@ -0,0 +1,132 @@ +import io +import unittest + +from munch import Munch +from minecraft.networking.packets import PacketBuffer +from minecraft.networking.types import Float, Long, VarInt, VarLong + +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.protocol.modern import PlayerCommandPacket, UpdateTimePacket +from minecraft.networking.modern import ClientTickEnd + + +class FakeConnection: + def __init__(self): + self.packets = [] + + def write_packet(self, packet, force=False): + self.packets.append((packet, force)) + + +class FakeWorld: + def __init__(self, block=blocks.AIR): + self.block = block + + def block_at(self, *position): + return self.block + + +class FakeGame: + def __init__(self): + self.placements = [] + + def place_block(self, location, face, hand=0): + self.placements.append((location, face, hand)) + + +class TestSleepWithBed(unittest.TestCase): + def test_update_time_packet_decodes_clock_updates(self): + payload = PacketBuffer() + Long.send(123456, payload) + VarInt.send(1, payload) + VarInt.send(0, payload) + VarLong.send(13000, payload) + Float.send(0.5, payload) + Float.send(1.0, payload) + + packet = UpdateTimePacket() + packet.read(io.BytesIO(payload.get_writable())) + self.assertEqual(packet.id, 0x73) + self.assertEqual(packet.age, 123456) + self.assertEqual(packet.clock_updates, [{ + 'clock_id': 0, 'total_ticks': 13000, + 'partial_tick': 0.5, 'rate': 1.0, + }]) + + def test_player_command_encodes_stop_sleeping(self): + packet = PlayerCommandPacket( + entity_id=7, action_id=0, jump_boost=0) + payload = PacketBuffer() + packet.write_fields(payload) + self.assertEqual(packet.id, 0x2A) + self.assertEqual(payload.get_writable(), b'\x07\x00\x00') + + def test_game_leave_bed_sends_protocol_777_stop_sleeping(self): + connection = FakeConnection() + game = Game.__new__(Game) + game.g = Munch(connection=connection, eid=42) + + game.leave_bed() + + packet, force = connection.packets[0] + self.assertIsInstance(packet, PlayerCommandPacket) + self.assertEqual((packet.entity_id, packet.action_id, + packet.jump_boost), (42, 0, 0)) + self.assertTrue(force) + self.assertIsInstance(connection.packets[1][0], ClientTickEnd) + + def test_bed_in_offhand_is_selectable_without_swapping_inventory(self): + connection = FakeConnection() + game = Game.__new__(Game) + bed_id = next(iter(items.BED_IDS)) + game.g = Munch( + connection=connection, inv={45: Slot(True, bed_id, 1)}, + holding_hand=0) + + self.assertTrue(game.select_item(items.BED_IDS)) + self.assertEqual(game.g.holding, bed_id) + self.assertEqual(game.g.holding_hand, 1) + self.assertIsInstance(connection.packets[0][0], ClientTickEnd) + + def test_daytime_sleep_aborts_cleanly(self): + state = SleepWithBedStates(Munch(time=1000, dimension='overworld')) + state.init() + self.assertEqual(state.state, state.cleanup) + + def test_placing_bed_uses_support_block_face_top(self): + game = FakeGame() + state = SleepWithBedStates(Munch( + game=game, world=FakeWorld(), path=[], look_at=None, + holding_hand=1)) + state.area = (4, 70, 5) + + state.place_bed() + + self.assertEqual(len(game.placements), 1) + location, face, hand = game.placements[0] + self.assertEqual(location, (4, 69, 5)) + self.assertEqual(int(face), 1) + self.assertEqual(hand, 1) + self.assertTrue(state.my_bed) + self.assertEqual(state.state, state.wait_use) + + def test_wait_use_aborts_if_server_rejects_bed_placement(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.wait_use() + + self.assertIn(state.area, state.bad_areas) + self.assertFalse(state.my_bed) + self.assertEqual(state.state, state.cleanup) + + +if __name__ == '__main__': + unittest.main()