Added Glitch Room Source code to Gitty
This commit is contained in:
commit
1ac9eb5644
505 files changed
+28504
No files matched your search
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,22 @@
|
||||
extends SceneTree
|
||||
|
||||
func _initialize() -> void:
|
||||
var cabinet := CabinetProfile.new()
|
||||
var tempest: GameDefinition = load("res://data/games/tempest.tres")
|
||||
cabinet.capabilities.assign([&"spinner", &"button_1", &"start_button"])
|
||||
assert(not tempest.can_run_on(cabinet), "Superzapper requires a second button")
|
||||
cabinet.capabilities.append(&"button_2")
|
||||
assert(tempest.can_run_on(cabinet))
|
||||
cabinet.capabilities.erase(&"spinner")
|
||||
assert(not tempest.can_run_on(cabinet))
|
||||
cabinet.capabilities.append(&"trackball")
|
||||
assert(tempest.can_run_on(cabinet))
|
||||
var robotron: GameDefinition = load("res://data/games/robotron.tres")
|
||||
cabinet.capabilities.assign([&"dual_joystick_8way", &"start_button"])
|
||||
assert(robotron.can_run_on(cabinet))
|
||||
cabinet.capabilities.assign([&"joystick_8way", &"button_1", &"button_2", &"button_3", &"button_4", &"start_button"])
|
||||
assert(robotron.can_run_on(cabinet))
|
||||
cabinet.capabilities.erase(&"button_4")
|
||||
assert(not robotron.can_run_on(cabinet))
|
||||
print("PASS: cabinet control alternatives and common requirements")
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://c28svvvfj8ndh
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Start real HTTP services with fake GPIO, then a real headless Godot cabinet."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import HTTPServer
|
||||
from test_room import ROOT, server
|
||||
sys.path.insert(0, str(ROOT / "tests/gpio"))
|
||||
from test_bridge import bridge, FakePin
|
||||
|
||||
OP = "test-operator-token-1234"
|
||||
CAB = "test-cabinet-token-5678"
|
||||
|
||||
def post(path, data=None, token=OP):
|
||||
request = urllib.request.Request("http://127.0.0.1:8766" + path, data=json.dumps(data or {}).encode(),
|
||||
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(request, timeout=3) as response: return json.load(response)
|
||||
|
||||
def wait_for(predicate):
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
state = post("/operator/state")
|
||||
if predicate(state): return state
|
||||
time.sleep(.1)
|
||||
raise AssertionError("Timed out waiting for room state: " + str(state))
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
room = server.Fleet(Path(temp) / "room.sqlite3", json.loads((ROOT / "tools/room_controller/catalog.json").read_text()))
|
||||
gpio = bridge.Outputs(FakePin, "cabinet_01_gpio", [17], ledger=Path(temp) / "gpio.sqlite3")
|
||||
services = [HTTPServer(("127.0.0.1", 8766), server.handler_for(room, OP, {"cabinet_01": CAB, "cabinet_02": CAB + "-two"})),
|
||||
HTTPServer(("127.0.0.1", 8765), bridge.handler_for(gpio))]
|
||||
threads = [threading.Thread(target=s.serve_forever, daemon=True) for s in services]
|
||||
for thread in threads: thread.start()
|
||||
process = None
|
||||
second = None
|
||||
try:
|
||||
try:
|
||||
post("/operator/state", token=CAB)
|
||||
raise AssertionError("Cabinet token accessed operator API")
|
||||
except urllib.error.HTTPError as exc:
|
||||
assert exc.code == 401
|
||||
env = dict(os.environ, ARCADE_ROOM_URL="http://127.0.0.1:8766", ARCADE_CABINET_TOKEN=CAB)
|
||||
process = subprocess.Popen([sys.argv[1], "--headless", "--path", str(ROOT),
|
||||
"--log-file", str(ROOT / ".godot/managed_integration.log"),
|
||||
"--script", "tests/room_network/managed_agent.gd", "--", "--gpio=real"], env=env)
|
||||
second = subprocess.Popen([sys.argv[1], "--headless", "--path", str(ROOT),
|
||||
"--log-file", str(ROOT / ".godot/managed_second.log"),
|
||||
"--script", "tests/room_network/managed_agent.gd", "--", "--cabinet=cabinet_02", "--test-no-reward"],
|
||||
env=dict(env, ARCADE_CABINET_TOKEN=CAB + "-two"))
|
||||
wait_for(lambda s: s["online"] and s["cabinets"]["cabinet_02"]["online"])
|
||||
state = post("/operator/command", {"action": "prepare_scenario", "scenario": "proof_of_concept"})
|
||||
post("/operator/command", {"action": "prepare", "cabinet": "cabinet_02", "game": "galaga"})
|
||||
assert state["phase"] == "preparing"
|
||||
wait_for(lambda s: s["phase"] == "ready" and s["cabinets"]["cabinet_02"]["phase"] == "ready")
|
||||
post("/operator/command", {"action": "start"})
|
||||
post("/operator/command", {"action": "start", "cabinet": "cabinet_02"})
|
||||
final = wait_for(lambda s: s["reward"].startswith("GPIO accepted"))
|
||||
wait_for(lambda s: s["cabinets"]["cabinet_02"]["phase"] == "won")
|
||||
assert gpio.pins[17]["device"].activations == 1
|
||||
assert sum(c["type"] == "reward" for c in final["commands"]) == 1
|
||||
assert process.wait(timeout=8) == 0
|
||||
assert second.wait(timeout=8) == 0
|
||||
assert not any(c["type"] == "reward" for c in post("/operator/state")["cabinets"]["cabinet_02"]["commands"])
|
||||
print("PASS: two real Godot cabinets -> Pac-Man scenario + Galaga manual -> ready/start/win -> exactly one mapped GPIO acceptance")
|
||||
finally:
|
||||
if process and process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
if second and second.poll() is None:
|
||||
second.terminate()
|
||||
second.wait(timeout=5)
|
||||
for service in services:
|
||||
service.shutdown()
|
||||
service.server_close()
|
||||
for thread in threads: thread.join()
|
||||
gpio.close()
|
||||
for cabinet in room.rooms.values(): cabinet.db.close()
|
||||
@@ -0,0 +1,17 @@
|
||||
extends SceneTree
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
func run() -> void:
|
||||
var agent = load("res://core/network/managed_cabinet.gd").new()
|
||||
root.add_child(agent)
|
||||
# Only the test harness forces a win; production has no remote force-win endpoint.
|
||||
for i in range(140):
|
||||
await create_timer(.1).timeout
|
||||
if agent.phase == "playing":
|
||||
agent.game.win_game()
|
||||
if agent.phase == "won" and ("--test-no-reward" in OS.get_cmdline_user_args() or agent.outcomes.values().any(func(r): return str(r.detail).contains("GPIO bridge accepted"))):
|
||||
await create_timer(1.0).timeout
|
||||
quit()
|
||||
return
|
||||
push_error("Managed cabinet integration timed out")
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://da2ysnuinbmh6
|
||||
@@ -0,0 +1,145 @@
|
||||
import json
|
||||
import tempfile
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import HTTPServer
|
||||
from test_room import ROOT, server
|
||||
import unittest
|
||||
|
||||
|
||||
class FleetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = 100
|
||||
self.catalog = json.loads((ROOT / 'tools/room_controller/catalog.json').read_text())
|
||||
self.fleet = server.Fleet(':memory:', self.catalog, lambda: self.now)
|
||||
for cabinet in self.fleet.rooms: self.poll(cabinet)
|
||||
|
||||
def tearDown(self):
|
||||
for room in self.fleet.rooms.values(): room.db.close()
|
||||
|
||||
def poll(self, cabinet, reports=None):
|
||||
return self.fleet.poll(dict(cabinet=cabinet, protocol=2, boot=cabinet, reports=reports or []))
|
||||
|
||||
def ack(self, cabinet):
|
||||
commands = self.poll(cabinet)['commands']
|
||||
self.poll(cabinet, [dict(kind='ack', id=c['id'], session=c['session'], ok=True) for c in commands])
|
||||
|
||||
def test_scenario_routes_games_and_events_to_correct_cabinets(self):
|
||||
self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'})
|
||||
self.assertEqual(self.poll('cabinet_01')['commands'][0]['game'], 'pacman')
|
||||
self.assertEqual(self.poll('cabinet_02')['commands'][0]['game'], 'galaga')
|
||||
self.assertFalse(self.poll('cabinet_03')['commands'])
|
||||
self.ack('cabinet_01')
|
||||
self.assertEqual(self.fleet.rooms['cabinet_02'].state['phase'], 'preparing')
|
||||
self.ack('cabinet_02')
|
||||
self.assertEqual(self.poll('cabinet_02')['commands'][0]['action_id'], 'glitch_code')
|
||||
self.assertFalse(self.poll('cabinet_01')['commands'])
|
||||
|
||||
def test_offline_assignment_prevents_any_scenario_dispatch(self):
|
||||
self.now += 5
|
||||
self.poll('cabinet_01')
|
||||
with self.assertRaisesRegex(ValueError, 'offline'):
|
||||
self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'})
|
||||
self.assertFalse(self.fleet.rooms['cabinet_01'].state['commands'])
|
||||
|
||||
def test_manual_assignment_does_not_inherit_reward(self):
|
||||
self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'galaga'})
|
||||
self.ack('cabinet_01')
|
||||
room = self.fleet.rooms['cabinet_01']
|
||||
self.poll('cabinet_01', [dict(kind='event', id=event, event=event, session=room.state['session']) for event in ['started', 'won']])
|
||||
self.assertEqual(room.state['phase'], 'won')
|
||||
self.assertFalse(self.poll('cabinet_01')['commands'])
|
||||
|
||||
def test_compatibility_rejects_missing_controls_and_accepts_alternatives(self):
|
||||
with self.assertRaisesRegex(ValueError, 'hardware'):
|
||||
self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'missile_command'})
|
||||
cabinet = self.catalog['cabinets']['cabinet_03']
|
||||
self.assertFalse(self.fleet.compatible('cabinet_03', 'tempest'))
|
||||
cabinet['capabilities'].append('button_2')
|
||||
self.assertTrue(self.fleet.compatible('cabinet_03', 'tempest'))
|
||||
cabinet['capabilities'].remove('spinner')
|
||||
self.assertFalse(self.fleet.compatible('cabinet_03', 'tempest'))
|
||||
cabinet['capabilities'].append('trackball')
|
||||
self.assertTrue(self.fleet.compatible('cabinet_03', 'tempest'))
|
||||
|
||||
def test_old_export_cannot_silently_load_wrong_game(self):
|
||||
with self.assertRaisesRegex(ValueError, 'protocol 2'):
|
||||
self.fleet.poll(dict(cabinet='cabinet_01', protocol=1, boot='old'))
|
||||
|
||||
def test_invalid_mapping_does_not_dispatch(self):
|
||||
self.catalog['scenarios']['two_cabinet_test']['assignments'][1]['mappings']['unknown_event'] = ['coin_release']
|
||||
with self.assertRaisesRegex(ValueError, 'not implemented'):
|
||||
self.fleet.operator('prepare_scenario', {'scenario': 'two_cabinet_test'})
|
||||
self.assertFalse(self.poll('cabinet_01')['commands'])
|
||||
|
||||
def test_output_timeout_does_not_report_a_reward(self):
|
||||
room = self.fleet.rooms['cabinet_01']
|
||||
room.command('output')
|
||||
self.now += 16
|
||||
room.expire()
|
||||
self.assertEqual(room.state['reward'], 'not requested')
|
||||
self.assertIn('unknown', room.state['last_output'])
|
||||
|
||||
def test_one_accepted_output_cannot_hide_another_failed_reward(self):
|
||||
room = self.fleet.rooms['cabinet_01']
|
||||
first, second = room.command('reward'), room.command('reward')
|
||||
room.ack(dict(id=first['id'], ok=False))
|
||||
room.ack(dict(id=second['id'], ok=True))
|
||||
self.assertIn('failed', room.state['reward'])
|
||||
|
||||
def test_migration_and_independent_persistence(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = str(Path(temp) / 'migration.db')
|
||||
legacy = sqlite3.connect(path)
|
||||
legacy.execute('CREATE TABLE room (id INTEGER PRIMARY KEY, value TEXT NOT NULL)')
|
||||
old = dict(self.fleet.rooms['cabinet_01'].state, session='old-session', phase='won')
|
||||
legacy.execute('INSERT INTO room VALUES (1,?)', (json.dumps(old),))
|
||||
legacy.commit()
|
||||
legacy.close()
|
||||
fleet = server.Fleet(path, self.catalog)
|
||||
try:
|
||||
self.assertEqual(fleet.rooms['cabinet_01'].state['session'], 'old-session')
|
||||
self.assertEqual(fleet.rooms['cabinet_02'].state['session'], '')
|
||||
fleet.rooms['cabinet_02'].state['game'] = 'galaga'
|
||||
fleet.rooms['cabinet_02'].save()
|
||||
finally:
|
||||
for room in fleet.rooms.values(): room.db.close()
|
||||
restored = server.Fleet(path, self.catalog)
|
||||
try:
|
||||
self.assertEqual(restored.rooms['cabinet_01'].state['phase'], 'won')
|
||||
self.assertEqual(restored.rooms['cabinet_02'].state['game'], 'galaga')
|
||||
self.assertFalse(restored.rooms['cabinet_01'].online())
|
||||
finally:
|
||||
for room in restored.rooms.values(): room.db.close()
|
||||
|
||||
def test_http_browser_and_identity_bound_credentials(self):
|
||||
service = HTTPServer(('127.0.0.1', 0), server.handler_for(self.fleet, 'operator-secret', {'cabinet_01': 'cabinet-one', 'cabinet_02': 'cabinet-two'}))
|
||||
thread = threading.Thread(target=service.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = 'http://127.0.0.1:' + str(service.server_port)
|
||||
try:
|
||||
with urllib.request.urlopen(base) as response:
|
||||
self.assertIn(b'Room control', response.read())
|
||||
def request(path, token, data, origin=base):
|
||||
req = urllib.request.Request(base + path, data=json.dumps(data).encode(), headers={'Authorization': 'Bearer '+token, 'Origin': origin})
|
||||
return urllib.request.urlopen(req)
|
||||
with request('/operator/state', 'operator-secret', {}) as response:
|
||||
self.assertEqual(len(json.load(response)['cabinets']), 3)
|
||||
for path, token, data, code, origin in [
|
||||
('/operator/state', 'cabinet-one', {}, 401, base),
|
||||
('/cabinet/poll', 'cabinet-one', {'cabinet':'cabinet_02','protocol':2,'boot':'x'}, 401, base),
|
||||
('/operator/state', 'operator-secret', {}, 400, 'http://unrelated.example'),
|
||||
]:
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
request(path, token, data, origin)
|
||||
self.assertEqual(caught.exception.code, code)
|
||||
finally:
|
||||
service.shutdown()
|
||||
service.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,80 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location("room_server", ROOT / "tools/room_controller/server.py")
|
||||
server = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(server)
|
||||
|
||||
|
||||
class RoomTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.now = 100
|
||||
self.room = server.Room(":memory:", lambda: self.now)
|
||||
self.poll()
|
||||
|
||||
def tearDown(self): self.room.db.close()
|
||||
|
||||
def poll(self, reports=None, boot="boot1"):
|
||||
return self.room.poll(dict(cabinet="cabinet_01", protocol=1, boot=boot, reports=reports or []))
|
||||
|
||||
def ready(self):
|
||||
self.room.operator("prepare")
|
||||
command = self.poll()["commands"][0]
|
||||
self.assertEqual(self.room.state["phase"], "preparing")
|
||||
self.poll([dict(kind="ack", id=command["id"], session=command["session"], ok=True)])
|
||||
self.assertEqual(self.room.state["phase"], "ready")
|
||||
|
||||
def event(self, name, event_id):
|
||||
return dict(kind="event", event=name, id=event_id, session=self.room.state["session"])
|
||||
|
||||
def test_ready_then_win_queues_one_reward(self):
|
||||
self.ready()
|
||||
self.poll([self.event("started", "1"), self.event("won", "2")])
|
||||
reward = self.poll()["commands"]
|
||||
self.assertEqual(len(reward), 1)
|
||||
self.assertEqual(reward[0]["type"], "reward")
|
||||
self.poll([self.event("won", "2"), self.event("won", "3")])
|
||||
self.assertEqual(len(self.poll()["commands"]), 1)
|
||||
|
||||
def test_stale_session_cannot_reward_after_reset(self):
|
||||
self.ready()
|
||||
self.poll([self.event("started", "1")])
|
||||
old_win = self.event("won", "2")
|
||||
self.room.operator("reset")
|
||||
self.poll([old_win])
|
||||
self.assertEqual(self.room.state["reward"], "not requested")
|
||||
|
||||
def test_offline_and_expiry(self):
|
||||
self.now += 5
|
||||
self.assertFalse(self.room.snapshot()["online"])
|
||||
with self.assertRaises(ValueError): self.room.operator("prepare")
|
||||
self.poll()
|
||||
self.room.operator("prepare")
|
||||
self.now += 16
|
||||
self.assertEqual(self.poll()["commands"], [])
|
||||
self.assertEqual(self.room.state["phase"], "needs_prepare")
|
||||
|
||||
def test_new_agent_boot_requires_prepare(self):
|
||||
self.ready()
|
||||
self.poll(boot="boot2")
|
||||
self.assertEqual(self.room.state["session"], "")
|
||||
self.assertEqual(self.room.state["phase"], "needs_prepare")
|
||||
|
||||
def test_persistence(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp) / "room.db"
|
||||
room = server.Room(path, lambda: self.now)
|
||||
room.poll(dict(cabinet="cabinet_01", protocol=1, boot="a"))
|
||||
room.operator("prepare")
|
||||
session = room.state["session"]
|
||||
room.db.close()
|
||||
restored = server.Room(path, lambda: self.now)
|
||||
self.assertEqual(restored.state["session"], session)
|
||||
self.assertFalse(restored.online())
|
||||
restored.db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
Reference in new issue
Block a user