irregular6612 commited on
Commit
39c93ec
·
1 Parent(s): 2e262ce

feat(engine): per-scenario turn_order with predator-first step swap

Browse files

Add Scenario.turn_order class attribute ("focal_first" default) and honour it
in MotiveGridGame.step(): predator_first calls advance_threat before the focal
moves; focal_first (default) preserves legacy behaviour so predator_evade and
all existing tests are unaffected.

proteus/game/engine/grid.py CHANGED
@@ -145,27 +145,32 @@ class MotiveGridGame(ARCBaseGame):
145
  return self.perform_action(action_input)
146
 
147
  def step(self) -> None:
148
- """Resolve a single turn (one focal move + threat advance + outcome).
149
 
150
- Reads the pending engine action, moves the focal sprite, advances the
151
- threat, then checks for elimination or survival. Always completes the
152
- action so the engine loop terminates after one step.
 
153
  """
154
  action = _GAMEACTION_TO_ACTION.get(self._action.id, "stay")
155
  dx, dy = _DIRECTION_DELTAS[action]
156
-
157
  focal = self.focal_sprite
 
 
 
 
 
 
 
158
  if (
159
  focal is not None
160
  and (dx != 0 or dy != 0)
161
  and self._footprint_in_bounds(focal, dx, dy)
162
  ):
163
- # arc_grid has no implicit world border: try_move_sprite reverts
164
- # only on sprite collision, so the framework guards the grid edge
165
- # itself. Internal walls are still handled by try_move_sprite.
166
  self.try_move_sprite(focal, dx, dy)
167
 
168
- self.scenario.advance_threat(self)
 
169
 
170
  self.step_count += 1
171
 
 
145
  return self.perform_action(action_input)
146
 
147
  def step(self) -> None:
148
+ """Resolve a single turn, honouring ``scenario.turn_order``.
149
 
150
+ focal_first (default): move focal, then advance threat (chasing the new
151
+ focal cell). predator_first: advance threat first (chasing the current
152
+ focal cell), then move focal. Capture/survival are checked once, after
153
+ both have moved. Always completes the action so the engine loop ends.
154
  """
155
  action = _GAMEACTION_TO_ACTION.get(self._action.id, "stay")
156
  dx, dy = _DIRECTION_DELTAS[action]
 
157
  focal = self.focal_sprite
158
+ predator_first = (
159
+ getattr(self.scenario, "turn_order", "focal_first") == "predator_first"
160
+ )
161
+
162
+ if predator_first:
163
+ self.scenario.advance_threat(self)
164
+
165
  if (
166
  focal is not None
167
  and (dx != 0 or dy != 0)
168
  and self._footprint_in_bounds(focal, dx, dy)
169
  ):
 
 
 
170
  self.try_move_sprite(focal, dx, dy)
171
 
172
+ if not predator_first:
173
+ self.scenario.advance_threat(self)
174
 
175
  self.step_count += 1
176
 
proteus/game/scenarios/base.py CHANGED
@@ -66,6 +66,12 @@ class Scenario(ABC):
66
  # Default empty -> callers fall back to rules_text. Scenarios that want a
67
  # more transparent practice brief override this with their own text.
68
  memory_brief: str = ""
 
 
 
 
 
 
69
 
70
  @abstractmethod
71
  def build_level(self, rng: random.Random, difficulty: Difficulty) -> Level:
 
66
  # Default empty -> callers fall back to rules_text. Scenarios that want a
67
  # more transparent practice brief override this with their own text.
68
  memory_brief: str = ""
69
+ # Turn resolution order. "focal_first" (default): focal moves, then the
70
+ # threat advances (chasing the focal's NEW cell). "predator_first": the
71
+ # threat advances first (chasing the focal's CURRENT cell), then the focal
72
+ # moves. Scenarios override this; predator_evade keeps the default so its
73
+ # dead-end diagnostic is preserved.
74
+ turn_order: str = "focal_first"
75
 
76
  @abstractmethod
77
  def build_level(self, rng: random.Random, difficulty: Difficulty) -> Level:
tests/engine/test_turn_order.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/engine/test_turn_order.py
2
+ """predator-first reorders the turn: advance_threat sees the focal's PRE-move cell."""
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 (register scenarios)
9
+
10
+
11
+ def _focal_cell_seen_by_threat(turn_order: str, action: str = "up"):
12
+ scen = get_scenario("predator_evade")()
13
+ scen.turn_order = turn_order # instance override
14
+ game = MotiveGridGame(scen, random.Random(0), Difficulty.EASY, max_steps=10)
15
+ seen = {}
16
+ real_advance = scen.advance_threat
17
+
18
+ def spy(g):
19
+ f = g.focal_sprite
20
+ seen["focal"] = (f.x, f.y)
21
+ real_advance(g)
22
+
23
+ scen.advance_threat = spy # shadow the bound method on this instance
24
+ before = (game.focal_sprite.x, game.focal_sprite.y)
25
+ game.apply_motive_action(action)
26
+ return before, seen["focal"]
27
+
28
+
29
+ def test_predator_first_threat_sees_premove_focal():
30
+ before, seen = _focal_cell_seen_by_threat("predator_first", "up")
31
+ assert seen == before # predator advanced BEFORE the focal moved
32
+
33
+
34
+ def test_focal_first_threat_sees_postmove_focal():
35
+ before, seen = _focal_cell_seen_by_threat("focal_first", "up")
36
+ assert seen != before # legacy: focal moved first, predator chased the new cell