irregular6612 commited on
Commit
fc1cdb5
·
1 Parent(s): c73d53f

feat(cp1): port predator_evade + wire scenario registry

Browse files
proteus/grid/__init__.py CHANGED
@@ -1 +1,10 @@
1
- """proteus.grid — the motive_grid scenario family (ported)."""
 
 
 
 
 
 
 
 
 
 
1
+ """proteus.grid — the motive_grid scenario family (ported).
2
+
3
+ Importing this package imports :mod:`proteus.grid.scenarios`, whose
4
+ ``@register_scenario`` decorators populate the scenario registry so
5
+ ``get_scenario("predator_evade")`` resolves.
6
+ """
7
+
8
+ from proteus.grid import scenarios # noqa: F401 — side-effect: populate registry
9
+
10
+ __all__ = ["scenarios"]
proteus/grid/scenarios/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Scenario implementations for the motive_grid family.
2
+
3
+ Importing this package fires each scenario's ``@register_scenario`` decorator,
4
+ populating the registry in :mod:`proteus.grid.scenario`.
5
+ """
6
+
7
+ from proteus.grid.scenarios import predator_evade # noqa: F401 — side-effect: register
8
+
9
+ __all__ = ["predator_evade"]
proteus/grid/scenarios/predator_evade.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """predator_evade — the bear-chase evasion scenario for motive_grid.
2
+
3
+ A predator pursues the focal agent across an 8x8 grid by taking one
4
+ shortest-path (BFS) step toward it every turn, so its motion reads as a clear
5
+ *chase* intent rather than noise (the ToM signal the benchmark probes). The
6
+ world is laced with a short internal wall that forms a **dead-end to the west**,
7
+ exactly where the focal agent has been walking. At the Cut handover the only
8
+ survival-correct move is to *detour* away from the wall while the fixed habit
9
+ ("keep walking west") runs the agent straight into the dead-end with the
10
+ predator closing from behind. That divergence — ``optimal_action`` (escape) vs
11
+ ``habit_action`` (keep west) — is the diagnostic the benchmark measures.
12
+
13
+ Coordinate convention (shared with ``game.py``): ``y`` grows DOWNWARD. Actions
14
+ are the strings ``"up"`` ``(0,-1)``, ``"down"`` ``(0,1)``, ``"left"`` ``(-1,0)``,
15
+ ``"right"`` ``(1,0)``, ``"stay"`` ``(0,0)``.
16
+
17
+ Deterministic EASY layout (seed-independent; placement is fixed, not random,
18
+ so ``rng`` is threaded only for forward-compatible tie-breaks)::
19
+
20
+ col: 0 1 2 3 4 5 6 7 x ->
21
+ row 0 . . . . . . . .
22
+ row 1 . . . . . . . .
23
+ row 2 . . # . . . . . '#' = wall (PIXEL_PERFECT)
24
+ row 3 . . # A . S B . 'A' = focal start (5,3) ... walks west
25
+ row 4 . . # . . . . . 'B' = predator start (7,3)
26
+ row 5 . . # . . . . . 'S' = focal Cut-handover cell (3,3)
27
+ row 6 . . . . . . . .
28
+ row 7 . . . . . . . .
29
+
30
+ Cut pre-roll (``cut_length`` EASY = 2, ``cut_focal_policy`` = "left"):
31
+
32
+ start focal (5,3) predator (7,3)
33
+ step 1 focal (4,3) predator (6,3)
34
+ step 2 focal (3,3) predator (5,3) <- HANDOVER / diagnostic state
35
+
36
+ At the handover the wall column ``x=2`` (rows 2..5) sits directly west of the
37
+ focal at ``(3,3)``:
38
+
39
+ * ``habit_action`` = "left" -> blocked by the wall (dead-end), no progress,
40
+ BFS distance to predator stays 2.
41
+ * ``optimal_action`` = "up" -> steps to ``(3,2)``, the legal move that
42
+ MAXIMIZES BFS distance from the predator (distance 3). ("up" and "down"
43
+ tie at distance 3; the fixed action order breaks the tie in favour of
44
+ "up".)
45
+
46
+ Hence ``optimal_action != habit_action`` at the Cut — the property the whole
47
+ discrimination metric depends on.
48
+
49
+ Palette indices (consistent with :meth:`PredatorEvade.legend` and the pixels
50
+ handed to each sprite in :meth:`PredatorEvade.build_level`):
51
+
52
+ * ``5`` -> ``'.'`` background (the arc_grid camera default)
53
+ * ``1`` -> ``'A'`` focal agent
54
+ * ``2`` -> ``'B'`` predator
55
+ * ``3`` -> ``'#'`` wall
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import random
61
+ from collections import deque
62
+ from typing import TYPE_CHECKING
63
+
64
+ from proteus.arc_grid import BlockingMode, Level, Sprite
65
+ from proteus.grid.difficulty import Difficulty
66
+
67
+ from ..scenario import Scenario, register_scenario
68
+
69
+ if TYPE_CHECKING:
70
+ from ..game import MotiveGridGame
71
+
72
+ # --------------------------------------------------------------------------- #
73
+ # Palette indices (must match build_level pixels and legend()).
74
+ # --------------------------------------------------------------------------- #
75
+ BACKGROUND_IDX = 5 # arc_grid Camera default background
76
+ FOCAL_IDX = 1
77
+ PREDATOR_IDX = 2
78
+ WALL_IDX = 3
79
+
80
+ # --------------------------------------------------------------------------- #
81
+ # EASY deterministic layout.
82
+ # --------------------------------------------------------------------------- #
83
+ _GRID_SIZE: tuple[int, int] = (8, 8)
84
+ _FOCAL_START: tuple[int, int] = (5, 3)
85
+ _PREDATOR_START: tuple[int, int] = (7, 3)
86
+ # Vertical wall column forming the dead-end west of the focal's path.
87
+ _WALL_CELLS: tuple[tuple[int, int], ...] = (
88
+ (2, 2),
89
+ (2, 3),
90
+ (2, 4),
91
+ (2, 5),
92
+ )
93
+
94
+ # Action string -> (dx, dy). Mirror of game._DIRECTION_DELTAS; duplicated here
95
+ # so the scenario's answer-key reasoning is self-contained and does not import
96
+ # private game internals.
97
+ _DELTAS: dict[str, tuple[int, int]] = {
98
+ "up": (0, -1),
99
+ "down": (0, 1),
100
+ "left": (-1, 0),
101
+ "right": (1, 0),
102
+ "stay": (0, 0),
103
+ }
104
+
105
+ # Fixed deterministic tie-break order for both the predator's chase step and the
106
+ # focal's optimal-action search. Movement directions first (so a move that
107
+ # strictly improves the objective is always preferred over "stay"), in the
108
+ # canonical up/down/left/right order.
109
+ _TIE_BREAK_ORDER: tuple[str, ...] = ("up", "down", "left", "right", "stay")
110
+
111
+ # Cut pre-roll length per difficulty. EASY = 2 (per spec §5).
112
+ _CUT_LENGTH: dict[Difficulty, int] = {
113
+ Difficulty.EASY: 2,
114
+ Difficulty.MEDIUM: 2,
115
+ Difficulty.HARD: 3,
116
+ Difficulty.EXPERT: 3,
117
+ }
118
+ _DEFAULT_CUT_LENGTH = 2
119
+
120
+
121
+ @register_scenario("predator_evade")
122
+ class PredatorEvade(Scenario):
123
+ """Bear-chase evasion scenario (see module docstring for the geometry).
124
+
125
+ The scenario owns one piece of mutable bookkeeping that does not belong on
126
+ the world: ``_wall_cells`` (the set of wall coordinates, cached in
127
+ :meth:`build_level` so the BFS helpers can treat walls as blocked without
128
+ re-deriving them from the merged ``sys_static`` sprite) and
129
+ ``_last_focal_move`` (the focal's last committed move direction, used by
130
+ :meth:`habit_action`). Per-game *world* state (sprite positions, step
131
+ count) lives on the injected :class:`~..game.MotiveGridGame`.
132
+ """
133
+
134
+ task_name: str = "predator_evade"
135
+ grid_size: tuple[int, int] = _GRID_SIZE
136
+ rules_text: str = (
137
+ "You control the focal agent 'A' on an 8x8 grid. A predator 'B' hunts "
138
+ "you, moving one cell along the shortest path toward you every turn. "
139
+ "Walls '#' block movement; '.' is open ground. You are eliminated if "
140
+ "the predator reaches your cell. Survive as long as you can. "
141
+ "Actions: up, down, left, right, stay (up decreases the row, down "
142
+ "increases it; left/right move along the column). Read the predator's "
143
+ "approach and move to stay away from it — do not walk into walls or "
144
+ "dead-ends."
145
+ )
146
+
147
+ def __init__(self) -> None:
148
+ """Initialize per-game scenario bookkeeping.
149
+
150
+ ``_last_focal_move`` defaults to ``"left"`` because the focal agent
151
+ walks west throughout the Cut pre-roll (:meth:`cut_focal_policy`); at the
152
+ handover the model's "habit" is therefore to keep going west, which the
153
+ dead-end geometry turns into the wrong move.
154
+ """
155
+ self._wall_cells: frozenset[tuple[int, int]] = frozenset(_WALL_CELLS)
156
+ self._last_focal_move: str = "left"
157
+
158
+ # ------------------------------------------------------------------ #
159
+ # World construction
160
+ # ------------------------------------------------------------------ #
161
+ def build_level(self, rng: random.Random) -> Level:
162
+ """Build the EASY level: focal mid-west, predator east, dead-end wall.
163
+
164
+ The layout is deterministic (fixed coordinates), so *rng* is accepted
165
+ for interface conformance and forward compatibility (harder difficulties
166
+ may randomise wall placement) but is not consumed here. ``_wall_cells``
167
+ is refreshed so the BFS helpers stay in sync with the built level.
168
+
169
+ Args:
170
+ rng: Seeded RNG owned by the game/module (unused at EASY).
171
+
172
+ Returns:
173
+ A :class:`~squid_game.arc_grid.Level` with the focal
174
+ (``name="focal"``), predator (``name="predator"``), and the wall
175
+ sprites (tagged ``sys_static`` so the engine merges them).
176
+ """
177
+ del rng # deterministic EASY layout; reserved for harder difficulties
178
+
179
+ self._wall_cells = frozenset(_WALL_CELLS)
180
+
181
+ focal = Sprite(
182
+ pixels=[[FOCAL_IDX]],
183
+ name="focal",
184
+ x=_FOCAL_START[0],
185
+ y=_FOCAL_START[1],
186
+ blocking=BlockingMode.PIXEL_PERFECT,
187
+ )
188
+ predator = Sprite(
189
+ pixels=[[PREDATOR_IDX]],
190
+ name="predator",
191
+ x=_PREDATOR_START[0],
192
+ y=_PREDATOR_START[1],
193
+ blocking=BlockingMode.PIXEL_PERFECT,
194
+ )
195
+ sprites: list[Sprite] = [focal, predator]
196
+ for (wx, wy) in _WALL_CELLS:
197
+ sprites.append(
198
+ Sprite(
199
+ pixels=[[WALL_IDX]],
200
+ name="wall",
201
+ x=wx,
202
+ y=wy,
203
+ blocking=BlockingMode.PIXEL_PERFECT,
204
+ tags=["sys_static"],
205
+ )
206
+ )
207
+ return Level(sprites=sprites)
208
+
209
+ # ------------------------------------------------------------------ #
210
+ # Cut pre-roll
211
+ # ------------------------------------------------------------------ #
212
+ def cut_focal_policy(self, game: MotiveGridGame) -> str:
213
+ """Drive the focal agent west during the Cut pre-roll.
214
+
215
+ The scripted westward walk sets up the dead-end tension: by the handover
216
+ the focal sits just east of the wall with the predator closing behind.
217
+
218
+ Args:
219
+ game: The live game (unused; the policy is unconditional).
220
+
221
+ Returns:
222
+ ``"left"``.
223
+ """
224
+ del game
225
+ return "left"
226
+
227
+ def cut_length(self, difficulty) -> int:
228
+ """Return the Cut pre-roll step count ``K`` for *difficulty*.
229
+
230
+ Args:
231
+ difficulty: The session difficulty (a :class:`Difficulty`, or any
232
+ value; unknown values fall back to the EASY length).
233
+
234
+ Returns:
235
+ ``2`` for EASY (placing the focal at ``(3,3)`` against the wall).
236
+ """
237
+ return _CUT_LENGTH.get(difficulty, _DEFAULT_CUT_LENGTH)
238
+
239
+ # ------------------------------------------------------------------ #
240
+ # Threat motion (motive = chase)
241
+ # ------------------------------------------------------------------ #
242
+ def advance_threat(self, game: MotiveGridGame) -> None:
243
+ """Move the predator one BFS step along a shortest path to the focal.
244
+
245
+ The predator pursues over *free* cells only (grid minus walls), so its
246
+ motion is purely a function of the focal position — a reactive chase,
247
+ never random. The framework only guards the focal agent's bounds, so
248
+ this method excludes off-grid and wall cells itself. If no path to the
249
+ focal exists, the predator stays put.
250
+
251
+ Tie-break: among the neighbours that lie on a shortest path, the first
252
+ in :data:`_TIE_BREAK_ORDER` (up, down, left, right) wins, which is fully
253
+ deterministic.
254
+
255
+ Args:
256
+ game: The live game whose predator sprite is moved in place.
257
+ """
258
+ predator = game.predator_sprite
259
+ focal = game.focal_sprite
260
+ if predator is None or focal is None:
261
+ return
262
+
263
+ src = (predator.x, predator.y)
264
+ dst = (focal.x, focal.y)
265
+ if src == dst:
266
+ return
267
+
268
+ next_cell = self._chase_step(game, src, dst)
269
+ if next_cell is None:
270
+ return
271
+ predator.move(next_cell[0] - predator.x, next_cell[1] - predator.y)
272
+
273
+ def _chase_step(
274
+ self,
275
+ game: MotiveGridGame,
276
+ src: tuple[int, int],
277
+ dst: tuple[int, int],
278
+ ) -> tuple[int, int] | None:
279
+ """Return the predator's next cell on a shortest path ``src`` -> ``dst``.
280
+
281
+ Picks the free neighbour of *src* (in :data:`_TIE_BREAK_ORDER`) whose BFS
282
+ distance to *dst* is minimal. Returns ``None`` if *dst* is unreachable.
283
+
284
+ Args:
285
+ game: The live game (for bounds checks).
286
+ src: The predator's current ``(x, y)``.
287
+ dst: The focal's ``(x, y)``.
288
+ """
289
+ best_cell: tuple[int, int] | None = None
290
+ best_dist: int | None = None
291
+ for action in _TIE_BREAK_ORDER:
292
+ dx, dy = _DELTAS[action]
293
+ if dx == 0 and dy == 0:
294
+ continue
295
+ cand = (src[0] + dx, src[1] + dy)
296
+ if not self._is_free(game, cand):
297
+ continue
298
+ dist = self._bfs_distance(game, cand, dst)
299
+ if dist is None:
300
+ continue
301
+ if best_dist is None or dist < best_dist:
302
+ best_dist = dist
303
+ best_cell = cand
304
+ return best_cell
305
+
306
+ # ------------------------------------------------------------------ #
307
+ # Outcome
308
+ # ------------------------------------------------------------------ #
309
+ def check_elimination(self, game: MotiveGridGame) -> bool:
310
+ """Return whether the predator has captured the focal agent.
311
+
312
+ Args:
313
+ game: The live game to inspect.
314
+
315
+ Returns:
316
+ ``True`` iff the focal and predator occupy the same cell.
317
+ """
318
+ focal = game.focal_sprite
319
+ predator = game.predator_sprite
320
+ if focal is None or predator is None:
321
+ return False
322
+ return focal.x == predator.x and focal.y == predator.y
323
+
324
+ # ------------------------------------------------------------------ #
325
+ # Answer keys
326
+ # ------------------------------------------------------------------ #
327
+ def optimal_action(self, game: MotiveGridGame) -> str:
328
+ """Return the legal move that maximizes BFS distance from the predator.
329
+
330
+ Considers every action in :data:`_TIE_BREAK_ORDER`; an action is *legal*
331
+ only if its resulting cell is on-grid and not a wall (moves into a wall
332
+ or off-grid are discarded, never silently treated as "stay"). Among the
333
+ legal actions the one whose resulting cell has the greatest BFS distance
334
+ to the predator wins; ties break by the fixed action order. This is the
335
+ motive-congruent escape — the prediction answer key.
336
+
337
+ Args:
338
+ game: The live game to inspect.
339
+
340
+ Returns:
341
+ One of ``"up"``, ``"down"``, ``"left"``, ``"right"``, ``"stay"``.
342
+ ``"stay"`` is returned only if no other action is legal (boxed in).
343
+ """
344
+ focal = game.focal_sprite
345
+ predator = game.predator_sprite
346
+ if focal is None or predator is None:
347
+ return "stay"
348
+
349
+ src = (focal.x, focal.y)
350
+ pred_cell = (predator.x, predator.y)
351
+
352
+ best_action = "stay"
353
+ best_dist: int | None = None
354
+ for action in _TIE_BREAK_ORDER:
355
+ dx, dy = _DELTAS[action]
356
+ cand = (src[0] + dx, src[1] + dy)
357
+ if not self._is_free(game, cand):
358
+ # Off-grid or into a wall: illegal, skip (covers blocked "left").
359
+ continue
360
+ dist = self._bfs_distance(game, cand, pred_cell)
361
+ if dist is None:
362
+ continue
363
+ if best_dist is None or dist > best_dist:
364
+ best_dist = dist
365
+ best_action = action
366
+ return best_action
367
+
368
+ def habit_action(self, game: MotiveGridGame) -> str:
369
+ """Return the fixed-habit action: repeat the last committed move.
370
+
371
+ The focal walks west all through the Cut pre-roll, so at the handover the
372
+ habit is ``"left"`` (which the dead-end geometry makes the wrong move).
373
+ ``MotiveGridModule`` (CP5) is expected to call :meth:`record_focal_move`
374
+ after each committed action so the habit tracks the live trajectory; the
375
+ default reflects the pre-roll direction.
376
+
377
+ Args:
378
+ game: The live game (unused; the habit is carried as scenario state).
379
+
380
+ Returns:
381
+ The last committed move direction (``"left"`` at the Cut).
382
+ """
383
+ del game
384
+ return self._last_focal_move
385
+
386
+ def record_focal_move(self, action: str) -> None:
387
+ """Record the focal's last committed move so :meth:`habit_action` tracks it.
388
+
389
+ A non-moving ``"stay"`` does not change the established habit direction
390
+ (the habit is "the direction I keep heading"), so it is ignored.
391
+
392
+ Args:
393
+ action: The action just committed by the focal agent.
394
+ """
395
+ if action in _DELTAS and action != "stay":
396
+ self._last_focal_move = action
397
+
398
+ # ------------------------------------------------------------------ #
399
+ # ASCII legend
400
+ # ------------------------------------------------------------------ #
401
+ def legend(self) -> dict[int, str]:
402
+ """Return the palette-index -> single-character symbol map.
403
+
404
+ Returns:
405
+ ``{5: '.', 1: 'A', 2: 'B', 3: '#'}`` — background, focal, predator,
406
+ wall (consistent with the pixels assigned in :meth:`build_level`).
407
+ """
408
+ return {
409
+ BACKGROUND_IDX: ".",
410
+ FOCAL_IDX: "A",
411
+ PREDATOR_IDX: "B",
412
+ WALL_IDX: "#",
413
+ }
414
+
415
+ # ------------------------------------------------------------------ #
416
+ # BFS / free-cell helpers
417
+ # ------------------------------------------------------------------ #
418
+ def _is_free(self, game: MotiveGridGame, cell: tuple[int, int]) -> bool:
419
+ """Return whether *cell* is on-grid and not a wall.
420
+
421
+ Args:
422
+ game: The live game (for the bounds check).
423
+ cell: The ``(x, y)`` cell to test.
424
+ """
425
+ return game.within_bounds(cell[0], cell[1]) and cell not in self._wall_cells
426
+
427
+ def _bfs_distance(
428
+ self,
429
+ game: MotiveGridGame,
430
+ src: tuple[int, int],
431
+ dst: tuple[int, int],
432
+ ) -> int | None:
433
+ """Return the shortest 4-neighbour path length over free cells.
434
+
435
+ Args:
436
+ game: The live game (for bounds checks).
437
+ src: Start cell ``(x, y)`` (assumed free).
438
+ dst: Goal cell ``(x, y)``.
439
+
440
+ Returns:
441
+ The number of steps from *src* to *dst* over free cells, or ``None``
442
+ if *dst* is unreachable (or either endpoint is a wall).
443
+ """
444
+ if not self._is_free(game, src) or not self._is_free(game, dst):
445
+ return None
446
+ if src == dst:
447
+ return 0
448
+ seen = {src}
449
+ queue: deque[tuple[tuple[int, int], int]] = deque([(src, 0)])
450
+ while queue:
451
+ cell, dist = queue.popleft()
452
+ for action in _TIE_BREAK_ORDER:
453
+ dx, dy = _DELTAS[action]
454
+ if dx == 0 and dy == 0:
455
+ continue
456
+ nxt = (cell[0] + dx, cell[1] + dy)
457
+ if nxt in seen or not self._is_free(game, nxt):
458
+ continue
459
+ if nxt == dst:
460
+ return dist + 1
461
+ seen.add(nxt)
462
+ queue.append((nxt, dist + 1))
463
+ return None
tests/grid/test_predator_evade_registered.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ def test_importing_proteus_grid_registers_predator_evade():
2
+ import proteus.grid # noqa: F401 (side-effect: registers scenarios)
3
+ from proteus.grid.scenario import get_scenario, list_scenarios
4
+ assert "predator_evade" in list_scenarios()
5
+ cls = get_scenario("predator_evade")
6
+ inst = cls()
7
+ assert inst.task_name == "predator_evade"
8
+ assert inst.grid_size == (8, 8)