diff --git a/mosfet.py b/mosfet.py index e513d33..952a770 100644 --- a/mosfet.py +++ b/mosfet.py @@ -94,6 +94,24 @@ def _api_plain(value, depth=0): return str(value) +def _api_json_safe(value, depth=0): + """Normalize snapshots for JSON, including non-string dictionary keys.""" + if depth > 12: + return '[nested data omitted]' + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(key): _api_json_safe(item, depth + 1) + for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_api_json_safe(item, depth + 1) for item in value] + + converted = _api_json_default(value) + if converted is value: + return str(value) + return _api_json_safe(converted, depth + 1) + + def _api_json_default(value): """Serialize live protocol objects without invoking fragile __repr__ code.""" from minecraft.networking.packets import Packet @@ -133,7 +151,7 @@ def api_global(): 'dimension': chunks.dimension, } response = app.response_class( - response=json.dumps(snapshot, default=_api_json_default), + response=json.dumps(_api_json_safe(snapshot)), status=200, mimetype='application/json' ) diff --git a/mosfet/connect.py b/mosfet/connect.py index 79faca1..3726704 100644 --- a/mosfet/connect.py +++ b/mosfet/connect.py @@ -87,12 +87,15 @@ def main(global_state=None, *, server=None, port=None, username=None, from mosfet.world import World from mosfet.commands import Commands from mosfet.protocol.modern import ModernChatManager - from mosfet.protocol.managers import EntityManager, InventoryManager + from mosfet.protocol.managers import ( + ChestOpenManager, EntityManager, InventoryManager, + ) from mosfet.bot import start_automation global_state.world = World(global_state) global_state.chat = ModernChatManager(global_state) global_state.entities = EntityManager(global_state) global_state.inventory = InventoryManager(global_state) + global_state.chest_open_manager = ChestOpenManager(global_state) global_state.commands = Commands(global_state) start_automation(global_state) diff --git a/mosfet/game.py b/mosfet/game.py index 29a29f4..2b5444b 100644 --- a/mosfet/game.py +++ b/mosfet/game.py @@ -385,8 +385,15 @@ class LegacyGame: self.g.connection.write_packet(packet) def open_container(self, location): + location = tuple(int(value) for value in location) bid = self.g.chunks.get_block_at(*location) - # TODO: check if block is a chest?? + block_name = blocks.BLOCKS.get(bid) + if (block_name and block_name != 'ender_chest' and + (block_name == 'chest' or block_name.endswith('_chest'))): + self.g.pending_chest_open = { + 'position': location, + 'requested_at': time.monotonic(), + } self.place_block(location, BlockFace.TOP) def handle_window(self, packet): diff --git a/mosfet/jobs/grab_supplies.py b/mosfet/jobs/grab_supplies.py index 974a859..e2da9cc 100644 --- a/mosfet/jobs/grab_supplies.py +++ b/mosfet/jobs/grab_supplies.py @@ -204,6 +204,28 @@ class GrabSuppliesStates: self.ground_position = None self.state = self.find_ground_items + @staticmethod + def _chest_event_key(event): + return (event.get('timestamp'), tuple(event.get('position', ()))) + + def _refresh_useless_chests(self): + events = self.g.get('chest_open_events', ()) + current_events = {self._chest_event_key(event) for event in events} + for event in events: + event_key = self._chest_event_key(event) + if event_key in self._processed_chest_open_events: + continue + self._processed_chest_open_events.add(event_key) + position = tuple(event.get('position', ())) + reopened = [key for key in self.useless_chests + if position in key] + for key in reopened: + self.useless_chests.remove(key) + self.checked_supply_chests.discard(key) + print('Supply chest was opened by another player; checking it again:', + position) + self._processed_chest_open_events.intersection_update(current_events) + def find_supply_chests(self): print('Finding chests labeled for this bot\'s supplies...') p = utils.pint(self.g.pos) @@ -215,10 +237,11 @@ class GrabSuppliesStates: def choose_supply_chest(self): print('Choosing a supply-labeled chest...') for container in self.supply_chests: - key = container['positions'] - if key in self.checked_supply_chests: + key = tuple(container['positions']) + if key in self.useless_chests or key in self.checked_supply_chests: continue self.checked_supply_chests.add(key) + self.current_supply_chest_key = key self.chest = container['position'] self.area = self.chest print('Chose supply chest:', self.chest) @@ -286,6 +309,9 @@ class GrabSuppliesStates: return print('No matching items in this supply chest') + key = getattr(self, 'current_supply_chest_key', None) + if key is not None and key not in self.useless_chests: + self.useless_chests.append(key) self.state = self.close_supply_chest def grab_items(self): @@ -355,6 +381,12 @@ class GrabSuppliesStates: self.supplies = self.default_supplies() self.supply_chests = [] self.checked_supply_chests = set() + self.useless_chests = [] + self._processed_chest_open_events = { + self._chest_event_key(event) + for event in self.g.get('chest_open_events', ()) + } + self.current_supply_chest_key = None self.supply_chest = None self.checked_supplies = [] self.target_items = None @@ -365,4 +397,5 @@ class GrabSuppliesStates: self.wait_time = 0 def run(self): + self._refresh_useless_chests() self.state() diff --git a/mosfet/protocol/managers.py b/mosfet/protocol/managers.py index f87e02b..32c74e5 100644 --- a/mosfet/protocol/managers.py +++ b/mosfet/protocol/managers.py @@ -1,10 +1,11 @@ import json +import time from math import floor from munch import Munch from minecraft.networking.packets import clientbound, serverbound from minecraft.networking.modern import ( - AddEntityPacket, BlockEntityDataPacket, ContainerOpenPacket, + AddEntityPacket, BlockEntityDataPacket, BlockEventPacket, ContainerOpenPacket, ContainerSetContentPacket, ContainerSetSlotPacket, SetEntityDataPacket, nbt_plain, PaletteSection, @@ -355,6 +356,78 @@ class WorldState: self.center, self.radius) +class ChestOpenManager: + """Track chest viewer-count events and report openings by other players.""" + OWN_OPEN_TIMEOUT = 3.0 + + def __init__(self, global_state): + self.g = global_state + self.g.chest_viewers = self.g.get('chest_viewers') or {} + self.g.chest_open_events = self.g.get('chest_open_events') or [] + self.g.last_chest_opened_by_other = None + self.g.connection.register_packet_listener( + self.handle_block_event, BlockEventPacket) + + def _block_name(self, packet): + registry = getattr(self.g.connection, 'registry_ids', {}).get( + 'minecraft:block', {}) + name = next((name for name, block_id in registry.items() + if block_id == packet.block_id), None) + if name is not None: + return name.removeprefix('minecraft:') + location = packet.location + state_id = self.g.chunks.get_block_at( + location.x, location.y, location.z) + return blocks.BLOCKS.get(state_id) + + def handle_block_event(self, packet): + block_name = self._block_name(packet) + if (block_name is None or block_name == 'ender_chest' or + not (block_name == 'chest' or block_name.endswith('_chest')) or + packet.action != 1): + return + + location = packet.location + position = (location.x, location.y, location.z) + viewers = packet.parameter + previous_viewers = self.g.chest_viewers.get(position, 0) + if viewers: + self.g.chest_viewers[position] = viewers + else: + self.g.chest_viewers.pop(position, None) + + pending = self.g.get('pending_chest_open') + pending_age = (time.monotonic() - pending.get('requested_at', 0) + if pending is not None else None) + pending_is_fresh = (pending_age is not None and + 0 <= pending_age <= self.OWN_OPEN_TIMEOUT) + is_bot_opening = ( + pending_is_fresh and + tuple(pending.get('position', ())) == position + ) + if pending is not None and (is_bot_opening or not pending_is_fresh): + self.g.pending_chest_open = None + + if (viewers <= previous_viewers or + (is_bot_opening and + viewers <= max(1, previous_viewers))): + return + + event = { + 'position': position, + 'block': block_name, + 'viewers': viewers, + 'previous_viewers': previous_viewers, + 'timestamp': time.time(), + } + self.g.last_chest_opened_by_other = event + self.g.chest_open_events.append(event) + del self.g.chest_open_events[:-100] + print('[chest] another player opened {} at {} ({} viewer{})'.format( + block_name, position, viewers, '' if viewers == 1 else 's'), + flush=True) + + class EntityManager: def __init__(self, global_state): self.g = global_state diff --git a/tests/test_chest_detection.py b/tests/test_chest_detection.py new file mode 100644 index 0000000..7c9d569 --- /dev/null +++ b/tests/test_chest_detection.py @@ -0,0 +1,90 @@ +import time +import unittest +from munch import Munch + +from minecraft.networking.modern import BlockEventPacket +from mosfet.game import Game +from mosfet.protocol.managers import ChestOpenManager + + +class FakeConnection: + def __init__(self): + self.registry_ids = {'minecraft:block': { + 'minecraft:stone': 1, + 'minecraft:chest': 2, + 'minecraft:trapped_chest': 3, + }} + self.listeners = [] + + def register_packet_listener(self, handler, packet_type): + self.listeners.append((handler, packet_type)) + + +class ChestDetectionTests(unittest.TestCase): + def setUp(self): + self.connection = FakeConnection() + self.g = Munch(connection=self.connection) + self.manager = ChestOpenManager(self.g) + self.position = (12, 64, -7) + + def event(self, block_id=2, action=1, viewers=1): + x, y, z = self.position + return Munch( + location=Munch(x=x, y=y, z=z), + block_id=block_id, action=action, parameter=viewers, + ) + + def test_registers_block_event_packet_and_records_other_player_open(self): + self.assertIn((self.manager.handle_block_event, BlockEventPacket), + self.connection.listeners) + + self.manager.handle_block_event(self.event(viewers=1)) + + self.assertEqual(self.g.chest_viewers[self.position], 1) + self.assertEqual(self.g.last_chest_opened_by_other['position'], + self.position) + self.assertEqual(self.g.last_chest_opened_by_other['block'], 'chest') + self.assertEqual(len(self.g.chest_open_events), 1) + + def test_bot_open_is_not_misreported_but_later_viewer_is(self): + self.g.pending_chest_open = { + 'position': self.position, + 'requested_at': time.monotonic(), + } + self.manager.handle_block_event(self.event(viewers=1)) + + self.assertIsNone(self.g.last_chest_opened_by_other) + self.assertIsNone(self.g.pending_chest_open) + + self.manager.handle_block_event(self.event(viewers=2)) + self.assertEqual(self.g.last_chest_opened_by_other['viewers'], 2) + self.assertEqual(self.g.last_chest_opened_by_other['previous_viewers'], 1) + + def test_ignores_non_chests_and_tracks_close(self): + self.manager.handle_block_event(self.event(block_id=1, viewers=1)) + self.assertIsNone(self.g.last_chest_opened_by_other) + + self.manager.handle_block_event(self.event(viewers=1)) + self.manager.handle_block_event(self.event(viewers=0)) + + self.assertNotIn(self.position, self.g.chest_viewers) + self.assertEqual(len(self.g.chest_open_events), 1) + + def test_open_container_marks_bot_initiated_chest_open(self): + chest_state = 5552 + sent = [] + game = Game.__new__(Game) + game.g = Munch(chunks=Munch( + get_block_at=lambda *_position: chest_state)) + game.place_block = lambda location, face: sent.append( + (location, face)) + + game.open_container(self.position) + + self.assertEqual(sent[0][0], self.position) + self.assertEqual(game.g.pending_chest_open['position'], self.position) + self.assertIsInstance(game.g.pending_chest_open['requested_at'], float) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_grab_supplies.py b/tests/test_grab_supplies.py index 35a51fc..00d0954 100644 --- a/tests/test_grab_supplies.py +++ b/tests/test_grab_supplies.py @@ -78,6 +78,44 @@ class GroundSupplyTests(unittest.TestCase): self.assertEqual(state.chest, position) self.assertIs(state.state.__func__, state.path_to_supply_chest.__func__) + def test_empty_supply_chest_is_skipped_until_another_player_opens_it(self): + position = (4, 64, 0) + key = (position,) + event = {'position': position, 'timestamp': 1.0} + g = Munch(chest_open_events=[]) + state = GrabSuppliesStates(g) + state.supplies = {(999999,): (1, 1)} + state.current_supply_chest_key = key + + class FakeGame: + @staticmethod + def count_items(_item_ids): + return 1 + + state.g.game = FakeGame() + state.choose_items() + self.assertIn(key, state.useless_chests) + self.assertIs(state.state.__func__, state.close_supply_chest.__func__) + + second_position = (8, 64, 0) + state.supply_chests = [ + {'position': position, 'positions': key}, + {'position': second_position, 'positions': (second_position,)}, + ] + state.checked_supply_chests = {key} + state.choose_supply_chest() + self.assertEqual(state.chest, second_position) + + g.chest_open_events.append(event) + state.state = state.idle + state.run() + self.assertNotIn(key, state.useless_chests) + self.assertNotIn(key, state.checked_supply_chests) + + state.supply_chests = [{'position': position, 'positions': key}] + state.choose_supply_chest() + self.assertEqual(state.chest, position) + def test_unreachable_supply_chest_ends_task_without_searching_barrels(self): class FakeWorld: @staticmethod diff --git a/tests/test_web_inventory_api.py b/tests/test_web_inventory_api.py index 423e590..70a2be7 100644 --- a/tests/test_web_inventory_api.py +++ b/tests/test_web_inventory_api.py @@ -31,7 +31,8 @@ class TestWebInventoryAPI(unittest.TestCase): def setUp(self): self.g = mosfet_web.g self.original = { - key: self.g.get(key) for key in ('connected', 'game', 'inv') + key: self.g.get(key) for key in + ('connected', 'game', 'inv', 'chest_viewers') } self.game = FakeGame() self.g.connected = True @@ -53,6 +54,16 @@ class TestWebInventoryAPI(unittest.TestCase): item = response.get_json()['inv']['9'] self.assertEqual(item['components']['custom_name']['text'], 'Test Paper') + def test_global_api_stringifies_tuple_keys(self): + self.g.chest_viewers = {(12, 64, -7): 1} + + response = self.client.get('/api/global') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json()['chest_viewers'], { + '(12, 64, -7)': 1, + }) + def test_drop_endpoint_supports_one_stack_and_matching_actions(self): one = self.client.post('/api/inventory/drop', json={ 'slot': 9, 'action': 'one'})