irregular6612 Claude Sonnet 4.6 commited on
Commit
0464e32
·
1 Parent(s): 39c93ec

feat(pack_evade): 2x2 focal, 3x3 open-mouth predator, gap-2, predator-first

Browse files
proteus/game/scenarios/pack_evade.py CHANGED
@@ -1,6 +1,6 @@
1
  """pack_evade — a 64x64 open-field, multi-cell predator-evasion scenario.
2
 
3
- A single 5x5 predator hunts a 3x3 focal agent (the lone survivor of a pack of
4
  three; the two that were caught live only in the hand-authored handover memory,
5
  see ``proteus.game.runtime.pack_evade_memory``). The field is open (no walls), so all
6
  geometry is **center-to-center Manhattan** — no BFS. There is no habit/diagnostic
@@ -31,8 +31,19 @@ WALL_IDX = 3 # matches predator_evade + COLOR_MAP gray
31
  FOOD_IDX = 14 # COLOR_MAP green; observational food cells
32
 
33
  _GRID = (64, 64)
34
- _FOCAL_START = (5, 30) # 3x3 -> footprint x[5,8) y[30,33), center (6,31)
35
- _PREDATOR_START = (54, 29) # 5x5 -> footprint x[54,59) y[29,34), center (56,31)
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  _DELTAS: dict[str, tuple[int, int]] = {
38
  "up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "stay": (0, 0),
@@ -40,8 +51,8 @@ _DELTAS: dict[str, tuple[int, int]] = {
40
  _TIE_BREAK_ORDER: tuple[str, ...] = ("up", "down", "left", "right", "stay")
41
 
42
  # --- wall terrain config + geometry helpers -------------------------------- #
43
- _NARROW_GAP = 3 # focal (3-wide) fits; predator (5-wide) cannot
44
- _CLEARANCE = 6 # min free corridor around blocks/spawns (predator-wide)
45
  _MARGIN = 2 # keep walls off the very border
46
  # (solid blocks, focal-only channels) per difficulty
47
  _WALL_COUNTS = {
@@ -51,16 +62,21 @@ _WALL_COUNTS = {
51
  Difficulty.EXPERT: (5, 3),
52
  }
53
  _SPAWN_RECTS = (
54
- (_FOCAL_START[0], _FOCAL_START[1], _FOCAL_START[0] + 2, _FOCAL_START[1] + 2),
55
- (_PREDATOR_START[0], _PREDATOR_START[1], _PREDATOR_START[0] + 4, _PREDATOR_START[1] + 4),
 
 
56
  )
57
 
58
  # Extra (beyond the two mandatory) observational food cells per difficulty.
59
  _FOOD_EXTRAS = {
60
  Difficulty.EASY: 1, Difficulty.MEDIUM: 2, Difficulty.HARD: 3, Difficulty.EXPERT: 3,
61
  }
62
- _FOCAL_CENTER = (_FOCAL_START[0] + 1, _FOCAL_START[1] + 1) # 3x3 center
63
- _PREDATOR_CENTER = (_PREDATOR_START[0] + 2, _PREDATOR_START[1] + 2) # 5x5 center
 
 
 
64
 
65
 
66
  def _sign(v: int) -> int:
@@ -113,9 +129,10 @@ class PackEvade(Scenario):
113
 
114
  task_name: str = "pack_evade"
115
  grid_size: tuple[int, int] = _GRID
 
116
  rules_text: str = (
117
- "You control agent A, a 3x3 block, on a 64x64 field laced with impassable "
118
- "wall blocks (your 3x3 body cannot overlap a wall). A 5x5 predator B hunts "
119
  "you, moving one cell toward you every turn. You are eaten if B's block "
120
  "touches (overlaps) yours. Food cells (1x1) are scattered on the field — "
121
  "one near you and one behind the predator — they are edible context. Stay "
@@ -123,8 +140,8 @@ class PackEvade(Scenario):
123
  "decreases the row, down increases it; left/right move along the column)."
124
  )
125
  memory_brief: str = (
126
- "PRACTICE / MEMORY RUN. You are A (3x3) on a 64x64 field laced with "
127
- "impassable wall blocks (you cannot overlap a wall). A 5x5 predator B "
128
  "hunts you, stepping one cell toward you each turn; you are eaten if its "
129
  "block overlaps yours. Food cells (1x1) are scattered — one near you and "
130
  "one behind the predator — they are edible context. Keep maximum distance. "
@@ -144,12 +161,12 @@ class PackEvade(Scenario):
144
  )
145
  self._food_cells = self._generate_food(rng, difficulty)
146
  focal = Sprite(
147
- pixels=[[FOCAL_IDX] * 3 for _ in range(3)],
148
  name="focal", x=_FOCAL_START[0], y=_FOCAL_START[1],
149
  blocking=BlockingMode.BOUNDING_BOX,
150
  )
151
  predator = Sprite(
152
- pixels=[[PREDATOR_IDX] * 5 for _ in range(5)],
153
  name="predator", x=_PREDATOR_START[0], y=_PREDATOR_START[1],
154
  blocking=BlockingMode.NOT_BLOCKED,
155
  )
@@ -230,11 +247,11 @@ class PackEvade(Scenario):
230
  def _generate_walls(
231
  self, rng: random.Random, difficulty: Difficulty
232
  ) -> list[tuple[int, int, int, int]]:
233
- """Seeded rectangular blocks + focal-only (width-3) channels.
234
 
235
  Blocks/channels keep a predator-wide ``_CLEARANCE`` from each other, the
236
  border, and both spawns, so the predator-navigable space stays connected;
237
- each channel's 3-wide slot admits the 3x3 focal but not the 5x5 predator.
238
  """
239
  w, h = _GRID
240
  n_blocks, n_channels = _WALL_COUNTS.get(difficulty, (3, 2))
@@ -334,6 +351,8 @@ class PackEvade(Scenario):
334
  best_dist, best_action = dist, action
335
  dx, dy = _DELTAS[best_action]
336
  predator.move(dx, dy)
 
 
337
 
338
  def optimal_action(self, game: MotiveGridGame) -> str:
339
  focal, predator = game.focal_sprite, game.predator_sprite
@@ -368,7 +387,10 @@ class PackEvade(Scenario):
368
  if focal is None:
369
  return 0.0
370
  fw, fh = focal.width, focal.height
371
- pred_center_before = (predator_before[0] + 5 // 2, predator_before[1] + 5 // 2)
 
 
 
372
  center_after = (focal.x + fw // 2, focal.y + fh // 2)
373
  center_before = (focal_before[0] + fw // 2, focal_before[1] + fh // 2)
374
  return float(
@@ -392,7 +414,10 @@ class PackEvade(Scenario):
392
  if focal is None:
393
  return None
394
  fw, fh = focal.width, focal.height
395
- pred_center_before = (predator_before[0] + 5 // 2, predator_before[1] + 5 // 2)
 
 
 
396
  center_after = (focal.x + fw // 2, focal.y + fh // 2)
397
  center_before = (focal_before[0] + fw // 2, focal_before[1] + fh // 2)
398
  return float(
@@ -414,8 +439,8 @@ class PackEvade(Scenario):
414
  fc, pc = _center(focal), _center(predator)
415
  w, h = self.grid_size
416
  lines = [
417
- f"Open field {w}x{h}. You are A (3x3) centered at {fc}. "
418
- f"Predator B (5x5) centered at {pc}. Manhattan distance {_manhattan(fc, pc)}."
419
  ]
420
  if self._wall_rects:
421
  rects = "; ".join(
@@ -424,7 +449,7 @@ class PackEvade(Scenario):
424
  lines.append(
425
  "Walls (blocked rectangles, inclusive (x0,y0)-(x1,y1)): " + rects + "."
426
  )
427
- lines.append("Your 3x3 body cannot overlap these.")
428
  if self._food_cells:
429
  cells = "; ".join(f"({x},{y})" for (x, y) in self._food_cells)
430
  lines.append(
@@ -438,7 +463,7 @@ class PackEvade(Scenario):
438
 
439
  # --- persona-policy compatibility shims (wall-aware focal navigation) --- #
440
  def _is_free(self, game: MotiveGridGame, cell: tuple[int, int]) -> bool:
441
- """True iff the 3x3 focal footprint anchored at *cell* is on-grid + wall-free.
442
 
443
  Used only by the shared hidden-persona reference policy
444
  (``proteus.game.metrics.persona``), which calls it on the focal's top-left
 
1
  """pack_evade — a 64x64 open-field, multi-cell predator-evasion scenario.
2
 
3
+ A single 3x3 predator hunts a 2x2 focal agent (the lone survivor of a pack of
4
  three; the two that were caught live only in the hand-authored handover memory,
5
  see ``proteus.game.runtime.pack_evade_memory``). The field is open (no walls), so all
6
  geometry is **center-to-center Manhattan** — no BFS. There is no habit/diagnostic
 
31
  FOOD_IDX = 14 # COLOR_MAP green; observational food cells
32
 
33
  _GRID = (64, 64)
34
+ _FOCAL_SIZE = 2
35
+ _PREDATOR_SIZE = 3
36
+ _FOCAL_START = (5, 30) # 2x2 -> footprint x[5,7) y[30,32), center (6,31)
37
+ _PREDATOR_START = (54, 30) # 3x3 -> footprint x[54,57) y[30,33), center (55,31)
38
+ # ㄷ (open-mouth) predator pixels; mouth opens EAST in the base orientation.
39
+ # Transparent (-1) cells render as background and never collide.
40
+ _PREDATOR_PIXELS = [
41
+ [PREDATOR_IDX, PREDATOR_IDX, PREDATOR_IDX],
42
+ [PREDATOR_IDX, -1, -1],
43
+ [PREDATOR_IDX, PREDATOR_IDX, PREDATOR_IDX],
44
+ ]
45
+ # action -> clockwise rotation so the mouth faces the movement direction.
46
+ _FACING = {"right": 0, "down": 90, "left": 180, "up": 270}
47
 
48
  _DELTAS: dict[str, tuple[int, int]] = {
49
  "up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "stay": (0, 0),
 
51
  _TIE_BREAK_ORDER: tuple[str, ...] = ("up", "down", "left", "right", "stay")
52
 
53
  # --- wall terrain config + geometry helpers -------------------------------- #
54
+ _NARROW_GAP = 2 # focal (2-wide) fits; predator (3-wide) cannot
55
+ _CLEARANCE = 4 # min free corridor around blocks/spawns (> predator-wide)
56
  _MARGIN = 2 # keep walls off the very border
57
  # (solid blocks, focal-only channels) per difficulty
58
  _WALL_COUNTS = {
 
62
  Difficulty.EXPERT: (5, 3),
63
  }
64
  _SPAWN_RECTS = (
65
+ (_FOCAL_START[0], _FOCAL_START[1],
66
+ _FOCAL_START[0] + _FOCAL_SIZE - 1, _FOCAL_START[1] + _FOCAL_SIZE - 1),
67
+ (_PREDATOR_START[0], _PREDATOR_START[1],
68
+ _PREDATOR_START[0] + _PREDATOR_SIZE - 1, _PREDATOR_START[1] + _PREDATOR_SIZE - 1),
69
  )
70
 
71
  # Extra (beyond the two mandatory) observational food cells per difficulty.
72
  _FOOD_EXTRAS = {
73
  Difficulty.EASY: 1, Difficulty.MEDIUM: 2, Difficulty.HARD: 3, Difficulty.EXPERT: 3,
74
  }
75
+ _FOCAL_CENTER = (_FOCAL_START[0] + _FOCAL_SIZE // 2, _FOCAL_START[1] + _FOCAL_SIZE // 2)
76
+ _PREDATOR_CENTER = (
77
+ _PREDATOR_START[0] + _PREDATOR_SIZE // 2,
78
+ _PREDATOR_START[1] + _PREDATOR_SIZE // 2,
79
+ )
80
 
81
 
82
  def _sign(v: int) -> int:
 
129
 
130
  task_name: str = "pack_evade"
131
  grid_size: tuple[int, int] = _GRID
132
+ turn_order: str = "predator_first"
133
  rules_text: str = (
134
+ "You control agent A, a 2x2 block, on a 64x64 field laced with impassable "
135
+ "wall blocks (your 2x2 body cannot overlap a wall). A 3x3 predator B hunts "
136
  "you, moving one cell toward you every turn. You are eaten if B's block "
137
  "touches (overlaps) yours. Food cells (1x1) are scattered on the field — "
138
  "one near you and one behind the predator — they are edible context. Stay "
 
140
  "decreases the row, down increases it; left/right move along the column)."
141
  )
142
  memory_brief: str = (
143
+ "PRACTICE / MEMORY RUN. You are A (2x2) on a 64x64 field laced with "
144
+ "impassable wall blocks (you cannot overlap a wall). A 3x3 predator B "
145
  "hunts you, stepping one cell toward you each turn; you are eaten if its "
146
  "block overlaps yours. Food cells (1x1) are scattered — one near you and "
147
  "one behind the predator — they are edible context. Keep maximum distance. "
 
161
  )
162
  self._food_cells = self._generate_food(rng, difficulty)
163
  focal = Sprite(
164
+ pixels=[[FOCAL_IDX] * _FOCAL_SIZE for _ in range(_FOCAL_SIZE)],
165
  name="focal", x=_FOCAL_START[0], y=_FOCAL_START[1],
166
  blocking=BlockingMode.BOUNDING_BOX,
167
  )
168
  predator = Sprite(
169
+ pixels=[row[:] for row in _PREDATOR_PIXELS],
170
  name="predator", x=_PREDATOR_START[0], y=_PREDATOR_START[1],
171
  blocking=BlockingMode.NOT_BLOCKED,
172
  )
 
247
  def _generate_walls(
248
  self, rng: random.Random, difficulty: Difficulty
249
  ) -> list[tuple[int, int, int, int]]:
250
+ """Seeded rectangular blocks + focal-only (width-2) channels.
251
 
252
  Blocks/channels keep a predator-wide ``_CLEARANCE`` from each other, the
253
  border, and both spawns, so the predator-navigable space stays connected;
254
+ each channel's 2-wide slot admits the 2x2 focal but not the 3x3 predator.
255
  """
256
  w, h = _GRID
257
  n_blocks, n_channels = _WALL_COUNTS.get(difficulty, (3, 2))
 
351
  best_dist, best_action = dist, action
352
  dx, dy = _DELTAS[best_action]
353
  predator.move(dx, dy)
354
+ if best_action in _FACING:
355
+ predator.set_rotation(_FACING[best_action])
356
 
357
  def optimal_action(self, game: MotiveGridGame) -> str:
358
  focal, predator = game.focal_sprite, game.predator_sprite
 
387
  if focal is None:
388
  return 0.0
389
  fw, fh = focal.width, focal.height
390
+ pred_center_before = (
391
+ predator_before[0] + _PREDATOR_SIZE // 2,
392
+ predator_before[1] + _PREDATOR_SIZE // 2,
393
+ )
394
  center_after = (focal.x + fw // 2, focal.y + fh // 2)
395
  center_before = (focal_before[0] + fw // 2, focal_before[1] + fh // 2)
396
  return float(
 
414
  if focal is None:
415
  return None
416
  fw, fh = focal.width, focal.height
417
+ pred_center_before = (
418
+ predator_before[0] + _PREDATOR_SIZE // 2,
419
+ predator_before[1] + _PREDATOR_SIZE // 2,
420
+ )
421
  center_after = (focal.x + fw // 2, focal.y + fh // 2)
422
  center_before = (focal_before[0] + fw // 2, focal_before[1] + fh // 2)
423
  return float(
 
439
  fc, pc = _center(focal), _center(predator)
440
  w, h = self.grid_size
441
  lines = [
442
+ f"Open field {w}x{h}. You are A (2x2) centered at {fc}. "
443
+ f"Predator B (3x3) centered at {pc}. Manhattan distance {_manhattan(fc, pc)}."
444
  ]
445
  if self._wall_rects:
446
  rects = "; ".join(
 
449
  lines.append(
450
  "Walls (blocked rectangles, inclusive (x0,y0)-(x1,y1)): " + rects + "."
451
  )
452
+ lines.append("Your 2x2 body cannot overlap these.")
453
  if self._food_cells:
454
  cells = "; ".join(f"({x},{y})" for (x, y) in self._food_cells)
455
  lines.append(
 
463
 
464
  # --- persona-policy compatibility shims (wall-aware focal navigation) --- #
465
  def _is_free(self, game: MotiveGridGame, cell: tuple[int, int]) -> bool:
466
+ """True iff the 2x2 focal footprint anchored at *cell* is on-grid + wall-free.
467
 
468
  Used only by the shared hidden-persona reference policy
469
  (``proteus.game.metrics.persona``), which calls it on the focal's top-left
tests/grid/test_pack_evade.py CHANGED
@@ -19,8 +19,8 @@ def test_build_is_64x64_with_multicell_sprites():
19
  scenario, game = _game()
20
  assert scenario.grid_size == (64, 64)
21
  focal, predator = game.focal_sprite, game.predator_sprite
22
- assert (focal.width, focal.height) == (3, 3)
23
- assert (predator.width, predator.height) == (5, 5)
24
  # Disjoint at start (not already eaten).
25
  assert not scenario.check_elimination(game)
26
 
@@ -29,20 +29,20 @@ def test_eat_fires_on_footprint_overlap_not_adjacency():
29
  scenario, game = _game()
30
  focal, predator = game.focal_sprite, game.predator_sprite
31
  # Place predator footprint just touching but NOT overlapping the focal:
32
- focal.move(-focal.x, -focal.y) # focal anchor -> (0,0), occupies x,y in [0,3)
33
- predator.move(3 - predator.x, -predator.y) # predator anchor -> (3,0): x in [3,8), no overlap
34
  assert not scenario.check_elimination(game)
35
- predator.move(-1, 0) # anchor -> (2,0): x in [2,7) overlaps focal x in [0,3)
36
  assert scenario.check_elimination(game)
37
 
38
 
39
  def test_center_manhattan_safety_distance():
40
  scenario, game = _game()
41
  focal, predator = game.focal_sprite, game.predator_sprite
42
- focal.move(-focal.x, -focal.y) # center (1,1)
43
- predator.move(10 - predator.x, -predator.y) # anchor (10,0), center (12,2)
44
- # |12-1| + |2-1| = 12
45
- assert scenario.safety_distance(game) == 12
46
 
47
 
48
  def test_optimal_moves_away_and_no_diagnostic():
 
19
  scenario, game = _game()
20
  assert scenario.grid_size == (64, 64)
21
  focal, predator = game.focal_sprite, game.predator_sprite
22
+ assert (focal.width, focal.height) == (2, 2)
23
+ assert (predator.width, predator.height) == (3, 3)
24
  # Disjoint at start (not already eaten).
25
  assert not scenario.check_elimination(game)
26
 
 
29
  scenario, game = _game()
30
  focal, predator = game.focal_sprite, game.predator_sprite
31
  # Place predator footprint just touching but NOT overlapping the focal:
32
+ focal.move(-focal.x, -focal.y) # focal anchor -> (0,0), occupies x,y in [0,2)
33
+ predator.move(2 - predator.x, -predator.y) # predator anchor -> (2,0): x in [2,5), no overlap
34
  assert not scenario.check_elimination(game)
35
+ predator.move(-1, 0) # anchor -> (1,0): x in [1,4) overlaps focal x in [0,2)
36
  assert scenario.check_elimination(game)
37
 
38
 
39
  def test_center_manhattan_safety_distance():
40
  scenario, game = _game()
41
  focal, predator = game.focal_sprite, game.predator_sprite
42
+ focal.move(-focal.x, -focal.y) # focal 2x2: center (1,1)
43
+ predator.move(10 - predator.x, -predator.y) # anchor (10,0), predator 3x3: center (11,1)
44
+ # |11-1| + |1-1| = 10
45
+ assert scenario.safety_distance(game) == 10
46
 
47
 
48
  def test_optimal_moves_away_and_no_diagnostic():
tests/grid/test_pack_evade_walls.py CHANGED
@@ -26,8 +26,8 @@ def test_walls_are_generated_and_deterministic():
26
  def test_walls_clear_spawns_and_border():
27
  scn, _ = _scn(seed=7)
28
  w, h = scn.grid_size
29
- # spawn footprints must be wall-free
30
- for (sx, sy, sw, sh) in [(5, 30, 3, 3), (54, 29, 5, 5)]:
31
  for j in range(sh):
32
  for i in range(sw):
33
  assert (sx + i, sy + j) not in scn._wall_cells
@@ -37,7 +37,7 @@ def test_walls_clear_spawns_and_border():
37
 
38
 
39
  def test_has_a_focal_only_channel():
40
- """Some 3x3-passable cell is NOT 5x5-passable: a focal-only gap exists."""
41
  scn, _ = _scn(seed=7)
42
  w, h = scn.grid_size
43
  wc = scn._wall_cells
@@ -51,35 +51,35 @@ def test_has_a_focal_only_channel():
51
  return True
52
 
53
  found = any(
54
- fp_free(x, y, 3) and not fp_free(x, y, 5)
55
  for x in range(w) for y in range(h)
56
  )
57
- assert found, "expected at least one focal-only (3x3 yes, 5x5 no) anchor"
58
 
59
 
60
  def test_predator_can_reach_focal_region():
61
- """Predator (5x5) BFS from its spawn reaches adjacency of the focal spawn."""
62
  scn, _ = _scn(seed=7)
63
  w, h = scn.grid_size
64
  wc = scn._wall_cells
65
 
66
- def fp_free5(x, y):
67
  return all(
68
  0 <= x + i < w and 0 <= y + j < h and (x + i, y + j) not in wc
69
- for j in range(5) for i in range(5)
70
  )
71
 
72
  from collections import deque
73
- start = (54, 29)
74
  seen = {start}
75
  q = deque([start])
76
  while q:
77
  x, y = q.popleft()
78
  for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
79
  nx, ny = x + dx, y + dy
80
- if (nx, ny) not in seen and fp_free5(nx, ny):
81
  seen.add((nx, ny)); q.append((nx, ny))
82
- # an anchor whose 5x5 footprint can sit adjacent to the focal spawn (5,30)
83
  assert any(abs(ax - 5) <= 6 and abs(ay - 30) <= 6 for (ax, ay) in seen)
84
 
85
 
@@ -87,11 +87,11 @@ def test_focal_cannot_move_into_a_wall():
87
  scn, game = _scn(seed=7)
88
  focal = game.focal_sprite
89
  wx, wy = sorted(scn._wall_cells)[len(scn._wall_cells) // 2]
90
- # place focal so moving +1 x makes its 3x3 footprint cover (wx,wy)
91
- focal.set_position(wx - 3, wy) # footprint x[wx-3, wx), not yet on wall
92
- assert (focal.x, focal.y) == (wx - 3, wy)
93
  game.apply_motive_action("right") # would put footprint over (wx,wy)
94
- assert focal.x == wx - 3, "move into wall must be reverted by the engine"
95
 
96
 
97
  def test_predator_never_lands_on_a_wall():
@@ -101,7 +101,7 @@ def test_predator_never_lands_on_a_wall():
101
  break
102
  game.apply_motive_action("stay")
103
  pred = game.predator_sprite
104
- cells = {(pred.x + i, pred.y + j) for j in range(5) for i in range(5)}
105
  assert not (cells & scn._wall_cells), "predator footprint entered a wall"
106
 
107
 
 
26
  def test_walls_clear_spawns_and_border():
27
  scn, _ = _scn(seed=7)
28
  w, h = scn.grid_size
29
+ # spawn footprints must be wall-free (focal 2x2 at (5,30), predator 3x3 at (54,30))
30
+ for (sx, sy, sw, sh) in [(5, 30, 2, 2), (54, 30, 3, 3)]:
31
  for j in range(sh):
32
  for i in range(sw):
33
  assert (sx + i, sy + j) not in scn._wall_cells
 
37
 
38
 
39
  def test_has_a_focal_only_channel():
40
+ """Some 2x2-passable cell is NOT 3x3-passable: a focal-only gap exists."""
41
  scn, _ = _scn(seed=7)
42
  w, h = scn.grid_size
43
  wc = scn._wall_cells
 
51
  return True
52
 
53
  found = any(
54
+ fp_free(x, y, 2) and not fp_free(x, y, 3)
55
  for x in range(w) for y in range(h)
56
  )
57
+ assert found, "expected at least one focal-only (2x2 yes, 3x3 no) anchor"
58
 
59
 
60
  def test_predator_can_reach_focal_region():
61
+ """Predator (3x3) BFS from its spawn reaches adjacency of the focal spawn."""
62
  scn, _ = _scn(seed=7)
63
  w, h = scn.grid_size
64
  wc = scn._wall_cells
65
 
66
+ def fp_free3(x, y):
67
  return all(
68
  0 <= x + i < w and 0 <= y + j < h and (x + i, y + j) not in wc
69
+ for j in range(3) for i in range(3)
70
  )
71
 
72
  from collections import deque
73
+ start = (54, 30)
74
  seen = {start}
75
  q = deque([start])
76
  while q:
77
  x, y = q.popleft()
78
  for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
79
  nx, ny = x + dx, y + dy
80
+ if (nx, ny) not in seen and fp_free3(nx, ny):
81
  seen.add((nx, ny)); q.append((nx, ny))
82
+ # an anchor whose 3x3 footprint can sit adjacent to the focal spawn (5,30)
83
  assert any(abs(ax - 5) <= 6 and abs(ay - 30) <= 6 for (ax, ay) in seen)
84
 
85
 
 
87
  scn, game = _scn(seed=7)
88
  focal = game.focal_sprite
89
  wx, wy = sorted(scn._wall_cells)[len(scn._wall_cells) // 2]
90
+ # place focal so moving +1 x makes its 2x2 footprint cover (wx,wy)
91
+ focal.set_position(wx - 2, wy) # footprint x[wx-2, wx), not yet on wall
92
+ assert (focal.x, focal.y) == (wx - 2, wy)
93
  game.apply_motive_action("right") # would put footprint over (wx,wy)
94
+ assert focal.x == wx - 2, "move into wall must be reverted by the engine"
95
 
96
 
97
  def test_predator_never_lands_on_a_wall():
 
101
  break
102
  game.apply_motive_action("stay")
103
  pred = game.predator_sprite
104
+ cells = {(pred.x + i, pred.y + j) for j in range(pred.height) for i in range(pred.width)}
105
  assert not (cells & scn._wall_cells), "predator footprint entered a wall"
106
 
107
 
tests/scenarios/test_pack_evade_resize.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/scenarios/test_pack_evade_resize.py
2
+ """pack_evade after resize: 2x2 focal, 3x3 open-mouth predator, gap-2 channels."""
3
+ import random
4
+
5
+ from proteus.game.engine.difficulty import Difficulty
6
+ from proteus.game.engine.grid import MotiveGridGame
7
+ from proteus.game.scenarios.base import get_scenario
8
+ import proteus.game.scenarios # noqa: F401
9
+
10
+
11
+ def _game(diff=Difficulty.EASY, seed=42):
12
+ scen = get_scenario("pack_evade")()
13
+ return scen, MotiveGridGame(scen, random.Random(seed), diff, max_steps=50)
14
+
15
+
16
+ def test_sprite_sizes():
17
+ scen, game = _game()
18
+ assert (game.focal_sprite.width, game.focal_sprite.height) == (2, 2)
19
+ assert (game.predator_sprite.width, game.predator_sprite.height) == (3, 3)
20
+
21
+
22
+ def test_predator_has_open_mouth():
23
+ scen, game = _game()
24
+ # ㄷ shape: 9-cell bounding box, exactly 7 solid (2 transparent mouth cells).
25
+ pix = game.predator_sprite.render()
26
+ assert (pix != -1).sum() == 7
27
+
28
+
29
+ def test_turn_order_is_predator_first():
30
+ scen, _ = _game()
31
+ assert scen.turn_order == "predator_first"
32
+
33
+
34
+ def test_mouth_faces_movement_direction():
35
+ scen, game = _game()
36
+ # Force the predator to take a known step and assert its rotation updates.
37
+ pred = game.predator_sprite
38
+ pred.set_position(30, 30)
39
+ game.focal_sprite.set_position(30, 10) # straight up from predator
40
+ scen.advance_threat(game)
41
+ # Moving up => rotation 270 (see _FACING in pack_evade).
42
+ assert game.predator_sprite.rotation == 270
43
+
44
+
45
+ def test_narrow_gap_blocks_predator_admits_focal():
46
+ scen, game = _game()
47
+ scen.build_level(random.Random(0), Difficulty.EASY)
48
+ # A width-2 free corridor: 2-wide focal footprint fits, 3-wide predator does not.
49
+ # Use the scenario primitive on a synthetic 2-wide gap between two walls.
50
+ walls = {(10, y) for y in range(0, 20)} | {(13, y) for y in range(0, 20)}
51
+ scen._wall_cells = frozenset(walls)
52
+ # focal anchor (11,5): footprint x in {11,12} — clear of both wall columns.
53
+ assert scen._footprint_free(game, game.focal_sprite, 11, 5) is True
54
+ # predator anchor (11,5): footprint x in {11,12,13} — hits wall column 13.
55
+ assert scen._footprint_free(game, game.predator_sprite, 11, 5) is False