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