From 43b37b76f68dff121af0bb70a390590b3f9a51ed Mon Sep 17 00:00:00 2001 From: Tanner Date: Sat, 7 Nov 2026 16:35:57 +0000 Subject: [PATCH] Add sign-labeled chest storage --- mosfet/info/items.py | 19 ++++++ mosfet/jobs/cache_items.py | 103 +++++++++++++++++++++++++++--- mosfet/jobs/grab_supplies.py | 98 ++++++++++++++-------------- mosfet/world.py | 117 ++++++++++++++++++++++++++++++++++ tests/test_gather_wood.py | 2 +- tests/test_grab_supplies.py | 51 +++++++++++++-- tests/test_labeled_storage.py | 77 ++++++++++++++++++++++ 7 files changed, 401 insertions(+), 66 deletions(-) create mode 100644 tests/test_labeled_storage.py diff --git a/mosfet/info/items.py b/mosfet/info/items.py index 749ed2b..0c0d10e 100644 --- a/mosfet/info/items.py +++ b/mosfet/info/items.py @@ -83,6 +83,25 @@ EMERALD_ID = get_id('emerald') BERRIES_ID = get_id('sweet_berries') IRON_INGOT_ID = get_id('iron_ingot') + +_LABEL_ITEM_IDS = { + 'seeds': get_set({ + 'beetroot', 'beetroot_seeds', 'carrot', 'melon_seeds', + 'nether_wart', 'pitcher_pod', 'potato', 'pumpkin_seeds', + 'torchflower_seeds', 'wheat_seeds', + }), + 'wheat': {WHEAT_ID}, +} + + +def storage_item_ids_for_label(label): + """Return protocol item IDs assigned to a sign storage label.""" + normalized = ' '.join(str(label).casefold().split()).strip(' :') + normalized = (normalized.rstrip('s') + if normalized not in ('seeds', 'beds') else normalized) + return set(_LABEL_ITEM_IDS.get(normalized, ())) + + INIT_NEEDED_ITEMS = BED_IDS | FOOD_IDS INIT_NEEDED_ITEMS.add(CHEST_ID) NEEDED_ITEMS = INIT_NEEDED_ITEMS diff --git a/mosfet/jobs/cache_items.py b/mosfet/jobs/cache_items.py index 7e1e9c6..d7bb3b7 100644 --- a/mosfet/jobs/cache_items.py +++ b/mosfet/jobs/cache_items.py @@ -22,6 +22,8 @@ class CacheItemsStates: def init(self): self.skip_slots = [] self.skip_items = [] + self.is_labeled_container = False + self.container_item_ids = None num_stacks = self.g.game.count_inventory_slots() minimum = getattr( @@ -36,25 +38,54 @@ class CacheItemsStates: self.state = self.cleanup def find_trapped_chests(self): - print('Finding trapped chests...') + print('Finding labeled and trapped chests...') w = self.g.world p = utils.pint(self.g.pos) + self.labeled_chests = w.find_labeled_chests(p, 80) + self.checked_labeled_chests = set() self.trapped_chests = w.find_blocks_indexed(p, blocks.TRAPPED_CHEST_IDS) - print('Found:', self.trapped_chests) + print('Found labeled chests:', len(self.labeled_chests), + 'trapped chests:', self.trapped_chests) self.state = self.choose_trapped_chest def choose_trapped_chest(self): - print('Choosing a trapped chest...') + print('Choosing a labeled or trapped chest...') w = self.g.world p = utils.pint(self.g.pos) - c = self.g.chunks + + for container in self.labeled_chests: + key = container['positions'] + if key in self.checked_labeled_chests: + continue + self.checked_labeled_chests.add(key) + if not any(item.present and item.item_id in container['item_ids'] + for item in self.g.inv.values()): + continue + + self.area = container['position'] + self.container_item_ids = container['item_ids'] + self.is_labeled_container = True + for opening in w.find_cache_openings(self.area): + navpath = w.path_to_place(p, opening) + if navpath: + print('Going to labeled chest', container['labels'], + self.area) + self.opening = opening + self.g.path = navpath + self.state = self.going_to_trapped_chest + return + print('No path to labeled chest', self.area) if not len(self.trapped_chests): - print('No trapped chests') + self.is_labeled_container = False + self.container_item_ids = None + print('No labeled or trapped chests') self.state = self.select_chest return + self.is_labeled_container = False + self.container_item_ids = None chest = self.trapped_chests[0] self.area = chest @@ -83,6 +114,8 @@ class CacheItemsStates: self.state = self.open_chest def select_chest(self): + self.is_labeled_container = False + self.container_item_ids = None if self.g.game.select_item([items.CHEST_ID]): self.state = self.find_cache_spot else: @@ -149,6 +182,41 @@ class CacheItemsStates: else: self.state = self.move_items + def _needed_inventory_slots_to_keep(self): + needed = set(items.NEEDED_ITEMS) + groups = ( + items.BED_IDS, {items.CHEST_ID}, items.FOOD_IDS, + items.AXE_IDS, items.SHOVEL_IDS, + ) + keep = set() + covered = set() + for group in groups: + group = needed.intersection(group) + if not group: + continue + covered.update(group) + stacks = [(slot, item) for slot, item in self.g.inv.items() + if 5 <= slot <= 45 and item.present and + item.item_id in group] + if stacks: + keep.add(max(stacks, key=lambda pair: pair[1].item_count)[0]) + + for item_id in needed - covered: + stacks = [(slot, item) for slot, item in self.g.inv.items() + if 5 <= slot <= 45 and item.present and + item.item_id == item_id] + if stacks: + keep.add(max(stacks, key=lambda pair: pair[1].item_count)[0]) + return keep + + def _window_player_slot(self, window_slot): + offset = window_slot - self.g.window.container_slots + if 0 <= offset < 27: + return 9 + offset + if 27 <= offset < 36: + return 36 + offset - 27 + return None + def move_items(self): if self.g.item_lock: return w = self.g.window @@ -177,10 +245,13 @@ class CacheItemsStates: print('Container is too full, aborting') self.g.game.close_window() self.g.look_at = None - self.state = self.cleanup + self.state = (self.close_chest if self.is_labeled_container + else self.cleanup) return slot_list = [] + needed_slots = (self._needed_inventory_slots_to_keep() + if self.is_labeled_container else set()) for slot_num in w_inventory_slots: if slot_num not in w.contents: @@ -191,7 +262,14 @@ class CacheItemsStates: if not slot.present: continue - if slot.item_id in items.NEEDED_ITEMS: + if self.is_labeled_container: + if slot.item_id not in self.container_item_ids: + continue + inventory_slot = self._window_player_slot(slot_num) + if (slot.item_id in items.NEEDED_ITEMS and + inventory_slot in needed_slots): + continue + elif slot.item_id in items.NEEDED_ITEMS: continue if slot_num in self.skip_slots: @@ -222,7 +300,12 @@ class CacheItemsStates: self.g.game.close_window() if not self.silent: self.g.chat.send('cache at ' + str(self.area)[1:-1]) - self.state = self.cleanup + if self.is_labeled_container: + self.is_labeled_container = False + self.container_item_ids = None + self.state = self.choose_trapped_chest + else: + self.state = self.cleanup def cleanup(self): self.g.look_at = None @@ -244,6 +327,10 @@ class CacheItemsStates: self.skip_slots = [] self.skip_items = [] + self.labeled_chests = [] + self.checked_labeled_chests = set() + self.is_labeled_container = False + self.container_item_ids = None self.area = None self.opening = None self.trapped_chests = [] diff --git a/mosfet/jobs/grab_supplies.py b/mosfet/jobs/grab_supplies.py index b3facdd..974a859 100644 --- a/mosfet/jobs/grab_supplies.py +++ b/mosfet/jobs/grab_supplies.py @@ -30,7 +30,7 @@ class GrabSuppliesStates: def init(self): print('Started grab supplies states') - self.checked_barrels = [] + self.checked_supply_chests = set() used_slots = self.g.game.count_inventory_slots() print('used:', used_slots, 'total:', self.g.maximum_supply_slots) @@ -41,6 +41,7 @@ class GrabSuppliesStates: self.checked_ground_items = set() self.checked_ground_groups = set() + self.checked_supply_chests = set() self.ground_stack_counts = {} self.ground_entity_id = None self.ground_position = None @@ -78,8 +79,8 @@ class GrabSuppliesStates: self.ground_position = None def check_supplies(self): - # Search every configured item group on the ground before going to a - # barrel; a missing bed must not hide a nearby chest or food drop. + # Search every configured item group on the ground before looking in + # labeled chests; a missing bed must not hide a nearby chest or food drop. missing = [] for item_ids, limits in self.supplies.items(): minimum, maximum = limits @@ -110,9 +111,9 @@ class GrabSuppliesStates: if missing: self.target_items, self.target_minimum, self.target_maximum_stacks = missing[0] - print('No more ground drops; checking barrels for', + print('No more ground drops; checking supply-labeled chests for', self.target_items) - self.state = self.find_barrels + self.state = self.find_supply_chests return print('Aborting, dont need any supplies') @@ -122,7 +123,11 @@ class GrabSuppliesStates: stack_count = self.ground_stack_counts.get(self.target_items, 0) if self.target_maximum_stacks and stack_count >= self.target_maximum_stacks: self.checked_ground_groups.add(self.target_items) - self.state = self.check_supplies + if (self.g.game.count_items(self.target_items) < + self.target_minimum): + self.state = self.find_supply_chests + else: + self.state = self.check_supplies return print('Looking for supplies on the ground...') @@ -199,60 +204,52 @@ class GrabSuppliesStates: self.ground_position = None self.state = self.find_ground_items - def find_barrels(self): - print('Finding barrels...') - w = self.g.world + def find_supply_chests(self): + print('Finding chests labeled for this bot\'s supplies...') p = utils.pint(self.g.pos) + self.supply_chests = self.g.world.find_supply_chests( + p, self.g.name, 80) + print('Found:', len(self.supply_chests), 'supply-labeled chests') + self.state = self.choose_supply_chest - self.barrels = w.find_blocks_indexed(p, blocks.BARREL_IDS, 80) - print('Found:', self.barrels) - self.state = self.choose_barrel - - def choose_barrel(self): - print('Choosing a barrel...') - for barrel in self.barrels: - if barrel in self.checked_barrels: + 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: continue - if barrel in self.bad_barrels: - continue - - print('Chose:', barrel) - self.barrel = barrel - self.state = self.path_to_barrel + self.checked_supply_chests.add(key) + self.chest = container['position'] + self.area = self.chest + print('Chose supply chest:', self.chest) + self.state = self.path_to_supply_chest return - print('No barrels') + print('No more supply-labeled chests') self.state = self.cleanup - def path_to_barrel(self): - print('Finding path to barrel') - w = self.g.world + def path_to_supply_chest(self): + print('Finding path to supply chest') p = utils.pint(self.g.pos) - c = self.g.chunks - - navpath = w.path_to_place_faked(p, self.barrel) + navpath = self.g.world.path_to_place_faked(p, self.chest) print('navpath:', navpath) if navpath: self.g.path = navpath[:-1] self.opening = self.g.path[-1] - self.checked_barrels.append(self.barrel) - self.area = self.barrel - self.state = self.going_to_barrel + self.state = self.going_to_supply_chest self.checked_supplies = [] - return else: - print('Barrel is unreachable; ending supply task') - self.bad_barrels.append(self.barrel) - self.state = self.cleanup + print('Supply chest is unreachable; trying another labeled chest') + self.state = self.choose_supply_chest - def going_to_barrel(self): + def going_to_supply_chest(self): if utils.pint(self.g.pos) == self.opening: self.g.look_at = self.area - self.state = self.open_barrel + self.state = self.open_supply_chest - def open_barrel(self): - print('Opening barrel') + def open_supply_chest(self): + print('Opening supply chest') self.g.game.open_container(self.area) self.wait_time = 1 self.state = self.wait @@ -288,8 +285,8 @@ class GrabSuppliesStates: self.state = self.grab_items return - print('Aborting, dont need any more supplies') - self.state = self.close_barrel + print('No matching items in this supply chest') + self.state = self.close_supply_chest def grab_items(self): if self.g.item_lock: return @@ -299,7 +296,7 @@ class GrabSuppliesStates: print('Didnt get a window, aborting') self.state = self.cleanup return - if w.get('container_slots', 0) != 27: + if w.get('container_slots', 0) not in (27, 54): print('Got wrong container size, aborting') self.state = self.cleanup return @@ -338,11 +335,11 @@ class GrabSuppliesStates: self.wait_time = 0.25 self.state = self.wait - def close_barrel(self): - print('Closing barrel') + def close_supply_chest(self): + print('Closing supply chest') self.g.game.close_window() self.g.look_at = None - self.state = self.choose_barrel + self.state = self.choose_supply_chest def cleanup(self): self.state = self.done @@ -356,10 +353,9 @@ class GrabSuppliesStates: self.state = self.idle self.supplies = self.default_supplies() - self.barrels = [] - self.checked_barrels = [] - self.bad_barrels = [] - self.barrel = None + self.supply_chests = [] + self.checked_supply_chests = set() + self.supply_chest = None self.checked_supplies = [] self.target_items = None self.maximum_stacks = 0 diff --git a/mosfet/world.py b/mosfet/world.py index afa5ead..89fc42f 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -9,6 +9,7 @@ from copy import copy from mosfet import utils from mosfet import path from mosfet.info import blocks +from mosfet.info import items from mosfet.info import mobs class World: @@ -76,6 +77,122 @@ class World: result.sort(key=lambda sign: utils.phyp(center, sign['position'])) return result + def _double_chest_positions(self, chest): + state_id = self.block_at(*chest) + properties = blocks.PROPS.get(state_id, {}) + chest_type = properties.get('type') + if chest_type not in ('left', 'right'): + return (tuple(chest),) + + other_type = 'right' if chest_type == 'left' else 'left' + for offset in ((1, 0, 0), (-1, 0, 0), (0, 0, 1), (0, 0, -1)): + partner = utils.padd(chest, offset) + other = blocks.PROPS.get(self.block_at(*partner), {}) + if (other.get('type') == other_type and + other.get('facing') == properties.get('facing')): + return tuple(sorted((tuple(chest), partner))) + return (tuple(chest),) + + def find_labeled_chests(self, center, distance=80): + """Find single/double chests assigned item categories by nearby signs.""" + grouped = {} + sign_offsets = ( + (0, 0, 0), (0, -1, 0), + (1, 0, 0), (-1, 0, 0), (0, 0, 1), (0, 0, -1), + ) + for sign in self.find_signs(center, distance): + categories = set() + labels = set() + for line in sign['lines']: + item_ids = items.storage_item_ids_for_label(line) + if item_ids: + categories.update(item_ids) + labels.add(line.strip()) + if not categories: + continue + + sign_pos = sign['position'] + adjacent = { + utils.padd(sign_pos, offset) + for offset in sign_offsets + if self.block_at(*utils.padd(sign_pos, offset)) in blocks.CHEST_IDS + } + if not adjacent: + nearby = self.find_blocks_indexed( + sign_pos, blocks.CHEST_IDS, 2) + if nearby: + nearest = min(utils.phyp(sign_pos, chest) + for chest in nearby) + adjacent = {chest for chest in nearby + if utils.phyp(sign_pos, chest) == nearest} + + for chest in adjacent: + positions = self._double_chest_positions(chest) + record = grouped.setdefault(positions, { + 'position': tuple(chest), + 'positions': positions, + 'item_ids': set(), + 'labels': set(), + 'sign_positions': set(), + }) + record['item_ids'].update(categories) + record['labels'].update(labels) + record['sign_positions'].add(sign_pos) + + result = list(grouped.values()) + result.sort(key=lambda chest: utils.phyp(center, chest['position'])) + return result + + def find_supply_chests(self, center, bot_name, distance=80): + """Find chests whose sign is addressed to this bot's supplies.""" + bot_name = str(bot_name).casefold() + group_name = (bot_name[:-1] if len(bot_name) > 1 and + bot_name[-1] in '123' else None) + addresses = {bot_name} + if group_name: + addresses.add(group_name) + + grouped = {} + sign_offsets = ( + (0, 0, 0), (0, -1, 0), + (1, 0, 0), (-1, 0, 0), (0, 0, 1), (0, 0, -1), + ) + for sign in self.find_signs(center, distance): + text = ' '.join(line for line in sign['lines'] if line.strip()) + tokens = re.findall(r'[a-z0-9_]+', text.casefold()) + if (len(tokens) < 2 or tokens[0] not in addresses or + 'supplies' not in tokens[1:]): + continue + + sign_pos = sign['position'] + candidates = { + utils.padd(sign_pos, offset) + for offset in sign_offsets + if self.block_at(*utils.padd(sign_pos, offset)) in blocks.CHEST_IDS + } + if not candidates: + nearby = self.find_blocks_indexed( + sign_pos, blocks.CHEST_IDS, 2) + if nearby: + nearest = min(utils.phyp(sign_pos, chest) for chest in nearby) + candidates = {chest for chest in nearby + if utils.phyp(sign_pos, chest) == nearest} + + for chest in candidates: + positions = self._double_chest_positions(chest) + record = grouped.setdefault(positions, { + 'position': tuple(chest), + 'positions': positions, + 'labels': set(), + 'sign_positions': set(), + }) + record['labels'].update(tokens[1:]) + record['sign_positions'].add(sign_pos) + + result = list(grouped.values()) + result.sort(key=lambda chest: utils.phyp(center, chest['position'])) + return result + def sign_policies(self, bot_name, distance=64): """Parse sign policies addressed to this bot or its numbered group.""" bot_name = str(bot_name).casefold() diff --git a/tests/test_gather_wood.py b/tests/test_gather_wood.py index 461ac31..24e6498 100644 --- a/tests/test_gather_wood.py +++ b/tests/test_gather_wood.py @@ -147,7 +147,7 @@ class GatherWoodPortTests(unittest.TestCase): self.assertTrue(items.AXE_IDS.issubset(items.NEEDED_ITEMS)) self.assertTrue(items.SAPLING_IDS.issubset(items.WANTED_ITEMS)) - job.grab_supplies_states.state = job.grab_supplies_states.find_barrels + job.grab_supplies_states.state = job.grab_supplies_states.find_supply_chests job.clear_leaves_states.state = job.clear_leaves_states.find_leaves job.plant_tree_states.state = job.plant_tree_states.check_feet job.grab_sapling_states.state = job.grab_sapling_states.find_saplings diff --git a/tests/test_grab_supplies.py b/tests/test_grab_supplies.py index 3f27350..35a51fc 100644 --- a/tests/test_grab_supplies.py +++ b/tests/test_grab_supplies.py @@ -8,6 +8,14 @@ from mosfet.jobs.grab_supplies import GrabSuppliesStates class GroundSupplyTests(unittest.TestCase): def test_default_needed_supplies_are_configured_by_constructor(self): state = GrabSuppliesStates(Munch()) + self.assertEqual(items.FOOD, [ + 'cooked_porkchop', 'cooked_beef', 'bread', 'cooked_chicken', + 'cooked_cod', 'cooked_salmon', + ]) + self.assertEqual(items.FOOD_IDS, items.get_set({ + 'cooked_porkchop', 'cooked_beef', 'bread', 'cooked_chicken', + 'cooked_cod', 'cooked_salmon', + })) self.assertEqual(state.supplies, { tuple(items.BED_IDS): (1, 1), (items.CHEST_ID,): (1, 1), @@ -44,19 +52,50 @@ class GroundSupplyTests(unittest.TestCase): self.assertEqual(state.target_items, (items.CHEST_ID,)) self.assertIs(state.state.__func__, state.find_ground_items.__func__) - def test_unreachable_barrel_ends_supply_task_without_trying_more(self): + def test_supply_selection_uses_only_addressed_supply_chests(self): + position = (4, 64, 0) + chest_record = { + 'position': position, + 'positions': (position,), + 'labels': {'mosfet supplies'}, + 'sign_positions': {(4, 64, 1)}, + } + class FakeWorld: @staticmethod - def path_to_place_faked(_start, _barrel): - return None + def find_supply_chests(_center, _bot_name, _distance): + return [chest_record] + + @staticmethod + def find_blocks_indexed(*_args): + raise AssertionError('grab_supplies must not search barrels') + state = GrabSuppliesStates(Munch( + name='mosfet1', pos=(0.5, 64.0, 0.5), world=FakeWorld())) + state.find_supply_chests() + state.choose_supply_chest() + + self.assertEqual(state.chest, position) + self.assertIs(state.state.__func__, state.path_to_supply_chest.__func__) + + def test_unreachable_supply_chest_ends_task_without_searching_barrels(self): + class FakeWorld: + @staticmethod + def path_to_place_faked(_start, _chest): + return None + + position = (10, 64, 10) state = GrabSuppliesStates(Munch( pos=(0.5, 64.0, 0.5), world=FakeWorld(), chunks=Munch())) - state.barrel = (10, 64, 10) - state.path_to_barrel() + state.supply_chests = [{ + 'position': position, 'positions': (position,), + 'labels': {'mosfet supplies'}, + }] + state.choose_supply_chest() + state.path_to_supply_chest() + state.choose_supply_chest() self.assertIs(state.state.__func__, state.cleanup.__func__) - self.assertEqual(state.bad_barrels, [(10, 64, 10)]) def test_job_walks_to_and_picks_up_dropped_supply_before_barrels(self): axe_id = next(iter(items.AXE_IDS)) diff --git a/tests/test_labeled_storage.py b/tests/test_labeled_storage.py new file mode 100644 index 0000000..ec9ede6 --- /dev/null +++ b/tests/test_labeled_storage.py @@ -0,0 +1,77 @@ +import unittest +from munch import Munch + +from mosfet.info import blocks, items +from mosfet.jobs.cache_items import CacheItemsStates +from mosfet.world import World + + +class LabeledStorageTests(unittest.TestCase): + def test_labels_resolve_to_item_categories(self): + seeds = items.storage_item_ids_for_label('Seeds') + wheat = items.storage_item_ids_for_label('Wheat') + + self.assertEqual(seeds, items.get_set({ + 'beetroot', 'beetroot_seeds', 'carrot', 'melon_seeds', + 'nether_wart', 'pitcher_pod', 'potato', 'pumpkin_seeds', + 'torchflower_seeds', 'wheat_seeds', + })) + self.assertEqual(items.storage_item_ids_for_label('Dirt'), set()) + self.assertEqual(items.storage_item_ids_for_label('Stone'), set()) + self.assertIn(items.WHEAT_SEEDS_ID, seeds) + self.assertEqual(wheat, {items.WHEAT_ID}) + self.assertNotIn(items.WHEAT_SEEDS_ID, wheat) + + def test_sign_label_associates_with_adjacent_double_chest(self): + sign_pos = (0, 64, 0) + left_chest = (0, 64, 1) + right_chest = (1, 64, 1) + blocks_by_pos = {left_chest: 5552, right_chest: 5560} + chunks = Munch( + block_entities={sign_pos: { + 'type': 7, + 'data': {'front_text': {'messages': [ + 'Seeds', '', '', '', + ]}}, + }}, + index={}, + get_block_at=lambda *pos: blocks_by_pos.get(tuple(pos)), + ) + chests = World(Munch(chunks=chunks)).find_labeled_chests( + sign_pos, 10) + + self.assertEqual(len(chests), 1) + self.assertEqual(chests[0]['positions'], (left_chest, right_chest)) + self.assertIn(items.WHEAT_SEEDS_ID, chests[0]['item_ids']) + self.assertEqual(chests[0]['labels'], {'Seeds'}) + + def test_wheat_chest_does_not_cache_seeds(self): + wheat = Munch(present=True, item_id=items.WHEAT_ID, item_count=64) + seeds = Munch(present=True, item_id=items.WHEAT_SEEDS_ID, item_count=64) + clicks = [] + + class FakeGame: + @staticmethod + def count_window_slots(): + return 0 + + @staticmethod + def click_window(*args): + clicks.append(args) + + cache = CacheItemsStates(Munch( + inv={9: wheat, 10: seeds}, item_lock=False, + window=Munch(container_slots=27, contents={ + **dict.fromkeys(range(27)), 27: wheat, 28: seeds, + }), + game=FakeGame(), + )) + cache.is_labeled_container = True + cache.container_item_ids = items.storage_item_ids_for_label('Wheat') + cache.move_items() + + self.assertEqual(clicks, [(27, 0, 1, wheat)]) + + +if __name__ == '__main__': + unittest.main()