harness.pyΒΆ

Part of Mr. Rescue.

  1"""Scripted headless playthrough: the implementation behind ``main.py --test``.
  2
  3:func:`capture_playthrough` drives a real :class:`MrRescueRoot` with
  4:class:`~simvx.core.InputSimulator` and writes one PNG per stage into
  5``screenshots/``.
  6
  7The pilot below plays from live world state rather than from a fixed key
  8timeline: it presses "up" on the frame it is actually standing on a ladder,
  9steps off once it has climbed a full storey, and presses "grab" on the frame a
 10civilian comes into reach. That is what makes each screenshot show the outcome
 11its name promises instead of the key that was sent.
 12
 13Module-level ``random`` is seeded first: civilians, fire bugs and screenshake all
 14draw from it, so without a seed the run would not repeat frame for frame and the
 15capture points would drift.
 16
 17Run it through the port's own entry point::
 18
 19    uv run python examples/ports/mr_rescue/main.py --test
 20"""
 21
 22from __future__ import annotations
 23
 24import random
 25from collections.abc import Callable
 26from pathlib import Path
 27
 28from nodes.building import FLOOR_HEIGHT
 29from nodes.tile_grid import TILE_SIZE
 30
 31from simvx.core import InputSimulator, Key, Node
 32from simvx.graphics import App, save_png
 33
 34# Height of one storey in pixels: the exact distance a ladder spans.
 35STOREY_PX = FLOOR_HEIGHT * TILE_SIZE
 36
 37RUN_SEED = 42  # scene generator + module-level ``random``
 38START_FRAME = 30  # confirm on the title screen
 39RUN_FRAME = 50  # start running right
 40SPRAY_FRAME = 70  # open the hose
 41SPRAY_STOP_X = 400.0  # close it again short of the ladder, so climbing can start
 42CARRY_WALK = 40  # frames spent carrying the civilian clear of the group
 43CARRY_HOLD = 60  # frames spent carrying before the suit is pushed over
 44RESTART_DELAY = 40  # frames on the end screen before confirming
 45
 46# Frame at which each screenshot is taken. The pilot is state-driven but the run
 47# is deterministic, so these land on the stages listed in ``capture_playthrough``.
 48CAPTURES = {
 49    20: "01_title.png",
 50    48: "02_world_overview.png",
 51    157: "03_water_spray.png",
 52    330: "04_ladder_climb.png",
 53    500: "05_upper_floor.png",
 54    580: "06_civilian_grab.png",
 55    620: "07_end_screen.png",
 56    664: "08_post_restart.png",
 57}
 58TOTAL_FRAMES = 700
 59
 60
 61class _Pilot:
 62    """Plays one run, deciding each frame's input from the live scene."""
 63
 64    def __init__(self, sim: InputSimulator):
 65        self._sim = sim
 66        self._held: set[Key] = set()
 67        self._taps: dict[Key, int] = {}
 68        self.stage = "title"
 69        self._mount_y = 0.0
 70        self._carry_frame = 0
 71        self._end_frame = -1
 72
 73    # ------------------------------------------------------------- keys
 74
 75    def _hold(self, key: Key):
 76        if key not in self._held:
 77            self._sim.press_key(key)
 78            self._held.add(key)
 79
 80    def _let_go(self, key: Key):
 81        if key in self._held:
 82            self._sim.release_key(key)
 83            self._held.discard(key)
 84
 85    def _tap(self, idx: int, key: Key):
 86        """Press *key* now and release it two frames later (a one-shot action)."""
 87        self._hold(key)
 88        self._taps[key] = idx + 2
 89
 90    # ------------------------------------------------------------- driver
 91
 92    def step(self, idx: int, root: Node):
 93        for key, due in list(self._taps.items()):
 94            if idx >= due:
 95                self._let_go(key)
 96                del self._taps[key]
 97
 98        if root.phase == "end":
 99            self._end(idx)
100            return
101        if root.phase == "menu":
102            if self.stage == "title" and idx >= START_FRAME:
103                self._tap(idx, Key.ENTER)
104                self.stage = "advance"
105            return
106
107        scene = root.scene
108        if self.stage == "advance":
109            self._advance(idx, scene)
110        elif self.stage == "climb":
111            self._climb(scene.player)
112        elif self.stage == "cross":
113            self._cross(idx, scene.player)
114        elif self.stage == "carry":
115            self._carry(idx, scene.player)
116
117    # ------------------------------------------------------------- stages
118
119    def _advance(self, idx: int, scene):
120        """Run right along the ground floor with the hose open, up to the ladder."""
121        player, grid = scene.player, scene.grid
122        if idx >= RUN_FRAME:
123            self._hold(Key.RIGHT)
124        if SPRAY_FRAME <= idx and player.position.x < SPRAY_STOP_X:
125            self._hold(Key.LEFT_SHIFT)
126        else:
127            self._let_go(Key.LEFT_SHIFT)
128        # Mount the moment the gun-height cell is a rung: holding "up" over a
129        # ladder is exactly how a player climbs.
130        if grid.is_ladder(*grid.cell_for(player.position.x, player.position.y - 11)):
131            self._let_go(Key.RIGHT)
132            self._hold(Key.UP)
133            self._mount_y = float(player.position.y)
134            self.stage = "climb"
135
136    def _climb(self, player):
137        """Hold "up" for one full storey, then step off sideways onto the floor."""
138        if player.position.y <= self._mount_y - STOREY_PX:
139            self._let_go(Key.UP)
140            self._hold(Key.LEFT)
141            self.stage = "cross"
142
143    def _cross(self, idx: int, player):
144        """Walk the upper floor hosing the fires down, and grab the first civilian."""
145        self._hold(Key.LEFT)
146        self._hold(Key.LEFT_SHIFT)
147        if player.can_grab:
148            self._let_go(Key.LEFT)
149            self._let_go(Key.LEFT_SHIFT)
150            self._tap(idx, Key.E)
151            self._carry_frame = idx
152            self.stage = "carry"
153
154    def _carry(self, idx: int, player):
155        """Carry her back the way we came, then let the suit cook."""
156        held = idx - self._carry_frame
157        if held < CARRY_WALK:
158            self._hold(Key.RIGHT)
159        else:
160            self._let_go(Key.RIGHT)
161        # Push the suit past its heat limit so the end screen is reachable inside
162        # the frame budget; real play gets there through fire exposure.
163        if held >= CARRY_HOLD:
164            player.temperature = player.max_temperature + 1.0
165
166    def _end(self, idx: int):
167        if self._end_frame < 0:
168            self._end_frame = idx
169            for key in list(self._held):
170                self._let_go(key)
171        elif idx == self._end_frame + RESTART_DELAY:
172            self._tap(idx, Key.ENTER)
173
174
175def capture_playthrough(
176    root_factory: Callable[..., Node],
177    *,
178    window_size: tuple[int, int],
179    bg_colour,
180    out_dir: Path,
181) -> None:
182    """Run the scripted playthrough headlessly and save the stage screenshots.
183
184    The stages, in the order they are captured:
185
186    ``01_title``
187        the title screen.
188    ``02_world_overview``
189        the generated building, camera on the player at his ground-floor spawn.
190    ``03_water_spray``
191        running right with the hose open, the jet landing on a burning
192        ground-floor fire (a second one is already out: score 20, fires 5).
193    ``04_ladder_climb``
194        on the ladder, part-way between the ground floor and the one above.
195    ``05_upper_floor``
196        stepped off the ladder and crossing the floor above, hosing down the two
197        fires that were burning on it.
198    ``06_civilian_grab``
199        carrying a civilian, picked up on the frame she came into reach.
200    ``07_end_screen``
201        the overheat end screen with the run summary.
202    ``08_post_restart``
203        back on the title screen after confirming a restart.
204    """
205    out_dir.mkdir(exist_ok=True)
206    random.seed(RUN_SEED)
207
208    width, height = window_size
209    app = App(title="Mr. Rescue (test)", width=width, height=height, visible=False, bg_colour=bg_colour)
210    sim = InputSimulator()
211    root = root_factory(seed=RUN_SEED)
212    pilot = _Pilot(sim)
213
214    def drive(idx: int, _t: float):
215        pilot.step(idx, root)
216        return None
217
218    capture_frames = sorted(CAPTURES)
219    frames = app.run_headless(
220        root,
221        frames=TOTAL_FRAMES,
222        on_frame=drive,
223        capture_frames=capture_frames,
224    )
225    for idx, frame in zip(capture_frames, frames, strict=False):
226        if frame is None:
227            continue
228        # Headless captures come back with the scene's own alpha; force opaque
229        # so the PNGs composite predictably in docs and image viewers.
230        frame[..., 3] = 255
231        path = out_dir / CAPTURES[idx]
232        save_png(frame, path)
233        print(f"saved {path}")