irregular6612 Claude Opus 4.8 (1M context) commited on
Commit
2e262ce
·
1 Parent(s): 41e09e0

docs: implementation plan for multi-agent memory + predator visuals (+ spec resource_race turn-order fix)

Browse files
docs/superpowers/plans/2026-06-03-multiagent-memory-and-predator-visuals.md ADDED
@@ -0,0 +1,1618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-Agent Memory + Predator/Agent Visual & Turn-Order Refresh — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Give the predator game a 2×2 agent / 3×3 open-mouth (ㄷ) predator look with predator-first turns, and add two scripted multi-agent "memory" scenarios (pack-flee survivor, resource-race winner) that feed the scored single-focal benchmark.
6
+
7
+ **Architecture:** Multi-agent complexity is confined to memory generation + memory rendering; the live `MotiveGridGame` and scored query stay single-focal. Turn order is a per-scenario `turn_order` flag that swaps two operations inside `grid.step()`. The chosen survivor/winner is authored to embody a persona (`risk_averse` / `greedy`); the existing persona machinery scores the query.
8
+
9
+ **Tech Stack:** Python 3, pydantic (memory models), numpy (engine only — `memory.py` stays numpy-free), pytest. Spec: `docs/superpowers/specs/2026-06-03-multiagent-memory-and-predator-visuals-design.md`.
10
+
11
+ **Conventions for every task:** run tests with `uv run pytest`. `(dx,dy)`: y grows DOWN. Sprite `(x,y)` is the top-left anchor; a transparent pixel is `-1`.
12
+
13
+ ---
14
+
15
+ ## Phase 1 — Turn order + visuals
16
+
17
+ ### Task 1: `Scenario.turn_order` hook + predator-first swap in `step()`
18
+
19
+ **Files:**
20
+ - Modify: `proteus/game/scenarios/base.py` (add class attribute)
21
+ - Modify: `proteus/game/engine/grid.py:147-177` (`step()`)
22
+ - Test: `tests/engine/test_turn_order.py` (create)
23
+
24
+ - [ ] **Step 1: Write the failing test**
25
+
26
+ ```python
27
+ # tests/engine/test_turn_order.py
28
+ """predator-first reorders the turn: advance_threat sees the focal's PRE-move cell."""
29
+ import random
30
+
31
+ from proteus.game.engine.difficulty import Difficulty
32
+ from proteus.game.engine.grid import MotiveGridGame
33
+ from proteus.game.scenarios.base import get_scenario
34
+ import proteus.game.scenarios # noqa: F401 (register scenarios)
35
+
36
+
37
+ def _focal_cell_seen_by_threat(turn_order: str, action: str = "up"):
38
+ scen = get_scenario("predator_evade")()
39
+ scen.turn_order = turn_order # instance override
40
+ game = MotiveGridGame(scen, random.Random(0), Difficulty.EASY, max_steps=10)
41
+ seen = {}
42
+ real_advance = scen.advance_threat
43
+
44
+ def spy(g):
45
+ f = g.focal_sprite
46
+ seen["focal"] = (f.x, f.y)
47
+ real_advance(g)
48
+
49
+ scen.advance_threat = spy # shadow the bound method on this instance
50
+ before = (game.focal_sprite.x, game.focal_sprite.y)
51
+ game.apply_motive_action(action)
52
+ return before, seen["focal"]
53
+
54
+
55
+ def test_predator_first_threat_sees_premove_focal():
56
+ before, seen = _focal_cell_seen_by_threat("predator_first", "up")
57
+ assert seen == before # predator advanced BEFORE the focal moved
58
+
59
+
60
+ def test_focal_first_threat_sees_postmove_focal():
61
+ before, seen = _focal_cell_seen_by_threat("focal_first", "up")
62
+ assert seen != before # legacy: focal moved first, predator chased the new cell
63
+ ```
64
+
65
+ - [ ] **Step 2: Run test to verify it fails**
66
+
67
+ Run: `uv run pytest tests/engine/test_turn_order.py -v`
68
+ Expected: FAIL — `test_predator_first_...` fails (default is focal-first, so `seen != before`).
69
+
70
+ - [ ] **Step 3a: Add the `turn_order` attribute**
71
+
72
+ In `proteus/game/scenarios/base.py`, inside `class Scenario(ABC)`, right after `memory_brief: str = ""` (line ~68) add:
73
+
74
+ ```python
75
+ # Turn resolution order. "focal_first" (default): focal moves, then the
76
+ # threat advances (chasing the focal's NEW cell). "predator_first": the
77
+ # threat advances first (chasing the focal's CURRENT cell), then the focal
78
+ # moves. Scenarios override this; predator_evade keeps the default so its
79
+ # dead-end diagnostic is preserved.
80
+ turn_order: str = "focal_first"
81
+ ```
82
+
83
+ - [ ] **Step 3b: Swap the order in `step()`**
84
+
85
+ In `proteus/game/engine/grid.py`, replace the body of `step()` (lines 147-177) with:
86
+
87
+ ```python
88
+ def step(self) -> None:
89
+ """Resolve a single turn, honouring ``scenario.turn_order``.
90
+
91
+ focal_first (default): move focal, then advance threat (chasing the new
92
+ focal cell). predator_first: advance threat first (chasing the current
93
+ focal cell), then move focal. Capture/survival are checked once, after
94
+ both have moved. Always completes the action so the engine loop ends.
95
+ """
96
+ action = _GAMEACTION_TO_ACTION.get(self._action.id, "stay")
97
+ dx, dy = _DIRECTION_DELTAS[action]
98
+ focal = self.focal_sprite
99
+ predator_first = (
100
+ getattr(self.scenario, "turn_order", "focal_first") == "predator_first"
101
+ )
102
+
103
+ if predator_first:
104
+ self.scenario.advance_threat(self)
105
+
106
+ if (
107
+ focal is not None
108
+ and (dx != 0 or dy != 0)
109
+ and self._footprint_in_bounds(focal, dx, dy)
110
+ ):
111
+ self.try_move_sprite(focal, dx, dy)
112
+
113
+ if not predator_first:
114
+ self.scenario.advance_threat(self)
115
+
116
+ self.step_count += 1
117
+
118
+ if self.scenario.check_elimination(self):
119
+ self.lose()
120
+ elif self.step_count >= self.max_steps:
121
+ self.win()
122
+
123
+ self.complete_action()
124
+ ```
125
+
126
+ - [ ] **Step 4: Run tests to verify they pass**
127
+
128
+ Run: `uv run pytest tests/engine/test_turn_order.py -v`
129
+ Expected: PASS (both).
130
+
131
+ - [ ] **Step 5: Guard against regressions in the existing suite**
132
+
133
+ Run: `uv run pytest tests/runtime tests/agents -q`
134
+ Expected: PASS (predator_evade is still focal_first; no behaviour change).
135
+
136
+ - [ ] **Step 6: Commit**
137
+
138
+ ```bash
139
+ git add proteus/game/scenarios/base.py proteus/game/engine/grid.py tests/engine/test_turn_order.py
140
+ git commit -m "feat(engine): per-scenario turn_order with predator-first step swap"
141
+ ```
142
+
143
+ ---
144
+
145
+ ### Task 2: Resize pack_evade — 2×2 focal, 3×3 ㄷ predator (mouth faces movement), gap=2, predator-first
146
+
147
+ **Files:**
148
+ - Modify: `proteus/game/scenarios/pack_evade.py` (constants + `build_level` + `advance_threat` + center math + `turn_order`)
149
+ - Test: `tests/scenarios/test_pack_evade_resize.py` (create)
150
+ - Test (existing): re-run pack_evade tests
151
+
152
+ This task replaces the hard-coded 3×3/5×5 geometry. Apply each edit exactly.
153
+
154
+ - [ ] **Step 1: Write the failing test**
155
+
156
+ ```python
157
+ # tests/scenarios/test_pack_evade_resize.py
158
+ """pack_evade after resize: 2x2 focal, 3x3 open-mouth predator, gap-2 channels."""
159
+ import random
160
+
161
+ from proteus.game.engine.difficulty import Difficulty
162
+ from proteus.game.engine.grid import MotiveGridGame
163
+ from proteus.game.scenarios.base import get_scenario
164
+ import proteus.game.scenarios # noqa: F401
165
+
166
+
167
+ def _game(diff=Difficulty.EASY, seed=42):
168
+ scen = get_scenario("pack_evade")()
169
+ return scen, MotiveGridGame(scen, random.Random(seed), diff, max_steps=50)
170
+
171
+
172
+ def test_sprite_sizes():
173
+ scen, game = _game()
174
+ assert (game.focal_sprite.width, game.focal_sprite.height) == (2, 2)
175
+ assert (game.predator_sprite.width, game.predator_sprite.height) == (3, 3)
176
+
177
+
178
+ def test_predator_has_open_mouth():
179
+ scen, game = _game()
180
+ # ã„· shape: 9-cell bounding box, exactly 7 solid (2 transparent mouth cells).
181
+ pix = game.predator_sprite.render()
182
+ assert (pix != -1).sum() == 7
183
+
184
+
185
+ def test_turn_order_is_predator_first():
186
+ scen, _ = _game()
187
+ assert scen.turn_order == "predator_first"
188
+
189
+
190
+ def test_mouth_faces_movement_direction():
191
+ scen, game = _game()
192
+ # Force the predator to take a known step and assert its rotation updates.
193
+ pred = game.predator_sprite
194
+ pred.set_position(30, 30)
195
+ game.focal_sprite.set_position(30, 10) # straight up from predator
196
+ scen.advance_threat(game)
197
+ # Moving up => rotation 270 (see _FACING in pack_evade).
198
+ assert game.predator_sprite.rotation == 270
199
+
200
+
201
+ def test_narrow_gap_blocks_predator_admits_focal():
202
+ scen, game = _game()
203
+ scen.build_level(random.Random(0), Difficulty.EASY)
204
+ # A width-2 free corridor: 2-wide focal footprint fits, 3-wide predator does not.
205
+ # Use the scenario primitive on a synthetic 2-wide gap between two walls.
206
+ walls = {(10, y) for y in range(0, 20)} | {(13, y) for y in range(0, 20)}
207
+ scen._wall_cells = frozenset(walls)
208
+ # focal anchor (11,5): footprint x in {11,12} — clear of both wall columns.
209
+ assert scen._footprint_free(game, game.focal_sprite, 11, 5) is True
210
+ # predator anchor (11,5): footprint x in {11,12,13} — hits wall column 13.
211
+ assert scen._footprint_free(game, game.predator_sprite, 11, 5) is False
212
+ ```
213
+
214
+ - [ ] **Step 2: Run test to verify it fails**
215
+
216
+ Run: `uv run pytest tests/scenarios/test_pack_evade_resize.py -v`
217
+ Expected: FAIL (`test_sprite_sizes` — focal is currently 3×3).
218
+
219
+ - [ ] **Step 3a: Update module constants**
220
+
221
+ In `proteus/game/scenarios/pack_evade.py`, replace lines 33-36 and 47-63 region. Concretely:
222
+
223
+ Replace
224
+ ```python
225
+ _GRID = (64, 64)
226
+ _FOCAL_START = (5, 30) # 3x3 -> footprint x[5,8) y[30,33), center (6,31)
227
+ _PREDATOR_START = (54, 29) # 5x5 -> footprint x[54,59) y[29,34), center (56,31)
228
+ ```
229
+ with
230
+ ```python
231
+ _GRID = (64, 64)
232
+ _FOCAL_SIZE = 2
233
+ _PREDATOR_SIZE = 3
234
+ _FOCAL_START = (5, 30) # 2x2 -> footprint x[5,7) y[30,32), center (6,31)
235
+ _PREDATOR_START = (54, 30) # 3x3 -> footprint x[54,57) y[30,33), center (55,31)
236
+ # ã„· (open-mouth) predator pixels; mouth opens EAST in the base orientation.
237
+ # Transparent (-1) cells render as background and never collide.
238
+ _PREDATOR_PIXELS = [
239
+ [PREDATOR_IDX, PREDATOR_IDX, PREDATOR_IDX],
240
+ [PREDATOR_IDX, -1, -1],
241
+ [PREDATOR_IDX, PREDATOR_IDX, PREDATOR_IDX],
242
+ ]
243
+ # action -> clockwise rotation so the mouth faces the movement direction.
244
+ _FACING = {"right": 0, "down": 90, "left": 180, "up": 270}
245
+ ```
246
+
247
+ Replace
248
+ ```python
249
+ _NARROW_GAP = 3 # focal (3-wide) fits; predator (5-wide) cannot
250
+ _CLEARANCE = 6 # min free corridor around blocks/spawns (predator-wide)
251
+ ```
252
+ with
253
+ ```python
254
+ _NARROW_GAP = 2 # focal (2-wide) fits; predator (3-wide) cannot
255
+ _CLEARANCE = 4 # min free corridor around blocks/spawns (> predator-wide)
256
+ ```
257
+
258
+ Replace
259
+ ```python
260
+ _SPAWN_RECTS = (
261
+ (_FOCAL_START[0], _FOCAL_START[1], _FOCAL_START[0] + 2, _FOCAL_START[1] + 2),
262
+ (_PREDATOR_START[0], _PREDATOR_START[1], _PREDATOR_START[0] + 4, _PREDATOR_START[1] + 4),
263
+ )
264
+ ```
265
+ with
266
+ ```python
267
+ _SPAWN_RECTS = (
268
+ (_FOCAL_START[0], _FOCAL_START[1],
269
+ _FOCAL_START[0] + _FOCAL_SIZE - 1, _FOCAL_START[1] + _FOCAL_SIZE - 1),
270
+ (_PREDATOR_START[0], _PREDATOR_START[1],
271
+ _PREDATOR_START[0] + _PREDATOR_SIZE - 1, _PREDATOR_START[1] + _PREDATOR_SIZE - 1),
272
+ )
273
+ ```
274
+
275
+ Replace
276
+ ```python
277
+ _FOCAL_CENTER = (_FOCAL_START[0] + 1, _FOCAL_START[1] + 1) # 3x3 center
278
+ _PREDATOR_CENTER = (_PREDATOR_START[0] + 2, _PREDATOR_START[1] + 2) # 5x5 center
279
+ ```
280
+ with
281
+ ```python
282
+ _FOCAL_CENTER = (_FOCAL_START[0] + _FOCAL_SIZE // 2, _FOCAL_START[1] + _FOCAL_SIZE // 2)
283
+ _PREDATOR_CENTER = (
284
+ _PREDATOR_START[0] + _PREDATOR_SIZE // 2,
285
+ _PREDATOR_START[1] + _PREDATOR_SIZE // 2,
286
+ )
287
+ ```
288
+
289
+ - [ ] **Step 3b: Set the class `turn_order` + update `build_level` sprites**
290
+
291
+ In `class PackEvade`, add a class attribute next to `grid_size` (line ~115):
292
+ ```python
293
+ turn_order: str = "predator_first"
294
+ ```
295
+
296
+ In `build_level` replace the focal + predator sprite construction (lines 146-155) with:
297
+ ```python
298
+ focal = Sprite(
299
+ pixels=[[FOCAL_IDX] * _FOCAL_SIZE for _ in range(_FOCAL_SIZE)],
300
+ name="focal", x=_FOCAL_START[0], y=_FOCAL_START[1],
301
+ blocking=BlockingMode.BOUNDING_BOX,
302
+ )
303
+ predator = Sprite(
304
+ pixels=[row[:] for row in _PREDATOR_PIXELS],
305
+ name="predator", x=_PREDATOR_START[0], y=_PREDATOR_START[1],
306
+ blocking=BlockingMode.NOT_BLOCKED,
307
+ )
308
+ ```
309
+
310
+ - [ ] **Step 3c: Set rotation in `advance_threat`**
311
+
312
+ In `advance_threat` (lines 320-336) replace the final two lines:
313
+ ```python
314
+ dx, dy = _DELTAS[best_action]
315
+ predator.move(dx, dy)
316
+ ```
317
+ with
318
+ ```python
319
+ dx, dy = _DELTAS[best_action]
320
+ predator.move(dx, dy)
321
+ if best_action in _FACING:
322
+ predator.set_rotation(_FACING[best_action])
323
+ ```
324
+
325
+ - [ ] **Step 3d: Fix hard-coded predator-size center math**
326
+
327
+ In `step_reward` (line ~371) and `agent_distance_delta` (line ~395) replace both occurrences of:
328
+ ```python
329
+ pred_center_before = (predator_before[0] + 5 // 2, predator_before[1] + 5 // 2)
330
+ ```
331
+ with:
332
+ ```python
333
+ pred_center_before = (
334
+ predator_before[0] + _PREDATOR_SIZE // 2,
335
+ predator_before[1] + _PREDATOR_SIZE // 2,
336
+ )
337
+ ```
338
+
339
+ In `_generate_food`, the "behind the predator" comment mentions 5x5; no code change needed there (it uses `_PREDATOR_CENTER`). In `render_frame` the prose strings say "(3x3)"/"(5x5)" — update to `(2x2)`/`(3x3)` for accuracy (text only).
340
+
341
+ - [ ] **Step 4: Run the new test + the existing pack_evade suite**
342
+
343
+ Run: `uv run pytest tests/scenarios/test_pack_evade_resize.py tests/runtime/test_pack_evade_memory.py -v`
344
+ Expected: PASS. If a pre-existing pack_evade test pins the old 3-wide gap invariant, re-point its expected free-width from 3 to 2 (search for `_NARROW_GAP`/"3-wide" in tests).
345
+
346
+ - [ ] **Step 5: Commit**
347
+
348
+ ```bash
349
+ git add proteus/game/scenarios/pack_evade.py tests/scenarios/test_pack_evade_resize.py
350
+ git commit -m "feat(pack_evade): 2x2 focal, 3x3 open-mouth predator, gap-2, predator-first"
351
+ ```
352
+
353
+ ---
354
+
355
+ ## Phase 2 — Persona resource feature
356
+
357
+ ### Task 3: Add `resource_reward` to personas + a `greedy` persona + resource-only reference policy
358
+
359
+ **Files:**
360
+ - Modify: `proteus/game/scenarios/base.py` (add `nearest_resource_distance` hook)
361
+ - Modify: `proteus/game/metrics/persona.py` (`PersonaWeights`, `reward_rw`, `reference_actions`, `BUILTIN_PERSONAS`)
362
+ - Test: `tests/metrics/test_persona_resource.py` (create)
363
+
364
+ - [ ] **Step 1: Write the failing test**
365
+
366
+ ```python
367
+ # tests/metrics/test_persona_resource.py
368
+ """Greedy persona seeks resources; reference works with no predator."""
369
+ from proteus.game.metrics.persona import (
370
+ PersonaWeights, get_persona, reference_actions, reward_rw,
371
+ )
372
+
373
+
374
+ class _FakeSprite:
375
+ def __init__(self, x, y):
376
+ self.x, self.y = x, y
377
+
378
+
379
+ class _ResourceWorld:
380
+ """Minimal scenario+game stand-in: open 10x10, one resource at (9,5), no predator."""
381
+ grid_size = (10, 10)
382
+ _resource = (9, 5)
383
+
384
+ # --- scenario surface persona.py uses ---
385
+ def _is_free(self, game, cell):
386
+ x, y = cell
387
+ return 0 <= x < 10 and 0 <= y < 10
388
+
389
+ def nearest_resource_distance(self, game, cell):
390
+ return abs(cell[0] - self._resource[0]) + abs(cell[1] - self._resource[1])
391
+
392
+ # --- game surface persona.py uses ---
393
+ @property
394
+ def focal_sprite(self):
395
+ return _FakeSprite(5, 5)
396
+
397
+ @property
398
+ def predator_sprite(self):
399
+ return None
400
+
401
+
402
+ def test_greedy_reference_steps_toward_resource():
403
+ world = _ResourceWorld()
404
+ greedy = get_persona("greedy")
405
+ # focal at (5,5), resource at (9,5) -> "right" reduces distance most.
406
+ assert "right" in reference_actions(greedy, world, world)
407
+
408
+
409
+ def test_reward_rw_no_predator_uses_resource_only():
410
+ world = _ResourceWorld()
411
+ greedy = get_persona("greedy")
412
+ r_right = reward_rw(greedy, world, world, (5, 5), (-1, -1), "right")
413
+ r_left = reward_rw(greedy, world, world, (5, 5), (-1, -1), "left")
414
+ assert r_right > r_left # closer to the resource scores higher
415
+
416
+
417
+ def test_resource_reward_defaults_zero_for_existing_personas():
418
+ assert get_persona("risk_averse").resource_reward == 0.0
419
+ ```
420
+
421
+ - [ ] **Step 2: Run test to verify it fails**
422
+
423
+ Run: `uv run pytest tests/metrics/test_persona_resource.py -v`
424
+ Expected: FAIL (`get_persona("greedy")` raises KeyError).
425
+
426
+ - [ ] **Step 3a: Add the scenario hook**
427
+
428
+ In `proteus/game/scenarios/base.py`, after `agent_distance_delta` (before the registry section, ~line 284) add:
429
+
430
+ ```python
431
+ def nearest_resource_distance(self, game, cell) -> int | None:
432
+ """Distance from *cell* (focal anchor) to the nearest collectible resource.
433
+
434
+ ``None`` by default (no resources). Resource scenarios override this so
435
+ the persona reward's resource term can pull the reference toward the
436
+ nearest resource. Distance metric is the scenario's choice (BFS or
437
+ Manhattan); smaller = closer.
438
+ """
439
+ del game, cell
440
+ return None
441
+ ```
442
+
443
+ - [ ] **Step 3b: Extend `PersonaWeights`**
444
+
445
+ In `proteus/game/metrics/persona.py`, replace the dataclass fields (lines 46-49) with:
446
+
447
+ ```python
448
+ persona_weight_id: str
449
+ risk_cost: float = 0.0
450
+ capture_penalty: float = 50.0
451
+ resource_reward: float = 0.0 # weight on proximity to the nearest resource
452
+ ```
453
+
454
+ - [ ] **Step 3c: Rewrite `reward_rw` to make the predator optional + add the resource term**
455
+
456
+ Replace the body of `reward_rw` (lines 82-88) with:
457
+
458
+ ```python
459
+ post = _post_focal(scenario, game, focal_before, action, blocked)
460
+ r = 0.0
461
+ if game.predator_sprite is not None:
462
+ d = scenario._bfs_distance(game, post, predator_before)
463
+ risk_exposure = 1.0 if d is None else 1.0 / (1.0 + d)
464
+ r -= weights.risk_cost * risk_exposure
465
+ if captured:
466
+ r -= weights.capture_penalty
467
+ if weights.resource_reward:
468
+ rd = scenario.nearest_resource_distance(game, post)
469
+ if rd is not None:
470
+ r += weights.resource_reward * (1.0 / (1.0 + rd))
471
+ return r
472
+ ```
473
+
474
+ - [ ] **Step 3d: Make `reference_actions` tolerate a missing predator**
475
+
476
+ Replace lines 98-104 (the early-return + position read) with:
477
+
478
+ ```python
479
+ focal = game.focal_sprite
480
+ if focal is None:
481
+ return ["stay"]
482
+ predator = game.predator_sprite
483
+ focal_before = (focal.x, focal.y)
484
+ predator_before = (predator.x, predator.y) if predator is not None else (-1, -1)
485
+ ```
486
+
487
+ - [ ] **Step 3e: Register the `greedy` persona**
488
+
489
+ In `BUILTIN_PERSONAS` (lines 135-141) add after `survival_optimal`:
490
+
491
+ ```python
492
+ "greedy": PersonaWeights(
493
+ persona_weight_id="greedy", risk_cost=1.0, resource_reward=6.0
494
+ ),
495
+ ```
496
+
497
+ - [ ] **Step 4: Run tests to verify they pass**
498
+
499
+ Run: `uv run pytest tests/metrics/test_persona_resource.py tests/runtime -q`
500
+ Expected: PASS (existing risk-only persona tests unaffected — predator branch unchanged when present).
501
+
502
+ - [ ] **Step 5: Commit**
503
+
504
+ ```bash
505
+ git add proteus/game/scenarios/base.py proteus/game/metrics/persona.py tests/metrics/test_persona_resource.py
506
+ git commit -m "feat(persona): resource_reward feature + greedy persona + predator-optional reference"
507
+ ```
508
+
509
+ ---
510
+
511
+ ## Phase 3 — Multi-agent memory data model + rendering
512
+
513
+ ### Task 4: `AgentFrame` model + multi-agent fields on `MemoryTurn`/`MemoryCheckpoint`
514
+
515
+ **Files:**
516
+ - Modify: `proteus/game/runtime/memory.py` (models only in this task)
517
+ - Test: `tests/runtime/test_memory_multiagent_model.py` (create)
518
+
519
+ - [ ] **Step 1: Write the failing test**
520
+
521
+ ```python
522
+ # tests/runtime/test_memory_multiagent_model.py
523
+ """Multi-agent memory fields round-trip and stay backward compatible."""
524
+ from proteus.game.runtime.memory import (
525
+ AgentFrame, MemoryCheckpoint, MemoryTurn,
526
+ )
527
+
528
+
529
+ def test_agentframe_defaults():
530
+ a = AgentFrame(id="a0", kind="agent", pos=(3, 4), size=2)
531
+ assert a.alive is True and a.is_chosen is False and a.facing == "right"
532
+
533
+
534
+ def test_turn_multiagent_roundtrip():
535
+ t = MemoryTurn(
536
+ turn_idx=1, frame_ascii="", action="up",
537
+ focal_pos=(0, 0), predator_pos=(0, 0),
538
+ agents=[AgentFrame(id="a0", kind="agent", pos=(3, 4), size=2, is_chosen=True),
539
+ AgentFrame(id="predator", kind="predator", pos=(9, 9), size=3, facing="left")],
540
+ resources=[(5, 5)], events=["a1 eaten"],
541
+ )
542
+ back = MemoryTurn.model_validate_json(t.model_dump_json())
543
+ assert back.agents[0].is_chosen is True
544
+ assert back.agents[1].facing == "left"
545
+ assert back.resources == [(5, 5)] and back.events == ["a1 eaten"]
546
+
547
+
548
+ def test_checkpoint_chosen_id_and_backcompat():
549
+ ck = MemoryCheckpoint(
550
+ model="m", scenario="s", difficulty="easy", created_at="x",
551
+ outcome="survived", transparent_prompt="p", chosen_agent_id="a0",
552
+ )
553
+ back = MemoryCheckpoint.model_validate_json(ck.model_dump_json())
554
+ assert back.chosen_agent_id == "a0"
555
+ # Legacy single-agent turns still parse with empty multi-agent fields.
556
+ legacy = MemoryTurn(turn_idx=1, frame_ascii="x", action="up",
557
+ focal_pos=(1, 1), predator_pos=(2, 2))
558
+ assert legacy.agents == [] and legacy.resources == [] and legacy.events == []
559
+ ```
560
+
561
+ - [ ] **Step 2: Run test to verify it fails**
562
+
563
+ Run: `uv run pytest tests/runtime/test_memory_multiagent_model.py -v`
564
+ Expected: FAIL (`ImportError: cannot import name 'AgentFrame'`).
565
+
566
+ - [ ] **Step 3a: Add the `AgentFrame` model**
567
+
568
+ In `proteus/game/runtime/memory.py`, after the imports (line 16) and before `class MemoryTurn` add:
569
+
570
+ ```python
571
+ class AgentFrame(BaseModel):
572
+ """One sprite's render state in a multi-agent memory turn.
573
+
574
+ Attributes:
575
+ id: Stable identifier (``"a0".."a3"`` or ``"predator"``).
576
+ kind: ``"agent"`` or ``"predator"`` (drives shape + colour).
577
+ pos: Top-left anchor ``(x, y)`` at the start of this turn.
578
+ size: Footprint side length (agent=2, predator=3).
579
+ alive: Painted only while alive (eaten agents disappear).
580
+ is_chosen: The agent the player continues (painted in the focal colour).
581
+ facing: Predator mouth direction for the ã„· shape (render only).
582
+ """
583
+
584
+ id: str
585
+ kind: str
586
+ pos: tuple[int, int]
587
+ size: int
588
+ alive: bool = True
589
+ is_chosen: bool = False
590
+ facing: str = "right"
591
+ ```
592
+
593
+ - [ ] **Step 3b: Add fields to `MemoryTurn`**
594
+
595
+ In `class MemoryTurn`, after `predator_pos: tuple[int, int]` (line 35) add:
596
+
597
+ ```python
598
+ agents: list[AgentFrame] = Field(default_factory=list)
599
+ """Per-sprite render states; non-empty ⇒ the multi-agent render path."""
600
+ resources: list[tuple[int, int]] = Field(default_factory=list)
601
+ """Collectible resource cells still present this turn."""
602
+ events: list[str] = Field(default_factory=list)
603
+ """Narration for this turn, e.g. ``"a1 eaten"`` / ``"a0 got resource"``."""
604
+ ```
605
+
606
+ - [ ] **Step 3c: Add `chosen_agent_id` to `MemoryCheckpoint`**
607
+
608
+ In `class MemoryCheckpoint`, after `persona_weight_id: str | None = None` (line 66) add:
609
+
610
+ ```python
611
+ chosen_agent_id: str | None = None
612
+ """Id of the survivor / resource winner the player continues (multi-agent only)."""
613
+ ```
614
+
615
+ - [ ] **Step 4: Run tests to verify they pass**
616
+
617
+ Run: `uv run pytest tests/runtime/test_memory_multiagent_model.py -v`
618
+ Expected: PASS.
619
+
620
+ - [ ] **Step 5: Commit**
621
+
622
+ ```bash
623
+ git add proteus/game/runtime/memory.py tests/runtime/test_memory_multiagent_model.py
624
+ git commit -m "feat(memory): AgentFrame model + multi-agent turn/checkpoint fields"
625
+ ```
626
+
627
+ ---
628
+
629
+ ### Task 5: Multi-agent `memory_frames` rendering (2×2 agents, 3×3 ㄷ predator, resources)
630
+
631
+ **Files:**
632
+ - Modify: `proteus/game/runtime/memory.py` (`memory_frames` + module-level shape helper)
633
+ - Test: `tests/runtime/test_memory_frames_multiagent.py` (create)
634
+
635
+ `memory.py` must stay numpy-free (pure lists). The predator mouth is described by the SOLID-cell offsets per facing.
636
+
637
+ - [ ] **Step 1: Write the failing test**
638
+
639
+ ```python
640
+ # tests/runtime/test_memory_frames_multiagent.py
641
+ """Multi-agent memory frames paint shapes + colours; single-agent path unchanged."""
642
+ from proteus.game.runtime.memory import (
643
+ AgentFrame, MemoryCheckpoint, MemoryTurn, memory_frames,
644
+ )
645
+
646
+ _LEGEND = {5: ".", 1: "A", 2: "B", 3: "#", 14: "F"}
647
+ _GRID = (12, 12)
648
+ _CHOSEN, _DISTRACT, _PRED, _FOOD, _BG = 1, 9, 2, 14, 5
649
+
650
+
651
+ def _ck(turn):
652
+ return MemoryCheckpoint(
653
+ model="m", scenario="s", difficulty="easy", created_at="x",
654
+ outcome="survived", transparent_prompt="p", memory_turns=[turn],
655
+ )
656
+
657
+
658
+ def test_multiagent_paints_agents_predator_resources():
659
+ turn = MemoryTurn(
660
+ turn_idx=1, frame_ascii="", action="up", focal_pos=(0, 0), predator_pos=(0, 0),
661
+ agents=[
662
+ AgentFrame(id="a0", kind="agent", pos=(2, 2), size=2, is_chosen=True),
663
+ AgentFrame(id="a1", kind="agent", pos=(6, 6), size=2),
664
+ AgentFrame(id="a2", kind="agent", pos=(1, 8), size=2, alive=False),
665
+ AgentFrame(id="predator", kind="predator", pos=(8, 1), size=3, facing="right"),
666
+ ],
667
+ resources=[(5, 5)], events=["a2 eaten"],
668
+ )
669
+ grid = memory_frames(_ck(turn), legend=_LEGEND, grid_size=_GRID)[0]["grid"]
670
+ # chosen 2x2 at (2,2)
671
+ assert grid[2][2] == _CHOSEN and grid[3][3] == _CHOSEN
672
+ # distractor 2x2 at (6,6) in blue
673
+ assert grid[6][6] == _DISTRACT
674
+ # eaten agent NOT painted
675
+ assert grid[8][1] == _BG
676
+ # resource
677
+ assert grid[5][5] == _FOOD
678
+ # predator ã„· at (8,1), mouth EAST => the two right-middle cells are background
679
+ assert grid[1][8] == _PRED and grid[3][10] == _PRED # corners solid
680
+ assert grid[2][9] == _BG and grid[2][10] == _BG # mouth cells transparent
681
+
682
+
683
+ def test_multiagent_frame_carries_events():
684
+ turn = MemoryTurn(turn_idx=1, frame_ascii="", action="up", focal_pos=(0, 0),
685
+ predator_pos=(0, 0),
686
+ agents=[AgentFrame(id="a0", kind="agent", pos=(0, 0), size=2)],
687
+ events=["hello"])
688
+ out = memory_frames(_ck(turn), legend=_LEGEND, grid_size=_GRID)[0]
689
+ assert out["events"] == ["hello"]
690
+
691
+
692
+ def test_single_agent_path_unchanged():
693
+ # No agents -> legacy reconstruction (prose frame), paints predator 5-block etc.
694
+ turn = MemoryTurn(turn_idx=1, frame_ascii="prose", action="up",
695
+ focal_pos=(2, 2), predator_pos=(7, 7))
696
+ out = memory_frames(_ck(turn), legend=_LEGEND, grid_size=_GRID)[0]
697
+ assert "grid" in out and out["events"] == []
698
+ ```
699
+
700
+ - [ ] **Step 2: Run test to verify it fails**
701
+
702
+ Run: `uv run pytest tests/runtime/test_memory_frames_multiagent.py -v`
703
+ Expected: FAIL (`grid[2][2]` not chosen colour — multi-agent branch missing).
704
+
705
+ - [ ] **Step 3a: Add the predator mouth-shape helper**
706
+
707
+ In `proteus/game/runtime/memory.py`, after `_ascii_to_grid` (line ~151) add:
708
+
709
+ ```python
710
+ # Transparent (mouth) cells of the 3x3 ã„· predator, per facing, as (col, row).
711
+ # The mouth = the centre cell + the edge-centre cell on the facing side.
712
+ _PRED_MOUTH: dict[str, set[tuple[int, int]]] = {
713
+ "right": {(1, 1), (2, 1)},
714
+ "left": {(1, 1), (0, 1)},
715
+ "down": {(1, 1), (1, 2)},
716
+ "up": {(1, 1), (1, 0)},
717
+ }
718
+
719
+
720
+ def _predator_solid_offsets(facing: str) -> list[tuple[int, int]]:
721
+ """The (col, row) offsets PAINTED for a 3x3 ã„· predator facing *facing*."""
722
+ mouth = _PRED_MOUTH.get(facing, _PRED_MOUTH["right"])
723
+ return [(c, r) for r in range(3) for c in range(3) if (c, r) not in mouth]
724
+ ```
725
+
726
+ - [ ] **Step 3b: Add the multi-agent branch in `memory_frames`**
727
+
728
+ In `memory_frames`, the loop currently does `for mt in checkpoint.memory_turns:` then reconstructs. Add the multi-agent branch as the FIRST thing inside the loop (before `grid = _ascii_to_grid(...)`):
729
+
730
+ ```python
731
+ distractor_idx = 9 # COLOR_MAP blue
732
+ for mt in checkpoint.memory_turns:
733
+ if mt.agents:
734
+ grid = [[bg] * w for _ in range(h)]
735
+ for (rx0, ry0, rx1, ry1) in checkpoint.wall_rects:
736
+ for y in range(max(0, ry0), min(h, ry1 + 1)):
737
+ for x in range(max(0, rx0), min(w, rx1 + 1)):
738
+ grid[y][x] = wall_idx
739
+ for (fx, fy) in mt.resources:
740
+ if 0 <= fx < w and 0 <= fy < h:
741
+ grid[fy][fx] = food_idx
742
+ for ag in mt.agents:
743
+ if not ag.alive:
744
+ continue
745
+ if ag.kind == "predator":
746
+ for (c, r) in _predator_solid_offsets(ag.facing):
747
+ x, y = ag.pos[0] + c, ag.pos[1] + r
748
+ if 0 <= x < w and 0 <= y < h:
749
+ grid[y][x] = predator_idx
750
+ else:
751
+ color = focal_idx if ag.is_chosen else distractor_idx
752
+ for r in range(ag.size):
753
+ for c in range(ag.size):
754
+ x, y = ag.pos[0] + c, ag.pos[1] + r
755
+ if 0 <= x < w and 0 <= y < h:
756
+ grid[y][x] = color
757
+ out.append({"turn_idx": mt.turn_idx, "action": mt.action,
758
+ "grid": grid, "events": list(mt.events)})
759
+ continue
760
+ grid = _ascii_to_grid(mt.frame_ascii, sym2idx)
761
+ ... # (existing reconstruction unchanged)
762
+ ```
763
+
764
+ NOTE: the existing loop body that follows must be preserved. Also change the existing single-agent append (the last line of the loop) from
765
+ ```python
766
+ out.append({"turn_idx": mt.turn_idx, "action": mt.action, "grid": grid})
767
+ ```
768
+ to
769
+ ```python
770
+ out.append({"turn_idx": mt.turn_idx, "action": mt.action,
771
+ "grid": grid, "events": list(mt.events)})
772
+ ```
773
+ so every frame carries `events` (legacy = `[]`). Delete the now-duplicate `for mt in checkpoint.memory_turns:` line that was already there (the branch above reuses the same loop header — keep exactly one loop header).
774
+
775
+ - [ ] **Step 4: Run tests to verify they pass**
776
+
777
+ Run: `uv run pytest tests/runtime/test_memory_frames_multiagent.py -v`
778
+ Expected: PASS.
779
+
780
+ - [ ] **Step 5: Regression-check existing frame tests**
781
+
782
+ Run: `uv run pytest tests/runtime/test_memory_frames.py -q`
783
+ Expected: PASS (they should tolerate the extra `events` key; if one asserts exact dict equality, update it to include `"events": []`).
784
+
785
+ - [ ] **Step 6: Commit**
786
+
787
+ ```bash
788
+ git add proteus/game/runtime/memory.py tests/runtime/test_memory_frames_multiagent.py
789
+ git commit -m "feat(memory): multi-agent memory_frames (2x2 agents, 3x3 mouth predator, resources)"
790
+ ```
791
+
792
+ ---
793
+
794
+ ## Phase 4 — Director + new scenarios
795
+
796
+ ### Task 6: Scripted director — scenario ① `author_pack_flee`
797
+
798
+ **Files:**
799
+ - Create: `proteus/game/runtime/multiagent_director.py`
800
+ - Test: `tests/runtime/test_director_pack_flee.py` (create)
801
+
802
+ Open-field (no walls), equal speed. The chosen agent (`a0`) is **kill-immune** (authored survivor) and flees; distractors panic-wander and are caught one at a time after a free phase; a `max_turns` safeguard force-resolves any stragglers so the memory always ends with exactly one survivor.
803
+
804
+ - [ ] **Step 1: Write the failing test**
805
+
806
+ ```python
807
+ # tests/runtime/test_director_pack_flee.py
808
+ from proteus.game.runtime.multiagent_director import author_pack_flee
809
+
810
+
811
+ def _run():
812
+ return author_pack_flee(
813
+ seed=7,
814
+ agent_starts=[(10, 30), (14, 38), (18, 24), (12, 46)],
815
+ predator_start=(54, 31),
816
+ )
817
+
818
+
819
+ def test_deterministic():
820
+ a, b = _run(), _run()
821
+ assert a.model_dump_json() == b.model_dump_json()
822
+
823
+
824
+ def test_ends_with_single_chosen_survivor():
825
+ ck = _run()
826
+ last = ck.memory_turns[-1]
827
+ alive = [a for a in last.agents if a.kind == "agent" and a.alive]
828
+ assert len(alive) == 1 and alive[0].is_chosen and alive[0].id == "a0"
829
+ assert ck.chosen_agent_id == "a0"
830
+
831
+
832
+ def test_chosen_alive_every_turn_and_kills_recorded():
833
+ ck = _run()
834
+ for t in ck.memory_turns:
835
+ chosen = next(a for a in t.agents if a.id == "a0")
836
+ assert chosen.alive
837
+ all_events = [e for t in ck.memory_turns for e in t.events]
838
+ assert sum("eaten" in e for e in all_events) == 3 # 3 distractors caught
839
+
840
+
841
+ def test_persona_id_recorded():
842
+ assert _run().persona_weight_id == "risk_averse"
843
+ ```
844
+
845
+ - [ ] **Step 2: Run test to verify it fails**
846
+
847
+ Run: `uv run pytest tests/runtime/test_director_pack_flee.py -v`
848
+ Expected: FAIL (module does not exist).
849
+
850
+ - [ ] **Step 3: Create the director with scenario â‘ **
851
+
852
+ ```python
853
+ # proteus/game/runtime/multiagent_director.py
854
+ """Deterministic scripted directors that author multi-agent handover memories.
855
+
856
+ These do NOT run the live engine (which is single-focal). They own the
857
+ multi-agent truth on an open field and emit a ``MemoryCheckpoint`` whose
858
+ ``memory_turns`` carry per-sprite ``AgentFrame``s, resources, and events. The
859
+ chosen agent (``a0``) is authored to embody a persona: it flees optimally
860
+ (``risk_averse``, scenario â‘ ) or beelines to the resource (``greedy``,
861
+ scenario â‘¡). Distractors wander. Geometry is open-field Manhattan.
862
+ """
863
+ from __future__ import annotations
864
+
865
+ import random
866
+ from dataclasses import dataclass, field
867
+
868
+ from proteus.game.runtime.memory import AgentFrame, MemoryCheckpoint, MemoryTurn
869
+
870
+ _GRID = (64, 64)
871
+ _DELTAS = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "stay": (0, 0)}
872
+ _MOVES = ("up", "down", "left", "right")
873
+ _FACING_FROM = {"right": "right", "left": "left", "up": "up", "down": "down"}
874
+ _STAMP = "0000-00-00T00-00-00"
875
+
876
+
877
+ @dataclass
878
+ class _Agent:
879
+ id: str
880
+ x: int
881
+ y: int
882
+ size: int
883
+ alive: bool = True
884
+ is_chosen: bool = False
885
+
886
+
887
+ def _in_bounds(x: int, y: int, size: int, grid=_GRID) -> bool:
888
+ return 0 <= x and 0 <= y and x + size <= grid[0] and y + size <= grid[1]
889
+
890
+
891
+ def _center(a: _Agent) -> tuple[int, int]:
892
+ return (a.x + a.size // 2, a.y + a.size // 2)
893
+
894
+
895
+ def _manhattan(a: tuple[int, int], b: tuple[int, int]) -> int:
896
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
897
+
898
+
899
+ def _overlap(a: _Agent, b: _Agent) -> bool:
900
+ return (a.x < b.x + b.size and b.x < a.x + a.size
901
+ and a.y < b.y + b.size and b.y < a.y + a.size)
902
+
903
+
904
+ def _legal_moves(a: _Agent, grid=_GRID) -> list[str]:
905
+ out = []
906
+ for m in _MOVES:
907
+ dx, dy = _DELTAS[m]
908
+ if _in_bounds(a.x + dx, a.y + dy, a.size, grid):
909
+ out.append(m)
910
+ return out
911
+
912
+
913
+ def _wander(a: _Agent, rng: random.Random) -> str:
914
+ legal = _legal_moves(a)
915
+ return rng.choice(legal) if legal else "stay"
916
+
917
+
918
+ def _flee(a: _Agent, threat: tuple[int, int]) -> str:
919
+ """Pick the legal move that MAXIMISES Manhattan distance from *threat*."""
920
+ best, best_d = "stay", -1
921
+ for m in _legal_moves(a):
922
+ dx, dy = _DELTAS[m]
923
+ cc = (a.x + dx + a.size // 2, a.y + dy + a.size // 2)
924
+ d = _manhattan(cc, threat)
925
+ if d > best_d:
926
+ best, best_d = m, d
927
+ return best
928
+
929
+
930
+ def _step_toward(a: _Agent, target: tuple[int, int]) -> str:
931
+ """One greedy footprint-safe step reducing Manhattan distance to *target*."""
932
+ best, best_d = "stay", _manhattan(_center(a), target)
933
+ for m in _legal_moves(a):
934
+ dx, dy = _DELTAS[m]
935
+ cc = (a.x + dx + a.size // 2, a.y + dy + a.size // 2)
936
+ d = _manhattan(cc, target)
937
+ if d < best_d:
938
+ best, best_d = m, d
939
+ return best
940
+
941
+
942
+ def _apply(a: _Agent, action: str) -> None:
943
+ dx, dy = _DELTAS[action]
944
+ if _in_bounds(a.x + dx, a.y + dy, a.size):
945
+ a.x, a.y = a.x + dx, a.y + dy
946
+
947
+
948
+ def _frame(agents: list[_Agent], pred: _Agent, facing: str,
949
+ resources, events, action: str, idx: int) -> MemoryTurn:
950
+ frames = [AgentFrame(id=a.id, kind="agent", pos=(a.x, a.y), size=a.size,
951
+ alive=a.alive, is_chosen=a.is_chosen) for a in agents]
952
+ if pred is not None:
953
+ frames.append(AgentFrame(id="predator", kind="predator",
954
+ pos=(pred.x, pred.y), size=pred.size, facing=facing))
955
+ alive_n = sum(1 for a in agents if a.alive)
956
+ return MemoryTurn(
957
+ turn_idx=idx, frame_ascii=f"{alive_n} agents alive; predator at {(pred.x, pred.y) if pred else None}",
958
+ action=action, focal_pos=(0, 0), predator_pos=(0, 0),
959
+ agents=frames, resources=list(resources), events=list(events),
960
+ )
961
+
962
+
963
+ def author_pack_flee(
964
+ *, seed: int, agent_starts: list[tuple[int, int]], predator_start: tuple[int, int],
965
+ agent_size: int = 2, predator_size: int = 3, free_turns: int = 8, max_turns: int = 80,
966
+ persona_id: str = "risk_averse",
967
+ ) -> MemoryCheckpoint:
968
+ """Author scenario â‘ : pack flees a predator; one survivor (a0) remains."""
969
+ rng = random.Random(seed)
970
+ agents = [_Agent(id=f"a{i}", x=p[0], y=p[1], size=agent_size, is_chosen=(i == 0))
971
+ for i, p in enumerate(agent_starts)]
972
+ pred = _Agent(id="predator", x=predator_start[0], y=predator_start[1], size=predator_size)
973
+ facing = "left"
974
+ turns: list[MemoryTurn] = []
975
+ catalyst_done = False
976
+
977
+ for t in range(1, max_turns + 1):
978
+ events: list[str] = []
979
+ chosen = next(a for a in agents if a.is_chosen)
980
+ # Record the PRE-move frame; the stored action is the chosen agent's.
981
+ chosen_action = (_flee(chosen, _center(pred)) if (t > free_turns or catalyst_done)
982
+ else _wander(chosen, rng))
983
+ turns.append(_frame(agents, pred, facing, [], events, chosen_action, t))
984
+
985
+ # --- predator-first resolution ---
986
+ targets = [a for a in agents if a.alive and not a.is_chosen]
987
+ if targets:
988
+ nearest = min(targets, key=lambda a: _manhattan(_center(pred), _center(a)))
989
+ move = _step_toward(pred, _center(nearest))
990
+ if move != "stay":
991
+ facing = _FACING_FROM[move]
992
+ _apply(pred, move)
993
+ # chosen + distractors move
994
+ _apply(chosen, chosen_action)
995
+ for a in agents:
996
+ if a.alive and not a.is_chosen:
997
+ _apply(a, _wander(a, rng))
998
+ # kills (distractors only; chosen is the authored survivor)
999
+ for a in agents:
1000
+ if a.alive and not a.is_chosen and _overlap(pred, a):
1001
+ a.alive = False
1002
+ catalyst_done = True
1003
+ events.append(f"{a.id} eaten")
1004
+ # patch events onto the frame we just appended
1005
+ turns[-1].events = list(events)
1006
+
1007
+ if sum(1 for a in agents if a.alive and not a.is_chosen) == 0:
1008
+ break
1009
+
1010
+ # Safeguard: force-resolve any stragglers so exactly the chosen survives.
1011
+ for a in agents:
1012
+ if a.alive and not a.is_chosen:
1013
+ a.alive = False
1014
+ if turns:
1015
+ # Append a final settled frame (predator removed for clarity).
1016
+ turns.append(_frame(agents, pred, facing, [], ["only a0 survives"], "stay",
1017
+ len(turns) + 1))
1018
+
1019
+ return MemoryCheckpoint(
1020
+ model=f"director:{persona_id}", scenario="pack_flee", motive_category="survival",
1021
+ difficulty="easy", seed=seed, created_at=_STAMP, memory_turns=turns,
1022
+ outcome="survived", transparent_prompt="Pack-flee handover memory.",
1023
+ persona_weight_id=persona_id, chosen_agent_id="a0",
1024
+ )
1025
+ ```
1026
+
1027
+ - [ ] **Step 4: Run tests to verify they pass**
1028
+
1029
+ Run: `uv run pytest tests/runtime/test_director_pack_flee.py -v`
1030
+ Expected: PASS. (If `test_chosen_alive_every_turn_and_kills_recorded` finds ≠3 "eaten" events because the safeguard fired, increase `max_turns` to 120 — the open-field random-walk distractors must be caught by the directed predator within the budget; 80 is tuned for these starts.)
1031
+
1032
+ - [ ] **Step 5: Commit**
1033
+
1034
+ ```bash
1035
+ git add proteus/game/runtime/multiagent_director.py tests/runtime/test_director_pack_flee.py
1036
+ git commit -m "feat(director): author_pack_flee multi-agent survivor memory"
1037
+ ```
1038
+
1039
+ ---
1040
+
1041
+ ### Task 7: Scripted director — scenario ② `author_resource_race`
1042
+
1043
+ **Files:**
1044
+ - Modify: `proteus/game/runtime/multiagent_director.py` (add function)
1045
+ - Test: `tests/runtime/test_director_resource_race.py` (create)
1046
+
1047
+ - [ ] **Step 1: Write the failing test**
1048
+
1049
+ ```python
1050
+ # tests/runtime/test_director_resource_race.py
1051
+ from proteus.game.runtime.multiagent_director import author_resource_race
1052
+
1053
+
1054
+ def _run():
1055
+ return author_resource_race(
1056
+ seed=3,
1057
+ agent_starts=[(8, 52), (20, 10), (40, 50), (55, 20), (30, 30)],
1058
+ resource=(54, 12),
1059
+ )
1060
+
1061
+
1062
+ def test_deterministic():
1063
+ assert _run().model_dump_json() == _run().model_dump_json()
1064
+
1065
+
1066
+ def test_ends_when_chosen_collects_resource():
1067
+ ck = _run()
1068
+ last = ck.memory_turns[-1]
1069
+ assert any("got resource" in e for e in last.events)
1070
+ chosen = next(a for a in last.agents if a.id == "a0")
1071
+ rx, ry = (54, 12)
1072
+ # chosen footprint covers the resource cell
1073
+ assert chosen.pos[0] <= rx < chosen.pos[0] + chosen.size
1074
+ assert chosen.pos[1] <= ry < chosen.pos[1] + chosen.size
1075
+ assert ck.chosen_agent_id == "a0" and ck.persona_weight_id == "greedy"
1076
+
1077
+
1078
+ def test_resource_present_until_collected():
1079
+ ck = _run()
1080
+ # Every turn except the last carries the resource; the last records pickup.
1081
+ for t in ck.memory_turns[:-1]:
1082
+ assert t.resources == [(54, 12)]
1083
+ ```
1084
+
1085
+ - [ ] **Step 2: Run test to verify it fails**
1086
+
1087
+ Run: `uv run pytest tests/runtime/test_director_resource_race.py -v`
1088
+ Expected: FAIL (`author_resource_race` undefined).
1089
+
1090
+ - [ ] **Step 3: Add `author_resource_race` to the director**
1091
+
1092
+ Append to `proteus/game/runtime/multiagent_director.py`:
1093
+
1094
+ ```python
1095
+ def _covers(a: _Agent, cell: tuple[int, int]) -> bool:
1096
+ return (a.x <= cell[0] < a.x + a.size and a.y <= cell[1] < a.y + a.size)
1097
+
1098
+
1099
+ def author_resource_race(
1100
+ *, seed: int, agent_starts: list[tuple[int, int]], resource: tuple[int, int],
1101
+ agent_size: int = 2, max_turns: int = 120, persona_id: str = "greedy",
1102
+ ) -> MemoryCheckpoint:
1103
+ """Author scenario â‘¡: a0 beelines to the lone resource; others wander."""
1104
+ rng = random.Random(seed)
1105
+ agents = [_Agent(id=f"a{i}", x=p[0], y=p[1], size=agent_size, is_chosen=(i == 0))
1106
+ for i, p in enumerate(agent_starts)]
1107
+ turns: list[MemoryTurn] = []
1108
+
1109
+ for t in range(1, max_turns + 1):
1110
+ chosen = next(a for a in agents if a.is_chosen)
1111
+ if _covers(chosen, resource):
1112
+ turns.append(_frame(agents, None, "right", [resource],
1113
+ ["a0 got resource"], "stay", t))
1114
+ break
1115
+ action = _step_toward(chosen, resource)
1116
+ turns.append(_frame(agents, None, "right", [resource], [], action, t))
1117
+ _apply(chosen, action)
1118
+ for a in agents:
1119
+ if not a.is_chosen:
1120
+ _apply(a, _wander(a, rng))
1121
+
1122
+ return MemoryCheckpoint(
1123
+ model=f"director:{persona_id}", scenario="resource_race",
1124
+ motive_category="resource", difficulty="easy", seed=seed, created_at=_STAMP,
1125
+ memory_turns=turns, outcome="survived",
1126
+ transparent_prompt="Resource-race handover memory.",
1127
+ persona_weight_id=persona_id, chosen_agent_id="a0",
1128
+ )
1129
+ ```
1130
+
1131
+ - [ ] **Step 4: Run tests to verify they pass**
1132
+
1133
+ Run: `uv run pytest tests/runtime/test_director_resource_race.py -v`
1134
+ Expected: PASS.
1135
+
1136
+ - [ ] **Step 5: Commit**
1137
+
1138
+ ```bash
1139
+ git add proteus/game/runtime/multiagent_director.py tests/runtime/test_director_resource_race.py
1140
+ git commit -m "feat(director): author_resource_race multi-agent winner memory"
1141
+ ```
1142
+
1143
+ ---
1144
+
1145
+ ### Task 8: `pack_flee` scenario (single-focal query, reuses pack_evade live mechanics)
1146
+
1147
+ **Files:**
1148
+ - Create: `proteus/game/scenarios/pack_flee.py`
1149
+ - Modify: `proteus/game/scenarios/__init__.py`
1150
+ - Test: `tests/scenarios/test_pack_flee.py` (create)
1151
+
1152
+ - [ ] **Step 1: Write the failing test**
1153
+
1154
+ ```python
1155
+ # tests/scenarios/test_pack_flee.py
1156
+ import random
1157
+
1158
+ from proteus.game.engine.difficulty import Difficulty
1159
+ from proteus.game.scenarios.base import get_scenario, list_scenarios
1160
+ import proteus.game.scenarios # noqa: F401
1161
+
1162
+
1163
+ def test_registered():
1164
+ assert "pack_flee" in list_scenarios()
1165
+
1166
+
1167
+ def test_predator_first_and_sizes():
1168
+ scen = get_scenario("pack_flee")()
1169
+ assert scen.turn_order == "predator_first"
1170
+
1171
+
1172
+ def test_default_memory_is_multiagent_survivor():
1173
+ scen = get_scenario("pack_flee")()
1174
+ ck = scen.default_memory(seed=7, difficulty=Difficulty.EASY)
1175
+ assert ck.chosen_agent_id == "a0" and ck.persona_weight_id == "risk_averse"
1176
+ last = ck.memory_turns[-1]
1177
+ alive = [a for a in last.agents if a.kind == "agent" and a.alive]
1178
+ assert len(alive) == 1
1179
+ ```
1180
+
1181
+ - [ ] **Step 2: Run test to verify it fails**
1182
+
1183
+ Run: `uv run pytest tests/scenarios/test_pack_flee.py -v`
1184
+ Expected: FAIL (`pack_flee` not registered).
1185
+
1186
+ - [ ] **Step 3a: Create the scenario**
1187
+
1188
+ ```python
1189
+ # proteus/game/scenarios/pack_flee.py
1190
+ """pack_flee — scenario ①: a single-focal predator-evasion QUERY whose handover
1191
+ memory is a hand-authored multi-agent pack flee (see
1192
+ ``proteus.game.runtime.multiagent_director.author_pack_flee``). The lone survivor
1193
+ of that memory (``a0``, a risk_averse evader) is the focal the player continues.
1194
+
1195
+ Live mechanics are identical to the resized ``pack_evade`` (2x2 focal, 3x3
1196
+ open-mouth predator, predator-first), so this subclasses it and only swaps the
1197
+ handover memory + briefs.
1198
+ """
1199
+ from __future__ import annotations
1200
+
1201
+ from proteus.game.scenarios.base import register_scenario
1202
+ from proteus.game.scenarios.pack_evade import PackEvade
1203
+
1204
+
1205
+ @register_scenario("pack_flee")
1206
+ class PackFlee(PackEvade):
1207
+ task_name: str = "pack_flee"
1208
+ rules_text: str = (
1209
+ "You are the lone survivor of a pack. A 3x3 predator hunts you (2x2). "
1210
+ "It steps toward you every turn; you are eaten on contact. Stay as far "
1211
+ "from it as you can. Actions: up, down, left, right, stay."
1212
+ )
1213
+ memory_brief: str = (
1214
+ "MEMORY. Your pack scattered as a predator closed in; the others were "
1215
+ "caught one by one and you alone escaped by keeping maximum distance."
1216
+ )
1217
+
1218
+ def default_memory(self, seed, difficulty):
1219
+ from proteus.game.runtime.multiagent_director import author_pack_flee
1220
+ # Deterministic spread of 4 agents + a distant predator on the 64x64 field.
1221
+ return author_pack_flee(
1222
+ seed=(seed if seed is not None else 0),
1223
+ agent_starts=[(10, 30), (14, 38), (18, 24), (12, 46)],
1224
+ predator_start=(54, 31),
1225
+ persona_id="risk_averse",
1226
+ )
1227
+ ```
1228
+
1229
+ - [ ] **Step 3b: Register it**
1230
+
1231
+ In `proteus/game/scenarios/__init__.py` add after the `pack_evade` import:
1232
+ ```python
1233
+ from proteus.game.scenarios import pack_flee # noqa: F401 — side-effect: register
1234
+ ```
1235
+ and add `"pack_flee"` to `__all__`.
1236
+
1237
+ - [ ] **Step 4: Run tests to verify they pass**
1238
+
1239
+ Run: `uv run pytest tests/scenarios/test_pack_flee.py -v`
1240
+ Expected: PASS.
1241
+
1242
+ - [ ] **Step 5: Commit**
1243
+
1244
+ ```bash
1245
+ git add proteus/game/scenarios/pack_flee.py proteus/game/scenarios/__init__.py tests/scenarios/test_pack_flee.py
1246
+ git commit -m "feat(scenario): pack_flee single-focal query + multi-agent survivor memory"
1247
+ ```
1248
+
1249
+ ---
1250
+
1251
+ ### Task 9: `resource_race` scenario (resources-only single-focal query, greedy persona)
1252
+
1253
+ **Files:**
1254
+ - Create: `proteus/game/scenarios/resource_race.py`
1255
+ - Modify: `proteus/game/scenarios/__init__.py`
1256
+ - Test: `tests/scenarios/test_resource_race.py` (create)
1257
+
1258
+ No predator. `turn_order` stays `focal_first` (irrelevant without a threat; resource collection runs in `advance_threat` after the focal move). Ends at horizon (no early win).
1259
+
1260
+ - [ ] **Step 1: Write the failing test**
1261
+
1262
+ ```python
1263
+ # tests/scenarios/test_resource_race.py
1264
+ import random
1265
+
1266
+ from proteus.game.engine.difficulty import Difficulty
1267
+ from proteus.game.engine.grid import MotiveGridGame
1268
+ from proteus.game.scenarios.base import get_scenario, list_scenarios
1269
+ import proteus.game.scenarios # noqa: F401
1270
+
1271
+
1272
+ def _game(seed=1):
1273
+ scen = get_scenario("resource_race")()
1274
+ return scen, MotiveGridGame(scen, random.Random(seed), Difficulty.EASY, max_steps=40)
1275
+
1276
+
1277
+ def test_registered():
1278
+ assert "resource_race" in list_scenarios()
1279
+
1280
+
1281
+ def test_no_predator_never_eliminates():
1282
+ scen, game = _game()
1283
+ assert game.predator_sprite is None
1284
+ assert scen.check_elimination(game) is False
1285
+
1286
+
1287
+ def test_optimal_action_heads_to_nearest_resource():
1288
+ scen, game = _game()
1289
+ a = scen.optimal_action(game)
1290
+ assert a in ("up", "down", "left", "right", "stay")
1291
+ assert scen.nearest_resource_distance(game, (game.focal_sprite.x, game.focal_sprite.y)) is not None
1292
+
1293
+
1294
+ def test_collecting_a_resource_removes_it():
1295
+ scen, game = _game()
1296
+ res = scen.food_cells()
1297
+ assert res, "expected resources on the field"
1298
+ target = res[0]
1299
+ # Teleport focal onto the resource and advance one turn (collection runs there).
1300
+ game.focal_sprite.set_position(target[0], target[1])
1301
+ scen.advance_threat(game)
1302
+ assert target not in scen.food_cells()
1303
+
1304
+
1305
+ def test_default_memory_greedy_winner():
1306
+ scen, _ = _game()
1307
+ ck = scen.default_memory(seed=3, difficulty=Difficulty.EASY)
1308
+ assert ck.persona_weight_id == "greedy" and ck.chosen_agent_id == "a0"
1309
+ ```
1310
+
1311
+ - [ ] **Step 2: Run test to verify it fails**
1312
+
1313
+ Run: `uv run pytest tests/scenarios/test_resource_race.py -v`
1314
+ Expected: FAIL (`resource_race` not registered).
1315
+
1316
+ - [ ] **Step 3a: Create the scenario**
1317
+
1318
+ ```python
1319
+ # proteus/game/scenarios/resource_race.py
1320
+ """resource_race — scenario ②: a resources-only, predator-free single-focal QUERY.
1321
+
1322
+ The handover memory is a hand-authored multi-agent race for one resource where
1323
+ ``a0`` (a greedy resource-seeker) wins (see
1324
+ ``proteus.game.runtime.multiagent_director.author_resource_race``). The query
1325
+ field has several resources; the greedy reference policy heads for the nearest
1326
+ one each turn, and the existing persona metrics score how well the player
1327
+ maintains that resource-seeking persona.
1328
+
1329
+ No predator: ``check_elimination`` is always False, distance/pressure metrics are
1330
+ skipped (None), and the episode ends at the horizon. ``turn_order`` stays
1331
+ focal_first so resource collection (run in ``advance_threat``) sees the focal's
1332
+ post-move cell.
1333
+ """
1334
+ from __future__ import annotations
1335
+
1336
+ import random
1337
+ from typing import TYPE_CHECKING
1338
+
1339
+ from proteus.game.engine import BlockingMode, Level, Sprite
1340
+ from proteus.game.engine.difficulty import Difficulty
1341
+
1342
+ from .base import Scenario, register_scenario
1343
+
1344
+ if TYPE_CHECKING:
1345
+ from ..engine.grid import MotiveGridGame
1346
+
1347
+ BACKGROUND_IDX = 5
1348
+ FOCAL_IDX = 1
1349
+ FOOD_IDX = 14
1350
+ _GRID = (64, 64)
1351
+ _FOCAL_SIZE = 2
1352
+ _FOCAL_START = (8, 52)
1353
+ _DELTAS = {"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "stay": (0, 0)}
1354
+ _ORDER = ("up", "down", "left", "right", "stay")
1355
+ _N_RESOURCES = {Difficulty.EASY: 6, Difficulty.MEDIUM: 6, Difficulty.HARD: 8, Difficulty.EXPERT: 8}
1356
+ _MARGIN = 2
1357
+
1358
+
1359
+ def _manhattan(a, b):
1360
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
1361
+
1362
+
1363
+ @register_scenario("resource_race")
1364
+ class ResourceRace(Scenario):
1365
+ task_name: str = "resource_race"
1366
+ grid_size: tuple[int, int] = _GRID
1367
+ turn_order: str = "focal_first"
1368
+ rules_text: str = (
1369
+ "You are a 2x2 agent on a 64x64 field scattered with resources (green "
1370
+ "cells). Move onto a resource to collect it. Gather as many as you can. "
1371
+ "Actions: up, down, left, right, stay."
1372
+ )
1373
+ memory_brief: str = (
1374
+ "MEMORY. Among five agents you alone made straight for the resource and "
1375
+ "took it while the others wandered."
1376
+ )
1377
+
1378
+ _resources: list[tuple[int, int]] = []
1379
+
1380
+ def build_level(self, rng: random.Random, difficulty: Difficulty) -> Level:
1381
+ self.grid_size = _GRID
1382
+ w, h = _GRID
1383
+ spawn = {(_FOCAL_START[0] + i, _FOCAL_START[1] + j)
1384
+ for i in range(_FOCAL_SIZE) for j in range(_FOCAL_SIZE)}
1385
+ cells: list[tuple[int, int]] = []
1386
+ while len(cells) < _N_RESOURCES.get(difficulty, 6):
1387
+ c = (rng.randint(_MARGIN, w - 1 - _MARGIN), rng.randint(_MARGIN, h - 1 - _MARGIN))
1388
+ if c not in spawn and c not in cells:
1389
+ cells.append(c)
1390
+ self._resources = cells
1391
+ focal = Sprite(
1392
+ pixels=[[FOCAL_IDX] * _FOCAL_SIZE for _ in range(_FOCAL_SIZE)],
1393
+ name="focal", x=_FOCAL_START[0], y=_FOCAL_START[1],
1394
+ blocking=BlockingMode.BOUNDING_BOX,
1395
+ )
1396
+ food = [Sprite(pixels=[[FOOD_IDX]], name="food", x=fx, y=fy,
1397
+ blocking=BlockingMode.NOT_BLOCKED) for (fx, fy) in cells]
1398
+ return Level(sprites=[*food, focal])
1399
+
1400
+ # --- no-predator stubs -------------------------------------------------- #
1401
+ def cut_focal_policy(self, game: MotiveGridGame) -> str:
1402
+ del game
1403
+ return "stay"
1404
+
1405
+ def cut_length(self, difficulty) -> int:
1406
+ del difficulty
1407
+ return 0
1408
+
1409
+ def advance_threat(self, game: MotiveGridGame) -> None:
1410
+ """No predator; collect any resource the focal now overlaps."""
1411
+ focal = game.focal_sprite
1412
+ if focal is None:
1413
+ return
1414
+ covered = {(focal.x + i, focal.y + j)
1415
+ for i in range(focal.width) for j in range(focal.height)}
1416
+ self._resources = [c for c in self._resources if c not in covered]
1417
+
1418
+ def check_elimination(self, game: MotiveGridGame) -> bool:
1419
+ del game
1420
+ return False
1421
+
1422
+ # --- geometry the persona/metrics use ----------------------------------- #
1423
+ def _is_free(self, game: MotiveGridGame, cell: tuple[int, int]) -> bool:
1424
+ x, y = cell
1425
+ w, h = self.grid_size
1426
+ return 0 <= x and 0 <= y and x + _FOCAL_SIZE <= w and y + _FOCAL_SIZE <= h
1427
+
1428
+ def nearest_resource_distance(self, game, cell) -> int | None:
1429
+ if not self._resources:
1430
+ return None
1431
+ center = (cell[0] + _FOCAL_SIZE // 2, cell[1] + _FOCAL_SIZE // 2)
1432
+ return min(_manhattan(center, r) for r in self._resources)
1433
+
1434
+ def optimal_action(self, game: MotiveGridGame) -> str:
1435
+ focal = game.focal_sprite
1436
+ if focal is None or not self._resources:
1437
+ return "stay"
1438
+ src = (focal.x, focal.y)
1439
+ best, best_d = "stay", self.nearest_resource_distance(game, src)
1440
+ for a in _ORDER:
1441
+ dx, dy = _DELTAS[a]
1442
+ cand = (src[0] + dx, src[1] + dy)
1443
+ if a != "stay" and not self._is_free(game, cand):
1444
+ continue
1445
+ d = self.nearest_resource_distance(game, cand)
1446
+ if d is not None and (best_d is None or d < best_d):
1447
+ best, best_d = a, d
1448
+ return best
1449
+
1450
+ def habit_action(self, game: MotiveGridGame) -> str:
1451
+ return self.optimal_action(game) # no diagnostic
1452
+
1453
+ def step_reward(self, game, action, blocked, focal_before, predator_before) -> float:
1454
+ del action, blocked, predator_before
1455
+ focal = game.focal_sprite
1456
+ if focal is None:
1457
+ return 0.0
1458
+ before = self.nearest_resource_distance(game, focal_before)
1459
+ after = self.nearest_resource_distance(game, (focal.x, focal.y))
1460
+ if before is None or after is None:
1461
+ return 0.0
1462
+ return float(before - after) # positive = moved toward a resource
1463
+
1464
+ def legend(self) -> dict[int, str]:
1465
+ return {BACKGROUND_IDX: ".", FOCAL_IDX: "A", FOOD_IDX: "F"}
1466
+
1467
+ def food_cells(self) -> list[tuple[int, int]]:
1468
+ return list(self._resources)
1469
+
1470
+ def render_frame(self, game: MotiveGridGame) -> str:
1471
+ focal = game.focal_sprite
1472
+ if focal is None:
1473
+ return "field state unavailable"
1474
+ fc = (focal.x + _FOCAL_SIZE // 2, focal.y + _FOCAL_SIZE // 2)
1475
+ cells = "; ".join(f"({x},{y})" for (x, y) in self._resources) or "none"
1476
+ return (f"Open field {_GRID[0]}x{_GRID[1]}. You are A (2x2) centered at {fc}. "
1477
+ f"Resources remaining: {cells}.")
1478
+
1479
+ def default_memory(self, seed, difficulty):
1480
+ from proteus.game.runtime.multiagent_director import author_resource_race
1481
+ return author_resource_race(
1482
+ seed=(seed if seed is not None else 0),
1483
+ agent_starts=[(8, 52), (20, 10), (40, 50), (55, 20), (30, 30)],
1484
+ resource=(54, 12), persona_id="greedy",
1485
+ )
1486
+ ```
1487
+
1488
+ - [ ] **Step 3b: Register it**
1489
+
1490
+ In `proteus/game/scenarios/__init__.py` add:
1491
+ ```python
1492
+ from proteus.game.scenarios import resource_race # noqa: F401 — side-effect: register
1493
+ ```
1494
+ and add `"resource_race"` to `__all__`.
1495
+
1496
+ - [ ] **Step 4: Run tests + a full-loop smoke**
1497
+
1498
+ Run: `uv run pytest tests/scenarios/test_resource_race.py -v`
1499
+ Expected: PASS.
1500
+
1501
+ Then verify a full interactive episode runs end-to-end (no predator, ends at horizon):
1502
+ Run:
1503
+ ```bash
1504
+ uv run python -c "
1505
+ from proteus.game.runtime.interactive import InteractiveSession
1506
+ import proteus.game.scenarios # noqa
1507
+ s = InteractiveSession('resource_race', seed=1, play_turns=5)
1508
+ for _ in range(5):
1509
+ st = s.step('right')
1510
+ print('outcome', st['outcome'])
1511
+ "
1512
+ ```
1513
+ Expected: prints `outcome survived`.
1514
+
1515
+ - [ ] **Step 5: Commit**
1516
+
1517
+ ```bash
1518
+ git add proteus/game/scenarios/resource_race.py proteus/game/scenarios/__init__.py tests/scenarios/test_resource_race.py
1519
+ git commit -m "feat(scenario): resource_race resources-only query + greedy multi-agent memory"
1520
+ ```
1521
+
1522
+ ---
1523
+
1524
+ ## Phase 5 — Web wiring
1525
+
1526
+ ### Task 10: Surface multi-agent memory in the web replay (events caption + legend)
1527
+
1528
+ **Files:**
1529
+ - Modify: `proteus/web/local/static/index.html` (caption + legend)
1530
+ - Test: `tests/web/test_multiagent_memory_route.py` (create)
1531
+
1532
+ `server._memory_info` already returns `memory_frames(...)`, which now includes
1533
+ `agents`-painted grids + `events`. No server change is needed for data; this task
1534
+ adds the client caption/legend and a route smoke test.
1535
+
1536
+ - [ ] **Step 1: Write the failing test**
1537
+
1538
+ ```python
1539
+ # tests/web/test_multiagent_memory_route.py
1540
+ """Creating a pack_flee session returns multi-agent memory frames over HTTP."""
1541
+ from proteus.web.local.server import handle_request
1542
+ import proteus.game.scenarios # noqa: F401
1543
+
1544
+
1545
+ def test_pack_flee_session_returns_painted_multiagent_memory():
1546
+ registry: dict = {}
1547
+ body = {"scenario": "pack_flee", "difficulty": "easy", "seed": 7,
1548
+ "play_turns": 5, "memory": "default"}
1549
+ status, payload, _ = handle_request("POST", "/session", body, registry)
1550
+ assert status == 200, payload
1551
+ frames = payload["memory"]["frames"]
1552
+ assert frames, "expected memory frames"
1553
+ # Distractor blue (9) appears somewhere in an early frame's grid.
1554
+ flat0 = [v for row in frames[0]["grid"] for v in row]
1555
+ assert 9 in flat0
1556
+ # events key is present on frames.
1557
+ assert "events" in frames[0]
1558
+ ```
1559
+
1560
+ - [ ] **Step 2: Run test to verify it fails (or passes data-only) **
1561
+
1562
+ Run: `uv run pytest tests/web/test_multiagent_memory_route.py -v`
1563
+ Expected: PASS for the data assertions (server already forwards frames). If it
1564
+ fails on `9 in flat0`, the memory_frames branch (Task 5) regressed — fix there.
1565
+ This test pins the contract before the JS change.
1566
+
1567
+ - [ ] **Step 3: Add the events caption + legend in `index.html`**
1568
+
1569
+ In `proteus/web/local/static/index.html`, in `renderMemFrame()` (lines ~261-267) replace:
1570
+ ```javascript
1571
+ $("memReplayCap").textContent =
1572
+ `Memory ${f.turn_idx} / ${MEM_FRAMES.length} — you chose: ${f.action}`;
1573
+ ```
1574
+ with:
1575
+ ```javascript
1576
+ const ev = (f.events && f.events.length) ? " · " + f.events.join(", ") : "";
1577
+ $("memReplayCap").textContent =
1578
+ `Memory ${f.turn_idx} / ${MEM_FRAMES.length} — you chose: ${f.action}${ev}`;
1579
+ ```
1580
+
1581
+ In `showMem(m)` (after setting `$("memInfo").textContent`, ~line 295) append a static legend so the viewer can read the colours:
1582
+ ```javascript
1583
+ $("memInfo").textContent +=
1584
+ " · blue = other agents, light = you, mouth-block = predator";
1585
+ ```
1586
+
1587
+ - [ ] **Step 4: Re-run the web test + full web suite**
1588
+
1589
+ Run: `uv run pytest tests/web -q`
1590
+ Expected: PASS.
1591
+
1592
+ - [ ] **Step 5: Manual smoke (optional)**
1593
+
1594
+ Run: `uv run python -m proteus.web.local` then open the printed URL, pick
1595
+ scenario `pack_flee`, memory `scenario default`, start, and step through the
1596
+ memory replay — distractors render blue, the survivor light, the predator as an
1597
+ open-mouth block; the caption shows "aN eaten" on kill turns.
1598
+
1599
+ - [ ] **Step 6: Commit**
1600
+
1601
+ ```bash
1602
+ git add proteus/web/local/static/index.html tests/web/test_multiagent_memory_route.py
1603
+ git commit -m "feat(web): multi-agent memory replay caption + colour legend"
1604
+ ```
1605
+
1606
+ ---
1607
+
1608
+ ## Final verification
1609
+
1610
+ - [ ] **Run the whole suite**
1611
+
1612
+ Run: `uv run pytest -q`
1613
+ Expected: PASS (all phases green).
1614
+
1615
+ - [ ] **Confirm the four scenarios are registered**
1616
+
1617
+ Run: `uv run python -c "import proteus.game.scenarios as s; from proteus.game.scenarios.base import list_scenarios; print(list_scenarios())"`
1618
+ Expected: includes `pack_evade`, `pack_flee`, `predator_evade`, `resource_race`.
docs/superpowers/specs/2026-06-03-multiagent-memory-and-predator-visuals-design.md CHANGED
@@ -119,8 +119,11 @@ width 2.
119
  ### 2.3 Turn order = predator-first
120
 
121
  - New `Scenario.turn_order` hook returns `"focal_first"` by default;
122
- `pack_evade`, `pack_flee`, `resource_race` return `"predator_first"`.
123
- `predator_evade` keeps the default (preserves its diagnostic).
 
 
 
124
  - **Implementation = swap two operations inside `grid.step()`, guarded by
125
  `turn_order`.** No session-loop changes are needed.
126
  - `focal_first` (current): move focal → `advance_threat` → check terminal.
 
119
  ### 2.3 Turn order = predator-first
120
 
121
  - New `Scenario.turn_order` hook returns `"focal_first"` by default;
122
+ `pack_evade` and `pack_flee` return `"predator_first"`. `predator_evade` keeps
123
+ the default (preserves its diagnostic). `resource_race` also keeps
124
+ `"focal_first"`: it has no predator, and its resource collection runs in
125
+ `advance_threat`, which must fire *after* the focal moves — so focal-first is
126
+ required there.
127
  - **Implementation = swap two operations inside `grid.step()`, guarded by
128
  `turn_order`.** No session-loop changes are needed.
129
  - `focal_first` (current): move focal → `advance_threat` → check terminal.