Add headless-automation.md explanation document
Comprehensive guide to headless mode and mcrfpy.step() testing: - Time control with step() (seconds, not milliseconds) - Timer behavior and callback signatures - Screenshot automation - Test pattern comparison table - LLM agent integration patterns - Best practices for deterministic testing Based on Frick's draft, updated with patterns from test refactoring. 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4528ece0a7
commit
bb86cece2b
1 changed files with 240 additions and 0 deletions
240
explanation/headless-automation.md
Normal file
240
explanation/headless-automation.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Headless Mode and Automation
|
||||
|
||||
McRogueFace supports headless operation for automated testing, CI/CD pipelines, and programmatic control without a display.
|
||||
|
||||
## Running Headless
|
||||
|
||||
Launch with the `--headless` flag:
|
||||
|
||||
```bash
|
||||
mcrogueface --headless --exec game.py
|
||||
```
|
||||
|
||||
Or use xvfb for a virtual framebuffer (required for rendering):
|
||||
|
||||
```bash
|
||||
xvfb-run -a -s "-screen 0 1024x768x24" mcrogueface --headless --exec game.py
|
||||
```
|
||||
|
||||
## Time Control with mcrfpy.step()
|
||||
|
||||
In headless mode, you control time explicitly rather than waiting for real-time to pass. The `step()` function takes **seconds** as a float:
|
||||
|
||||
```python
|
||||
import mcrfpy
|
||||
|
||||
# Advance simulation by 100ms
|
||||
mcrfpy.step(0.1)
|
||||
|
||||
# Advance by 1 second
|
||||
mcrfpy.step(1.0)
|
||||
|
||||
# Advance by 16ms (~60fps frame)
|
||||
mcrfpy.step(0.016)
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
Traditional timer-based code waits for real time and the game loop:
|
||||
|
||||
```python
|
||||
# OLD PATTERN - waits for game loop, subject to timeouts
|
||||
def delayed_action(timer, runtime):
|
||||
print("Action!")
|
||||
sys.exit(0)
|
||||
|
||||
mcrfpy.Timer("delay", delayed_action, 500, once=True)
|
||||
# Script ends, game loop runs, timer eventually fires
|
||||
```
|
||||
|
||||
With `mcrfpy.step()`, you control the clock synchronously:
|
||||
|
||||
```python
|
||||
# NEW PATTERN - instant, deterministic
|
||||
mcrfpy.Timer("delay", delayed_action, 500, once=True)
|
||||
mcrfpy.step(0.6) # Advance 600ms - timer fires during this call
|
||||
```
|
||||
|
||||
### Timer Behavior
|
||||
|
||||
- Timers fire **once per `step()` call** if their interval has elapsed
|
||||
- To fire a timer multiple times, call `step()` multiple times:
|
||||
|
||||
```python
|
||||
count = 0
|
||||
def tick(timer, runtime):
|
||||
global count
|
||||
count += 1
|
||||
|
||||
timer = mcrfpy.Timer("tick", tick, 100) # Fire every 100ms
|
||||
|
||||
# Each step() processes timers once
|
||||
for i in range(5):
|
||||
mcrfpy.step(0.1) # 100ms each
|
||||
|
||||
print(count) # 5
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
The `automation.screenshot()` function captures the current frame:
|
||||
|
||||
```python
|
||||
from mcrfpy import automation
|
||||
|
||||
# Capture screenshot (synchronous in headless mode)
|
||||
result = automation.screenshot("output.png")
|
||||
print(f"Screenshot saved: {result}")
|
||||
```
|
||||
|
||||
**Key insight:** In headless mode, `screenshot()` is synchronous - no timer dance needed.
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Synchronous Test Structure
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import mcrfpy
|
||||
import sys
|
||||
|
||||
# Setup scene
|
||||
scene = mcrfpy.Scene("test")
|
||||
scene.activate()
|
||||
mcrfpy.step(0.1) # Initialize scene
|
||||
|
||||
# Create test objects
|
||||
frame = mcrfpy.Frame(pos=(100, 100), size=(50, 50))
|
||||
scene.children.append(frame)
|
||||
|
||||
# Verify state
|
||||
if frame.x != 100:
|
||||
print("FAIL: frame.x should be 100")
|
||||
sys.exit(1)
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
```
|
||||
|
||||
### Testing Animations
|
||||
|
||||
```python
|
||||
# Create frame and start animation
|
||||
frame = mcrfpy.Frame(pos=(100, 100), size=(50, 50))
|
||||
scene.children.append(frame)
|
||||
|
||||
anim = mcrfpy.Animation("x", 200.0, 1.0, "linear") # 1 second duration
|
||||
anim.start(frame)
|
||||
|
||||
# Advance to midpoint (0.5 seconds)
|
||||
mcrfpy.step(0.5)
|
||||
# frame.x should be ~150 (halfway between 100 and 200)
|
||||
|
||||
# Advance to completion
|
||||
mcrfpy.step(0.6) # Past the 1.0s duration
|
||||
# frame.x should be 200
|
||||
|
||||
if frame.x == 200.0:
|
||||
print("PASS: Animation completed")
|
||||
sys.exit(0)
|
||||
```
|
||||
|
||||
### Testing Timers
|
||||
|
||||
```python
|
||||
callback_count = 0
|
||||
|
||||
def on_timer(timer, runtime):
|
||||
"""Timer callbacks receive (timer_object, runtime_ms)"""
|
||||
global callback_count
|
||||
callback_count += 1
|
||||
print(f"Timer fired! Count: {callback_count}, runtime: {runtime}ms")
|
||||
|
||||
# Create timer that fires every 100ms
|
||||
timer = mcrfpy.Timer("test", on_timer, 100)
|
||||
|
||||
# Advance time - each step() can fire the timer once
|
||||
mcrfpy.step(0.15) # First fire at ~100ms
|
||||
mcrfpy.step(0.15) # Second fire at ~200ms
|
||||
|
||||
if callback_count >= 2:
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
```
|
||||
|
||||
### Testing with once=True Timers
|
||||
|
||||
```python
|
||||
fired = False
|
||||
|
||||
def one_shot(timer, runtime):
|
||||
global fired
|
||||
fired = True
|
||||
print(f"One-shot timer fired! once={timer.once}")
|
||||
|
||||
# Create one-shot timer
|
||||
timer = mcrfpy.Timer("oneshot", one_shot, 100, once=True)
|
||||
|
||||
mcrfpy.step(0.15) # Should fire
|
||||
mcrfpy.step(0.15) # Should NOT fire again
|
||||
|
||||
if fired:
|
||||
print("PASS: One-shot timer worked")
|
||||
```
|
||||
|
||||
## Pattern Comparison
|
||||
|
||||
| Aspect | Timer-based (old) | step()-based (new) |
|
||||
|--------|-------------------|-------------------|
|
||||
| Execution | Async (game loop) | Sync (immediate) |
|
||||
| Timeout risk | High | None |
|
||||
| Determinism | Variable | Consistent |
|
||||
| Script flow | Callbacks | Linear |
|
||||
|
||||
## LLM Agent Integration
|
||||
|
||||
Headless mode enables AI agents to interact with McRogueFace programmatically:
|
||||
|
||||
1. **Observe**: Capture screenshots, read game state
|
||||
2. **Decide**: Process with vision models or state analysis
|
||||
3. **Act**: Send input commands, modify game state
|
||||
4. **Verify**: Check results, capture new state
|
||||
|
||||
```python
|
||||
from mcrfpy import automation
|
||||
|
||||
# Agent loop
|
||||
while not game_over:
|
||||
automation.screenshot("state.png")
|
||||
action = agent.decide("state.png")
|
||||
execute_action(action)
|
||||
mcrfpy.step(0.1) # Let action take effect
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `mcrfpy.step()`** instead of real-time waiting for all headless tests
|
||||
2. **Initialize scenes** with a brief `step(0.1)` after `activate()`
|
||||
3. **Be deterministic** - same inputs should produce same outputs
|
||||
4. **Test incrementally** - advance time in small steps to catch intermediate states
|
||||
5. **Use `sys.exit(0/1)`** for clear pass/fail signals to test runners
|
||||
6. **Multiple `step()` calls** to fire repeating timers multiple times
|
||||
|
||||
## Running the Test Suite
|
||||
|
||||
```bash
|
||||
# Quick run with pytest wrapper
|
||||
pytest tests/ -q --mcrf-timeout=5
|
||||
|
||||
# Using the original runner with xvfb
|
||||
xvfb-run -a python3 tests/run_tests.py -q
|
||||
|
||||
# Run specific test directly
|
||||
xvfb-run -a mcrogueface --headless --exec tests/unit/test_animation.py
|
||||
```
|
||||
|
||||
## Related Topics
|
||||
|
||||
- [Animation System](animation.md) - How animations work
|
||||
- [Scene API](scene-api.md) - Managing scenes
|
||||
- [Timer API](timer-api.md) - Timer details
|
||||
Loading…
Add table
Add a link
Reference in a new issue