diff --git a/mosfet/job.py b/mosfet/job.py index b6676cd..9b10de3 100644 --- a/mosfet/job.py +++ b/mosfet/job.py @@ -82,7 +82,11 @@ class LegacyJobStates: if m.state == m.idle: continue if m.state != m.done: - m.run() + self.active_machine = m + try: + m.run() + finally: + self.active_machine = None return # if we went through them all for m in machines: @@ -256,6 +260,7 @@ class LegacyJobStates: def __init__(self, global_state): self.g = global_state + self.active_machine = None self.init_machines() self.state = self.idle @@ -281,6 +286,7 @@ class JobStates(LegacyJobStates): self.eat_food_states = eat_food.EatFoodStates(global_state) self.check_threats_states = check_threats.CheckThreatsStates( global_state) + self.active_machine = None self.state = self.idle def farm_wood(self): @@ -356,4 +362,5 @@ class JobStates(LegacyJobStates): self.check_threats_states.external_safety = False self.gather_crop_states.active_section_anchor = None self.gather_crop_states.completed_crops = 0 + self.active_machine = None self.state = self.idle diff --git a/mosfet/jobs/gather_crop.py b/mosfet/jobs/gather_crop.py index 3b3bc2c..1beac21 100644 --- a/mosfet/jobs/gather_crop.py +++ b/mosfet/jobs/gather_crop.py @@ -92,10 +92,12 @@ class GatherCropStates: blocks.MATURE_BEETROOT_ID, ] - candidates = w.find_blocks_indexed(p, mature_crops, 50) + candidates = w.filter_work_positions( + w.find_blocks_indexed(p, mature_crops, 50)) crop_ids = (blocks.WHEAT_IDS | blocks.POTATO_IDS | blocks.CARROT_IDS | blocks.BEETROOT_IDS) - crop_positions = w.find_blocks_indexed(p, crop_ids, 50) + crop_positions = w.filter_work_positions( + w.find_blocks_indexed(p, crop_ids, 50)) crop_types = { position: blocks.BLOCKS.get(w.block_at(*position)) for position in crop_positions diff --git a/mosfet/jobs/gather_sand.py b/mosfet/jobs/gather_sand.py index 1d7d251..9052e31 100644 --- a/mosfet/jobs/gather_sand.py +++ b/mosfet/jobs/gather_sand.py @@ -61,12 +61,13 @@ class GatherSandStates: head = utils.padd(p, path.BLOCK_ABOVE) for sand in w.find_sand(self.slice, 2, p): - if sand not in self.bad_sand: + if (sand not in self.bad_sand and + w.work_position_allowed(sand)): print('Found sand:', sand) break else: # for - print('No good sands left, aborting.') - self.state = self.cleanup + print('No in-bounds sand left in this slice; trying another.') + self.state = self.find_new_slice return self.sand = sand diff --git a/mosfet/jobs/gather_wart.py b/mosfet/jobs/gather_wart.py index 3a3eb98..4405ced 100644 --- a/mosfet/jobs/gather_wart.py +++ b/mosfet/jobs/gather_wart.py @@ -30,7 +30,8 @@ class GatherWartStates: mature_wart = max(blocks.NETHERWART_IDS) for wart in w.find_blocks_3d(p, [mature_wart], 50, 20, True): - if wart not in self.bad_warts: + if (wart not in self.bad_warts and + w.work_position_allowed(wart)): break else: # for print('No good warts left, aborting.') diff --git a/mosfet/jobs/gather_wood.py b/mosfet/jobs/gather_wood.py index 48142a2..9637462 100644 --- a/mosfet/jobs/gather_wood.py +++ b/mosfet/jobs/gather_wood.py @@ -30,10 +30,11 @@ class GatherWoodStates: w = self.g.world p = utils.pint(self.g.pos) - for tree in w.find_trees(p, 100): - print('Found tree:', tree) + trees = w.filter_work_positions(w.find_trees(p, 100)) + for tree in trees: if tree in self.bad_trees: continue + print('Found tree:', tree) tree_type = blocks.BLOCKS[w.block_at(*tree)].replace('_log', '') if items.get_sapling_id(tree_type) is None: print('No plantable sapling for', tree_type, '- skipping') @@ -53,7 +54,10 @@ class GatherWoodStates: def find_openings(self): w = self.g.world - self.openings = w.find_tree_openings(self.tree) + self.openings = [ + opening for opening in w.find_tree_openings(self.tree) + if w.work_position_allowed(opening) + ] self.state = self.choose_opening def choose_opening(self): diff --git a/mosfet/jobs/grab_sapling.py b/mosfet/jobs/grab_sapling.py index 197fee6..3cbdad6 100644 --- a/mosfet/jobs/grab_sapling.py +++ b/mosfet/jobs/grab_sapling.py @@ -44,6 +44,8 @@ class GrabSaplingStates: if s.entity_id in self.eid_blacklist: continue + if not w.work_position_allowed(s_pos): + continue # skip if the sapling is floating if self.g.chunks.get_block_at(*check) in blocks.LEAF_IDS | {0}: diff --git a/mosfet/jobs/plant_tree.py b/mosfet/jobs/plant_tree.py index 199eb69..95a32c3 100644 --- a/mosfet/jobs/plant_tree.py +++ b/mosfet/jobs/plant_tree.py @@ -80,6 +80,8 @@ class PlantTreeStates: p = utils.pint(self.g.pos) for opening in w.find_tree_openings(p)[::-1]: + if not w.work_position_allowed(opening): + continue print('trying sapling opening', opening) navpath = w.path_to_place(p, opening) if navpath: diff --git a/mosfet/world.py b/mosfet/world.py index c38cf87..afa5ead 100644 --- a/mosfet/world.py +++ b/mosfet/world.py @@ -76,6 +76,113 @@ class World: result.sort(key=lambda sign: utils.phyp(center, sign['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() + 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) + + center = utils.pint(self.g.pos) + policies = [] + active_sign_position = None + for sign in self.find_signs(center, distance): + lines = [line.strip() for line in sign['lines'] if line.strip()] + if not lines: + continue + first = lines[0].split(maxsplit=1) + address = first[0].rstrip(':,').casefold() + if address not in addresses: + continue + directives = ([first[1]] if len(first) > 1 else []) + lines[1:] + text = ' '.join(directives).casefold() + sign_policies = [] + for match in re.finditer( + r'\bstay\s+within\s*(\d+)(?:\s+(squares?|circle))?\b', + text): + shape = match.group(2) or 'circle' + if shape == 'squares': + shape = 'square' + sign_policies.append({ + 'position': sign['position'], + 'distance': int(match.group(1)), + 'shape': shape, + 'side': sign['side'], + }) + if not sign_policies: + continue + if active_sign_position is None: + # The nearest addressed policy sign overrides farther signs. + active_sign_position = sign['position'] + if sign['position'] == active_sign_position: + policies.extend(sign_policies) + return policies + + def _active_work_policies(self): + job = self.g.get('job') + machine = getattr(job, 'active_machine', None) if job else None + if machine is None: + return [] + support_machines = ( + 'grab_supplies_states', 'sleep_with_bed_states', + 'eat_food_states', 'cache_items_states', + 'check_threats_states', + ) + if any(machine is getattr(job, name, None) + for name in support_machines): + return [] + return self.sign_policies(self.g.get('name', '')) + + @staticmethod + def _sign_policy_distance(position, policy): + # Work zones are horizontal; crop height or terrain elevation does not + # consume the sign's radius. + dx = abs(position[0] - policy['position'][0]) + dz = abs(position[2] - policy['position'][2]) + if policy['shape'] == 'square': + return max(dx, dz) + return utils.hypot(dx, dz) + + @classmethod + def _inside_sign_policy(cls, position, policy): + return cls._sign_policy_distance(position, policy) <= policy['distance'] + + def work_position_allowed(self, position): + """Whether a work target satisfies all currently active sign rules.""" + return all(self._inside_sign_policy(position, policy) + for policy in self._active_work_policies()) + + def filter_work_positions(self, positions): + """Filter a candidate set once against the active sign policies.""" + policies = self._active_work_policies() + positions = list(positions) + if not policies: + return positions + return [position for position in positions + if all(self._inside_sign_policy(position, policy) + for policy in policies)] + + @classmethod + def _path_allowed_by_sign(cls, points, policy): + distances = [cls._sign_policy_distance(point, policy) + for point in points] + radius = policy['distance'] + if distances[0] <= radius: + return all(distance <= radius for distance in distances) + + # If the bot starts just outside a newly read boundary, let it return + # without allowing it to travel farther away from that sign. + start_distance = distances[0] + entered = False + for distance in distances[1:]: + if distance > start_distance or (entered and distance > radius): + return False + if distance <= radius: + entered = True + return entered + def check_air_column(self, pos, distance): for i in range(distance): check = utils.padd(pos, (0, i, 0)) @@ -194,11 +301,23 @@ class World: maze_solver = path.Pathfinder(self.g) try: - s = maze_solver.astar(start, place) - return list(s) if s else None + solution = maze_solver.astar(start, place) + if not solution: + return None + navpath = list(solution) except path.AStarTimeout: return None + policies = self._active_work_policies() + points = [tuple(start)] + [tuple(point) for point in navpath] + for policy in policies: + if not self._path_allowed_by_sign(points, policy): + print('[sign policy] rejected work path outside {}-block {} ' + 'around {}'.format(policy['distance'], policy['shape'], + policy['position']), flush=True) + return None + return navpath + def path_to_place_faked(self, start, place): # same as above, but adds a fake block below and air before pathfinding # so that the pathfinder can actually make it to the block diff --git a/tests/test_gather_wood.py b/tests/test_gather_wood.py index 6facc3f..461ac31 100644 --- a/tests/test_gather_wood.py +++ b/tests/test_gather_wood.py @@ -36,6 +36,14 @@ class GatherWoodPortTests(unittest.TestCase): def find_trees(self, _position, _distance): return [(0, 64, 0), (3, 64, 0)] + @staticmethod + def work_position_allowed(_position): + return True + + def filter_work_positions(self, positions): + return [position for position in positions + if self.work_position_allowed(position)] + def block_at(self, x, y, z): return (blocks.MCD_BLOCKS['crimson_stem']['minStateId'] if x == 0 else diff --git a/tests/test_sign_work_targets.py b/tests/test_sign_work_targets.py new file mode 100644 index 0000000..926e833 --- /dev/null +++ b/tests/test_sign_work_targets.py @@ -0,0 +1,68 @@ +import unittest +from munch import Munch + +from mosfet.info import blocks +from mosfet.jobs.gather_crop import GatherCropStates +from mosfet.jobs.gather_wood import GatherWoodStates + + +class SignWorkTargetTests(unittest.TestCase): + def test_crop_search_filters_out_of_bounds_candidates_before_navigation(self): + outside = (10, 64, 0) + mature_wheat = blocks.MATURE_WHEAT_ID + crop_state = blocks.MCD_BLOCKS['wheat']['minStateId'] + + class FakeWorld: + def find_blocks_indexed(self, _center, _block_ids, _distance): + return [outside] + + @staticmethod + def work_position_allowed(position): + return position != outside + + def filter_work_positions(self, positions): + return [position for position in positions + if self.work_position_allowed(position)] + + @staticmethod + def block_at(*_position): + return crop_state + + state = GatherCropStates(Munch(world=FakeWorld(), pos=(0, 64, 0))) + state.find_new_crop() + + self.assertIs(state.state.__func__, state.wait_for_crops.__func__) + self.assertEqual(state.bad_crops, []) + + def test_tree_search_skips_out_of_bounds_tree_without_blacklisting_it(self): + outside = (10, 64, 0) + inside = (2, 64, 0) + oak_log = blocks.MCD_BLOCKS['oak_log']['minStateId'] + + class FakeWorld: + @staticmethod + def find_trees(_center, _distance): + return [outside, inside] + + @staticmethod + def work_position_allowed(position): + return position != outside + + def filter_work_positions(self, positions): + return [position for position in positions + if self.work_position_allowed(position)] + + @staticmethod + def block_at(*_position): + return oak_log + + state = GatherWoodStates(Munch(world=FakeWorld(), pos=(0, 64, 0))) + state.find_new_tree() + + self.assertEqual(state.tree, inside) + self.assertEqual(state.type, 'oak') + self.assertEqual(state.bad_trees, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_signs.py b/tests/test_signs.py index 0576a28..39d1605 100644 --- a/tests/test_signs.py +++ b/tests/test_signs.py @@ -1,4 +1,5 @@ import unittest +from unittest.mock import patch from munch import Munch from mosfet.commands import Commands @@ -44,6 +45,88 @@ class SignReadingTests(unittest.TestCase): signs = World(Munch(chunks=chunks)).find_signs((4, 65, -2), 1) self.assertEqual(signs[0]['lines'], ('Farm', '', '', '')) + def test_group_and_individual_sign_addresses(self): + group_world = World(Munch(chunks=Munch(block_entities={ + (0, 64, 0): {'data': {'front_text': {'messages': [ + 'mosfet', 'stay within25', 'square', '', + ]}}}, + }), pos=(0, 64, 0))) + for bot_name in ('mosfet1', 'mosfet2', 'mosfet3'): + self.assertEqual([p['shape'] for p in + group_world.sign_policies(bot_name)], ['square']) + self.assertEqual(group_world.sign_policies('mosfet4'), []) + + individual_world = World(Munch(chunks=Munch(block_entities={ + (1, 64, 0): {'data': {'front_text': {'messages': [ + 'mosfet2', 'stay within 10 circle', '', '', + ]}}}, + }), pos=(1, 64, 0))) + self.assertEqual([p['shape'] for p in + individual_world.sign_policies('mosfet2')], ['circle']) + self.assertEqual(individual_world.sign_policies('mosfet1'), []) + + def test_nearest_sign_policy_overrides_farther_matching_sign(self): + chunks = Munch(block_entities={ + (-138, 71, -268): {'data': {'front_text': {'messages': [ + 'mosfet1', 'stay within 25 square', '', '', + ]}}}, + (-158, 71, -248): {'data': {'front_text': {'messages': [ + 'mosfet1', 'stay within 6 squares', '', '', + ]}}}, + }) + world = World(Munch(chunks=chunks, pos=(-158, 71, -248))) + self.assertEqual(world.sign_policies('mosfet1'), [{ + 'position': (-158, 71, -248), 'distance': 6, + 'shape': 'square', 'side': 'front', + }]) + + def test_work_paths_obey_sign_boundary_but_cache_paths_do_not(self): + sign_data = {'front_text': {'messages': [ + 'mosfet1', 'stay within 25 square', '', '', + ]}} + chunks = Munch(block_entities={(0, 64, 0): {'data': sign_data}}) + work_machine = object() + cache_machine = object() + job = Munch(active_machine=work_machine, + cache_items_states=cache_machine) + world = World(Munch(chunks=chunks, job=job, name='mosfet1', + pos=(0, 64, 0))) + + class FixedPathfinder: + @staticmethod + def astar(_start, _place): + return [(0, 64, 0), (20, 64, 0), (30, 64, 0)] + + with patch('mosfet.world.path.Pathfinder', return_value=FixedPathfinder()): + self.assertIsNone(world.path_to_place((0, 64, 0), (30, 64, 0))) + job.active_machine = cache_machine + self.assertEqual(world.path_to_place( + (0, 64, 0), (30, 64, 0)), + [(0, 64, 0), (20, 64, 0), (30, 64, 0)]) + + policy = {'position': (0, 64, 0), 'distance': 25, 'shape': 'square'} + self.assertTrue(World._path_allowed_by_sign( + [(30, 64, 0), (27, 64, 0), (24, 64, 0)], policy)) + self.assertFalse(World._path_allowed_by_sign( + [(30, 64, 0), (32, 64, 0), (24, 64, 0)], policy)) + + six_square = {'position': (-158, 71, -248), + 'distance': 6, 'shape': 'square'} + six_circle = dict(six_square, shape='circle') + crop = (-162, 68, -247) + self.assertEqual(World._sign_policy_distance(crop, six_square), 4) + self.assertAlmostEqual(World._sign_policy_distance(crop, six_circle), + (17 ** 0.5)) + + four_square = {'position': (0, 64, 0), + 'distance': 4, 'shape': 'square'} + self.assertEqual(World._sign_policy_distance((4, 70, 4), four_square), 4) + self.assertTrue(World._inside_sign_policy((4, 70, 4), four_square)) + self.assertFalse(World._inside_sign_policy((5, 64, 4), four_square)) + same_xz_far_y = (-158, -20, -248) + self.assertEqual( + World._sign_policy_distance(same_xz_far_y, six_square), 0) + def test_signs_command_replies_with_nearby_text(self): class FakeChat: def __init__(self):