Source code for simvx.graphics.playtest

"""Automated game playtest harness — run games headlessly with step-by-step screenshots.

Combines DemoRunner step execution with App.run_headless() to capture screenshots
and scene state at each step boundary. Produces structured reports that Claude can
read (JSON + PNGs + scene tree text) to diagnose bugs automatically.

Usage:
    from simvx.graphics.playtest import GamePlaytestHarness
    from simvx.core.scripted_demo import Click, Wait, Assert

    harness = GamePlaytestHarness(game_root, width=1280, height=720)
    report = harness.run([
        Wait(0.5),
        Click(400, 300),
        Assert(lambda g: g.clicked, "Button should be clicked"),
    ])
    print(report.summary())
"""


from __future__ import annotations

import json
import logging
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path

from simvx.core import Node
from simvx.core.scripted_demo import Assert, Click, DemoRunner, Do, MoveTo, Narrate, PressKey, TypeText, Wait
from simvx.core.testing import NodeCounter, scene_describe, ui_describe

log = logging.getLogger(__name__)

__all__ = ["GamePlaytestHarness", "PlaytestReport", "StepSnapshot"]


[docs] @dataclass class StepSnapshot: """Captured state at a single playtest step boundary.""" step_index: int step_type: str step_desc: str frame_index: int time_seconds: float screenshot_path: Path | None = None scene_tree: str = "" ui_tree: str = "" assertion_result: str | None = None
[docs] @dataclass class PlaytestReport: """Full results of a playtest run.""" game_path: str timestamp: str total_frames: int total_steps: int completed: bool passed: bool failures: list[str] steps: list[StepSnapshot] duration_seconds: float node_count: int output_dir: Path
[docs] def save(self) -> Path: """Write report.json + screenshots/ + state/ to output_dir.""" self.output_dir.mkdir(parents=True, exist_ok=True) # Save per-step state files state_dir = self.output_dir / "state" state_dir.mkdir(exist_ok=True) for snap in self.steps: state_path = state_dir / f"step_{snap.step_index:03d}.txt" content = f"=== Step {snap.step_index}: {snap.step_type}{snap.step_desc} ===\n" content += f"Frame: {snap.frame_index} Time: {snap.time_seconds:.2f}s\n" if snap.assertion_result: content += f"Assertion: {snap.assertion_result}\n" content += f"\n--- Scene Tree ---\n{snap.scene_tree}\n" content += f"\n--- UI Tree ---\n{snap.ui_tree}\n" state_path.write_text(content, encoding="utf-8") # Save report.json report_data = { "game_path": self.game_path, "timestamp": self.timestamp, "total_frames": self.total_frames, "total_steps": self.total_steps, "completed": self.completed, "passed": self.passed, "failures": self.failures, "duration_seconds": self.duration_seconds, "node_count": self.node_count, "steps": [ { "step_index": s.step_index, "step_type": s.step_type, "step_desc": s.step_desc, "frame_index": s.frame_index, "time_seconds": s.time_seconds, "screenshot_path": str(s.screenshot_path) if s.screenshot_path else None, "assertion_result": s.assertion_result, } for s in self.steps ], } report_path = self.output_dir / "report.json" report_path.write_text(json.dumps(report_data, indent=2), encoding="utf-8") return report_path
[docs] def summary(self) -> str: """One-paragraph text summary for Claude.""" status = "PASSED" if self.passed else "FAILED" fail_text = "" if self.failures: fail_text = " Failures: " + "; ".join(self.failures) screenshot_count = sum(1 for s in self.steps if s.screenshot_path) return ( f"Playtest {status}: {self.total_steps} steps in {self.total_frames} frames " f"({self.duration_seconds:.1f}s). {screenshot_count} screenshots captured. " f"{self.node_count} nodes in scene.{fail_text}" )
[docs] class GamePlaytestHarness: """Run a game headlessly with DemoRunner steps, capturing screenshots and state. Args: game_root: The game's root node. width: Window width. height: Window height. output_dir: Where to save screenshots and reports. Defaults to /tmp/playtest/<timestamp>. max_frames: Safety limit on total frames. speed: DemoRunner speed multiplier. """ def __init__( self, game_root: Node, *, width: int = 1280, height: int = 720, output_dir: str | Path | None = None, max_frames: int = 30000, speed: float = 50.0, ): self._game_root = game_root self._width = width self._height = height self._max_frames = max_frames self._speed = speed if output_dir is None: ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") self._output_dir = Path(f"/tmp/playtest/{ts}") else: self._output_dir = Path(output_dir)
[docs] def run( self, steps: list, *, capture_every_n: int = 0, capture_on_assert: bool = True, capture_on_click: bool = True, ) -> PlaytestReport: """Execute playtest steps and return a report with screenshots and state. Args: steps: List of DemoRunner step dataclasses. capture_every_n: Also capture every N frames (0 = disabled). capture_on_assert: Screenshot before each Assert step. capture_on_click: Screenshot after each Click step. """ from .app import App from .testing import save_png # Create DemoRunner as child of game root demo = DemoRunner( steps, test_mode=False, speed=self._speed, delay_between_steps=0.0, ) self._game_root.add_child(demo) # Track the last known root so we can re-parent DemoRunner after change_scene() last_root = [self._game_root] # Track step changes snapshots: list[StepSnapshot] = [] capture_frame_set: set[int] = set() current_frame = [0] last_step = [-1] physics_dt = 1.0 / 60 def _step_desc(step) -> str: if isinstance(step, Click): return f"Click ({int(step.x)}, {int(step.y)})" if isinstance(step, TypeText): text = step.text[:30] + "..." if len(step.text) > 30 else step.text return f"TypeText \"{text}\"" if isinstance(step, PressKey): return f"PressKey {step.key}" if isinstance(step, MoveTo): return f"MoveTo ({int(step.x)}, {int(step.y)})" if isinstance(step, Wait): return f"Wait {step.duration}s" if isinstance(step, Assert): return f"Assert: {step.message}" if step.message else "Assert" if isinstance(step, Do): return f"Do: {step.message}" if step.message else "Do" if isinstance(step, Narrate): text = step.text[:30] + "..." if len(step.text) > 30 else step.text return f"Narrate \"{text}\"" return type(step).__name__ def _capture_state(step_idx: int, step) -> StepSnapshot: snap = StepSnapshot( step_index=step_idx, step_type=type(step).__name__, step_desc=_step_desc(step), frame_index=current_frame[0], time_seconds=current_frame[0] * physics_dt, ) root = last_root[0] try: snap.scene_tree = scene_describe(root) except Exception: snap.scene_tree = "<error capturing scene tree>" try: snap.ui_tree = ui_describe(root) except Exception: snap.ui_tree = "<error capturing UI tree>" return snap # Capture initial state initial_snap = StepSnapshot( step_index=-1, step_type="Initial", step_desc="Initial state before any steps", frame_index=0, time_seconds=0.0, ) snapshots.append(initial_snap) capture_frame_set.add(0) def on_step_changed(new_idx: int): prev_idx = last_step[0] if prev_idx >= 0 and prev_idx < len(steps): prev_step = steps[prev_idx] snap = _capture_state(prev_idx, prev_step) # Check assertion results if isinstance(prev_step, Assert): if demo.failures and demo.failures[-1:] != getattr(self, "_last_failures", []): snap.assertion_result = f"FAIL: {demo.failures[-1]}" else: snap.assertion_result = "PASS" snapshots.append(snap) # Mark frame for capture based on step type and options should_cap = True if isinstance(prev_step, Assert) and not capture_on_assert: should_cap = False if isinstance(prev_step, Click) and not capture_on_click: should_cap = False if should_cap: capture_frame_set.add(current_frame[0]) # Track which failures we've seen self._last_failures = list(demo.failures) last_step[0] = new_idx demo.step_changed.connect(on_step_changed) self._last_failures: list[str] = [] # Reference to the SceneTree — captured on first frame scene_tree_ref = [None] def _current_root(): """Get the current scene root (may change after change_scene).""" tree = scene_tree_ref[0] if tree and tree.root: return tree.root return last_root[0] def on_frame(frame_idx: int, _t: float): current_frame[0] = frame_idx # Capture tree reference on first frame (created inside run_headless) if scene_tree_ref[0] is None and demo._tree: scene_tree_ref[0] = demo._tree # Detect scene change: if root changed, re-parent DemoRunner onto new root cur_root = _current_root() if cur_root is not last_root[0]: # Scene changed — re-attach DemoRunner to new root if demo.parent: demo.parent.remove_child(demo) cur_root.add_child(demo) last_root[0] = cur_root elif demo._tree is None or demo.parent is None: # DemoRunner lost its tree reference — re-attach to root cur_root.add_child(demo) last_root[0] = cur_root # Capture initial scene/UI state on first frame if frame_idx == 1 and snapshots and snapshots[0].step_index == -1: root = _current_root() try: snapshots[0].scene_tree = scene_describe(root) except Exception: pass try: snapshots[0].ui_tree = ui_describe(root) except Exception: pass # Stop early once demo is done (return False signals run_headless to exit) if demo.is_done: return False def should_capture(frame_idx: int) -> bool: if frame_idx in capture_frame_set: return True if capture_every_n > 0 and frame_idx % capture_every_n == 0: return True return False # Run headless start_time = time.monotonic() app = App(width=self._width, height=self._height, visible=False) captured = app.run_headless( self._game_root, frames=self._max_frames, on_frame=on_frame, capture_fn=should_capture, ) elapsed = time.monotonic() - start_time # Capture final step state if demo finished mid-step if last_step[0] >= 0 and last_step[0] < len(steps): final_step = steps[last_step[0]] snap = _capture_state(last_step[0], final_step) if isinstance(final_step, Assert): if demo.failures and len(demo.failures) > len(self._last_failures): snap.assertion_result = f"FAIL: {demo.failures[-1]}" else: snap.assertion_result = "PASS" snapshots.append(snap) # Save screenshots screenshots_dir = self._output_dir / "screenshots" screenshots_dir.mkdir(parents=True, exist_ok=True) for i, pixels in enumerate(captured): # Match screenshot to nearest snapshot label = f"frame_{i:03d}" if i < len(snapshots): snap = snapshots[i] label = f"step_{snap.step_index:03d}_{snap.step_type.lower()}" snap.screenshot_path = screenshots_dir / f"{label}.png" save_png(snap.screenshot_path, pixels) else: path = screenshots_dir / f"{label}.png" save_png(path, pixels) # If we have more snapshots than screenshots, at least the paths are set correctly # Save a final screenshot if available if captured and len(captured) > len(snapshots): final_path = screenshots_dir / "final.png" save_png(final_path, captured[-1]) # Count nodes node_count = NodeCounter.total(self._game_root) report = PlaytestReport( game_path="", timestamp=datetime.now(UTC).isoformat(), total_frames=current_frame[0], total_steps=len(steps), completed=demo.is_done, passed=demo.is_done and not demo.failures, failures=list(demo.failures), steps=snapshots, duration_seconds=elapsed, node_count=node_count, output_dir=self._output_dir, ) report.save() return report