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.
- cogames_agents/__init__.py +0 -0
- cogames_agents/evals/__init__.py +5 -0
- cogames_agents/evals/planky_evals.py +415 -0
- cogames_agents/policy/__init__.py +0 -0
- cogames_agents/policy/evolution/__init__.py +0 -0
- cogames_agents/policy/evolution/cogsguard/__init__.py +0 -0
- cogames_agents/policy/evolution/cogsguard/evolution.py +695 -0
- cogames_agents/policy/evolution/cogsguard/evolutionary_coordinator.py +540 -0
- cogames_agents/policy/nim_agents/__init__.py +20 -0
- cogames_agents/policy/nim_agents/agents.py +98 -0
- cogames_agents/policy/nim_agents/bindings/generated/libnim_agents.dylib +0 -0
- cogames_agents/policy/nim_agents/bindings/generated/nim_agents.py +215 -0
- cogames_agents/policy/nim_agents/cogsguard_agents.nim +555 -0
- cogames_agents/policy/nim_agents/cogsguard_align_all_agents.nim +569 -0
- cogames_agents/policy/nim_agents/common.nim +1054 -0
- cogames_agents/policy/nim_agents/install.sh +1 -0
- cogames_agents/policy/nim_agents/ladybug_agent.nim +954 -0
- cogames_agents/policy/nim_agents/nim_agents.nim +68 -0
- cogames_agents/policy/nim_agents/nim_agents.nims +14 -0
- cogames_agents/policy/nim_agents/nimby.lock +3 -0
- cogames_agents/policy/nim_agents/racecar_agents.nim +844 -0
- cogames_agents/policy/nim_agents/random_agents.nim +68 -0
- cogames_agents/policy/nim_agents/test_agents.py +53 -0
- cogames_agents/policy/nim_agents/thinky_agents.nim +677 -0
- cogames_agents/policy/nim_agents/thinky_eval.py +230 -0
- cogames_agents/policy/scripted_agent/README.md +360 -0
- cogames_agents/policy/scripted_agent/__init__.py +0 -0
- cogames_agents/policy/scripted_agent/baseline_agent.py +1031 -0
- cogames_agents/policy/scripted_agent/cogas/__init__.py +5 -0
- cogames_agents/policy/scripted_agent/cogas/context.py +68 -0
- cogames_agents/policy/scripted_agent/cogas/entity_map.py +152 -0
- cogames_agents/policy/scripted_agent/cogas/goal.py +115 -0
- cogames_agents/policy/scripted_agent/cogas/goals/__init__.py +27 -0
- cogames_agents/policy/scripted_agent/cogas/goals/aligner.py +160 -0
- cogames_agents/policy/scripted_agent/cogas/goals/gear.py +197 -0
- cogames_agents/policy/scripted_agent/cogas/goals/miner.py +441 -0
- cogames_agents/policy/scripted_agent/cogas/goals/scout.py +40 -0
- cogames_agents/policy/scripted_agent/cogas/goals/scrambler.py +174 -0
- cogames_agents/policy/scripted_agent/cogas/goals/shared.py +160 -0
- cogames_agents/policy/scripted_agent/cogas/goals/stem.py +60 -0
- cogames_agents/policy/scripted_agent/cogas/goals/survive.py +100 -0
- cogames_agents/policy/scripted_agent/cogas/navigator.py +401 -0
- cogames_agents/policy/scripted_agent/cogas/obs_parser.py +238 -0
- cogames_agents/policy/scripted_agent/cogas/policy.py +525 -0
- cogames_agents/policy/scripted_agent/cogas/trace.py +69 -0
- cogames_agents/policy/scripted_agent/cogsguard/CLAUDE.md +517 -0
- cogames_agents/policy/scripted_agent/cogsguard/README.md +252 -0
- cogames_agents/policy/scripted_agent/cogsguard/__init__.py +74 -0
- cogames_agents/policy/scripted_agent/cogsguard/aligned_junction_held_investigation.md +152 -0
- cogames_agents/policy/scripted_agent/cogsguard/aligner.py +333 -0
- cogames_agents/policy/scripted_agent/cogsguard/behavior_hooks.py +44 -0
- cogames_agents/policy/scripted_agent/cogsguard/control_agent.py +323 -0
- cogames_agents/policy/scripted_agent/cogsguard/debug_agent.py +533 -0
- cogames_agents/policy/scripted_agent/cogsguard/miner.py +589 -0
- cogames_agents/policy/scripted_agent/cogsguard/options.py +67 -0
- cogames_agents/policy/scripted_agent/cogsguard/parity_metrics.py +36 -0
- cogames_agents/policy/scripted_agent/cogsguard/policy.py +1967 -0
- cogames_agents/policy/scripted_agent/cogsguard/prereq_trace.py +33 -0
- cogames_agents/policy/scripted_agent/cogsguard/role_trace.py +50 -0
- cogames_agents/policy/scripted_agent/cogsguard/roles.py +31 -0
- cogames_agents/policy/scripted_agent/cogsguard/rollout_trace.py +40 -0
- cogames_agents/policy/scripted_agent/cogsguard/scout.py +69 -0
- cogames_agents/policy/scripted_agent/cogsguard/scrambler.py +350 -0
- cogames_agents/policy/scripted_agent/cogsguard/targeted_agent.py +418 -0
- cogames_agents/policy/scripted_agent/cogsguard/teacher.py +224 -0
- cogames_agents/policy/scripted_agent/cogsguard/types.py +381 -0
- cogames_agents/policy/scripted_agent/cogsguard/v2_agent.py +49 -0
- cogames_agents/policy/scripted_agent/common/__init__.py +0 -0
- cogames_agents/policy/scripted_agent/common/geometry.py +24 -0
- cogames_agents/policy/scripted_agent/common/roles.py +34 -0
- cogames_agents/policy/scripted_agent/common/tag_utils.py +48 -0
- cogames_agents/policy/scripted_agent/demo_policy.py +242 -0
- cogames_agents/policy/scripted_agent/pathfinding.py +126 -0
- cogames_agents/policy/scripted_agent/pinky/DESIGN.md +317 -0
- cogames_agents/policy/scripted_agent/pinky/__init__.py +5 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/__init__.py +17 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/aligner.py +400 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/base.py +119 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/miner.py +632 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/scout.py +138 -0
- cogames_agents/policy/scripted_agent/pinky/behaviors/scrambler.py +433 -0
- cogames_agents/policy/scripted_agent/pinky/policy.py +570 -0
- cogames_agents/policy/scripted_agent/pinky/services/__init__.py +7 -0
- cogames_agents/policy/scripted_agent/pinky/services/map_tracker.py +808 -0
- cogames_agents/policy/scripted_agent/pinky/services/navigator.py +864 -0
- cogames_agents/policy/scripted_agent/pinky/services/safety.py +189 -0
- cogames_agents/policy/scripted_agent/pinky/state.py +299 -0
- cogames_agents/policy/scripted_agent/pinky/types.py +138 -0
- cogames_agents/policy/scripted_agent/planky/CLAUDE.md +124 -0
- cogames_agents/policy/scripted_agent/planky/IMPROVEMENTS.md +160 -0
- cogames_agents/policy/scripted_agent/planky/NOTES.md +153 -0
- cogames_agents/policy/scripted_agent/planky/PLAN.md +254 -0
- cogames_agents/policy/scripted_agent/planky/README.md +214 -0
- cogames_agents/policy/scripted_agent/planky/STRATEGY.md +100 -0
- cogames_agents/policy/scripted_agent/planky/__init__.py +5 -0
- cogames_agents/policy/scripted_agent/planky/context.py +68 -0
- cogames_agents/policy/scripted_agent/planky/entity_map.py +152 -0
- cogames_agents/policy/scripted_agent/planky/goal.py +107 -0
- cogames_agents/policy/scripted_agent/planky/goals/__init__.py +27 -0
- cogames_agents/policy/scripted_agent/planky/goals/aligner.py +168 -0
- cogames_agents/policy/scripted_agent/planky/goals/gear.py +179 -0
- cogames_agents/policy/scripted_agent/planky/goals/miner.py +416 -0
- cogames_agents/policy/scripted_agent/planky/goals/scout.py +40 -0
- cogames_agents/policy/scripted_agent/planky/goals/scrambler.py +174 -0
- cogames_agents/policy/scripted_agent/planky/goals/shared.py +160 -0
- cogames_agents/policy/scripted_agent/planky/goals/stem.py +49 -0
- cogames_agents/policy/scripted_agent/planky/goals/survive.py +96 -0
- cogames_agents/policy/scripted_agent/planky/navigator.py +388 -0
- cogames_agents/policy/scripted_agent/planky/obs_parser.py +238 -0
- cogames_agents/policy/scripted_agent/planky/policy.py +485 -0
- cogames_agents/policy/scripted_agent/planky/tests/__init__.py +0 -0
- cogames_agents/policy/scripted_agent/planky/tests/conftest.py +66 -0
- cogames_agents/policy/scripted_agent/planky/tests/helpers.py +152 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_aligner.py +24 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_miner.py +30 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_scout.py +15 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_scrambler.py +29 -0
- cogames_agents/policy/scripted_agent/planky/tests/test_stem.py +36 -0
- cogames_agents/policy/scripted_agent/planky/trace.py +69 -0
- cogames_agents/policy/scripted_agent/types.py +239 -0
- cogames_agents/policy/scripted_agent/unclipping_agent.py +461 -0
- cogames_agents/policy/scripted_agent/utils.py +381 -0
- cogames_agents/policy/scripted_registry.py +80 -0
- cogames_agents/py.typed +0 -0
- cogames_agents-0.0.0.7.dist-info/METADATA +98 -0
- cogames_agents-0.0.0.7.dist-info/RECORD +128 -0
- cogames_agents-0.0.0.7.dist-info/WHEEL +6 -0
- 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")
|