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,138 @@
1
+ """
2
+ Scout behavior for Pinky policy.
3
+
4
+ Scouts explore the map to discover structures for other roles.
5
+ Strategy: Frontier-based exploration, venture deep with +400 HP from gear.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import deque
11
+ from typing import TYPE_CHECKING, Optional
12
+
13
+ from cogames_agents.policy.scripted_agent.pinky.behaviors.base import Services, is_adjacent
14
+ from cogames_agents.policy.scripted_agent.pinky.types import DEBUG, ROLE_TO_STATION, RiskTolerance, Role
15
+ from mettagrid.simulator import Action
16
+
17
+ if TYPE_CHECKING:
18
+ from cogames_agents.policy.scripted_agent.pinky.state import AgentState
19
+
20
+
21
+ class ScoutBehavior:
22
+ """Scout agent: explore and discover the map."""
23
+
24
+ role = Role.SCOUT
25
+ risk_tolerance = RiskTolerance.AGGRESSIVE
26
+
27
+ def act(self, state: AgentState, services: Services) -> Action:
28
+ """Execute scout behavior."""
29
+ # Priority 1: Retreat only if critically low HP (scouts are tanky)
30
+ if state.hp < 50:
31
+ if DEBUG:
32
+ print(f"[A{state.agent_id}] SCOUT: Retreating! HP={state.hp}")
33
+ return self._retreat_to_safety(state, services)
34
+
35
+ # Priority 2: Get gear if missing (high priority - +400 HP is huge)
36
+ if self.needs_gear(state):
37
+ return self._get_gear(state, services)
38
+
39
+ # Priority 3: Frontier-based exploration
40
+ return self._explore_frontier(state, services)
41
+
42
+ def needs_gear(self, state: AgentState) -> bool:
43
+ """Scouts need scout gear for +400 HP."""
44
+ return not state.scout_gear
45
+
46
+ def has_resources_to_act(self, state: AgentState) -> bool:
47
+ """Scouts don't need resources to explore."""
48
+ return True
49
+
50
+ def _retreat_to_safety(self, state: AgentState, services: Services) -> Action:
51
+ """Return to nearest safe zone."""
52
+ safe_pos = services.safety.nearest_safe_zone(state)
53
+ if safe_pos is None:
54
+ # No safe zone known, just explore
55
+ return services.navigator.explore(state)
56
+ return services.navigator.move_to(state, safe_pos, reach_adjacent=True)
57
+
58
+ def _get_gear(self, state: AgentState, services: Services) -> Action:
59
+ """Get scout gear from station."""
60
+ station_name = ROLE_TO_STATION[Role.SCOUT]
61
+ station_pos = state.map.stations.get(station_name)
62
+
63
+ if station_pos is None:
64
+ # No gear station found after initial search, proceed without gear
65
+ if state.step > 20:
66
+ return self._explore_frontier(state, services)
67
+ if DEBUG:
68
+ print(f"[A{state.agent_id}] SCOUT: Station not found, exploring")
69
+ return services.navigator.explore(state)
70
+
71
+ if is_adjacent(state.pos, station_pos):
72
+ if DEBUG:
73
+ print(f"[A{state.agent_id}] SCOUT: Getting gear from {station_pos}")
74
+ return services.navigator.use_object_at(state, station_pos)
75
+
76
+ return services.navigator.move_to(state, station_pos, reach_adjacent=True)
77
+
78
+ def _explore_frontier(self, state: AgentState, services: Services) -> Action:
79
+ """Find and move toward nearest unexplored frontier cell."""
80
+ frontier = self._find_nearest_frontier(state)
81
+
82
+ if frontier is None:
83
+ # Map fully explored or boxed in - patrol
84
+ if DEBUG and state.step % 50 == 0:
85
+ explored = sum(sum(row) for row in state.map.explored)
86
+ total = state.map.grid_size * state.map.grid_size
87
+ print(f"[A{state.agent_id}] SCOUT: No frontier, explored={explored}/{total}")
88
+ return services.navigator.explore(state)
89
+
90
+ return services.navigator.move_to(state, frontier)
91
+
92
+ def _find_nearest_frontier(self, state: AgentState) -> Optional[tuple[int, int]]:
93
+ """BFS to find nearest unexplored cell adjacent to explored cell.
94
+
95
+ A frontier is an unexplored cell next to an explored cell.
96
+ """
97
+ if not state.map.explored:
98
+ return None
99
+
100
+ start = state.pos
101
+ visited: set[tuple[int, int]] = {start}
102
+ queue: deque[tuple[tuple[int, int], Optional[tuple[int, int]]]] = deque()
103
+ queue.append((start, None))
104
+
105
+ directions = [(-1, 0), (1, 0), (0, 1), (0, -1)]
106
+
107
+ while queue:
108
+ pos, first_step = queue.popleft()
109
+ r, c = pos
110
+
111
+ for dr, dc in directions:
112
+ nr, nc = r + dr, c + dc
113
+
114
+ # Check bounds
115
+ if not (0 <= nr < state.map.grid_size and 0 <= nc < state.map.grid_size):
116
+ continue
117
+
118
+ if (nr, nc) in visited:
119
+ continue
120
+
121
+ visited.add((nr, nc))
122
+
123
+ # Found unexplored cell - this is our frontier target
124
+ if not state.map.explored[nr][nc]:
125
+ if first_step is None:
126
+ return (nr, nc)
127
+ return first_step
128
+
129
+ # Continue BFS through explored, free cells
130
+ from cogames_agents.policy.scripted_agent.pinky.types import CellType
131
+
132
+ if state.map.occupancy[nr][nc] == CellType.FREE.value:
133
+ next_first_step = first_step
134
+ if first_step is None and (r, c) == start:
135
+ next_first_step = (nr, nc)
136
+ queue.append(((nr, nc), next_first_step))
137
+
138
+ return None
@@ -0,0 +1,433 @@
1
+ """
2
+ Scrambler behavior for Pinky policy.
3
+
4
+ Scramblers raid enemy junctions to neutralize them, enabling aligners.
5
+ Strategy: Get hearts, target enemy junctions that block neutral territory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Optional
11
+
12
+ from cogames_agents.policy.scripted_agent.pinky.behaviors.base import (
13
+ Services,
14
+ explore_for_station,
15
+ get_explore_direction_for_agent,
16
+ is_adjacent,
17
+ manhattan_distance,
18
+ )
19
+ from cogames_agents.policy.scripted_agent.pinky.types import (
20
+ DEBUG,
21
+ JUNCTION_AOE_RANGE,
22
+ ROLE_TO_STATION,
23
+ DebugInfo,
24
+ RiskTolerance,
25
+ Role,
26
+ StructureInfo,
27
+ )
28
+ from mettagrid.simulator import Action
29
+
30
+ if TYPE_CHECKING:
31
+ from cogames_agents.policy.scripted_agent.pinky.state import AgentState
32
+
33
+
34
+ class ScramblerBehavior:
35
+ """Scrambler agent: neutralize enemy junctions."""
36
+
37
+ role = Role.SCRAMBLER
38
+ risk_tolerance = RiskTolerance.AGGRESSIVE
39
+
40
+ # How many steps to explore before giving up on gear
41
+ EXPLORATION_STEPS = 100
42
+
43
+ # How many ticks to explore before retrying gear station
44
+ GEAR_RETRY_INTERVAL = 100
45
+
46
+ # How many steps before junction alignment data is considered stale
47
+ # Since alignment can change (e.g., enemy aligners convert junctions),
48
+ # scramblers should revisit old junctions to check for new targets
49
+ JUNCTION_STALE_THRESHOLD = 50
50
+
51
+ def act(self, state: AgentState, services: Services) -> Action:
52
+ """Execute scrambler behavior.
53
+
54
+ Priority order:
55
+ 1. Stuck detection - break out of stuck loops (using navigator's escape handling)
56
+ 2. Critical HP retreat - survive first
57
+ 3. Get gear (REQUIRED) - must have scrambler gear to scramble effectively
58
+ 4. Get hearts (REQUIRED) - must have hearts to scramble
59
+ 5. Hunt and scramble enemy junctions
60
+ """
61
+ # Track gear state
62
+ state.had_gear_last_step = state.scrambler_gear
63
+
64
+ # Priority 0: Check for stuck patterns and handle escape mode (via navigator)
65
+ escape_action = services.navigator.check_and_handle_escape(state)
66
+ if escape_action:
67
+ debug_info = services.navigator.get_escape_debug_info(state)
68
+ state.debug_info = DebugInfo(**debug_info)
69
+ return escape_action
70
+
71
+ # Priority 1: Retreat only if critically low (scramblers are tanky)
72
+ # Only retreat when HP drops below 30 (they start with 50)
73
+ if state.hp < 30:
74
+ if DEBUG:
75
+ print(f"[A{state.agent_id}] SCRAMBLER: Retreating! HP={state.hp}")
76
+ state.debug_info = DebugInfo(mode="retreat", goal="safety", target_object="safe_zone", signal="hp_low")
77
+ return self._retreat_to_safety(state, services)
78
+
79
+ # Priority 2: Get gear (REQUIRED) - scrambler gear is needed to scramble effectively
80
+ # Gear gives +200 HP which is essential for surviving in enemy territory
81
+ if self.needs_gear(state):
82
+ return self._get_gear(state, services)
83
+
84
+ # Priority 3: Get hearts (REQUIRED) - hearts are needed to scramble junctions
85
+ if not self.has_resources_to_act(state):
86
+ return self._get_hearts(state, services)
87
+
88
+ # Priority 4: Hunt and scramble enemy junctions
89
+ return self._scramble_junction(state, services)
90
+
91
+ def needs_gear(self, state: AgentState) -> bool:
92
+ """Scramblers MUST have scrambler gear to scramble effectively.
93
+
94
+ Gear provides +200 HP which is essential for surviving enemy territory.
95
+ Without gear, scramblers would die too quickly to be effective.
96
+ """
97
+ return not state.scrambler_gear
98
+
99
+ def has_resources_to_act(self, state: AgentState) -> bool:
100
+ """Scramblers need hearts to scramble junctions."""
101
+ return state.heart >= 1
102
+
103
+ def _retreat_to_safety(self, state: AgentState, services: Services) -> Action:
104
+ """Return to nearest safe zone."""
105
+ safe_pos = services.safety.nearest_safe_zone(state)
106
+ if safe_pos is None:
107
+ return services.navigator.explore(state)
108
+ return services.navigator.move_to(state, safe_pos, reach_adjacent=True)
109
+
110
+ def _get_gear(self, state: AgentState, services: Services) -> Action:
111
+ """Get scrambler gear from station."""
112
+ station_name = ROLE_TO_STATION[Role.SCRAMBLER]
113
+
114
+ # First try visible scrambler_station in current observation
115
+ if state.last_obs is not None:
116
+ result = services.map_tracker.get_direction_to_nearest(state, state.last_obs, frozenset({station_name}))
117
+ if result:
118
+ direction, target_pos = result
119
+ if DEBUG:
120
+ print(f"[A{state.agent_id}] SCRAMBLER: Station visible at {target_pos}, moving {direction}")
121
+ state.debug_info = DebugInfo(
122
+ mode="get_gear", goal="scrambler_station", target_object=station_name, target_pos=target_pos
123
+ )
124
+ return Action(name=f"move_{direction}")
125
+
126
+ # Use accumulated map knowledge if station was found
127
+ station_pos = state.map.stations.get(station_name)
128
+
129
+ if station_pos is not None:
130
+ dist = manhattan_distance(state.pos, station_pos)
131
+
132
+ # If ON the station, we should have received gear from walking in
133
+ if state.pos == station_pos:
134
+ if DEBUG:
135
+ print(f"[A{state.agent_id}] SCRAMBLER: ON station {station_pos}, no gear - explore")
136
+ state.debug_info = DebugInfo(
137
+ mode="get_gear", goal="on_station_no_gear", target_object=station_name, target_pos=station_pos
138
+ )
139
+ state.last_gear_attempt_step = state.step
140
+ return Action(name="move_east")
141
+
142
+ if is_adjacent(state.pos, station_pos):
143
+ if DEBUG:
144
+ print(f"[A{state.agent_id}] SCRAMBLER: Getting gear from {station_pos}")
145
+ state.debug_info = DebugInfo(
146
+ mode="get_gear", goal="use_station", target_object=station_name, target_pos=station_pos
147
+ )
148
+ return services.navigator.use_object_at(state, station_pos)
149
+
150
+ if DEBUG and state.step % 10 == 0:
151
+ print(f"[A{state.agent_id}] SCRAMBLER: Moving to station at {station_pos} (dist={dist})")
152
+ state.debug_info = DebugInfo(
153
+ mode="get_gear", goal=f"move_to_station({dist})", target_object=station_name, target_pos=station_pos
154
+ )
155
+ return services.navigator.move_to(state, station_pos, reach_adjacent=True)
156
+
157
+ # Station not found yet - explore
158
+ if DEBUG and state.step % 10 == 0:
159
+ print(f"[A{state.agent_id}] SCRAMBLER: Exploring for station (step {state.step})")
160
+ state.debug_info = DebugInfo(mode="explore", goal="find_station", target_object=station_name)
161
+ return self._explore_for_station(state, services)
162
+
163
+ def _explore_for_station(self, state: AgentState, services: Services) -> Action:
164
+ """Explore to find the scrambler station."""
165
+ # Spread scramblers out by giving each agent a different primary direction
166
+ direction = get_explore_direction_for_agent(state.agent_id)
167
+ return explore_for_station(state, services, primary_direction=direction)
168
+
169
+ # How often to cycle exploration direction (in steps)
170
+ EXPLORE_DIRECTION_CYCLE = 100
171
+
172
+ def _explore_for_enemy_junctions(self, state: AgentState, services: Services) -> Action:
173
+ """Explore to find clips junctions, ensuring whole-map coverage.
174
+
175
+ Strategy:
176
+ 1. Revisit stale junctions (alignment data may be outdated) - least recently seen first
177
+ 2. If no stale junctions, patrol the map by cycling through different directions
178
+ 3. Reset exploration origin periodically to cover new areas
179
+ """
180
+ # Find junctions that haven't been visited recently (alignment could have changed)
181
+ # Prioritize least recently seen - these are most likely to have outdated alignment info
182
+ stale_junctions: list[tuple[int, int, StructureInfo]] = [] # (steps_since_seen, dist, junction)
183
+
184
+ for junction in state.map.get_junctions():
185
+ steps_since_seen = state.step - junction.last_seen_step
186
+
187
+ # Only consider junctions we haven't seen recently
188
+ if steps_since_seen < self.JUNCTION_STALE_THRESHOLD:
189
+ continue
190
+
191
+ # Skip known clips junctions (we already found them, _find_best_target handles them)
192
+ # Focus on neutral and unknown junctions that could have become clips
193
+ if junction.is_clips_aligned():
194
+ continue
195
+
196
+ dist = manhattan_distance(state.pos, junction.position)
197
+ stale_junctions.append((steps_since_seen, dist, junction))
198
+
199
+ if stale_junctions:
200
+ # Sort by: most stale first (highest steps_since_seen), then by distance
201
+ stale_junctions.sort(key=lambda x: (-x[0], x[1]))
202
+ target = stale_junctions[0][2]
203
+
204
+ if DEBUG and state.step % 50 == 0:
205
+ print(
206
+ f"[A{state.agent_id}] SCRAMBLER: Revisiting stale junction at {target.position} "
207
+ f"(last_seen={target.last_seen_step}, age={state.step - target.last_seen_step})"
208
+ )
209
+
210
+ state.debug_info = DebugInfo(
211
+ mode="explore",
212
+ goal=f"revisit_stale(age={state.step - target.last_seen_step})",
213
+ target_object="junction",
214
+ target_pos=target.position,
215
+ )
216
+ return services.navigator.move_to(state, target.position, reach_adjacent=True)
217
+
218
+ # No stale junctions - patrol the map to find enemy territory
219
+ # Cycle through directions over time to ensure whole-map coverage
220
+ # Each scrambler starts at a different direction (agent_id offset) and cycles through all 4
221
+ directions = ["north", "east", "south", "west"]
222
+ current_cycle = state.step // self.EXPLORE_DIRECTION_CYCLE
223
+ cycle_index = (current_cycle + state.agent_id) % 4
224
+ direction_bias = directions[cycle_index]
225
+
226
+ # Reset exploration origin when we've been exploring from the same origin for too long
227
+ # This prevents getting stuck in one area - forces movement to new regions
228
+ if state.nav.explore_origin is not None:
229
+ steps_at_origin = state.step - state.nav.explore_start_step
230
+ if steps_at_origin >= self.EXPLORE_DIRECTION_CYCLE:
231
+ # Time to move to a new area - reset origin to current position
232
+ state.nav.explore_origin = state.pos
233
+ state.nav.explore_start_step = state.step
234
+ if DEBUG:
235
+ print(
236
+ f"[A{state.agent_id}] SCRAMBLER: Resetting patrol origin to {state.pos}, "
237
+ f"direction={direction_bias}"
238
+ )
239
+
240
+ if DEBUG and state.step % 50 == 0:
241
+ clips_count = len(state.map.get_clips_junctions())
242
+ all_count = len(state.map.get_junctions())
243
+ print(
244
+ f"[A{state.agent_id}] SCRAMBLER: Patrolling (clips={clips_count}, "
245
+ f"all={all_count}, direction={direction_bias}, cycle={current_cycle})"
246
+ )
247
+
248
+ state.debug_info = DebugInfo(mode="explore", goal=f"patrol_{direction_bias}", target_object="junction")
249
+ return services.navigator.explore(state, direction_bias=direction_bias)
250
+
251
+ def _get_hearts(self, state: AgentState, services: Services) -> Action:
252
+ """Get hearts from chest."""
253
+ # First try visible chest in current observation
254
+ if state.last_obs is not None:
255
+ result = services.map_tracker.get_direction_to_nearest(state, state.last_obs, frozenset({"chest"}))
256
+ if result:
257
+ direction, target_pos = result
258
+ if DEBUG:
259
+ print(f"[A{state.agent_id}] SCRAMBLER: Chest visible at {target_pos}, moving {direction}")
260
+ state.debug_info = DebugInfo(
261
+ mode="get_hearts", goal="chest", target_object="chest", target_pos=target_pos
262
+ )
263
+ return Action(name=f"move_{direction}")
264
+
265
+ # Use accumulated map knowledge
266
+ chest_pos = state.map.stations.get("chest")
267
+
268
+ if chest_pos is None:
269
+ # Try hub as fallback
270
+ hub_pos = state.map.stations.get("hub")
271
+ if hub_pos is not None:
272
+ chest_pos = hub_pos
273
+ else:
274
+ if DEBUG:
275
+ print(f"[A{state.agent_id}] SCRAMBLER: No chest/hub, exploring")
276
+ state.debug_info = DebugInfo(mode="explore", goal="find_chest", target_object="chest")
277
+ return services.navigator.explore(state)
278
+
279
+ dist = manhattan_distance(state.pos, chest_pos)
280
+
281
+ if is_adjacent(state.pos, chest_pos):
282
+ if DEBUG:
283
+ print(f"[A{state.agent_id}] SCRAMBLER: Getting hearts from {chest_pos}")
284
+ state.debug_info = DebugInfo(
285
+ mode="get_hearts", goal="use_chest", target_object="chest", target_pos=chest_pos
286
+ )
287
+ return services.navigator.use_object_at(state, chest_pos)
288
+
289
+ state.debug_info = DebugInfo(
290
+ mode="get_hearts", goal=f"move_to_chest(dist={dist})", target_object="chest", target_pos=chest_pos
291
+ )
292
+ return services.navigator.move_to(state, chest_pos, reach_adjacent=True)
293
+
294
+ def _scramble_junction(self, state: AgentState, services: Services) -> Action:
295
+ """Find and scramble an enemy (clips) junction.
296
+
297
+ Strategy: ONLY target clips-aligned junctions (save hearts for real scrambles).
298
+ Always verify alignment AND resources before using - both can change at any time.
299
+ """
300
+ # SAFETY CHECK: Verify we still have the required resources
301
+ # This is a defensive check - act() should have already verified this
302
+ if self.needs_gear(state):
303
+ if DEBUG:
304
+ print(f"[A{state.agent_id}] SCRAMBLER: In _scramble_junction but missing gear, getting gear")
305
+ return self._get_gear(state, services)
306
+
307
+ if not self.has_resources_to_act(state):
308
+ if DEBUG:
309
+ print(f"[A{state.agent_id}] SCRAMBLER: In _scramble_junction but no hearts, getting hearts")
310
+ return self._get_hearts(state, services)
311
+
312
+ # First try to find CLIPS junction in current observation
313
+ if state.last_obs is not None:
314
+ result = services.map_tracker.get_direction_to_nearest(
315
+ state, state.last_obs, frozenset({"junction", "supply_depot"})
316
+ )
317
+ if result:
318
+ direction, target_pos = result
319
+ struct = state.map.get_structure_at(target_pos)
320
+
321
+ # ONLY target confirmed clips-aligned junctions
322
+ # Don't waste time on neutral/unknown - let exploration handle discovery
323
+ if struct is not None and struct.is_clips_aligned():
324
+ if DEBUG:
325
+ print(f"[A{state.agent_id}] SCRAMBLER: Enemy junction at {target_pos}, moving {direction}")
326
+ state.debug_info = DebugInfo(
327
+ mode="scramble", goal="enemy_junction", target_object="junction", target_pos=target_pos
328
+ )
329
+ return Action(name=f"move_{direction}")
330
+ # If not clips-aligned, don't target it - fall through to map knowledge or explore
331
+
332
+ # Fall back to map knowledge - find known clips junctions
333
+ target = self._find_best_target(state, services)
334
+
335
+ if target is not None:
336
+ dist = manhattan_distance(state.pos, target.position)
337
+
338
+ if is_adjacent(state.pos, target.position):
339
+ # IMPORTANT: Re-verify alignment before using!
340
+ # Alignment could have changed while we were moving toward it
341
+ current_struct = state.map.get_structure_at(target.position)
342
+ if current_struct is None or not current_struct.is_clips_aligned():
343
+ if DEBUG:
344
+ alignment = current_struct.alignment if current_struct else "unknown"
345
+ print(
346
+ f"[A{state.agent_id}] SCRAMBLER: Target at {target.position} no longer clips "
347
+ f"(now {alignment}), finding new target"
348
+ )
349
+ state.debug_info = DebugInfo(
350
+ mode="scramble",
351
+ goal="target_alignment_changed",
352
+ target_object="junction",
353
+ signal="alignment_changed",
354
+ )
355
+ # Target is no longer clips - explore to find a new one
356
+ return self._explore_for_enemy_junctions(state, services)
357
+
358
+ # CRITICAL: Final check before spending hearts - verify we have resources
359
+ if not self.has_resources_to_act(state):
360
+ if DEBUG:
361
+ print(
362
+ f"[A{state.agent_id}] SCRAMBLER: Adjacent to junction but no hearts! Getting hearts first."
363
+ )
364
+ return self._get_hearts(state, services)
365
+
366
+ if DEBUG:
367
+ print(f"[A{state.agent_id}] SCRAMBLER: Scrambling junction at {target.position}")
368
+ state.debug_info = DebugInfo(
369
+ mode="scramble", goal="use_junction", target_object="junction", target_pos=target.position
370
+ )
371
+ return services.navigator.use_object_at(state, target.position)
372
+
373
+ # Not adjacent yet - verify target is still clips before continuing to move toward it
374
+ current_struct = state.map.get_structure_at(target.position)
375
+ if current_struct is None or not current_struct.is_clips_aligned():
376
+ if DEBUG:
377
+ alignment = current_struct.alignment if current_struct else "unknown"
378
+ print(
379
+ f"[A{state.agent_id}] SCRAMBLER: Target at {target.position} no longer clips "
380
+ f"(now {alignment}), finding new target"
381
+ )
382
+ # Target changed - find a new one next tick
383
+ return self._explore_for_enemy_junctions(state, services)
384
+
385
+ state.debug_info = DebugInfo(
386
+ mode="scramble",
387
+ goal=f"move_to_junction(dist={dist})",
388
+ target_object="junction",
389
+ target_pos=target.position,
390
+ )
391
+ return services.navigator.move_to(state, target.position, reach_adjacent=True)
392
+
393
+ # No known clips junctions - explore to find them or revisit stale junctions
394
+ # Since alignment can change, revisit old junctions (least recently seen first)
395
+ return self._explore_for_enemy_junctions(state, services)
396
+
397
+ def _find_best_target(self, state: AgentState, services: Services) -> Optional[StructureInfo]:
398
+ """Find enemy junction to scramble.
399
+
400
+ ONLY returns confirmed clips-aligned junctions.
401
+ Stale/neutral junctions are handled by exploration, not targeting.
402
+ """
403
+ max_dist = services.safety.max_safe_distance(state, self.risk_tolerance)
404
+
405
+ enemy_junctions = state.map.get_clips_junctions()
406
+ neutral_junctions = state.map.get_neutral_junctions()
407
+
408
+ if not enemy_junctions:
409
+ return None
410
+
411
+ # Score each enemy junction by how many neutrals it blocks
412
+ scored: list[tuple[int, int, StructureInfo]] = [] # (blocked_count, dist, junction)
413
+
414
+ for enemy in enemy_junctions:
415
+ dist = manhattan_distance(state.pos, enemy.position)
416
+ if dist > max_dist:
417
+ continue
418
+
419
+ # Count how many neutral junctions this enemy is blocking
420
+ blocked = sum(
421
+ 1
422
+ for neutral in neutral_junctions
423
+ if manhattan_distance(enemy.position, neutral.position) <= JUNCTION_AOE_RANGE
424
+ )
425
+
426
+ scored.append((blocked, dist, enemy))
427
+
428
+ if not scored:
429
+ return None
430
+
431
+ # Sort by: most blocked neutrals first, then by distance
432
+ scored.sort(key=lambda x: (-x[0], x[1]))
433
+ return scored[0][2]