irregular6612 commited on
Commit
38e746c
·
1 Parent(s): 0c450d4

feat(cp1): port MotiveGridGame turn loop into proteus.grid

Browse files
proteus/grid/game.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MotiveGridGame — the shared per-turn game loop for the motive_grid family.
2
+
3
+ ``MotiveGridGame`` subclasses ``proteus.arc_grid.ARCBaseGame``. It owns the
4
+ turn loop and delegates all scenario-specific behaviour (level construction,
5
+ threat motion, elimination, answer keys) to an injected :class:`Scenario`.
6
+
7
+ One ``perform_action`` call drives exactly one turn (the engine calls
8
+ ``step()`` repeatedly until ``complete_action()``; this game completes the
9
+ action in a single ``step``). The public surface is
10
+ :meth:`apply_motive_action`, which accepts a plain action string so callers
11
+ (the future ``MotiveGridModule``) never touch the ``GameAction`` enums.
12
+
13
+ See ``docs/superpowers/specs/2026-06-01-motive-grid-design.md`` §3.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import random
19
+ from typing import TYPE_CHECKING
20
+
21
+ import numpy as np
22
+
23
+ from proteus.arc_grid import (
24
+ ARCBaseGame,
25
+ ActionInput,
26
+ Camera,
27
+ GameAction,
28
+ GameState,
29
+ Sprite,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from .scenario import Scenario
34
+
35
+ # Action string -> (dx, dy) grid delta. y grows downward (arc_grid convention).
36
+ _DIRECTION_DELTAS: dict[str, tuple[int, int]] = {
37
+ "up": (0, -1),
38
+ "down": (0, 1),
39
+ "left": (-1, 0),
40
+ "right": (1, 0),
41
+ "stay": (0, 0),
42
+ }
43
+
44
+ # Action string -> engine GameAction (per spec §3: up/down/left/right =
45
+ # ACTION1..4, stay = ACTION5).
46
+ _ACTION_TO_GAMEACTION: dict[str, GameAction] = {
47
+ "up": GameAction.ACTION1,
48
+ "down": GameAction.ACTION2,
49
+ "left": GameAction.ACTION3,
50
+ "right": GameAction.ACTION4,
51
+ "stay": GameAction.ACTION5,
52
+ }
53
+
54
+ # Reverse map: engine GameAction -> action string, for step() dispatch.
55
+ _GAMEACTION_TO_ACTION: dict[GameAction, str] = {
56
+ v: k for k, v in _ACTION_TO_GAMEACTION.items()
57
+ }
58
+
59
+ # Sprite names the scenario contract relies on.
60
+ FOCAL_NAME = "focal"
61
+ PREDATOR_NAME = "predator"
62
+
63
+
64
+ class MotiveGridGame(ARCBaseGame):
65
+ """Shared turn loop for motive_grid scenarios.
66
+
67
+ The game is constructed with a concrete :class:`Scenario`, builds its world
68
+ via ``scenario.build_level``, and runs each turn through :meth:`step`:
69
+
70
+ 1. Move the focal sprite (internal-wall collision or an off-grid target
71
+ keeps it in place).
72
+ 2. ``scenario.advance_threat`` moves the predator/NPCs.
73
+ 3. If ``scenario.check_elimination`` -> :meth:`lose`.
74
+ 4. Else if the step budget is exhausted -> :meth:`win`.
75
+ 5. ``complete_action`` ends the turn.
76
+
77
+ Boundary handling: arc_grid enforces no world border, so the framework
78
+ guards the grid edge for the focal agent itself. Every scenario uses
79
+ ``camera == grid_size`` with no panning, so a focal move whose target cell
80
+ falls outside ``[0, width) x [0, height)`` is rejected (see
81
+ :meth:`within_bounds`). Internal walls (dead-ends, obstacles) are still
82
+ handled by ``try_move_sprite``. Scenarios therefore need not ring the grid
83
+ with perimeter walls, but DO own their own threat sprites' bounds.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ scenario: Scenario,
89
+ rng: random.Random,
90
+ difficulty,
91
+ max_steps: int,
92
+ ) -> None:
93
+ """Initialize the game from a scenario.
94
+
95
+ Args:
96
+ scenario: The injected scenario driving world/threat/answer-key
97
+ behaviour.
98
+ rng: Seeded RNG, threaded into level construction and threat
99
+ tie-breaks for determinism.
100
+ difficulty: Session difficulty (passed through to the scenario).
101
+ max_steps: Survival budget; surviving this many played turns wins.
102
+ """
103
+ level = scenario.build_level(rng)
104
+ width, height = scenario.grid_size
105
+ camera = Camera(width=width, height=height)
106
+
107
+ # Direction actions (1..5) only; placeable/click action 6 is unused.
108
+ super().__init__(
109
+ game_id=scenario.task_name,
110
+ levels=[level],
111
+ camera=camera,
112
+ available_actions=[1, 2, 3, 4, 5],
113
+ )
114
+
115
+ self.scenario = scenario
116
+ self.rng = rng
117
+ self.difficulty = difficulty
118
+ self.max_steps = max_steps
119
+ self.step_count = 0
120
+
121
+ # ------------------------------------------------------------------ #
122
+ # Public turn surface
123
+ # ------------------------------------------------------------------ #
124
+ def apply_motive_action(self, action: str):
125
+ """Play one turn from a plain action string.
126
+
127
+ Wraps the action string in an ``ActionInput`` and drives one turn via
128
+ the engine's ``perform_action`` (which calls :meth:`step`). This is the
129
+ public surface so callers never build ``GameAction`` enums themselves.
130
+
131
+ Args:
132
+ action: One of ``"up"``, ``"down"``, ``"left"``, ``"right"``,
133
+ ``"stay"``.
134
+
135
+ Returns:
136
+ The ``FrameData`` returned by ``perform_action``.
137
+
138
+ Raises:
139
+ ValueError: If *action* is not a recognised direction string.
140
+ """
141
+ if action not in _ACTION_TO_GAMEACTION:
142
+ valid = ", ".join(_ACTION_TO_GAMEACTION)
143
+ raise ValueError(f"Unknown action '{action}'. Valid: {valid}")
144
+ action_input = ActionInput(id=_ACTION_TO_GAMEACTION[action])
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.within_bounds(focal.x + dx, focal.y + 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
+
172
+ if self.scenario.check_elimination(self):
173
+ self.lose()
174
+ elif self.step_count >= self.max_steps:
175
+ self.win()
176
+
177
+ self.complete_action()
178
+
179
+ # ------------------------------------------------------------------ #
180
+ # Sprite / state accessors for scenarios
181
+ # ------------------------------------------------------------------ #
182
+ def within_bounds(self, x: int, y: int) -> bool:
183
+ """Return True if cell ``(x, y)`` lies inside the scenario grid.
184
+
185
+ Every motive_grid scenario uses ``camera == grid_size`` with no
186
+ panning, so the grid is the world. A move whose target cell falls
187
+ outside ``[0, width) x [0, height)`` is rejected, keeping sprites
188
+ on-screen without each scenario needing a perimeter wall. Public so a
189
+ scenario's ``advance_threat`` hook can reuse it to keep its own threat
190
+ sprites on-grid (the framework only guards the focal agent).
191
+
192
+ Args:
193
+ x: Grid column (cell x-coordinate).
194
+ y: Grid row (cell y-coordinate).
195
+ """
196
+ width, height = self.scenario.grid_size
197
+ return 0 <= x < width and 0 <= y < height
198
+
199
+ def get_sprite(self, name: str) -> Sprite | None:
200
+ """Return the first sprite with *name*, or ``None`` if absent.
201
+
202
+ Args:
203
+ name: The sprite name to look up on the current level.
204
+
205
+ Returns:
206
+ The matching :class:`~proteus.arc_grid.Sprite`, or ``None``.
207
+ """
208
+ sprites = self.current_level.get_sprites_by_name(name)
209
+ return sprites[0] if sprites else None
210
+
211
+ @property
212
+ def focal_sprite(self) -> Sprite | None:
213
+ """The model-controlled focal sprite (``name="focal"``)."""
214
+ return self.get_sprite(FOCAL_NAME)
215
+
216
+ @property
217
+ def predator_sprite(self) -> Sprite | None:
218
+ """The threat/predator sprite (``name="predator"``)."""
219
+ return self.get_sprite(PREDATOR_NAME)
220
+
221
+ @property
222
+ def eliminated(self) -> bool:
223
+ """Whether the focal agent has been eliminated (game over)."""
224
+ return self._state == GameState.GAME_OVER
225
+
226
+ @property
227
+ def survived(self) -> bool:
228
+ """Whether the focal agent survived the full step budget (win)."""
229
+ return self._state == GameState.WIN
230
+
231
+ def current_grid(self) -> np.ndarray:
232
+ """Render the current world at native resolution (for ASCII).
233
+
234
+ Returns the native ``width x height`` palette array (e.g. ``(8, 8)`` for
235
+ an 8x8 grid), one palette index per cell, which ``ascii_view`` converts
236
+ to text. This is distinct from :meth:`current_frame`, which returns the
237
+ 64x64 upscaled render used for PNG/debug output.
238
+
239
+ We deliberately call the arc_grid-internal ``camera._raw_render`` here:
240
+ it is the correct native-resolution accessor (``render`` always upscales
241
+ to 64x64). No public native-resolution accessor exists.
242
+
243
+ Returns:
244
+ A ``(height, width)`` numpy array of palette indices at native grid
245
+ resolution.
246
+ """
247
+ return self.camera._raw_render(self.current_level.get_sprites())
248
+
249
+ def current_frame(self) -> np.ndarray:
250
+ """Render the current world as a 64x64 numpy palette frame.
251
+
252
+ The ASCII conversion (palette index -> symbol via ``scenario.legend``)
253
+ lives in the later ``ascii_view`` module; this exposes the raw frame
254
+ for it to consume.
255
+
256
+ Returns:
257
+ A ``(64, 64)`` numpy array of palette indices.
258
+ """
259
+ return self.camera.render(self.current_level.get_sprites())
tests/grid/test_game_import.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_game_module_imports_and_action_maps_are_consistent():
2
+ from proteus.grid.game import (
3
+ MotiveGridGame,
4
+ _DIRECTION_DELTAS,
5
+ _ACTION_TO_GAMEACTION,
6
+ _GAMEACTION_TO_ACTION,
7
+ )
8
+ # Every action has a delta and a GameAction, and the reverse map is exact.
9
+ assert set(_DIRECTION_DELTAS) == set(_ACTION_TO_GAMEACTION)
10
+ assert _GAMEACTION_TO_ACTION == {v: k for k, v in _ACTION_TO_GAMEACTION.items()}