cogames-agents 0.0.0.7__cp312-cp312-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. cogames_agents/__init__.py +0 -0
  2. cogames_agents/evals/__init__.py +5 -0
  3. cogames_agents/evals/planky_evals.py +415 -0
  4. cogames_agents/policy/__init__.py +0 -0
  5. cogames_agents/policy/evolution/__init__.py +0 -0
  6. cogames_agents/policy/evolution/cogsguard/__init__.py +0 -0
  7. cogames_agents/policy/evolution/cogsguard/evolution.py +695 -0
  8. cogames_agents/policy/evolution/cogsguard/evolutionary_coordinator.py +540 -0
  9. cogames_agents/policy/nim_agents/__init__.py +20 -0
  10. cogames_agents/policy/nim_agents/agents.py +98 -0
  11. cogames_agents/policy/nim_agents/bindings/generated/libnim_agents.dylib +0 -0
  12. cogames_agents/policy/nim_agents/bindings/generated/nim_agents.py +215 -0
  13. cogames_agents/policy/nim_agents/cogsguard_agents.nim +555 -0
  14. cogames_agents/policy/nim_agents/cogsguard_align_all_agents.nim +569 -0
  15. cogames_agents/policy/nim_agents/common.nim +1054 -0
  16. cogames_agents/policy/nim_agents/install.sh +1 -0
  17. cogames_agents/policy/nim_agents/ladybug_agent.nim +954 -0
  18. cogames_agents/policy/nim_agents/nim_agents.nim +68 -0
  19. cogames_agents/policy/nim_agents/nim_agents.nims +14 -0
  20. cogames_agents/policy/nim_agents/nimby.lock +3 -0
  21. cogames_agents/policy/nim_agents/racecar_agents.nim +844 -0
  22. cogames_agents/policy/nim_agents/random_agents.nim +68 -0
  23. cogames_agents/policy/nim_agents/test_agents.py +53 -0
  24. cogames_agents/policy/nim_agents/thinky_agents.nim +677 -0
  25. cogames_agents/policy/nim_agents/thinky_eval.py +230 -0
  26. cogames_agents/policy/scripted_agent/README.md +360 -0
  27. cogames_agents/policy/scripted_agent/__init__.py +0 -0
  28. cogames_agents/policy/scripted_agent/baseline_agent.py +1031 -0
  29. cogames_agents/policy/scripted_agent/cogas/__init__.py +5 -0
  30. cogames_agents/policy/scripted_agent/cogas/context.py +68 -0
  31. cogames_agents/policy/scripted_agent/cogas/entity_map.py +152 -0
  32. cogames_agents/policy/scripted_agent/cogas/goal.py +115 -0
  33. cogames_agents/policy/scripted_agent/cogas/goals/__init__.py +27 -0
  34. cogames_agents/policy/scripted_agent/cogas/goals/aligner.py +160 -0
  35. cogames_agents/policy/scripted_agent/cogas/goals/gear.py +197 -0
  36. cogames_agents/policy/scripted_agent/cogas/goals/miner.py +441 -0
  37. cogames_agents/policy/scripted_agent/cogas/goals/scout.py +40 -0
  38. cogames_agents/policy/scripted_agent/cogas/goals/scrambler.py +174 -0
  39. cogames_agents/policy/scripted_agent/cogas/goals/shared.py +160 -0
  40. cogames_agents/policy/scripted_agent/cogas/goals/stem.py +60 -0
  41. cogames_agents/policy/scripted_agent/cogas/goals/survive.py +100 -0
  42. cogames_agents/policy/scripted_agent/cogas/navigator.py +401 -0
  43. cogames_agents/policy/scripted_agent/cogas/obs_parser.py +238 -0
  44. cogames_agents/policy/scripted_agent/cogas/policy.py +525 -0
  45. cogames_agents/policy/scripted_agent/cogas/trace.py +69 -0
  46. cogames_agents/policy/scripted_agent/cogsguard/CLAUDE.md +517 -0
  47. cogames_agents/policy/scripted_agent/cogsguard/README.md +252 -0
  48. cogames_agents/policy/scripted_agent/cogsguard/__init__.py +74 -0
  49. cogames_agents/policy/scripted_agent/cogsguard/aligned_junction_held_investigation.md +152 -0
  50. cogames_agents/policy/scripted_agent/cogsguard/aligner.py +333 -0
  51. cogames_agents/policy/scripted_agent/cogsguard/behavior_hooks.py +44 -0
  52. cogames_agents/policy/scripted_agent/cogsguard/control_agent.py +323 -0
  53. cogames_agents/policy/scripted_agent/cogsguard/debug_agent.py +533 -0
  54. cogames_agents/policy/scripted_agent/cogsguard/miner.py +589 -0
  55. cogames_agents/policy/scripted_agent/cogsguard/options.py +67 -0
  56. cogames_agents/policy/scripted_agent/cogsguard/parity_metrics.py +36 -0
  57. cogames_agents/policy/scripted_agent/cogsguard/policy.py +1967 -0
  58. cogames_agents/policy/scripted_agent/cogsguard/prereq_trace.py +33 -0
  59. cogames_agents/policy/scripted_agent/cogsguard/role_trace.py +50 -0
  60. cogames_agents/policy/scripted_agent/cogsguard/roles.py +31 -0
  61. cogames_agents/policy/scripted_agent/cogsguard/rollout_trace.py +40 -0
  62. cogames_agents/policy/scripted_agent/cogsguard/scout.py +69 -0
  63. cogames_agents/policy/scripted_agent/cogsguard/scrambler.py +350 -0
  64. cogames_agents/policy/scripted_agent/cogsguard/targeted_agent.py +418 -0
  65. cogames_agents/policy/scripted_agent/cogsguard/teacher.py +224 -0
  66. cogames_agents/policy/scripted_agent/cogsguard/types.py +381 -0
  67. cogames_agents/policy/scripted_agent/cogsguard/v2_agent.py +49 -0
  68. cogames_agents/policy/scripted_agent/common/__init__.py +0 -0
  69. cogames_agents/policy/scripted_agent/common/geometry.py +24 -0
  70. cogames_agents/policy/scripted_agent/common/roles.py +34 -0
  71. cogames_agents/policy/scripted_agent/common/tag_utils.py +48 -0
  72. cogames_agents/policy/scripted_agent/demo_policy.py +242 -0
  73. cogames_agents/policy/scripted_agent/pathfinding.py +126 -0
  74. cogames_agents/policy/scripted_agent/pinky/DESIGN.md +317 -0
  75. cogames_agents/policy/scripted_agent/pinky/__init__.py +5 -0
  76. cogames_agents/policy/scripted_agent/pinky/behaviors/__init__.py +17 -0
  77. cogames_agents/policy/scripted_agent/pinky/behaviors/aligner.py +400 -0
  78. cogames_agents/policy/scripted_agent/pinky/behaviors/base.py +119 -0
  79. cogames_agents/policy/scripted_agent/pinky/behaviors/miner.py +632 -0
  80. cogames_agents/policy/scripted_agent/pinky/behaviors/scout.py +138 -0
  81. cogames_agents/policy/scripted_agent/pinky/behaviors/scrambler.py +433 -0
  82. cogames_agents/policy/scripted_agent/pinky/policy.py +570 -0
  83. cogames_agents/policy/scripted_agent/pinky/services/__init__.py +7 -0
  84. cogames_agents/policy/scripted_agent/pinky/services/map_tracker.py +808 -0
  85. cogames_agents/policy/scripted_agent/pinky/services/navigator.py +864 -0
  86. cogames_agents/policy/scripted_agent/pinky/services/safety.py +189 -0
  87. cogames_agents/policy/scripted_agent/pinky/state.py +299 -0
  88. cogames_agents/policy/scripted_agent/pinky/types.py +138 -0
  89. cogames_agents/policy/scripted_agent/planky/CLAUDE.md +124 -0
  90. cogames_agents/policy/scripted_agent/planky/IMPROVEMENTS.md +160 -0
  91. cogames_agents/policy/scripted_agent/planky/NOTES.md +153 -0
  92. cogames_agents/policy/scripted_agent/planky/PLAN.md +254 -0
  93. cogames_agents/policy/scripted_agent/planky/README.md +214 -0
  94. cogames_agents/policy/scripted_agent/planky/STRATEGY.md +100 -0
  95. cogames_agents/policy/scripted_agent/planky/__init__.py +5 -0
  96. cogames_agents/policy/scripted_agent/planky/context.py +68 -0
  97. cogames_agents/policy/scripted_agent/planky/entity_map.py +152 -0
  98. cogames_agents/policy/scripted_agent/planky/goal.py +107 -0
  99. cogames_agents/policy/scripted_agent/planky/goals/__init__.py +27 -0
  100. cogames_agents/policy/scripted_agent/planky/goals/aligner.py +168 -0
  101. cogames_agents/policy/scripted_agent/planky/goals/gear.py +179 -0
  102. cogames_agents/policy/scripted_agent/planky/goals/miner.py +416 -0
  103. cogames_agents/policy/scripted_agent/planky/goals/scout.py +40 -0
  104. cogames_agents/policy/scripted_agent/planky/goals/scrambler.py +174 -0
  105. cogames_agents/policy/scripted_agent/planky/goals/shared.py +160 -0
  106. cogames_agents/policy/scripted_agent/planky/goals/stem.py +49 -0
  107. cogames_agents/policy/scripted_agent/planky/goals/survive.py +96 -0
  108. cogames_agents/policy/scripted_agent/planky/navigator.py +388 -0
  109. cogames_agents/policy/scripted_agent/planky/obs_parser.py +238 -0
  110. cogames_agents/policy/scripted_agent/planky/policy.py +485 -0
  111. cogames_agents/policy/scripted_agent/planky/tests/__init__.py +0 -0
  112. cogames_agents/policy/scripted_agent/planky/tests/conftest.py +66 -0
  113. cogames_agents/policy/scripted_agent/planky/tests/helpers.py +152 -0
  114. cogames_agents/policy/scripted_agent/planky/tests/test_aligner.py +24 -0
  115. cogames_agents/policy/scripted_agent/planky/tests/test_miner.py +30 -0
  116. cogames_agents/policy/scripted_agent/planky/tests/test_scout.py +15 -0
  117. cogames_agents/policy/scripted_agent/planky/tests/test_scrambler.py +29 -0
  118. cogames_agents/policy/scripted_agent/planky/tests/test_stem.py +36 -0
  119. cogames_agents/policy/scripted_agent/planky/trace.py +69 -0
  120. cogames_agents/policy/scripted_agent/types.py +239 -0
  121. cogames_agents/policy/scripted_agent/unclipping_agent.py +461 -0
  122. cogames_agents/policy/scripted_agent/utils.py +381 -0
  123. cogames_agents/policy/scripted_registry.py +80 -0
  124. cogames_agents/py.typed +0 -0
  125. cogames_agents-0.0.0.7.dist-info/METADATA +98 -0
  126. cogames_agents-0.0.0.7.dist-info/RECORD +128 -0
  127. cogames_agents-0.0.0.7.dist-info/WHEEL +6 -0
  128. cogames_agents-0.0.0.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,160 @@
1
+ """Shared goals used by multiple roles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from cogames_agents.policy.scripted_agent.planky.goal import Goal
8
+ from cogames_agents.policy.scripted_agent.planky.navigator import _manhattan
9
+ from mettagrid.simulator import Action
10
+
11
+ if TYPE_CHECKING:
12
+ from cogames_agents.policy.scripted_agent.planky.context import PlankyContext
13
+
14
+
15
+ class GetHeartsGoal(Goal):
16
+ """Navigate to a chest to acquire hearts.
17
+
18
+ Hearts cost 1 of each element from the collective. Skip if the
19
+ collective can't afford it to avoid wasting time at the chest.
20
+ """
21
+
22
+ name = "GetHearts"
23
+ # Cost per heart: 1 of each element
24
+ HEART_COST = {"carbon": 1, "oxygen": 1, "germanium": 1, "silicon": 1}
25
+
26
+ def __init__(self, min_hearts: int = 1) -> None:
27
+ self._min_hearts = min_hearts
28
+
29
+ # Minimum collective resource reserve — don't consume below this level
30
+ RESOURCE_RESERVE = 3
31
+
32
+ def _collective_can_afford_heart(self, ctx: PlankyContext) -> bool:
33
+ s = ctx.state
34
+ r = self.RESOURCE_RESERVE
35
+ return (
36
+ s.collective_carbon >= 1 + r
37
+ and s.collective_oxygen >= 1 + r
38
+ and s.collective_germanium >= 1 + r
39
+ and s.collective_silicon >= 1 + r
40
+ )
41
+
42
+ def is_satisfied(self, ctx: PlankyContext) -> bool:
43
+ if ctx.state.heart >= self._min_hearts:
44
+ return True
45
+ # Skip if collective can't afford a heart
46
+ if not self._collective_can_afford_heart(ctx):
47
+ if ctx.trace:
48
+ ctx.trace.skip(self.name, "collective lacks resources for heart")
49
+ return True
50
+ return False
51
+
52
+ def execute(self, ctx: PlankyContext) -> Action:
53
+ # Find own team's chest
54
+ pf = {"collective_id": ctx.my_collective_id} if ctx.my_collective_id is not None else None
55
+ result = ctx.map.find_nearest(ctx.state.position, type_contains="chest", property_filter=pf)
56
+ if result is None:
57
+ # Try hub as fallback
58
+ result = ctx.map.find_nearest(ctx.state.position, type_contains="hub", property_filter=pf)
59
+ if result is None:
60
+ return ctx.navigator.explore(ctx.state.position, ctx.map)
61
+
62
+ chest_pos, _ = result
63
+ if ctx.trace:
64
+ ctx.trace.nav_target = chest_pos
65
+
66
+ dist = _manhattan(ctx.state.position, chest_pos)
67
+ if dist <= 1:
68
+ return _move_toward(ctx.state.position, chest_pos)
69
+ return ctx.navigator.get_action(ctx.state.position, chest_pos, ctx.map, reach_adjacent=True)
70
+
71
+
72
+ class FallbackMineGoal(Goal):
73
+ """Fallback: mine resources when combat roles can't act.
74
+
75
+ Used at the bottom of aligner/scrambler goal lists so they contribute
76
+ to the economy instead of idling when they lack gear or hearts.
77
+ """
78
+
79
+ name = "FallbackMine"
80
+
81
+ def is_satisfied(self, ctx: PlankyContext) -> bool:
82
+ from .miner import _collective_resources_sufficient
83
+
84
+ # Stop fallback mining when collective is well-stocked
85
+ if _collective_resources_sufficient(ctx) and ctx.state.cargo_total == 0:
86
+ return True
87
+ return False
88
+
89
+ def execute(self, ctx: PlankyContext) -> Action:
90
+ from .miner import RESOURCE_TYPES, _extractor_recently_failed
91
+
92
+ # If carrying resources, deposit first
93
+ if ctx.state.cargo_total > 0:
94
+ depot_pos = _find_deposit(ctx)
95
+ if depot_pos is not None:
96
+ if ctx.trace:
97
+ ctx.trace.nav_target = depot_pos
98
+ dist = _manhattan(ctx.state.position, depot_pos)
99
+ if dist <= 1:
100
+ return _move_toward(ctx.state.position, depot_pos)
101
+ return ctx.navigator.get_action(ctx.state.position, depot_pos, ctx.map, reach_adjacent=True)
102
+
103
+ # Find nearest usable extractor (any resource type)
104
+ best: tuple[int, tuple[int, int]] | None = None
105
+ for resource in RESOURCE_TYPES:
106
+ for pos, e in ctx.map.find(type=f"{resource}_extractor"):
107
+ if e.properties.get("remaining_uses", 999) <= 0:
108
+ continue
109
+ if e.properties.get("inventory_amount", -1) == 0:
110
+ continue
111
+ if _extractor_recently_failed(ctx, pos):
112
+ continue
113
+ d = _manhattan(ctx.state.position, pos)
114
+ if best is None or d < best[0]:
115
+ best = (d, pos)
116
+
117
+ if best is not None:
118
+ if ctx.trace:
119
+ ctx.trace.nav_target = best[1]
120
+ dist = best[0]
121
+ if dist <= 1:
122
+ return _move_toward(ctx.state.position, best[1])
123
+ return ctx.navigator.get_action(ctx.state.position, best[1], ctx.map, reach_adjacent=True)
124
+
125
+ # No extractors known — explore
126
+ return ctx.navigator.explore(
127
+ ctx.state.position,
128
+ ctx.map,
129
+ direction_bias=["north", "east", "south", "west"][ctx.agent_id % 4],
130
+ )
131
+
132
+
133
+ def _find_deposit(ctx: "PlankyContext") -> tuple[int, int] | None:
134
+ """Find nearest cogs-aligned depot for depositing resources."""
135
+ pos = ctx.state.position
136
+ hub_filter = {"collective_id": ctx.my_collective_id} if ctx.my_collective_id is not None else None
137
+ candidates: list[tuple[int, tuple[int, int]]] = []
138
+ for apos, _ in ctx.map.find(type_contains="hub", property_filter=hub_filter):
139
+ candidates.append((_manhattan(pos, apos), apos))
140
+ for jpos, _ in ctx.map.find(type_contains="junction", property_filter={"alignment": "cogs"}):
141
+ candidates.append((_manhattan(pos, jpos), jpos))
142
+ if not candidates:
143
+ return None
144
+ candidates.sort()
145
+ return candidates[0][1]
146
+
147
+
148
+ def _move_toward(current: tuple[int, int], target: tuple[int, int]) -> Action:
149
+ dr = target[0] - current[0]
150
+ dc = target[1] - current[1]
151
+ if abs(dr) >= abs(dc):
152
+ if dr > 0:
153
+ return Action(name="move_south")
154
+ elif dr < 0:
155
+ return Action(name="move_north")
156
+ if dc > 0:
157
+ return Action(name="move_east")
158
+ elif dc < 0:
159
+ return Action(name="move_west")
160
+ return Action(name="move_north")
@@ -0,0 +1,49 @@
1
+ """Stem goal — select a role based on map and collective state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from cogames_agents.policy.scripted_agent.planky.goal import Goal
8
+ from mettagrid.simulator import Action
9
+
10
+ if TYPE_CHECKING:
11
+ from cogames_agents.policy.scripted_agent.planky.context import PlankyContext
12
+
13
+
14
+ class SelectRoleGoal(Goal):
15
+ """Evaluate map + collective inventory to select a role.
16
+
17
+ Once a role is selected, the agent's goal list is replaced with
18
+ the selected role's goal list. This is a one-time decision.
19
+ """
20
+
21
+ name = "SelectRole"
22
+
23
+ def __init__(self, role_goal_lists: dict | None = None) -> None:
24
+ """
25
+ Args:
26
+ role_goal_lists: Deprecated, ignored. Roles are now vibe-driven.
27
+ """
28
+ self._selected = False
29
+
30
+ def is_satisfied(self, ctx: PlankyContext) -> bool:
31
+ return self._selected
32
+
33
+ def execute(self, ctx: PlankyContext) -> Action:
34
+ role = self._select_role(ctx)
35
+ ctx.blackboard["selected_role"] = role
36
+ ctx.blackboard["change_role"] = role
37
+ self._selected = True
38
+
39
+ if ctx.trace:
40
+ ctx.trace.activate(self.name, f"selected={role}")
41
+
42
+ return Action(name="noop")
43
+
44
+ def _select_role(self, ctx: PlankyContext) -> str:
45
+ """Distribute roles by agent_id: 5 miners, 5 aligners."""
46
+ agent_id = ctx.agent_id
47
+ if agent_id < 5:
48
+ return "miner"
49
+ return "aligner"
@@ -0,0 +1,96 @@
1
+ """SurviveGoal — retreat to safety when HP is critical."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from cogames_agents.policy.scripted_agent.planky.goal import Goal
8
+ from cogames_agents.policy.scripted_agent.planky.navigator import _manhattan
9
+ from mettagrid.simulator import Action
10
+
11
+ if TYPE_CHECKING:
12
+ from cogames_agents.policy.scripted_agent.planky.context import PlankyContext
13
+
14
+ # Game constants
15
+ JUNCTION_AOE_RANGE = 10
16
+ HP_SAFETY_MARGIN = 10
17
+
18
+
19
+ class SurviveGoal(Goal):
20
+ """Retreat to nearest safe zone when HP is low."""
21
+
22
+ name = "Survive"
23
+
24
+ def __init__(self, hp_threshold: int = 30) -> None:
25
+ self._hp_threshold = hp_threshold
26
+
27
+ def is_satisfied(self, ctx: PlankyContext) -> bool:
28
+ # If we're in a safe zone, we're fine
29
+ if _is_in_safe_zone(ctx):
30
+ return True
31
+ # If HP is above threshold, we're fine
32
+ safe_pos = _nearest_safe_zone(ctx)
33
+ if safe_pos is None:
34
+ return ctx.state.hp > 20 # No known safe zone, be conservative
35
+ steps_to_safety = max(0, _manhattan(ctx.state.position, safe_pos) - JUNCTION_AOE_RANGE)
36
+ hp_needed = steps_to_safety + HP_SAFETY_MARGIN
37
+ return ctx.state.hp > hp_needed
38
+
39
+ def execute(self, ctx: PlankyContext) -> Action:
40
+ safe_pos = _nearest_safe_zone(ctx)
41
+ if safe_pos is None:
42
+ return ctx.navigator.explore(ctx.state.position, ctx.map)
43
+ if ctx.trace:
44
+ ctx.trace.nav_target = safe_pos
45
+ return ctx.navigator.get_action(ctx.state.position, safe_pos, ctx.map, reach_adjacent=True)
46
+
47
+
48
+ def _is_in_safe_zone(ctx: PlankyContext) -> bool:
49
+ """Check if agent is within AOE of any cogs structure."""
50
+ pos = ctx.state.position
51
+ # Check hub
52
+ hubs = ctx.map.find(type="hub")
53
+ for apos, _ in hubs:
54
+ if _manhattan(pos, apos) <= JUNCTION_AOE_RANGE:
55
+ return True
56
+ # Check cogs junctions
57
+ junctions = ctx.map.find(type_contains="junction", property_filter={"alignment": "cogs"})
58
+ for jpos, _ in junctions:
59
+ if _manhattan(pos, jpos) <= JUNCTION_AOE_RANGE:
60
+ return True
61
+ # Check cogs junctions
62
+ junctions = ctx.map.find(type_contains="junction", property_filter={"alignment": "cogs"})
63
+ for cpos, _ in junctions:
64
+ if _manhattan(pos, cpos) <= JUNCTION_AOE_RANGE:
65
+ return True
66
+ return False
67
+
68
+
69
+ def _is_in_enemy_aoe(ctx: PlankyContext) -> bool:
70
+ """Check if agent is within AOE of any clips structure."""
71
+ pos = ctx.state.position
72
+ for jpos, _ in ctx.map.find(type_contains="junction", property_filter={"alignment": "clips"}):
73
+ if _manhattan(pos, jpos) <= JUNCTION_AOE_RANGE:
74
+ return True
75
+ for cpos, _ in ctx.map.find(type_contains="junction", property_filter={"alignment": "clips"}):
76
+ if _manhattan(pos, cpos) <= JUNCTION_AOE_RANGE:
77
+ return True
78
+ return False
79
+
80
+
81
+ def _nearest_safe_zone(ctx: PlankyContext) -> tuple[int, int] | None:
82
+ """Find nearest cogs-aligned structure."""
83
+ pos = ctx.state.position
84
+ candidates: list[tuple[int, tuple[int, int]]] = []
85
+
86
+ for apos, _ in ctx.map.find(type="hub"):
87
+ candidates.append((_manhattan(pos, apos), apos))
88
+ for jpos, _ in ctx.map.find(type_contains="junction", property_filter={"alignment": "cogs"}):
89
+ candidates.append((_manhattan(pos, jpos), jpos))
90
+ for cpos, _ in ctx.map.find(type_contains="junction", property_filter={"alignment": "cogs"}):
91
+ candidates.append((_manhattan(pos, cpos), cpos))
92
+
93
+ if not candidates:
94
+ return None
95
+ candidates.sort()
96
+ return candidates[0][1]
@@ -0,0 +1,388 @@
1
+ """A* navigator for Planky policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import random
7
+ from typing import TYPE_CHECKING, Optional
8
+
9
+ from mettagrid.simulator import Action
10
+
11
+ if TYPE_CHECKING:
12
+ from .entity_map import EntityMap
13
+
14
+ MOVE_DELTAS: dict[str, tuple[int, int]] = {
15
+ "north": (-1, 0),
16
+ "south": (1, 0),
17
+ "east": (0, 1),
18
+ "west": (0, -1),
19
+ }
20
+
21
+ DIRECTIONS = ["north", "south", "east", "west"]
22
+
23
+
24
+ class Navigator:
25
+ """A* pathfinding over the entity map."""
26
+
27
+ def __init__(self) -> None:
28
+ self._cached_path: Optional[list[tuple[int, int]]] = None
29
+ self._cached_target: Optional[tuple[int, int]] = None
30
+ self._cached_reach_adjacent: bool = False
31
+ self._position_history: list[tuple[int, int]] = []
32
+
33
+ def get_action(
34
+ self,
35
+ current: tuple[int, int],
36
+ target: tuple[int, int],
37
+ map: EntityMap,
38
+ reach_adjacent: bool = False,
39
+ ) -> Action:
40
+ """Navigate from current to target using A*.
41
+
42
+ Args:
43
+ current: Current position
44
+ target: Target position
45
+ map: Entity map for pathfinding
46
+ reach_adjacent: If True, stop adjacent to target
47
+ """
48
+ # Track position history for stuck detection
49
+ self._position_history.append(current)
50
+ if len(self._position_history) > 30:
51
+ self._position_history.pop(0)
52
+
53
+ # Stuck detection
54
+ if self._is_stuck():
55
+ action = self._break_stuck(current, map)
56
+ if action:
57
+ return action
58
+
59
+ if current == target and not reach_adjacent:
60
+ return Action(name="noop")
61
+
62
+ # Check if adjacent to target (for reach_adjacent mode)
63
+ if reach_adjacent and _manhattan(current, target) == 1:
64
+ return Action(name="noop")
65
+
66
+ # Get or compute path
67
+ path = self._get_path(current, target, map, reach_adjacent)
68
+
69
+ if not path:
70
+ # No path found — try exploring toward target
71
+ return self._move_toward_greedy(current, target, map)
72
+
73
+ next_pos = path[0]
74
+
75
+ # Check if next position is blocked by agent
76
+ if map.has_agent(next_pos):
77
+ sidestep = self._find_sidestep(current, next_pos, target, map)
78
+ if sidestep:
79
+ self._cached_path = None
80
+ return _move_action(current, sidestep)
81
+ return Action(name="noop") # Wait for agent to move
82
+
83
+ # Advance path
84
+ self._cached_path = path[1:] if len(path) > 1 else None
85
+ return _move_action(current, next_pos)
86
+
87
+ def explore(
88
+ self,
89
+ current: tuple[int, int],
90
+ map: EntityMap,
91
+ direction_bias: Optional[str] = None,
92
+ ) -> Action:
93
+ """Navigate toward unexplored frontier cells."""
94
+ self._position_history.append(current)
95
+ if len(self._position_history) > 30:
96
+ self._position_history.pop(0)
97
+
98
+ if self._is_stuck():
99
+ action = self._break_stuck(current, map)
100
+ if action:
101
+ return action
102
+
103
+ frontier = self._find_frontier(current, map, direction_bias)
104
+ if frontier:
105
+ return self.get_action(current, frontier, map)
106
+
107
+ # No frontier — random walk
108
+ return self._random_move(current, map)
109
+
110
+ def _get_path(
111
+ self,
112
+ start: tuple[int, int],
113
+ target: tuple[int, int],
114
+ map: EntityMap,
115
+ reach_adjacent: bool,
116
+ ) -> Optional[list[tuple[int, int]]]:
117
+ """Get cached path or compute new one."""
118
+ if self._cached_path and self._cached_target == target and self._cached_reach_adjacent == reach_adjacent:
119
+ # Verify path is still valid
120
+ for pos in self._cached_path:
121
+ if map.has_agent(pos):
122
+ break
123
+ else:
124
+ return self._cached_path
125
+
126
+ # Compute new path
127
+ goal_cells = self._compute_goals(target, map, reach_adjacent)
128
+ if not goal_cells:
129
+ return None
130
+
131
+ # Try known terrain first
132
+ path = self._astar(start, goal_cells, map, allow_unknown=False)
133
+ if not path:
134
+ # Allow unknown cells
135
+ path = self._astar(start, goal_cells, map, allow_unknown=True)
136
+
137
+ self._cached_path = path.copy() if path else None
138
+ self._cached_target = target
139
+ self._cached_reach_adjacent = reach_adjacent
140
+ return path
141
+
142
+ def _compute_goals(
143
+ self,
144
+ target: tuple[int, int],
145
+ map: EntityMap,
146
+ reach_adjacent: bool,
147
+ ) -> list[tuple[int, int]]:
148
+ if not reach_adjacent:
149
+ return [target]
150
+ goals = []
151
+ for dr, dc in MOVE_DELTAS.values():
152
+ nr, nc = target[0] + dr, target[1] + dc
153
+ pos = (nr, nc)
154
+ if self._is_traversable(pos, map, allow_unknown=True):
155
+ goals.append(pos)
156
+ return goals
157
+
158
+ def _astar(
159
+ self,
160
+ start: tuple[int, int],
161
+ goals: list[tuple[int, int]],
162
+ map: EntityMap,
163
+ allow_unknown: bool,
164
+ ) -> list[tuple[int, int]]:
165
+ """A* pathfinding with iteration limit to prevent hanging."""
166
+ goal_set = set(goals)
167
+ if not goals:
168
+ return []
169
+
170
+ def h(pos: tuple[int, int]) -> int:
171
+ return min(_manhattan(pos, g) for g in goals)
172
+
173
+ tie = 0
174
+ iterations = 0
175
+ max_iterations = 5000 # Prevent infinite search on large unknown maps
176
+
177
+ open_set: list[tuple[int, int, tuple[int, int]]] = [(h(start), tie, start)]
178
+ came_from: dict[tuple[int, int], Optional[tuple[int, int]]] = {start: None}
179
+ g_score: dict[tuple[int, int], int] = {start: 0}
180
+
181
+ while open_set and iterations < max_iterations:
182
+ iterations += 1
183
+ _, _, current = heapq.heappop(open_set)
184
+
185
+ if current in goal_set:
186
+ return self._reconstruct(came_from, current)
187
+
188
+ current_g = g_score.get(current, float("inf"))
189
+ if isinstance(current_g, float):
190
+ continue
191
+
192
+ for dr, dc in MOVE_DELTAS.values():
193
+ neighbor = (current[0] + dr, current[1] + dc)
194
+ is_goal = neighbor in goal_set
195
+ if not is_goal and not self._is_traversable(neighbor, map, allow_unknown):
196
+ continue
197
+
198
+ tentative_g = current_g + 1
199
+ if tentative_g < g_score.get(neighbor, float("inf")):
200
+ came_from[neighbor] = current
201
+ g_score[neighbor] = tentative_g
202
+ f = tentative_g + h(neighbor)
203
+ tie += 1
204
+ heapq.heappush(open_set, (f, tie, neighbor))
205
+
206
+ return []
207
+
208
+ def _reconstruct(
209
+ self,
210
+ came_from: dict[tuple[int, int], Optional[tuple[int, int]]],
211
+ current: tuple[int, int],
212
+ ) -> list[tuple[int, int]]:
213
+ path = []
214
+ while came_from[current] is not None:
215
+ path.append(current)
216
+ prev = came_from[current]
217
+ assert prev is not None
218
+ current = prev
219
+ path.reverse()
220
+ return path
221
+
222
+ def _is_traversable(
223
+ self,
224
+ pos: tuple[int, int],
225
+ map: EntityMap,
226
+ allow_unknown: bool = False,
227
+ ) -> bool:
228
+ """Check if a cell can be walked through."""
229
+ if map.is_wall(pos) or map.is_structure(pos):
230
+ return False
231
+ if map.has_agent(pos):
232
+ return False
233
+ if pos in map.explored:
234
+ return pos not in map.entities or map.entities[pos].type == "agent"
235
+ # Unknown cell
236
+ return allow_unknown
237
+
238
+ def _find_frontier(
239
+ self,
240
+ from_pos: tuple[int, int],
241
+ map: EntityMap,
242
+ direction_bias: Optional[str] = None,
243
+ ) -> Optional[tuple[int, int]]:
244
+ """BFS to find nearest unexplored cell adjacent to explored free cell."""
245
+ from collections import deque
246
+
247
+ if direction_bias == "north":
248
+ deltas = [(-1, 0), (0, -1), (0, 1), (1, 0)]
249
+ elif direction_bias == "south":
250
+ deltas = [(1, 0), (0, -1), (0, 1), (-1, 0)]
251
+ elif direction_bias == "east":
252
+ deltas = [(0, 1), (-1, 0), (1, 0), (0, -1)]
253
+ elif direction_bias == "west":
254
+ deltas = [(0, -1), (-1, 0), (1, 0), (0, 1)]
255
+ else:
256
+ deltas = [(-1, 0), (1, 0), (0, -1), (0, 1)]
257
+
258
+ visited: set[tuple[int, int]] = {from_pos}
259
+ queue: deque[tuple[int, int, int]] = deque([(from_pos[0], from_pos[1], 0)])
260
+
261
+ while queue:
262
+ r, c, dist = queue.popleft()
263
+ if dist > 50:
264
+ continue
265
+
266
+ for dr, dc in deltas:
267
+ nr, nc = r + dr, c + dc
268
+ pos = (nr, nc)
269
+ if pos in visited:
270
+ continue
271
+ visited.add(pos)
272
+
273
+ if pos not in map.explored:
274
+ # Check if any neighbor is explored and free
275
+ for dr2, dc2 in deltas:
276
+ adj = (nr + dr2, nc + dc2)
277
+ if adj in map.explored and map.is_free(adj):
278
+ return pos
279
+ continue
280
+
281
+ if map.is_free(pos):
282
+ queue.append((nr, nc, dist + 1))
283
+
284
+ return None
285
+
286
+ def _find_sidestep(
287
+ self,
288
+ current: tuple[int, int],
289
+ blocked: tuple[int, int],
290
+ target: tuple[int, int],
291
+ map: EntityMap,
292
+ ) -> Optional[tuple[int, int]]:
293
+ """Find sidestep around blocking agent."""
294
+ current_dist = _manhattan(current, target)
295
+ candidates = []
296
+ for d in DIRECTIONS:
297
+ dr, dc = MOVE_DELTAS[d]
298
+ pos = (current[0] + dr, current[1] + dc)
299
+ if pos == blocked:
300
+ continue
301
+ if not self._is_traversable(pos, map, allow_unknown=True):
302
+ continue
303
+ new_dist = _manhattan(pos, target)
304
+ score = new_dist - current_dist
305
+ candidates.append((score, pos))
306
+
307
+ if not candidates:
308
+ return None
309
+ candidates.sort()
310
+ if candidates[0][0] <= 2:
311
+ return candidates[0][1]
312
+ return None
313
+
314
+ def _is_stuck(self) -> bool:
315
+ history = self._position_history
316
+ if len(history) < 6:
317
+ return False
318
+ recent = history[-6:]
319
+ if len(set(recent)) <= 2:
320
+ return True
321
+ if len(history) >= 20:
322
+ current = history[-1]
323
+ earlier = history[:-10]
324
+ if earlier.count(current) >= 2:
325
+ return True
326
+ return False
327
+
328
+ def _break_stuck(self, current: tuple[int, int], map: EntityMap) -> Optional[Action]:
329
+ self._cached_path = None
330
+ self._cached_target = None
331
+ self._position_history.clear()
332
+ return self._random_move(current, map)
333
+
334
+ def _random_move(self, current: tuple[int, int], map: EntityMap) -> Action:
335
+ dirs = list(DIRECTIONS)
336
+ random.shuffle(dirs)
337
+ for d in dirs:
338
+ dr, dc = MOVE_DELTAS[d]
339
+ pos = (current[0] + dr, current[1] + dc)
340
+ if pos in map.explored and not map.is_wall(pos) and not map.is_structure(pos):
341
+ return Action(name=f"move_{d}")
342
+ # Try unknown cells
343
+ for d in dirs:
344
+ dr, dc = MOVE_DELTAS[d]
345
+ pos = (current[0] + dr, current[1] + dc)
346
+ if not map.is_wall(pos):
347
+ return Action(name=f"move_{d}")
348
+ return Action(name="noop")
349
+
350
+ def _move_toward_greedy(self, current: tuple[int, int], target: tuple[int, int], map: EntityMap) -> Action:
351
+ """Move greedily toward target without pathfinding."""
352
+ dr = target[0] - current[0]
353
+ dc = target[1] - current[1]
354
+
355
+ # Try primary direction
356
+ if abs(dr) >= abs(dc):
357
+ primary = "south" if dr > 0 else "north"
358
+ secondary = "east" if dc > 0 else "west"
359
+ else:
360
+ primary = "east" if dc > 0 else "west"
361
+ secondary = "south" if dr > 0 else "north"
362
+
363
+ for d in [primary, secondary]:
364
+ ddr, ddc = MOVE_DELTAS[d]
365
+ pos = (current[0] + ddr, current[1] + ddc)
366
+ if not map.is_wall(pos) and not map.is_structure(pos) and not map.has_agent(pos):
367
+ return Action(name=f"move_{d}")
368
+
369
+ return self._random_move(current, map)
370
+
371
+
372
+ def _manhattan(a: tuple[int, int], b: tuple[int, int]) -> int:
373
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
374
+
375
+
376
+ def _move_action(current: tuple[int, int], target: tuple[int, int]) -> Action:
377
+ """Return move action from current to adjacent target."""
378
+ dr = target[0] - current[0]
379
+ dc = target[1] - current[1]
380
+ if dr == -1 and dc == 0:
381
+ return Action(name="move_north")
382
+ if dr == 1 and dc == 0:
383
+ return Action(name="move_south")
384
+ if dr == 0 and dc == 1:
385
+ return Action(name="move_east")
386
+ if dr == 0 and dc == -1:
387
+ return Action(name="move_west")
388
+ return Action(name="noop")