irregular6612 Claude Opus 4.8 (1M context) commited on
Commit
58f91e9
·
1 Parent(s): 6b31d7e

feat(director): predator_chase pack scatters (all survivors flee) after the first kill

Browse files
proteus/game/runtime/multiagent_director.py CHANGED
@@ -159,7 +159,8 @@ def author_predator_chase(
159
  *, seed: int, agent_starts: list[tuple[int, int]], predator_start: tuple[int, int],
160
  agent_size: int = 2, predator_size: int = 3, free_turns: int = 24,
161
  predator_grace: int = 24, # predator roams (does not pursue) for this many turns, delaying the first kill
162
- max_turns: int = 260, # grace + wider roaming lengthen the episode; seed=7 catches all 3 distractors by genuine contact well within this budget
 
163
  persona_id: str = "risk_averse",
164
  ) -> MemoryCheckpoint:
165
  """Author scenario ①: pack flees a predator; one survivor (a0) remains.
@@ -176,6 +177,18 @@ def author_predator_chase(
176
  travels far in distinct directions and covers a wider area. Distractors
177
  stay NON-predator-aware (they never flee), preserving the "caught by
178
  genuine contact" contract.
 
 
 
 
 
 
 
 
 
 
 
 
179
  """
180
  rng = random.Random(seed)
181
  agents = [_Agent(id=f"a{i}", x=p[0], y=p[1], size=agent_size, is_chosen=(i == 0))
@@ -184,6 +197,7 @@ def author_predator_chase(
184
  facing = "left"
185
  turns: list[MemoryTurn] = []
186
  catalyst_done = False
 
187
  # Per-agent + predator roam headings (seeded; ttl=0 forces a first roll).
188
  roam: dict[str, _RoamState] = {a.id: _RoamState(heading="stay", ttl=0) for a in agents}
189
  roam["predator"] = _RoamState(heading="stay", ttl=0)
@@ -215,14 +229,22 @@ def author_predator_chase(
215
  _apply_safe(chosen, chosen_action, agents)
216
  for a in agents:
217
  if a.alive and not a.is_chosen:
218
- # Wider, varied pre-kill roam; distractors stay non-predator-aware.
219
- a_action = _roam(a, rng, roam[a.id]) if not catalyst_done else _wander(a, rng)
 
 
 
 
 
220
  _apply_safe(a, a_action, agents)
 
 
221
  # kills (distractors only; chosen is the authored survivor)
222
  for a in agents:
223
  if a.alive and not a.is_chosen and _overlap(pred, a):
224
  a.alive = False
225
  catalyst_done = True
 
226
  events.append(f"{a.id} eaten")
227
  # patch events onto the frame we just appended
228
  turns[-1].events = list(events)
 
159
  *, seed: int, agent_starts: list[tuple[int, int]], predator_start: tuple[int, int],
160
  agent_size: int = 2, predator_size: int = 3, free_turns: int = 24,
161
  predator_grace: int = 24, # predator roams (does not pursue) for this many turns, delaying the first kill
162
+ panic_turns: int = 14, # after EACH kill all survivors flee for this many turns (the visible scatter), then resume roaming
163
+ max_turns: int = 400, # grace + wider roaming + panic bursts lengthen the episode; seed=7 catches all 3 distractors by genuine contact within this budget
164
  persona_id: str = "risk_averse",
165
  ) -> MemoryCheckpoint:
166
  """Author scenario ①: pack flees a predator; one survivor (a0) remains.
 
177
  travels far in distinct directions and covers a wider area. Distractors
178
  stay NON-predator-aware (they never flee), preserving the "caught by
179
  genuine contact" contract.
180
+
181
+ Post-kill SCATTER (panic burst). Once a distractor is eaten the whole pack
182
+ scatters: for ``panic_turns`` turns AFTER each kill every surviving
183
+ distractor flees the predator (maximising Manhattan distance), exactly like
184
+ the chosen a0. This reproduces "when one agent is eaten, the whole pack
185
+ moves away from the predator". The burst is finite: because the predator and
186
+ the agents move at EQUAL speed, perfectly-fleeing distractors run to opposite
187
+ field corners and deadlock — they can never be cornered. So after the burst
188
+ expires the surviving distractors resume the (non-predator-aware) roam, which
189
+ lets the predator close in and genuinely catch the next one; that next catch
190
+ re-arms the burst, scattering the remaining survivors again. a0 (``is_chosen``,
191
+ never killed) flees permanently once the catalyst fires.
192
  """
193
  rng = random.Random(seed)
194
  agents = [_Agent(id=f"a{i}", x=p[0], y=p[1], size=agent_size, is_chosen=(i == 0))
 
197
  facing = "left"
198
  turns: list[MemoryTurn] = []
199
  catalyst_done = False
200
+ panic_ttl = 0 # >0 => survivors are mid-scatter (flee); re-armed on each kill
201
  # Per-agent + predator roam headings (seeded; ttl=0 forces a first roll).
202
  roam: dict[str, _RoamState] = {a.id: _RoamState(heading="stay", ttl=0) for a in agents}
203
  roam["predator"] = _RoamState(heading="stay", ttl=0)
 
229
  _apply_safe(chosen, chosen_action, agents)
230
  for a in agents:
231
  if a.alive and not a.is_chosen:
232
+ # While a panic burst is active every surviving distractor FLEES
233
+ # the predator (the scatter); otherwise it uses the wider, varied
234
+ # roam (non-predator-aware) so the predator can close in for the
235
+ # next genuine catch. Pre-catalyst there is never a burst, so this
236
+ # is the original wide roam.
237
+ a_action = (_flee(a, _center(pred)) if panic_ttl > 0
238
+ else _roam(a, rng, roam[a.id]))
239
  _apply_safe(a, a_action, agents)
240
+ if panic_ttl > 0:
241
+ panic_ttl -= 1
242
  # kills (distractors only; chosen is the authored survivor)
243
  for a in agents:
244
  if a.alive and not a.is_chosen and _overlap(pred, a):
245
  a.alive = False
246
  catalyst_done = True
247
+ panic_ttl = panic_turns # (re-)arm the scatter burst on every kill
248
  events.append(f"{a.id} eaten")
249
  # patch events onto the frame we just appended
250
  turns[-1].events = list(events)
tests/runtime/test_predator_chase_memory.py CHANGED
@@ -72,6 +72,95 @@ def test_distractors_roam_wider_before_first_kill():
72
  )
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def test_guardrails_survivor_and_three_genuine_eaten():
76
  """a0 is the sole survivor, alive every turn, and exactly 3 distractors are
77
  eaten by GENUINE predator contact (not the silent end safeguard)."""
 
72
  )
73
 
74
 
75
+ def _pred_center(turn):
76
+ for a in turn.agents:
77
+ if a.kind == "predator":
78
+ return (a.pos[0] + a.size // 2, a.pos[1] + a.size // 2)
79
+ return None
80
+
81
+
82
+ def _agent_center(a):
83
+ return (a.pos[0] + a.size // 2, a.pos[1] + a.size // 2)
84
+
85
+
86
+ # Window (in turns) right after the first kill over which the pack must visibly
87
+ # move AWAY from the predator. Kept short so edge-cornering of one agent cannot
88
+ # make the assertion vacuous or fragile.
89
+ _SCATTER_WINDOW = 6
90
+ # One-step slack: agent-agent collision (the no-overlap invariant) can block a
91
+ # survivor's optimal flee move for a single transition, costing at most ~2 cells
92
+ # of Manhattan distance before it recovers. We allow that much and no more.
93
+ # Measured separation between the two regimes for seed 7:
94
+ # roaming (pre-change): nearest survivor is steadily approached, min distance
95
+ # drops by 6 over the window -> exceeds the slack -> FAILS.
96
+ # fleeing (post-change): the pack holds the predator off, min distance drops
97
+ # by at most 2 (one blocked step) then holds -> within slack -> PASSES.
98
+ _BLOCK_SLACK = 2
99
+
100
+
101
+ def _min_survivor_dist(turn, ids):
102
+ """Minimum Manhattan distance from any alive distractor in *ids* to the
103
+ predator on *turn*, or None if no predator / none of *ids* alive."""
104
+ pred = _pred_center(turn)
105
+ if pred is None:
106
+ return None
107
+ dists = [
108
+ abs(_agent_center(a)[0] - pred[0]) + abs(_agent_center(a)[1] - pred[1])
109
+ for a in turn.agents
110
+ if a.kind == "agent" and a.id in ids and a.alive
111
+ ]
112
+ return min(dists) if dists else None
113
+
114
+
115
+ def test_pack_scatters_after_first_kill():
116
+ """After the FIRST 'eaten' event the whole pack scatters and FLEES, so the
117
+ predator cannot keep closing the gap: the MINIMUM surviving-distractor
118
+ distance to the predator never drops more than one blocked step below its
119
+ value at the kill turn across the short window right after the kill.
120
+
121
+ Pre-change the survivors keep roaming (predator-unaware), so the predator
122
+ steadily corners the nearest one and the minimum distance collapses well
123
+ past the slack -> this assertion FAILS. With the scatter change every
124
+ survivor flees, the pack holds the predator off, and the minimum distance is
125
+ maintained -> it PASSES."""
126
+ ck = _run()
127
+ first = _first_eat_turn(ck)
128
+ assert first is not None
129
+ by_turn = {t.turn_idx: t for t in ck.memory_turns}
130
+
131
+ # The kill-turn frame is recorded PRE-move, so the agent being eaten is still
132
+ # alive in it. Restrict to the genuine SURVIVORS (alive non-chosen distractors
133
+ # that are NOT named in any 'eaten' event on the kill turn) so the baseline
134
+ # reflects the pack that must flee, not the agent about to die.
135
+ kill_turn = by_turn[first]
136
+ eaten_now = {e.split()[0] for e in kill_turn.events if "eaten" in e}
137
+ survivor_ids = {
138
+ a.id for a in kill_turn.agents
139
+ if a.kind == "agent" and a.id != "a0" and a.alive and a.id not in eaten_now
140
+ }
141
+ assert survivor_ids, "expected surviving distractors at the first-kill turn"
142
+
143
+ kill_min = _min_survivor_dist(kill_turn, survivor_ids)
144
+ assert kill_min is not None
145
+ floor = kill_min - _BLOCK_SLACK
146
+
147
+ checked = 0
148
+ for off in range(1, _SCATTER_WINDOW + 1):
149
+ t = by_turn.get(first + off)
150
+ if t is None:
151
+ break
152
+ m = _min_survivor_dist(t, survivor_ids)
153
+ if m is None: # final settled frame (predator removed) or no survivors
154
+ break
155
+ checked += 1
156
+ assert m >= floor, (
157
+ f"nearest survivor closed on the predator after the kill: "
158
+ f"min distance {m} at turn {t.turn_idx} < floor {floor} "
159
+ f"(kill-turn min {kill_min} - slack {_BLOCK_SLACK}); the pack did not flee"
160
+ )
161
+ assert checked >= 3, f"scatter window too short to be meaningful ({checked} frames)"
162
+
163
+
164
  def test_guardrails_survivor_and_three_genuine_eaten():
165
  """a0 is the sole survivor, alive every turn, and exactly 3 distractors are
166
  eaten by GENUINE predator contact (not the silent end safeguard)."""