Add Game-to-API Bridge for external client integration
Implements a general-purpose HTTP API that exposes McRogueFace games to external clients (LLMs, accessibility tools, Twitch integrations, testing harnesses). API endpoints: - GET /scene - Full scene graph with all UI elements - GET /affordances - Interactive elements with semantic labels - GET /screenshot - PNG screenshot (binary or base64) - GET /metadata - Game metadata for LLM context - GET /wait - Long-poll for state changes - POST /input - Inject clicks, keys, or affordance clicks Key features: - Automatic affordance detection from Frame+Caption+on_click patterns - Label extraction from caption text with fallback to element.name - Thread-safe scene access via mcrfpy.lock() - Fuzzy label matching for click_affordance - Full input injection via mcrfpy.automation Usage: from api import start_server; start_server(8765) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b47132b052
commit
ff46043023
12 changed files with 2391 additions and 0 deletions
1
tests/api/__init__.py
Normal file
1
tests/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# API test package
|
||||
84
tests/api/run_api_server.py
Normal file
84
tests/api/run_api_server.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Simple script to start the McRogueFace Game API server.
|
||||
|
||||
Run with: cd build && ./mcrogueface --exec ../tests/api/run_api_server.py
|
||||
|
||||
Then test with:
|
||||
curl http://localhost:8765/health
|
||||
curl http://localhost:8765/scene
|
||||
curl http://localhost:8765/affordances
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '../src/scripts')
|
||||
|
||||
import mcrfpy
|
||||
|
||||
print("Creating test scene...", flush=True)
|
||||
|
||||
# Create a simple test scene
|
||||
scene = mcrfpy.Scene("test")
|
||||
ui = scene.children
|
||||
|
||||
font = mcrfpy.Font("assets/JetbrainsMono.ttf")
|
||||
|
||||
# Title
|
||||
title = mcrfpy.Caption(text="API Test Scene", pos=(50, 20), font=font, fill_color=(255, 255, 0))
|
||||
title.font_size = 24
|
||||
ui.append(title)
|
||||
|
||||
# A clickable button
|
||||
button = mcrfpy.Frame(pos=(50, 80), size=(200, 50), fill_color=(64, 64, 128))
|
||||
button.name = "test_button"
|
||||
button.on_click = lambda pos, btn, action: print(f"Button clicked: {action}", flush=True)
|
||||
button_text = mcrfpy.Caption(text="Click Me", pos=(50, 10), font=font, fill_color=(255, 255, 255))
|
||||
button.children.append(button_text)
|
||||
ui.append(button)
|
||||
|
||||
# A second button
|
||||
button2 = mcrfpy.Frame(pos=(50, 150), size=(200, 50), fill_color=(64, 128, 64))
|
||||
button2.name = "settings_button"
|
||||
button2.on_click = lambda pos, btn, action: print(f"Settings clicked: {action}", flush=True)
|
||||
button2_text = mcrfpy.Caption(text="Settings", pos=(50, 10), font=font, fill_color=(255, 255, 255))
|
||||
button2.children.append(button2_text)
|
||||
ui.append(button2)
|
||||
|
||||
# Status text
|
||||
status = mcrfpy.Caption(text="API Server running on http://localhost:8765", pos=(50, 230), font=font, fill_color=(128, 255, 128))
|
||||
ui.append(status)
|
||||
|
||||
status2 = mcrfpy.Caption(text="Press ESC to exit", pos=(50, 260), font=font, fill_color=(200, 200, 200))
|
||||
ui.append(status2)
|
||||
|
||||
mcrfpy.current_scene = scene
|
||||
|
||||
print("Starting API server...", flush=True)
|
||||
|
||||
# Start the API server
|
||||
from api import start_server
|
||||
server = start_server(8765)
|
||||
|
||||
print("", flush=True)
|
||||
print("=" * 50, flush=True)
|
||||
print("API Server is ready!", flush=True)
|
||||
print("", flush=True)
|
||||
print("Test endpoints:", flush=True)
|
||||
print(" curl http://localhost:8765/health", flush=True)
|
||||
print(" curl http://localhost:8765/scene", flush=True)
|
||||
print(" curl http://localhost:8765/affordances", flush=True)
|
||||
print(" curl http://localhost:8765/metadata", flush=True)
|
||||
print(" curl http://localhost:8765/screenshot?format=base64 | jq -r .image", flush=True)
|
||||
print("", flush=True)
|
||||
print("Input examples:", flush=True)
|
||||
print(' curl -X POST -H "Content-Type: application/json" -d \'{"action":"key","key":"W"}\' http://localhost:8765/input', flush=True)
|
||||
print(' curl -X POST -H "Content-Type: application/json" -d \'{"action":"click","x":150,"y":100}\' http://localhost:8765/input', flush=True)
|
||||
print("=" * 50, flush=True)
|
||||
print("", flush=True)
|
||||
|
||||
# Key handler
|
||||
def on_key(key, action):
|
||||
if key == mcrfpy.Key.ESCAPE and action == mcrfpy.InputState.PRESSED:
|
||||
print("Exiting...", flush=True)
|
||||
mcrfpy.exit()
|
||||
|
||||
scene.on_key = on_key
|
||||
198
tests/api/test_api_basic.py
Normal file
198
tests/api/test_api_basic.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Basic test for the McRogueFace Game API.
|
||||
|
||||
Run with: cd build && ./mcrogueface --headless --exec ../tests/api/test_api_basic.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
import mcrfpy
|
||||
|
||||
# Create a test scene with some UI elements
|
||||
test_scene = mcrfpy.Scene("api_test")
|
||||
ui = test_scene.children
|
||||
|
||||
# Add various interactive elements
|
||||
font = mcrfpy.Font("assets/JetbrainsMono.ttf")
|
||||
|
||||
# A button-like frame with click handler
|
||||
button_frame = mcrfpy.Frame(pos=(50, 50), size=(200, 60), fill_color=(64, 64, 128))
|
||||
button_frame.name = "play_button"
|
||||
|
||||
button_label = mcrfpy.Caption(text="Play Game", pos=(20, 15), font=font, fill_color=(255, 255, 255))
|
||||
button_frame.children.append(button_label)
|
||||
|
||||
click_count = [0]
|
||||
|
||||
def on_button_click(pos, button, action):
|
||||
if str(action) == "PRESSED" or action == mcrfpy.InputState.PRESSED:
|
||||
click_count[0] += 1
|
||||
print(f"Button clicked! Count: {click_count[0]}")
|
||||
|
||||
button_frame.on_click = on_button_click
|
||||
ui.append(button_frame)
|
||||
|
||||
# A second button
|
||||
settings_frame = mcrfpy.Frame(pos=(50, 130), size=(200, 60), fill_color=(64, 128, 64))
|
||||
settings_frame.name = "settings_button"
|
||||
settings_label = mcrfpy.Caption(text="Settings", pos=(20, 15), font=font, fill_color=(255, 255, 255))
|
||||
settings_frame.children.append(settings_label)
|
||||
settings_frame.on_click = lambda pos, btn, action: print("Settings clicked")
|
||||
ui.append(settings_frame)
|
||||
|
||||
# A caption without click (for display)
|
||||
title = mcrfpy.Caption(text="API Test Scene", pos=(50, 10), font=font, fill_color=(255, 255, 0))
|
||||
title.font_size = 24
|
||||
ui.append(title)
|
||||
|
||||
# Activate scene
|
||||
mcrfpy.current_scene = test_scene
|
||||
|
||||
|
||||
def run_api_tests(timer, runtime):
|
||||
"""Run the API tests after scene is set up."""
|
||||
print("\n=== Starting API Tests ===\n")
|
||||
|
||||
base_url = "http://localhost:8765"
|
||||
|
||||
# Test 1: Health check
|
||||
print("Test 1: Health check...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/health")
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
data = json.loads(response.read())
|
||||
assert data["status"] == "ok", f"Expected 'ok', got '{data['status']}'"
|
||||
print(f" PASS: Server healthy, version {data.get('version')}")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 2: Scene introspection
|
||||
print("\nTest 2: Scene introspection...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/scene")
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
data = json.loads(response.read())
|
||||
assert data["scene_name"] == "api_test", f"Expected 'api_test', got '{data['scene_name']}'"
|
||||
assert data["element_count"] == 3, f"Expected 3 elements, got {data['element_count']}"
|
||||
print(f" PASS: Scene '{data['scene_name']}' with {data['element_count']} elements")
|
||||
print(f" Viewport: {data['viewport']}")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 3: Affordances
|
||||
print("\nTest 3: Affordance extraction...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/affordances")
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
data = json.loads(response.read())
|
||||
affordances = data["affordances"]
|
||||
assert len(affordances) >= 2, f"Expected at least 2 affordances, got {len(affordances)}"
|
||||
|
||||
# Check for our named buttons
|
||||
labels = [a.get("label") for a in affordances]
|
||||
print(f" Found affordances with labels: {labels}")
|
||||
|
||||
# Find play_button by name hint
|
||||
play_affordance = None
|
||||
for a in affordances:
|
||||
if a.get("hint") and "play_button" in a.get("hint", ""):
|
||||
play_affordance = a
|
||||
break
|
||||
if a.get("label") and "Play" in a.get("label", ""):
|
||||
play_affordance = a
|
||||
break
|
||||
|
||||
if play_affordance:
|
||||
print(f" PASS: Found play button affordance, ID={play_affordance['id']}")
|
||||
else:
|
||||
print(f" WARN: Could not find play button by name or label")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 4: Metadata
|
||||
print("\nTest 4: Metadata...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/metadata")
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
data = json.loads(response.read())
|
||||
assert "current_scene" in data, "Missing current_scene"
|
||||
print(f" PASS: Got metadata, current_scene={data['current_scene']}")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 5: Input - click
|
||||
print("\nTest 5: Input click...")
|
||||
try:
|
||||
# Click the play button
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/input",
|
||||
data=json.dumps({
|
||||
"action": "click",
|
||||
"x": 150, # Center of play button
|
||||
"y": 80
|
||||
}).encode('utf-8'),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
data = json.loads(response.read())
|
||||
assert data["success"], "Click failed"
|
||||
print(f" PASS: Click executed at ({data['x']}, {data['y']})")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 6: Input - key
|
||||
print("\nTest 6: Input key press...")
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/input",
|
||||
data=json.dumps({
|
||||
"action": "key",
|
||||
"key": "ESCAPE"
|
||||
}).encode('utf-8'),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2) as response:
|
||||
data = json.loads(response.read())
|
||||
assert data["success"], "Key press failed"
|
||||
print(f" PASS: Key '{data['key']}' pressed")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Test 7: Wait endpoint (quick check)
|
||||
print("\nTest 7: Wait endpoint...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/wait?timeout=1")
|
||||
with urllib.request.urlopen(req, timeout=3) as response:
|
||||
data = json.loads(response.read())
|
||||
assert "hash" in data, "Missing hash"
|
||||
print(f" PASS: Got scene hash: {data['hash']}")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n=== All API Tests Passed ===\n")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Start the API server
|
||||
print("Starting API server...")
|
||||
import sys
|
||||
sys.path.insert(0, '../src/scripts')
|
||||
from api import start_server
|
||||
server = start_server(8765)
|
||||
|
||||
# Give server time to start
|
||||
time.sleep(0.5)
|
||||
|
||||
# Run tests after a short delay
|
||||
test_timer = mcrfpy.Timer("api_test", run_api_tests, 500)
|
||||
211
tests/api/test_api_windowed.py
Normal file
211
tests/api/test_api_windowed.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test for the McRogueFace Game API in windowed mode.
|
||||
|
||||
Run with: cd build && ./mcrogueface --exec ../tests/api/test_api_windowed.py
|
||||
|
||||
Tests all API endpoints and verifies proper functionality.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
import mcrfpy
|
||||
|
||||
# Force flush on print
|
||||
import functools
|
||||
print = functools.partial(print, flush=True)
|
||||
|
||||
def log(msg):
|
||||
print(msg)
|
||||
|
||||
# Create a test scene with some UI elements
|
||||
test_scene = mcrfpy.Scene("api_test")
|
||||
ui = test_scene.children
|
||||
|
||||
# Add various interactive elements
|
||||
font = mcrfpy.Font("assets/JetbrainsMono.ttf")
|
||||
|
||||
# A button-like frame with click handler
|
||||
button_frame = mcrfpy.Frame(pos=(50, 50), size=(200, 60), fill_color=(64, 64, 128))
|
||||
button_frame.name = "play_button"
|
||||
|
||||
button_label = mcrfpy.Caption(text="Play Game", pos=(20, 15), font=font, fill_color=(255, 255, 255))
|
||||
button_frame.children.append(button_label)
|
||||
|
||||
click_count = [0]
|
||||
|
||||
def on_button_click(pos, button, action):
|
||||
if action == mcrfpy.InputState.PRESSED:
|
||||
click_count[0] += 1
|
||||
print(f"Button clicked! Count: {click_count[0]}")
|
||||
|
||||
button_frame.on_click = on_button_click
|
||||
ui.append(button_frame)
|
||||
|
||||
# A second button
|
||||
settings_frame = mcrfpy.Frame(pos=(50, 130), size=(200, 60), fill_color=(64, 128, 64))
|
||||
settings_frame.name = "settings_button"
|
||||
settings_label = mcrfpy.Caption(text="Settings", pos=(20, 15), font=font, fill_color=(255, 255, 255))
|
||||
settings_frame.children.append(settings_label)
|
||||
settings_frame.on_click = lambda pos, btn, action: print("Settings clicked")
|
||||
ui.append(settings_frame)
|
||||
|
||||
# A caption without click (for display)
|
||||
title = mcrfpy.Caption(text="API Test Scene", pos=(50, 10), font=font, fill_color=(255, 255, 0))
|
||||
title.font_size = 24
|
||||
ui.append(title)
|
||||
|
||||
# Status caption to show test progress
|
||||
status = mcrfpy.Caption(text="Starting API tests...", pos=(50, 220), font=font, fill_color=(255, 255, 255))
|
||||
ui.append(status)
|
||||
|
||||
# Activate scene
|
||||
mcrfpy.current_scene = test_scene
|
||||
|
||||
# Start the API server
|
||||
log("Starting API server...")
|
||||
sys.path.insert(0, '../src/scripts')
|
||||
from api import start_server
|
||||
server = start_server(8765)
|
||||
print("API server started on http://localhost:8765")
|
||||
|
||||
|
||||
def run_api_tests(timer, runtime):
|
||||
"""Run the API tests after scene is set up."""
|
||||
print("\n=== Starting API Tests ===\n")
|
||||
status.text = "Running tests..."
|
||||
|
||||
base_url = "http://localhost:8765"
|
||||
all_passed = True
|
||||
|
||||
# Test 1: Health check
|
||||
print("Test 1: Health check...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/health")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read())
|
||||
assert data["status"] == "ok", f"Expected 'ok', got '{data['status']}'"
|
||||
print(f" PASS: Server healthy, version {data.get('version')}")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
all_passed = False
|
||||
|
||||
# Test 2: Scene introspection
|
||||
print("\nTest 2: Scene introspection...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/scene")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read())
|
||||
assert data["scene_name"] == "api_test", f"Expected 'api_test', got '{data['scene_name']}'"
|
||||
print(f" PASS: Scene '{data['scene_name']}' with {data['element_count']} elements")
|
||||
print(f" Viewport: {data['viewport']}")
|
||||
for elem in data.get('elements', []):
|
||||
print(f" - {elem['type']}: name='{elem.get('name', '')}' interactive={elem.get('interactive')}")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
all_passed = False
|
||||
|
||||
# Test 3: Affordances
|
||||
print("\nTest 3: Affordance extraction...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/affordances")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read())
|
||||
affordances = data["affordances"]
|
||||
print(f" Found {len(affordances)} affordances:")
|
||||
for aff in affordances:
|
||||
print(f" ID={aff['id']} type={aff['type']} label='{aff.get('label')}' actions={aff.get('actions')}")
|
||||
if len(affordances) >= 2:
|
||||
print(f" PASS")
|
||||
else:
|
||||
print(f" WARN: Expected at least 2 affordances")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
all_passed = False
|
||||
|
||||
# Test 4: Metadata
|
||||
print("\nTest 4: Metadata...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/metadata")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read())
|
||||
print(f" Game: {data.get('game_name')}")
|
||||
print(f" Current scene: {data.get('current_scene')}")
|
||||
print(f" PASS")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
all_passed = False
|
||||
|
||||
# Test 5: Input - click affordance by label
|
||||
print("\nTest 5: Click affordance by label...")
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/input",
|
||||
data=json.dumps({
|
||||
"action": "click_affordance",
|
||||
"label": "Play Game" # Matches the button text
|
||||
}).encode('utf-8'),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read())
|
||||
if data.get("success"):
|
||||
print(f" PASS: Clicked affordance '{data.get('affordance_label')}' at ({data.get('x'):.0f}, {data.get('y'):.0f})")
|
||||
else:
|
||||
print(f" FAIL: {data}")
|
||||
all_passed = False
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
all_passed = False
|
||||
|
||||
# Test 6: Screenshot (base64)
|
||||
print("\nTest 6: Screenshot...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/screenshot?format=base64")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
data = json.loads(response.read())
|
||||
if "image" in data and data["image"].startswith("data:image/png;base64,"):
|
||||
img_size = len(data["image"])
|
||||
print(f" PASS: Got base64 image ({img_size} chars)")
|
||||
else:
|
||||
print(f" FAIL: Invalid image data")
|
||||
all_passed = False
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
all_passed = False
|
||||
|
||||
# Test 7: Wait endpoint (quick check)
|
||||
print("\nTest 7: Wait endpoint...")
|
||||
try:
|
||||
req = urllib.request.Request(f"{base_url}/wait?timeout=1")
|
||||
with urllib.request.urlopen(req, timeout=3) as response:
|
||||
data = json.loads(response.read())
|
||||
print(f" PASS: Scene hash: {data.get('hash')}")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}")
|
||||
all_passed = False
|
||||
|
||||
if all_passed:
|
||||
print("\n=== All API Tests Passed ===\n")
|
||||
status.text = "All tests PASSED!"
|
||||
status.fill_color = (0, 255, 0)
|
||||
else:
|
||||
print("\n=== Some Tests Failed ===\n")
|
||||
status.text = "Some tests FAILED"
|
||||
status.fill_color = (255, 0, 0)
|
||||
|
||||
print("Press ESC to exit, or interact with the scene...")
|
||||
print(f"API still running at {base_url}")
|
||||
|
||||
|
||||
# Add key handler to exit
|
||||
def on_key(key, action):
|
||||
if key == mcrfpy.Key.ESCAPE and action == mcrfpy.InputState.PRESSED:
|
||||
mcrfpy.exit()
|
||||
|
||||
test_scene.on_key = on_key
|
||||
|
||||
# Run tests after a short delay to let rendering settle
|
||||
test_timer = mcrfpy.Timer("api_test", run_api_tests, 1000)
|
||||
Loading…
Add table
Add a link
Reference in a new issue