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}")