Expand Glitch operations hub, cabinet authoring, deployment, and RFID support

Operator hub
- Introduce Escape Gallery branding and Glitch: the Dark Machine room views
- Add cabinet illustrations, Pac-Man marquee/gameplay artwork, and waiting states
- Add device feedback panels with RFID readings, input/output states, and stale status
- Add logged operator hardware interventions with request deduplication

Room and cabinet authoring
- Add a visual custom-PCB connection editor with per-cabinet device inventory
- Support device IDs, bindings, wiring notes, input polarity, and validation
- Add physical-room label/dimension toggles and editable doorway annotations
- Preserve doorway changes through undo, save, and revert
- Expand cabinet profiles, room layout, and Glitch scenario resources

Scenarios and gameplay
- Add per-assignment game configuration and runtime feature validation
- Add timed test wins and Frogger individual-home objectives
- Add the three-cabinet timed/score/Frogger test scenario
- Add Cabinet 1 Pac-Man RFID credits, credit sound, and CREDIT 0/1 display
- Require physical Start after an accepted scan and prevent duplicate credits

Pi hardware and deployment
- Add GPIO feedback sampling and a separate multiplexed RFID helper
- Validate reader pin ownership and input/output conflicts
- Add SSH cabinet startup and Pi desktop-session service support
- Add master packaging, site setup, SSH enrollment, and staged cabinet releases
- Verify deployment manifests and support explicit activation/status/log commands

Documentation and verification
- Consolidate the root README around the current architecture and setup workflow
- Add the complete master/Pi/RFID export and deployment walkthrough
- Document network isolation, credentials, card identification, and rollback
- Add PCB, room-editor, feedback, credit, deployment, and controller tests
- Isolate protocol tests from designer-edited cabinet configurations

Known limitations
- RFID helper compilation and physical Pi commissioning remain unverified
- Fleet provisioning does not yet configure RFID-only installations completely
- Broader Pac-Man tests retain a ghost-capture assertion failure
- Staged exports must be rebuilt and checked before deploying runtime 2.3
This commit is contained in:
Jeff Luke committed 2026-10-17 14:53:54 -07:00
1 parent 705ec31e0b
commit c1b2195834
90 files changed
+4969 -456

No files matched your search

+440
View File
@@ -0,0 +1,440 @@
{
"cabinets": {
"cabinet_01": {
"actions": [
"coin_release",
"start_light"
],
"capabilities": [
"joystick_4way",
"button_1",
"start_button",
"joystick_horizontal",
"button_2",
"button_3",
"button_4",
"button_5",
"button_6",
"select_button",
"trackball",
"joystick_8way",
"joystick_dual"
],
"name": "Master Cabinet",
"position": [
-5.94849967956543,
0.147500038146973
],
"devices": [
{
"id": "cab1_btn_red",
"kind": "input",
"name": "Red Button",
"port": "J17",
"pull_up": true
},
{
"id": "cab1_btn_grn",
"kind": "input",
"name": "Green Button",
"port": "J18",
"pull_up": true
},
{
"id": "cab1_btn_blue",
"kind": "input",
"name": "Blue Button",
"port": "J19",
"pull_up": true
},
{
"id": "cab1_btn_ylw",
"kind": "input",
"name": "Yellow Btn",
"port": "J20",
"pull_up": true
},
{
"id": "cab1_rfid2",
"kind": "rfid",
"name": "RFID Reader 2",
"port": "J2",
"pull_up": true
},
{
"id": "cab1_rfid1",
"kind": "rfid",
"name": "RFID Reader 1",
"port": "J1",
"pull_up": true
}
]
},
"cabinet_02": {
"actions": [
"coin_release",
"start_light",
"glitch_code"
],
"capabilities": [
"joystick_4way",
"button_1",
"start_button",
"joystick_horizontal"
],
"name": "Cabinet 02",
"position": [
-3.51539182662964,
0.142745211720467
]
},
"cabinet_03": {
"actions": [
"coin_release"
],
"capabilities": [
"spinner",
"button_1",
"start_button",
"joystick_4way"
],
"name": "Cabinet 03",
"position": null
}
},
"games": {
"frogger": {
"alternatives": [],
"name": "Frogger",
"requirements": [
"joystick_4way",
"start_button"
]
},
"galaga": {
"alternatives": [],
"name": "Galaga",
"requirements": [
"joystick_horizontal",
"button_1",
"start_button"
]
},
"missile_command": {
"alternatives": [],
"name": "Missile Command",
"requirements": [
"trackball",
"button_1",
"button_2",
"button_3",
"start_button"
]
},
"pacman": {
"alternatives": [],
"name": "Pac-Man",
"requirements": [
"joystick_4way",
"start_button"
]
},
"robotron": {
"alternatives": [
[
"dual_joystick_8way"
],
[
"joystick_8way",
"button_1",
"button_2",
"button_3",
"button_4"
]
],
"name": "Robotron 2084",
"requirements": [
"start_button"
]
},
"space_invaders": {
"alternatives": [],
"name": "Space Invaders",
"requirements": [
"joystick_horizontal",
"button_1",
"start_button"
]
},
"tempest": {
"alternatives": [
[
"joystick_horizontal"
],
[
"spinner"
],
[
"trackball"
]
],
"name": "Tempest",
"requirements": [
"button_1",
"button_2",
"start_button"
]
}
},
"layout": {
"boundary": [
[
-7.31519985198975,
-0.609600007534027
],
[
-2.13359999656677,
-0.609600007534027
],
[
-2.13359999656677,
7.61999988555908
],
[
-7.31519985198975,
7.61999988555908
]
],
"devices": [
{
"id": "coin_release_a",
"name": "Coin Release A",
"position": [
-6.0149998664856,
0.555000007152557
]
},
{
"id": "coin_release_b",
"name": "Coin Release B",
"position": [
-3.57996416091919,
0.568386673927307
]
},
{
"id": "light_a",
"name": "Light A",
"position": [
-6.46999979019165,
0.560000002384186
]
},
{
"id": "light_b",
"name": "Light B",
"position": [
-4.0322380065918,
0.576531648635864
]
},
{
"id": "glitch_code",
"name": "Glitch Code",
"position": [
-2.75500297546387,
1.51234555244446
]
}
],
"walls": [
[
[
-7.01039981842041,
-0.304800003767014
],
[
-2.43840003013611,
-0.304800003767014
]
],
[
[
-2.43840003013611,
-0.304800003767014
],
[
-2.43840003013611,
7.31519985198975
]
],
[
[
-2.43840003013611,
7.31519985198975
],
[
-7.01039981842041,
7.31519985198975
]
],
[
[
-7.01039981842041,
7.31519985198975
],
[
-7.01039981842041,
-0.304800003767014
]
]
]
},
"scenarios": {
"cabinet_chain_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "cabinet_01",
"cabinet": "cabinet_02",
"game": "galaga",
"mappings": {},
"variant": "standard"
}
],
"name": "Cabinet 1 -> Cabinet 2"
},
"galaga_proof_of_concept": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "galaga",
"mappings": {
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Galaga Proof of Concept"
},
"proof_of_concept": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Proof of Concept"
},
"two_cabinet_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"mappings": {
"game_started": [
"start_light"
],
"game_won": [
"coin_release"
]
},
"variant": "standard"
},
{
"activate_after_win": "",
"cabinet": "cabinet_02",
"game": "galaga",
"mappings": {
"game_activated": [
"glitch_code"
],
"game_started": [
"start_light"
],
"game_won": [
"coin_release"
]
},
"variant": "standard"
}
],
"name": "Two Cabinet Test"
},
"three_cabinet_objective_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"game_config": {
"boards_to_win": 0,
"score_to_win": 0,
"test_auto_win_seconds": 5.0
},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "cabinet_01",
"cabinet": "cabinet_02",
"game": "galaga",
"game_config": {
"score_to_win": 500,
"stages_to_win": 0
},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "cabinet_02",
"cabinet": "cabinet_03",
"game": "frogger",
"game_config": {
"boards_to_win": 0,
"homes_to_win": 1
},
"mappings": {},
"variant": "standard"
}
],
"name": "3-Cabinet Test: 5 seconds / 500 points / 1 frog"
},
"pacman_rfid_credit_test": {
"assignments": [
{
"activate_after_win": "",
"cabinet": "cabinet_01",
"game": "pacman",
"game_config": {
"boards_to_win": 1,
"rfid_allowed_uids": [],
"rfid_credit_device": "cab1_rfid1",
"score_to_win": 0
},
"mappings": {},
"variant": "standard"
}
],
"name": "Cabinet 1: Pac-Man RFID credit"
}
},
"version": 1
}
@@ -0,0 +1,52 @@
extends SceneTree
func _initialize() -> void:
call_deferred("run")
func run() -> void:
var scenario: ScenarioDefinition = load("res://data/scenarios/three_cabinet_objective_test.tres")
var edit := ScenarioEditSession.new()
assert(edit.begin(scenario))
assert(edit.working_scenario.assignments[1].game_config.score_to_win == 500)
edit.working_scenario.assignments[1].game_config.score_to_win = 600
edit.mark_dirty()
assert(edit.is_dirty)
assert(scenario.assignments[1].game_config.score_to_win == 500, "Editing must not mutate source")
var frogger = load("res://games/frogger/Frogger.tscn").instantiate()
root.add_child(frogger)
frogger.configure({})
frogger.start_game()
frogger.homes[2] = true
frogger.phase = "home"
frogger.phase_time = 0.0
frogger._physics_process(.01)
assert(frogger.is_running, "Default Frogger must not win on one home")
frogger.reset_game()
frogger.configure({"homes_to_win": 1, "boards_to_win": 0})
frogger.start_game()
frogger.homes[2] = true
frogger.phase = "home"
frogger.phase_time = 0.0
frogger._physics_process(.01)
assert(not frogger.is_running and frogger.phase == "won")
frogger.queue_free()
var agent = load("res://core/network/managed_cabinet.gd").new()
# Keep this isolated agent outside the tree: no network polling.
var game := ArcadeGame.new()
agent.add_child(game)
agent.game = game
game.configure({"test_auto_win_seconds": 5.0})
game.start_game()
agent.event("started")
agent._process(4.0)
assert(game.is_running)
agent.unload()
var replacement := ArcadeGame.new()
agent.add_child(replacement)
agent.game = replacement
replacement.start_game()
agent.event("started")
agent._process(20.0)
assert(replacement.is_running, "Old timer must not affect the next game")
agent.free()
await process_frame
print("PASS: default Frogger, configured home objective, editor copy isolation, and timer cancellation")
quit()
@@ -0,0 +1 @@
uid://di2ndju42rh4t
@@ -0,0 +1,50 @@
"""Three real game clients; no GPIO, no live deployment database."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request
from http.server import HTTPServer
from test_room import ROOT, server
with tempfile.TemporaryDirectory() as temp:
fleet=server.Fleet(Path(temp)/'chain.db',json.loads((ROOT/'tools/room_controller/catalog.json').read_text()))
service=HTTPServer(('127.0.0.1',0),server.handler_for(fleet,'test-operator-token',{'cabinet_01':'test-token-one-123','cabinet_02':'test-token-two-123','cabinet_03':'test-token-three-123'}))
thread=threading.Thread(target=service.serve_forever,daemon=True)
thread.start()
url='http://127.0.0.1:'+str(service.server_port)
processes=[]
def post(path,data={}):
request=urllib.request.Request(url+path,data=json.dumps(data).encode(),headers={'Authorization':'Bearer test-operator-token'})
with urllib.request.urlopen(request,timeout=3) as response: return json.load(response)
def wait(predicate):
deadline=time.monotonic()+30
while time.monotonic()<deadline:
state=post('/operator/state')
if predicate(state['cabinets']): return state
time.sleep(.1)
raise AssertionError('Timed out waiting for chain state')
try:
for cabinet,token in [('cabinet_01','test-token-one-123'),('cabinet_02','test-token-two-123'),('cabinet_03','test-token-three-123')]:
processes.append(subprocess.Popen([sys.argv[1],'--headless','--path',str(ROOT),'--log-file',str(Path(temp)/(cabinet+'.log')),'--script','tests/room_network/objective_agent.gd','--','--cabinet='+cabinet,'--test-no-reward'],env=dict(os.environ,ARCADE_ROOM_URL=url,ARCADE_CABINET_TOKEN=token)))
wait(lambda c:all(c[k]['online'] for k in ['cabinet_01','cabinet_02','cabinet_03']))
post('/operator/command',{'action':'prepare_scenario','scenario':'three_cabinet_objective_test'})
wait(lambda c:c['cabinet_01']['phase']=='ready' and c['cabinet_02']['phase']=='waiting' and c['cabinet_03']['phase']=='waiting')
post('/operator/command',{'action':'start','cabinet':'cabinet_01'})
state=wait(lambda c:all(c[k]['phase']=='won' for k in ['cabinet_01','cabinet_02','cabinet_03']))
commands=state['cabinets']['cabinet_02']['commands']
assert [c['type'] for c in commands]==['reset','prepare','start']
assert all(c['status']=='accepted' for c in commands)
assert [c['type'] for c in state['cabinets']['cabinet_03']['commands']]==['reset','prepare','start']
for process in processes: assert process.wait(timeout=8)==0
print('PASS: actual five-second auto-win -> 499/500 score boundary -> one Frogger home -> all three wins')
finally:
for process in processes:
if process.poll() is None:
process.terminate(); process.wait(timeout=5)
service.shutdown(); service.server_close(); thread.join()
for room in fleet.rooms.values(): room.db.close()
+36
View File
@@ -0,0 +1,36 @@
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)
var injected := false
var started_at := -1
for i in range(350):
await create_timer(.1).timeout
if agent.phase == "playing" and started_at < 0: started_at = Time.get_ticks_msec()
if agent.phase == "playing" and not injected:
if agent.cabinet_id == "cabinet_02":
injected = true
agent.game._add_score(499)
assert(agent.game.is_running and agent.game.state != "victory", "499 must not win")
await create_timer(.3).timeout
assert(agent.phase == "playing")
agent.game._add_score(1)
elif agent.cabinet_id == "cabinet_03" and agent.game.phase == "play":
injected = true
assert(agent.game.homes.count(true) == 0)
agent.game.frog = Vector2(112,40)
agent.game.hop_left = 0
agent.game.step_world(0.0)
assert(agent.game.homes.count(true) == 1)
if agent.phase == "won":
if agent.cabinet_id == "cabinet_01":
assert(Time.get_ticks_msec()-started_at >= 4800, "Timer must run for five seconds")
elif agent.cabinet_id == "cabinet_02": assert(agent.game.score == 500)
elif agent.cabinet_id == "cabinet_03": assert(agent.game.homes.count(true) == 1)
await create_timer(1.0).timeout
quit()
return
push_error("Objective integration timed out on " + agent.cabinet_id)
quit(1)
@@ -0,0 +1 @@
uid://drmhfkifu2igv
+167
View File
@@ -0,0 +1,167 @@
import importlib.util
import io
import json
from pathlib import Path
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'tools/deploy'))
import deploy
import receiver
def site():
return dict(schema=1, operator_token='operator_token_12345678', controller_url='http://192.168.77.1:8766',
cabinets=[dict(id='cabinet_01', host='192.168.77.11', user='arcade', port=22,
token='cabinet1_token_12345678', gpio=False, pins=[], gpio_controller='cabinet_01_gpio')])
def archive(extra=None, missing=False, wrong_hash=False):
import hashlib
files = {name: b'example' for name in receiver.FILES}
manifest = dict(release='test-1', files={name: hashlib.sha256(data).hexdigest() for name, data in files.items()})
if wrong_hash:
manifest['files']['ArcadeEscape.pck'] = '0' * 64
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode='w:gz') as tar:
entries = [('manifest.json', json.dumps(manifest).encode())] + list(files.items())
if missing: entries.pop()
for name, data in entries:
item = tarfile.TarInfo(name)
item.size = len(data)
tar.addfile(item, io.BytesIO(data))
if extra:
tar.addfile(extra)
stream.seek(0)
return stream
class DeployTests(unittest.TestCase):
def test_config_separates_credentials_and_keeps_gpio_off(self):
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp)
settings = site()
deploy.generate(folder, settings)
cabinet = (folder / 'cabinets/cabinet_01.ini').read_text()
self.assertNotIn(settings['operator_token'], cabinet)
self.assertIn('gpio = false', cabinet)
self.assertIn('~/.local/state/arcade_gpio/requests.sqlite3', cabinet)
self.assertIn(settings['operator_token'], (folder / 'controller.ini').read_text())
def test_duplicate_identity_host_and_tokens_rejected(self):
for change in ('id', 'host', 'token'):
settings = site()
other = dict(settings['cabinets'][0], id='cabinet_02', host='192.168.77.12', token='cabinet2_token_12345678')
other[change] = settings['cabinets'][0][change]
settings['cabinets'].append(other)
with self.subTest(change=change), self.assertRaises(ValueError):
deploy.validate(settings)
def test_unknown_profile_and_command_injection_rejected(self):
for key, value in [('id', 'unknown'), ('host', 'pi; reboot'), ('user', '-root'), ('port', True)]:
settings = site()
settings['cabinets'][0][key] = value
with self.subTest(key=key), self.assertRaises(ValueError):
deploy.validate(settings)
def test_bad_url_and_gpio_allowlists_rejected(self):
for url in ('http://127.0.0.1:8766', 'http://user:secret@192.168.77.1:8766', 'http://192.168.77.1:8766/path'):
settings = site()
settings['controller_url'] = url
with self.assertRaises(ValueError): deploy.validate(settings)
for pins in ([], [17, 17], [28], [True]):
with self.assertRaises(ValueError):
receiver.render_config(dict(site()['cabinets'][0], url=site()['controller_url'], gpio=True, pins=pins))
def test_archive_success_and_retry(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
receiver.stage(root, archive())
receiver.stage(root, archive())
self.assertEqual(receiver.verify_release(root / 'releases/test-1')['release'], 'test-1')
self.assertFalse((root / 'active.json').exists())
def test_archive_traversal_symlinks_duplicates_and_corruption_rejected(self):
cases = []
cases.append(archive(extra=tarfile.TarInfo('../escape')))
link = tarfile.TarInfo('unexpected')
link.type = tarfile.SYMTYPE
link.linkname = '/etc/passwd'
cases.append(archive(extra=link))
cases.append(archive(extra=tarfile.TarInfo('ArcadeEscape.pck')))
cases.append(archive(missing=True))
cases.append(archive(wrong_hash=True))
for stream in cases:
with tempfile.TemporaryDirectory() as temp, self.assertRaises(ValueError):
receiver.unpack(stream, Path(temp))
def test_ssh_is_strict_and_no_token_in_command(self):
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp)
(folder / 'ssh').mkdir()
(folder / 'ssh/arcade_cabinets').touch()
command = deploy.ssh_args(folder, site()['cabinets'][0])
self.assertIn('StrictHostKeyChecking=yes', command)
self.assertIn('BatchMode=yes', command)
self.assertNotIn(site()['cabinets'][0]['token'], str(command))
def test_activation_refuses_running_game_before_writing_config(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
with patch.object(receiver, 'verify_release'), patch.object(receiver, 'render_config', return_value='config'), \
patch.object(receiver.Path, 'read_text', return_value=json.dumps({'cabinets': {'cabinet_01': {}}})), \
patch.object(receiver.subprocess, 'run') as run:
run.return_value.returncode = 0
with self.assertRaisesRegex(ValueError, 'running'):
receiver.activate(root, 'test-1', site()['cabinets'][0])
self.assertFalse((root / 'releases/test-1/tools/launch/launcher.ini').exists())
def test_setup_rerun_keeps_credentials(self):
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp)
settings = site()
receiver.private_write(folder / 'inventory.json', json.dumps(settings))
with patch('builtins.input', side_effect=['', '']):
deploy.setup(folder)
self.assertEqual(deploy.load(folder), settings)
def test_activation_failure_restores_previous_service_and_config(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp) / 'deploy'
home = Path(temp) / 'home'
folder = root / 'releases/test-1'
(folder / 'tools/room_controller').mkdir(parents=True)
(folder / 'tools/room_controller/catalog.json').write_text(json.dumps({'cabinets': {'cabinet_01': {}}}))
config = folder / 'tools/launch/launcher.ini'
receiver.private_write(config, 'old configuration')
unit = home / '.config/systemd/user/arcade-cabinet.service'
receiver.private_write(unit, receiver.MARKER + '\nold unit')
settings = dict(site()['cabinets'][0], url=site()['controller_url'])
def run(command, **kwargs):
import subprocess
if command[0] == sys.executable:
receiver.private_write(unit, receiver.MARKER + '\nchanged unit')
raise subprocess.CalledProcessError(1, command)
return subprocess.CompletedProcess(command, 3 if 'is-active' in command else 0)
import subprocess
with patch.object(receiver, 'verify_release'), patch.object(receiver.Path, 'home', return_value=home), \
patch.object(receiver.subprocess, 'run', side_effect=run), self.assertRaises(subprocess.CalledProcessError):
receiver.activate(root, 'test-1', settings)
self.assertEqual(config.read_text(), 'old configuration')
self.assertEqual(unit.read_text(), receiver.MARKER + '\nold unit')
self.assertFalse((root / 'active.json').exists())
def test_master_package_excludes_live_site_data(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / 'master'
deploy.master_package(target)
names = [str(p.relative_to(target)) for p in target.rglob('*') if p.is_file()]
self.assertTrue((target / 'tools/deploy/deploy.py').exists())
self.assertFalse(any(n.endswith(('.ini', '.sqlite3', '.cfg')) for n in names))
if __name__ == '__main__': unittest.main()
+24
View File
@@ -0,0 +1,24 @@
import unittest
from test_room import server
class FeedbackTests(unittest.TestCase):
def test_authenticated_poll_feedback_dedup_and_stale(self):
now=[100]
room=server.Room(':memory:',lambda:now[0])
packet=dict(cabinet='cabinet_01',protocol=2,boot='test',gpio='real',reports=[],feedback=dict(fresh=True,devices=[dict(id='reader',kind='rfid',port='J1',value='12345678',age_ms=0)],events=[dict(id='boot:1',device='reader',value='12345678')]))
room.poll(packet);room.poll(packet)
self.assertTrue(room.snapshot()['feedback']['fresh'])
self.assertEqual(sum('Device reader' in x['message'] for x in room.state['history']),1)
now[0]+=5
self.assertFalse(room.snapshot()['feedback']['fresh'])
packet['feedback']['fresh']=False
room.poll(packet)
self.assertFalse(room.snapshot()['feedback']['fresh'])
room.db.close()
def test_invalid_feedback_is_bounded(self):
room=server.Room(':memory:',lambda:100)
room.receive_feedback(dict(devices=[{}]*33,events=[]))
self.assertEqual(room.feedback,{})
room.receive_feedback(dict(devices=[dict(id='bad',kind='input',value=[],age_ms=0)],fresh=True))
self.assertEqual(room.feedback['devices'],[])
self.assertFalse(room.feedback['fresh'])
room.db.close()
+5 -1
View File
@@ -13,7 +13,8 @@ import unittest
class FleetTests(unittest.TestCase):
def setUp(self):
self.now = 100
self.catalog = json.loads((ROOT / 'tools/room_controller/catalog.json').read_text())
# Fixed historical test room: designer edits must not alter protocol assertions.
self.catalog = json.loads(Path(__file__).with_name('catalog_fixture.json').read_text(encoding='utf-8'))
self.fleet = server.Fleet(':memory:', self.catalog, lambda: self.now)
for cabinet in self.fleet.rooms: self.poll(cabinet)
@@ -54,6 +55,9 @@ class FleetTests(unittest.TestCase):
self.assertFalse(self.poll('cabinet_01')['commands'])
def test_compatibility_rejects_missing_controls_and_accepts_alternatives(self):
# Explicit fixture: actual cabinet hardware can evolve independently.
self.catalog['cabinets']['cabinet_01']['capabilities'] = ['joystick_4way', 'button_1', 'start_button']
self.catalog['cabinets']['cabinet_03']['capabilities'] = ['spinner', 'button_1', 'start_button']
with self.assertRaisesRegex(ValueError, 'hardware'):
self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'missile_command'})
cabinet = self.catalog['cabinets']['cabinet_03']
+54
View File
@@ -0,0 +1,54 @@
import json
import tempfile
import unittest
from pathlib import Path
from test_room import ROOT, server
class HubTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.catalog = json.loads(Path(__file__).with_name('catalog_fixture.json').read_text(encoding='utf-8'))
self.path = str(Path(self.temp.name) / 'state.sqlite3')
self.fleet = server.Fleet(self.path, self.catalog, lambda: 100)
self.room = self.fleet.rooms['cabinet_01']
self.room.last_seen = 100
self.room.gpio = 'real'
self.room.state.update(session='test-session', phase='ready')
self.data = dict(cabinet='cabinet_01', expected_session='test-session', action_id='coin_release', request_id='test-request-0001', reason='Test release')
def tearDown(self):
for r in self.fleet.rooms.values(): r.db.close()
self.temp.cleanup()
def test_durable_duplicate_does_not_repeat_output(self):
self.fleet.operator('trigger_output', self.data)
self.fleet.operator('trigger_output', self.data)
self.assertEqual(len(self.room.state['commands']), 1)
for r in self.fleet.rooms.values(): r.db.close()
self.fleet = server.Fleet(self.path, self.catalog, lambda: 100)
self.fleet.operator('trigger_output', self.data)
self.assertEqual(len(self.fleet.rooms['cabinet_01'].state['commands']), 1)
self.assertTrue(any('Test release' in h['message'] for h in self.fleet.rooms['cabinet_01'].state['history']))
def test_rejects_unsafe_or_stale_requests(self):
for key,value in [('expected_session','old'),('action_id','unknown'),('reason',''),('request_id','short')]:
with self.subTest(key=key), self.assertRaises(ValueError):
self.fleet.operator('trigger_output', dict(self.data, **{key:value}))
self.room.gpio = 'simulation'
with self.assertRaises(ValueError): self.fleet.operator('trigger_output', self.data)
self.room.gpio = 'real'
self.room.last_seen = 0
with self.assertRaises(ValueError): self.fleet.operator('trigger_output', self.data)
self.assertFalse(self.room.state['commands'])
def test_pending_and_unprepared_rejected(self):
self.room.state['phase']='idle'
with self.assertRaises(ValueError): self.fleet.operator('trigger_output',self.data)
self.room.state['phase']='ready'
self.fleet.operator('trigger_output',self.data)
with self.assertRaises(ValueError): self.fleet.operator('trigger_output',dict(self.data,request_id='test-request-0002'))
def test_hub_config(self):
p=Path(self.temp.name)/'hub.json'
service=dict(id='trivia',name='Trivia',kind='trivia',url='http://trivia.local:8080')
p.write_text(json.dumps(dict(services=[service])))
self.assertEqual(server.hub_config(p)['room_name'],'Glitch: the Dark Machine')
for url in ['javascript:alert(1)','http://user:password@host','http://host/?token=secret','http://host/#secret','http://host:invalid']:
p.write_text(json.dumps(dict(services=[dict(service,url=url)])))
with self.subTest(url=url),self.assertRaises(ValueError): server.hub_config(p)
self.assertEqual(self.fleet.snapshot()['hub']['name'],'Escape Gallery')
+78
View File
@@ -0,0 +1,78 @@
import unittest
import test_fleet
class ObjectiveTests(unittest.TestCase):
setUp = test_fleet.FleetTests.setUp
tearDown = test_fleet.FleetTests.tearDown
def poll(self, cabinet, reports=None, features=True):
return self.fleet.poll(dict(cabinet=cabinet,protocol=2,boot=cabinet,reports=reports or [],features=['assignment_config_v1'] if features else []))
def test_config_reaches_prepare_and_manual_clears_it(self):
self.fleet.operator('prepare_scenario',{'scenario':'three_cabinet_objective_test'})
command=self.poll('cabinet_01')['commands'][0]
self.assertEqual(command['game_config']['test_auto_win_seconds'],5)
self.poll('cabinet_01',[dict(kind='ack',id=command['id'],session=command['session'],ok=True)])
self.fleet.operator('reset',{'cabinet':'cabinet_01'})
command=self.poll('cabinet_01')['commands'][0]
self.poll('cabinet_01',[dict(kind='ack',id=command['id'],session=command['session'],ok=True)])
self.fleet.operator('prepare',{'cabinet':'cabinet_01','game':'pacman'})
self.assertEqual(self.poll('cabinet_01')['commands'][0]['game_config'],{})
def test_old_export_rejected_before_dispatch(self):
self.poll('cabinet_03', features=False)
with self.assertRaisesRegex(ValueError,'Update the game export on cabinet_03'):
self.fleet.operator('prepare_scenario',{'scenario':'three_cabinet_objective_test'})
self.assertFalse(self.poll('cabinet_01')['commands'])
def test_invalid_objective_rejected(self):
assignment=self.catalog['scenarios']['three_cabinet_objective_test']['assignments'][2]
for config in [{'homes_to_win':6},{'homes_to_win':True},{'homes_to_win':.5},{'unknown':1},{'test_auto_win_seconds':float('nan')}]:
assignment['game_config']=config
with self.assertRaises(ValueError):
self.fleet.operator('prepare_scenario',{'scenario':'three_cabinet_objective_test'})
self.assertFalse(self.poll('cabinet_01')['commands'])
def test_never_connected_cabinet_reports_offline_not_old_export(self):
room=self.fleet.rooms['cabinet_03']
room.last_seen=0
room.features=[]
with self.assertRaisesRegex(ValueError,'cabinet_03 is offline'):
self.fleet.operator('prepare_scenario',{'scenario':'three_cabinet_objective_test'})
self.assertFalse(self.poll('cabinet_01')['commands'])
def test_rfid_credit_validation_and_start_gate(self):
room=self.fleet.rooms['cabinet_01']
room.features=['assignment_config_v1','rfid_credit_v1']
room.gpio='real'
self.catalog['cabinets']['cabinet_01']['devices']=[dict(id='reader',kind='rfid')]
item=dict(cabinet='cabinet_01',game='pacman',game_config=dict(rfid_credit_device='reader',rfid_allowed_uids=[]))
self.fleet.validate_assignment(item)
for changes in [dict(rfid_credit_device='missing'),dict(rfid_allowed_uids=['bad']),dict(rfid_allowed_uids=True)]:
invalid=dict(item,game_config=dict(item['game_config'],**changes))
with self.assertRaises(ValueError): self.fleet.validate_assignment(invalid)
room.features=['assignment_config_v1']
with self.assertRaisesRegex(ValueError,'2.3'): self.fleet.validate_assignment(item)
room.features.append('rfid_credit_v1')
room.gpio='simulation'
with self.assertRaisesRegex(ValueError,'real GPIO'): self.fleet.validate_assignment(item)
room.state.update(phase='ready',game_config=item['game_config'],commands=[])
with self.assertRaisesRegex(ValueError,'press Start'): self.fleet.operator('start',dict(cabinet='cabinet_01'))
def test_shipped_rfid_scenario_prepares_without_outputs(self):
# Exercise the actual exported scenario as well as the protocol fixture.
self.tearDown()
self.catalog=test_fleet.json.loads((test_fleet.ROOT/'tools/room_controller/catalog.json').read_text(encoding='utf-8'))
self.fleet=test_fleet.server.Fleet(':memory:',self.catalog,lambda:self.now)
self.poll('cabinet_01')
room=self.fleet.rooms['cabinet_01']
room.features=['assignment_config_v1','rfid_credit_v1']
room.gpio='real'
self.fleet.operator('prepare_scenario', {'scenario':'pacman_rfid_credit_test'})
command=self.poll('cabinet_01')['commands'][0]
self.assertEqual(command['game_config']['rfid_credit_device'],'cab1_rfid1')
self.assertEqual(command['type'],'prepare')
self.poll('cabinet_01',[dict(kind='ack',id=command['id'],session=command['session'],ok=True)])
self.assertEqual(room.state['phase'],'ready')
self.assertFalse(any(c['type'] in ('output','reward','start') for c in room.state['commands']))
self.poll('cabinet_01',[dict(kind='event',id='physical-start',session=command['session'],event='started')])
self.assertEqual(room.state['phase'],'playing')
+138
View File
@@ -0,0 +1,138 @@
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import io
from contextlib import redirect_stdout
import unittest
from unittest.mock import patch
from test_room import ROOT
def module(name):
spec=importlib.util.spec_from_file_location(name,ROOT/'tools/launch'/f'{name}.py')
loaded=importlib.util.module_from_spec(spec)
spec.loader.exec_module(loaded)
return loaded
remote=module('remote_cabinets')
service=module('pi_service')
class RemoteTests(unittest.TestCase):
def config(self,folder,items,enabled=True):
path=Path(folder)/'launcher.ini'
path.write_text('[remote]\nenabled = '+str(enabled)+'\ncabinets = '+json.dumps(items))
return path
def test_opt_in_and_cabinet_mode_do_not_start_remotes(self):
self.assertEqual(remote.load_targets('missing.ini','cabinet'),[])
with tempfile.TemporaryDirectory() as folder:
path=self.config(folder,[],False)
self.assertEqual(remote.load_targets(path,'controller'),[])
def test_safe_idempotent_command(self):
with tempfile.TemporaryDirectory() as folder,patch.object(remote.shutil,'which',return_value='ssh'):
path=self.config(folder,[{'id':'cabinet_01','host':'10.0.0.12','user':'arcade'}])
target=remote.load_targets(path,'controller')[0]
self.assertEqual(target['command'][-5:],['arcade@10.0.0.12','systemctl','--user','start','arcade-cabinet.service'])
self.assertIn('StrictHostKeyChecking=yes',target['command'])
self.assertIn('BatchMode=yes',target['command'])
self.assertNotIn('restart',target['command'])
def test_invalid_hosts_and_duplicates_rejected(self):
with tempfile.TemporaryDirectory() as folder,patch.object(remote.shutil,'which',return_value='ssh'):
for host in ['-oProxyCommand=bad','host;reboot','user@host','host name']:
with self.assertRaises(ValueError):
remote.load_targets(self.config(folder,[{'id':'cabinet_01','host':host,'user':'arcade'}]),'controller')
item={'id':'cabinet_01','host':'pi-one','user':'arcade'}
with self.assertRaisesRegex(ValueError,'Duplicate'):
remote.load_targets(self.config(folder,[item,item]),'controller')
def test_ssh_failure_is_reported_not_retried(self):
target={'id':'cabinet_01','command':['ssh','example']}
with patch.object(remote.subprocess,'run',return_value=subprocess.CompletedProcess([],255,'','Permission denied')) as run:
cabinet,ok,message=remote.start_one(target)
self.assertFalse(ok)
self.assertIn('Permission denied',message)
run.assert_called_once()
with patch.object(remote.subprocess,'run',side_effect=subprocess.TimeoutExpired('ssh',15)):
self.assertFalse(remote.start_one(target)[1])
def test_service_has_desktop_environment_and_no_restart_loop(self):
unit=service.render_unit('/usr/bin/python3','/home/arcade/room/tools/launch/launch.py','/home/arcade/room/tools/launch/pi.ini')
self.assertIn('EnvironmentFile=%t/arcade-cabinet-display.env',unit)
self.assertIn('Restart=no',unit)
self.assertIn('KillMode=mixed',unit)
self.assertIn('UnsetEnvironment=ARCADE_ROOM_URL',unit)
self.assertNotIn('[Install]',unit)
self.assertIn('%%',service.quoted('/folder/100%/file'))
self.assertIn('refresh-session',service.render_desktop('/usr/bin/python3','/home/arcade/pi_service.py'))
def test_working_directory_is_not_shell_quoted(self):
launcher = Path('/home/arcade/room with spaces/100%/tools/launch/launch.py')
unit = service.render_unit('/usr/bin/python3', launcher, '/tmp/pi.ini')
line = next(line for line in unit.splitlines() if line.startswith('WorkingDirectory='))
expected = str(launcher.resolve().parents[2]).replace('%', '%%')
self.assertEqual(line, 'WorkingDirectory=' + expected)
self.assertEqual(service.working_directory('/home/arcade/room with spaces'),
'/home/arcade/room with spaces')
with self.assertRaises(ValueError):
service.working_directory('/home/arcade/room\nRestart=always')
def test_installer_checks_systemd_load_state(self):
for state in ('bad-setting', 'error', 'not-found'):
with self.subTest(state=state), patch.object(service.subprocess, 'run',
return_value=subprocess.CompletedProcess([], 0, state + '\n', '')):
with self.assertRaisesRegex(ValueError, 'systemd rejected'):
service.reload_service()
with patch.object(service.subprocess, 'run',
return_value=subprocess.CompletedProcess([], 0, 'loaded\n', '')) as run:
service.reload_service()
self.assertEqual(run.call_count, 2)
self.assertIn('--property=LoadState', run.call_args.args[0])
def test_remote_install_waits_for_desktop_without_guessing_display(self):
import sys
with tempfile.TemporaryDirectory() as folder:
from types import SimpleNamespace
with patch.object(service.sys, 'argv', ['pi_service.py', 'install', '--remote']), \
patch.object(service.sys, 'platform', 'linux'), \
patch.object(service.os, 'getuid', return_value=1000, create=True), \
patch.dict(service.os.environ, {}, clear=True), \
patch.dict(sys.modules, {'launch': SimpleNamespace(plan=lambda *args: None)}), \
patch.object(service.Path, 'home', return_value=Path(folder)), \
patch.object(service, 'reload_service'), patch.object(service, 'refresh_session') as refresh:
self.assertEqual(service.main(), 0)
refresh.assert_not_called()
self.assertTrue((Path(folder) / '.config/systemd/user/arcade-cabinet.service').exists())
self.assertTrue((Path(folder) / '.config/autostart/arcade-cabinet-display.desktop').exists())
def test_installer_refuses_unrelated_files(self):
with tempfile.TemporaryDirectory() as folder:
target=Path(folder)/'service'
target.write_text('User-owned settings')
with self.assertRaisesRegex(ValueError,'unrelated'):
service.write_managed(target,'replacement')
self.assertEqual(target.read_text(),'User-owned settings')
def test_online_confirmation_is_separate_from_ssh_success(self):
target={'id':'cabinet_01','host':'pi-one','command':['ssh']}
controller={'ready':'http://127.0.0.1:8766/','env':{'ARCADE_OPERATOR_TOKEN':'test-token'}}
response=io.BytesIO(json.dumps({'cabinets':{'cabinet_01':{'online':True}}}).encode())
output=io.StringIO()
with patch.object(remote,'start_one',return_value=('cabinet_01',True,'accepted')),patch.object(remote.urllib.request,'urlopen',return_value=response),redirect_stdout(output):
remote.start_and_check([target],controller)
self.assertIn('SSH OK',output.getvalue())
self.assertIn('ONLINE in room controller',output.getvalue())
self.assertNotIn('test-token',output.getvalue())
def test_offline_target_does_not_raise_or_stop_controller(self):
target={'id':'cabinet_01','host':'pi-one','command':['ssh']}
controller={'ready':'http://127.0.0.1:8766/','env':{'ARCADE_OPERATOR_TOKEN':'test-token'}}
output=io.StringIO()
with patch.object(remote,'start_one',return_value=('cabinet_01',False,'unreachable')),patch.object(remote.time,'monotonic',side_effect=[0,16]),redirect_stdout(output):
remote.start_and_check([target],controller)
self.assertIn('Controller remains running',output.getvalue())