harness.py

Part of PirateMaker.

  1"""Headless `--test` mode: capture screenshots covering the title menu, an empty
  2editor, placing tiles, entering play mode, mid-play, and coming back.
  3
  4Implemented as a single `app.run_headless()` with `on_frame` callbacks that
  5mutate the editor at scheduled frames. Direct API calls (open_editor,
  6place_tile_at, start_play, return_to_editor) are used so frames are
  7deterministic; the harness is for screenshot capture, not input fuzzing.
  8"""
  9
 10from __future__ import annotations
 11
 12from pathlib import Path
 13
 14import numpy as np
 15from main import PirateMakerRoot
 16from settings import WINDOW_HEIGHT, WINDOW_WIDTH
 17
 18from simvx.graphics import App, save_png
 19
 20OUT_DIR = Path(__file__).resolve().parent / "screenshots"
 21OUT_DIR.mkdir(exist_ok=True)
 22
 23
 24# Schedule:
 25# (frame_at_which_to_act, action_callable, screenshot_filename_or_None)
 26# Frame numbers are 0-based as seen by `on_frame`. capture_frames lists the
 27# *last frame index of each cohort*, i.e. the frame whose render is the one
 28# we want saved.
 29SCREENSHOTS: list[tuple[int, str]] = [
 30    (3, "01_title_menu.png"),
 31    (9, "02_editor_empty.png"),
 32    (15, "03_editor_with_tiles.png"),
 33    (22, "04_palette_coin.png"),
 34    (28, "05_play_boot.png"),
 35    (88, "06_play_mid.png"),
 36    (130, "07_play_walking.png"),
 37    (138, "08_back_to_editor.png"),
 38]
 39
 40
 41def run_test() -> None:
 42    from settings import SKY_COLOUR
 43
 44    root = PirateMakerRoot()
 45    app = App(width=WINDOW_WIDTH, height=WINDOW_HEIGHT, title="PirateMaker (test)", visible=False, bg_colour=SKY_COLOUR)
 46
 47    def setup_level(editor):
 48        # Origin defaults to (512, 360); rows 0..3 are within viewport.
 49        # Place a row of terrain at row=2 (world Y ≈ 488)
 50        for col in range(-3, 9):
 51            editor.place_tile_at(col, 2, tile_id=2)
 52        # A coin and a tooth above the row
 53        editor.place_tile_at(0, 1, tile_id=4)
 54        editor.place_tile_at(2, 1, tile_id=8)
 55        # A water column to the right
 56        for row in range(2, 5):
 57            editor.place_tile_at(10, row, tile_id=3)
 58            editor.place_tile_at(11, row, tile_id=3)
 59        editor.recheck_all_neighbours()
 60
 61    def on_frame(idx: int, t: float):
 62        # Pre-frame mutations
 63        if idx == 4:
 64            root.open_editor()
 65        if idx == 10:
 66            setup_level(root.editor)
 67        if idx == 16:
 68            root.editor.selection_index = 4
 69        if idx == 23:
 70            root.start_play()
 71        if idx == 89 and root.level is not None:
 72            root.level.player.direction.x = 1.0
 73            root.level.player.orientation = "right"
 74        if idx == 131:
 75            root.return_to_editor()
 76        return None
 77
 78    capture_frames = [f for f, _ in SCREENSHOTS]
 79    total = max(capture_frames) + 2
 80
 81    pixels = app.run_headless(root, frames=total, on_frame=on_frame, capture_frames=capture_frames)
 82
 83    # Composite alpha against the sky background so transparent textured-quad
 84    # pixels (caused by the engine's `dstAlphaBlendFactor=ZERO` blend state
 85    # punching alpha=0 through the framebuffer) display correctly when viewed
 86    # in standard PNG viewers.
 87    sky = np.array([221, 198, 161], dtype=np.float32)
 88    for (_, name), arr in zip(SCREENSHOTS, pixels, strict=False):
 89        if arr.shape[-1] == 4:
 90            alpha = arr[:, :, 3:4].astype(np.float32) / 255.0
 91            rgb = arr[:, :, :3].astype(np.float32)
 92            composited = (rgb * alpha + sky * (1.0 - alpha)).astype(np.uint8)
 93            arr = np.dstack([composited, np.full_like(arr[:, :, 3], 255)])
 94        path = OUT_DIR / name
 95        save_png(arr, path)
 96        print(f"saved {name}")
 97
 98    print(f"OK: {len(pixels)} screenshots in {OUT_DIR}")
 99
100
101if __name__ == "__main__":
102    run_test()