Added setup folder with needed launch files - Update when new version is ready
This commit is contained in:
1 parent
91b7840535
commit
0dcada4db2
50 files changed
+3753
-11
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# Start the Arcade stack
|
||||
|
||||
For the assigned internal operator/Cabinet 1–7 tokens and per-machine Godot CFG examples, see the [cabinet setup guide](../../docs/cabinet_setup.md).
|
||||
|
||||
One launcher starts the needed processes, waits for services to respond, saves logs, and stops its children when you press Ctrl+C. It runs on Windows and Linux/Pi (and supports controller-only use on macOS).
|
||||
|
||||
| Mode | Starts |
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[launcher]
|
||||
logs = logs
|
||||
|
||||
[cabinet]
|
||||
; Relative to this INI, or use an absolute executable path.
|
||||
executable = ../../ArcadeEscape.arm64
|
||||
id = cabinet_01
|
||||
; Leave these blank to use the game's existing user://arcade_network.cfg.
|
||||
url =
|
||||
token =
|
||||
gpio = true
|
||||
|
||||
[gpio]
|
||||
controller = cabinet_01_gpio
|
||||
pins = 17
|
||||
; Preserve this ledger across launches to prevent repeated rewards.
|
||||
ledger = ~/.local/state/arcade_gpio/requests.sqlite3
|
||||
@@ -1,5 +1,7 @@
|
||||
# Arcade room controller and browser operator
|
||||
|
||||
See the [cabinet setup guide](../../docs/cabinet_setup.md) for the supplied internal token registry and PC-versus-Pi configuration examples.
|
||||
|
||||
This controller is independent of Odin. It uses Python's standard library, authenticated HTTP, and SQLite; no Odin services or dependencies are needed.
|
||||
|
||||
For supervised startup on Windows or the Pi, use the [stack launch scripts](../launch/README.md). The manual commands below remain available for troubleshooting.
|
||||
@@ -51,7 +53,7 @@ The sample cabinet profiles are intentionally not expanded to claim hardware the
|
||||
|
||||
## 2. Start the controller on the Mac mini (or PC)
|
||||
|
||||
Python 3 is needed on the controller; Godot is not. Generate a different random token of at least 16 characters for the operator and for each cabinet. Keep tokens outside source control.
|
||||
Python 3 is needed on the controller; Godot is not. Generate a different random token of at least 16 characters for the operator and for each cabinet. The assigned tokens for this internal installation are recorded in the cabinet setup guide.
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
@@ -139,3 +141,27 @@ godot --headless --path . --script tests/room_network/check_capabilities.gd
|
||||
The integration test starts temporary localhost services on 8765/8766 and two actual headless Godot clients. It prepares Pac-Man via a scenario and Galaga via a manual assignment, forces wins only in the test harness, and verifies exactly one fake GPIO activation. Do not run while real services occupy those ports.
|
||||
|
||||
Deployment checklist: verify both cabinet IDs/tokens, test physical controls on each Pi, prepare a scenario and confirm both game names, win and check the intended LED/output only, reset, then unplug/reconnect one cabinet and confirm its offline/recovery behavior. Finally test the browser on the actual iPad; desktop browser checks do not replace device testing.
|
||||
|
||||
|
||||
## Two-Pi chain test: Cabinet 1 wins -> Cabinet 2 starts
|
||||
|
||||
Select **Cabinet 1 -> Cabinet 2** (`cabinet_chain_test`) in the browser. This separate sample uses Pac-Man on `cabinet_01` followed by Galaga on `cabinet_02`, with no GPIO event mappings required.
|
||||
|
||||
1. On Windows, run the controller only: `.\Start-Arcade.ps1 -Mode controller -Browser`. Do not also start a Windows game using either Pi's cabinet identity.
|
||||
2. Provision distinct cabinet tokens for `cabinet_01` and `cabinet_02` in the controller configuration.
|
||||
3. On Pi 1 set `[cabinet] id = cabinet_01`; on Pi 2 set `id = cabinet_02`. Use each cabinet's matching token and the Windows controller URL, then run `bash start-arcade.sh cabinet` on both.
|
||||
4. Confirm both cabinets are ONLINE. Reset any active assignments, select **Cabinet 1 -> Cabinet 2**, and click **Prepare assigned cabinets**.
|
||||
5. Cabinet 1 becomes READY with Pac-Man. Cabinet 2 unloads any game, then shows WAITING in the operator; the Pi shows its existing waiting-for-operator screen. Its physical Start button cannot bypass the dependency because no game is loaded.
|
||||
6. Start Cabinet 1 and win its configured Pac-Man objective. Cabinet 2 automatically loads Galaga, waits for the game's readiness acknowledgement, and starts it. No operator click or physical Start on Cabinet 2 is needed.
|
||||
|
||||
To repeat, reset Cabinet 1 and Cabinet 2, then prepare the scenario again. Resetting/reassigning/restarting a source cabinet invalidates its dependent sessions, including any running dependent game; prepare the scenario again. A loss does not activate the next cabinet. Duplicate win reports and acknowledgements do not create additional starts. An offline target waits until the same process reconnects; a restarted target or a failed/expired load requires operator preparation again.
|
||||
|
||||
The activation field is `activate_after_win` on a `ScenarioAssignment` resource (editable in the Inspector). Set it to another cabinet ID in the same scenario, or leave it empty for normal immediate preparation. Export the catalog after editing it. Missing dependencies and cycles are rejected before dispatch. This activation behavior belongs to the managed network controller; the local editor simulation does not execute this network chain.
|
||||
|
||||
For this update, deploy **server.py, catalog.json, and operator.html** together and restart the Windows controller, then refresh the browser. Existing protocol-2 Pi game exports already support the reset/prepare/start commands used here; a Pi game re-export is not required solely for this controller feature.
|
||||
|
||||
Automated two-client check (no GPIO; wins forced only by the test harness):
|
||||
|
||||
```sh
|
||||
python3 tests/room_network/check_chain_integration.py /path/to/godot
|
||||
```
|
||||
@@ -9,9 +9,18 @@
|
||||
"joystick_4way",
|
||||
"button_1",
|
||||
"start_button",
|
||||
"joystick_horizontal"
|
||||
"joystick_horizontal",
|
||||
"button_2",
|
||||
"button_3",
|
||||
"button_4",
|
||||
"button_5",
|
||||
"button_6",
|
||||
"select_button",
|
||||
"trackball",
|
||||
"joystick_8way",
|
||||
"joystick_dual"
|
||||
],
|
||||
"name": "Cabinet 01",
|
||||
"name": "Master Cabinet",
|
||||
"position": [
|
||||
-5.94849967956543,
|
||||
0.147500038146973
|
||||
@@ -237,9 +246,29 @@
|
||||
]
|
||||
},
|
||||
"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": {
|
||||
@@ -255,6 +284,7 @@
|
||||
"proof_of_concept": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"mappings": {
|
||||
@@ -270,6 +300,7 @@
|
||||
"two_cabinet_test": {
|
||||
"assignments": [
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_01",
|
||||
"game": "pacman",
|
||||
"mappings": {
|
||||
@@ -283,6 +314,7 @@
|
||||
"variant": "standard"
|
||||
},
|
||||
{
|
||||
"activate_after_win": "",
|
||||
"cabinet": "cabinet_02",
|
||||
"game": "galaga",
|
||||
"mappings": {
|
||||
|
||||
@@ -22,7 +22,7 @@ func run() -> void:
|
||||
for mapping in assignment.event_mappings:
|
||||
if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = []
|
||||
mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions))
|
||||
assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "mappings": mappings})
|
||||
assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "activate_after_win": str(assignment.activate_after_win), "mappings": mappings})
|
||||
catalog.scenarios[str(scenario.scenario_id)] = {"name": scenario.display_name, "assignments": assignments}
|
||||
for point in room.boundary_points: catalog.layout.boundary.append([point.x, point.y])
|
||||
for wall in room.walls: catalog.layout.walls.append([[wall.start.x, wall.start.y], [wall.end.x, wall.end.y]])
|
||||
|
||||
@@ -18,10 +18,10 @@ const colors={ready:'#69ddcd',playing:'#7bbaff',won:'#b4ec86',lost:'#ffae9e'};
|
||||
function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e}
|
||||
async function api(path,data={}){const response=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:AbortSignal.timeout(6000)});const value=await response.json();if(!response.ok)throw Error(value.error||'Controller request failed');return value}
|
||||
function options(id,entries){const select=$(id),before=select.value;if(select.options.length===entries.length&&entries.every(([value,name],i)=>select.options[i].value===value&&select.options[i].textContent===name))return;select.replaceChildren(...entries.map(([value,name])=>{const o=node('option',name);o.value=value;return o}));if(entries.some(x=>x[0]===before))select.value=before}
|
||||
function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value}
|
||||
function enable(){const c=state?.cabinets[selected],available=connected&&!pending;const queued=c?.commands.some(x=>x.status==='queued');$('start').disabled=!available||!c?.online||c.phase!=='ready'||!!c.gate||queued;$('reset').disabled=!available||!c?.online||['preparing','starting','resetting'].includes(c.phase);$('prepare').disabled=!available||!c?.online||queued||!['idle','won','lost','needs_prepare'].includes(c.phase)||!$('game').value;$('prepareScenario').disabled=!available||!$('scenario').value}
|
||||
function scenarioSummary(){if(!state)return;const s=state.catalog.scenarios[$('scenario').value];$('scenarioSummary').textContent=s?s.assignments.map(a=>(state.catalog.cabinets[a.cabinet]?.name||a.cabinet)+' → '+(state.catalog.games[a.game]?.name||a.game)).join(' · '):'No scenarios exported'}
|
||||
function drawMap(){const svg=$('map'),layout=state.catalog.layout,points=[...layout.boundary,...Object.values(state.cabinets).filter(c=>c.definition.position).map(c=>c.definition.position),...layout.walls.flat(),...layout.devices.map(d=>d.position)];svg.replaceChildren();if(!points.length)return;const xs=points.map(p=>p[0]),ys=points.map(p=>p[1]),minX=Math.min(...xs)-1,minY=Math.min(...ys)-1,w=Math.max(...xs)-minX+1,h=Math.max(...ys)-minY+1;svg.setAttribute('viewBox',`${minX} ${minY} ${w} ${h}`);const unit=Math.max(w,h)/18;function shape(tag,attrs,text){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [k,v]of Object.entries(attrs))e.setAttribute(k,v);if(text)e.textContent=text;svg.append(e);return e}if(layout.boundary.length)shape('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#152335',stroke:'#435770','stroke-width':unit/14});for(const wall of layout.walls)shape('line',{x1:wall[0][0],y1:wall[0][1],x2:wall[1][0],y2:wall[1][1],stroke:'#647a92','stroke-width':unit/10});for(const d of layout.devices){const e=shape('circle',{cx:d.position[0],cy:d.position[1],r:unit*.18,fill:'#8491a2'});const title=document.createElementNS(svg.namespaceURI,'title');title.textContent=d.name+' — no sensor feedback';e.append(title)}for(const [id,c]of Object.entries(state.cabinets)){const p=c.definition.position;if(!p)continue;const e=shape('circle',{cx:p[0],cy:p[1],r:unit*.5,fill:c.online?colors[c.phase]||'#e5c57b':'#647081',stroke:id===selected?'white':'none','stroke-width':unit/10});e.style.cursor='pointer';e.onclick=()=>{selected=id;render()};shape('text',{x:p[0],y:p[1]-unit*.75,fill:'#e3ebf5','font-size':unit*.6,'text-anchor':'middle'},c.definition.name)}}
|
||||
function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared');$('steps').replaceChildren(...['idle','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()}
|
||||
function render(){if(!state?.cabinets)throw Error('Update the room controller to use the browser operator');if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name]));scenarioSummary();$('cards').replaceChildren();for(const[id,c]of Object.entries(state.cabinets)){const b=node('button',c.definition.name,'card');b.setAttribute('aria-pressed',id===selected);b.append(node('span',(c.online?'ONLINE':'OFFLINE')+' · '+c.phase.toUpperCase()));b.append(node('span',state.catalog.games[c.game]?.name||c.game,'muted'));if(!c.definition.position)b.append(node('span','Not placed in room map','muted'));b.onclick=()=>{selected=id;render()};$('cards').append(b)}drawMap();const c=state.cabinets[selected];$('cabinetTitle').textContent=c.definition.name;$('connection').textContent=(c.online?'Online':'Offline')+' · '+c.version+' · GPIO: '+c.gpio;$('assignment').textContent='Game: '+(state.catalog.games[c.game]?.name||c.game)+'\nAssignment: '+(c.scenario||'None prepared')+(c.gate?'\nActivation: after '+c.gate.cabinet+' wins':'');$('steps').replaceChildren(...['idle','waiting','preparing','ready','playing','won','lost'].map(p=>node('span',p.toUpperCase(),c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,state.catalog.games[id].name]));$('reward').textContent='Win output: '+c.reward;const table=node('table'),head=node('tr');for(const name of ['Action','Status'])head.append(node('th',name));table.append(head);for(const command of c.commands.filter(x=>['output','reward'].includes(x.type))){const row=node('tr');row.append(node('td',command.action_id||command.type),node('td',command.status==='accepted'?'Accepted · physical result unconfirmed':command.status));table.append(row)}$('output').replaceChildren(table);$('events').replaceChildren(...c.history.slice().reverse().map(e=>node('li',new Date(e.time*1000).toLocaleTimeString()+' · '+e.message)));enable()}
|
||||
async function refresh(){if(!token||refreshing||pending)return;refreshing=true;try{state=await api('/operator/state');connected=true;render();$('workspace').hidden=false;$('login').hidden=true;$('signout').hidden=false;$('status').textContent='Controller connected'}catch(e){connected=false;$('status').textContent='Connection unavailable';$('error').textContent=e.message;enable()}finally{refreshing=false}}
|
||||
async function command(action,extra={}){if(pending||!connected)return;pending=true;enable();$('error').textContent='';try{state=await api('/operator/command',{action,cabinet:selected,...extra});render()}catch(e){$('error').textContent=e.message}finally{pending=false;enable();refresh()}}
|
||||
$('login').onsubmit=e=>{e.preventDefault();token=$('token').value.trim();$('token').value='';$('error').textContent='';refresh()};$('signout').onclick=()=>location.reload();$('scenario').onchange=scenarioSummary;$('prepareScenario').onclick=()=>command('prepare_scenario',{scenario:$('scenario').value});$('prepare').onclick=()=>command('prepare',{game:$('game').value});$('start').onclick=()=>command('start');$('reset').onclick=()=>{if(confirm('Reset '+state.cabinets[selected].definition.name+'? This ends its current game.'))command('reset')};setInterval(refresh,1000);
|
||||
|
||||
@@ -135,7 +135,10 @@ class Room:
|
||||
else:
|
||||
self.state["reward"] = "requested"
|
||||
elif success:
|
||||
self.state["phase"] = {"prepare": "ready", "reset": "idle", "start": "playing"}[command["type"]]
|
||||
self.state["phase"] = {"prepare": "ready", "reset": "waiting" if self.state.get("gate") else "idle", "start": "playing"}[command["type"]]
|
||||
if command["type"] == "prepare" and self.state.pop("auto_start", False):
|
||||
self.state["phase"] = "starting"
|
||||
self.command("start")
|
||||
if command["type"] == "prepare":
|
||||
for action in self.state["mappings"].get("game_activated", []):
|
||||
self.command("output")["action_id"] = action
|
||||
@@ -216,7 +219,24 @@ class Fleet:
|
||||
raise ValueError("Scenario must have unique, nonempty cabinet assignments")
|
||||
# Validate the whole plan before sending anything to any cabinet.
|
||||
for item in assignments: self.validate_assignment(item)
|
||||
by_id = {a["cabinet"]: a for a in assignments}
|
||||
for item in assignments:
|
||||
visited = {item["cabinet"]}
|
||||
source = item.get("activate_after_win", "")
|
||||
while source:
|
||||
if source not in by_id: raise ValueError("Dependency cabinet must be assigned in the same scenario")
|
||||
if source in visited: raise ValueError("Cabinet activation dependencies cannot form a cycle")
|
||||
visited.add(source)
|
||||
source = by_id[source].get("activate_after_win", "")
|
||||
for item in assignments: self.prepare(item, scenario_id)
|
||||
# Bind dependencies to this particular run, never an earlier win.
|
||||
for item in assignments:
|
||||
source = item.get("activate_after_win")
|
||||
if source:
|
||||
room = self.rooms[item["cabinet"]]
|
||||
room.state["gate"] = dict(cabinet=source, session=self.rooms[source].state["session"])
|
||||
room.log("Waiting for " + source + " to win")
|
||||
room.save()
|
||||
elif action == "prepare":
|
||||
cabinet = data.get("cabinet", "cabinet_01")
|
||||
item = dict(cabinet=cabinet, game=data.get("game", "pacman"), mappings={})
|
||||
@@ -226,14 +246,23 @@ class Fleet:
|
||||
else:
|
||||
cabinet = data.get("cabinet", "cabinet_01")
|
||||
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
|
||||
if action == "start" and self.rooms[cabinet].state.get("gate"):
|
||||
raise ValueError("This cabinet starts automatically after its dependency wins")
|
||||
self.rooms[cabinet].operator(action)
|
||||
if action == "reset":
|
||||
self.rooms[cabinet].state.pop("gate", None)
|
||||
self.rooms[cabinet].state.pop("auto_start", None)
|
||||
self.rooms[cabinet].save()
|
||||
self.advance()
|
||||
return self.snapshot()
|
||||
|
||||
def prepare(self, item, scenario):
|
||||
room = self.rooms[item["cabinet"]]
|
||||
room.state.update(game=item["game"], mappings=item.get("mappings", {}),
|
||||
variant=item.get("variant", "standard"), scenario=scenario)
|
||||
room.operator("prepare")
|
||||
room.state.pop("gate", None)
|
||||
room.state.pop("auto_start", None)
|
||||
room.operator("reset" if item.get("activate_after_win") else "prepare")
|
||||
room.log("Assignment from " + scenario)
|
||||
room.save()
|
||||
|
||||
@@ -241,7 +270,33 @@ class Fleet:
|
||||
cabinet = data.get("cabinet")
|
||||
if data.get("protocol") != 2: raise ValueError("Update cabinet export: controller requires protocol 2")
|
||||
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
|
||||
return self.rooms[cabinet].poll(data)
|
||||
self.rooms[cabinet].poll(data)
|
||||
self.advance()
|
||||
room = self.rooms[cabinet]
|
||||
return dict(ok=True, session=room.state["session"],
|
||||
commands=[c for c in room.state["commands"] if c["status"] == "queued"])
|
||||
|
||||
def advance(self):
|
||||
# Invalidate descendants when their source is reset/reassigned/restarted.
|
||||
# Iterate to propagate invalidation through longer chains.
|
||||
for _ in self.rooms:
|
||||
for room in self.rooms.values():
|
||||
gate = room.state.get("gate")
|
||||
if not gate: continue
|
||||
source = self.rooms.get(gate["cabinet"])
|
||||
if source is None or source.state["session"] != gate["session"]:
|
||||
room.state.update(session=str(uuid.uuid4()), phase="needs_prepare", commands=[], seen=[], gate=None, auto_start=False)
|
||||
room.log("Dependency session ended; prepare the scenario again")
|
||||
room.save()
|
||||
for room in self.rooms.values():
|
||||
gate = room.state.get("gate")
|
||||
if not gate or room.state["phase"] != "waiting" or not room.online(): continue
|
||||
source = self.rooms[gate["cabinet"]]
|
||||
if source.state["phase"] == "won":
|
||||
room.state.update(phase="preparing", auto_start=True)
|
||||
room.command("prepare")
|
||||
room.log("Activated by " + gate["cabinet"] + " win")
|
||||
room.save()
|
||||
|
||||
|
||||
def handler_for(room, operator_token, cabinet_token):
|
||||
|
||||
Reference in new issue
Block a user