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
+4967 -454

No files matched your search

+164 -268
View File
@@ -1,14 +1,33 @@
# Arcade Escape # Escape Gallery — Glitch: the Dark Machine
**An arcade-powered escape room framework built with Godot.** **A Godot arcade escape-room framework with a browser-based operations hub.**
Arcade Escape brings seven classic arcade recreations together with room-design tools, configurable cabinet controls, scenario-driven hardware outputs, and a browser operator dashboard. Designers define the room and its scenarios; Raspberry Pi cabinets run the games; staff prepare and monitor sessions from a computer or tablet. Design rooms, configure cabinets and their connected devices, assign arcade games to scenarios, and manage play from a staff computer or tablet. Raspberry Pi cabinets run exported games; a Python controller coordinates their sessions and serves the **Escape Gallery** operator webpage. The first room is **Glitch: the Dark Machine**.
The Arcade Escape controller is separate from Odin and does not require its code or services. The Arcade software is separate from Odin. Its RFID helper follows the tester PCB's selector arrangement and Odin's reader-library approach without requiring Odin's room services.
## Start here
**For a fresh installation, follow the [master walkthrough: Cabinet 1 Pac-Man RFID test](docs/pacman_rfid_first_setup.md).** It covers the entire process: PC exports, matching release packages, private cabinet LAN, Pi prerequisites, reader compilation, credentials, configuration, startup, and physical acceptance testing.
| Task | Guide |
| --- | --- |
| Export and package a matching release | [Master walkthrough, section 3](docs/pacman_rfid_first_setup.md#3-export-package-and-copy-one-complete-release) |
| Run Cabinet 1 with RFID credits | [Full master walkthrough](docs/pacman_rfid_first_setup.md) |
| Configure cabinet controls | [Launcher and control setup](games/launcher/README.md) |
| Author the custom PCB wiring inventory | [Visual PCB setup](docs/cabinet_pcb_setup.md) |
| Configure GPIO outputs and device feedback | [Pi GPIO guide](tools/pi_gpio/README.md) |
| Run controller/cabinet launch scripts | [Launch tools](tools/launch/README.md) |
| Start Pi services remotely over SSH | [Remote cabinet startup](docs/remote_pi_start.md) |
| Stage and activate fleet releases | [Deployment tools and current limitations](tools/deploy/README.md) |
| Configure the operator/controller | [Room controller guide](tools/room_controller/README.md) |
| Test sequential cabinet objectives | [5 seconds → 500 points → 1 frog](docs/three_cabinet_test.md) |
The fleet wizard is not yet a complete RFID installer: it does not generate the device/helper paths and rejects an empty output allowlist with real GPIO enabled. Use the master walkthrough's manual installation for the RFID-only test. Do not add an arbitrary output pin to bypass this limitation.
## Included games ## Included games
| | Game | Cabinet controls | | | Game | Controls |
| --- | --- | --- | | --- | --- | --- |
| ![Pac-Man](games/launcher/icons/ico_pacman.png) | **Pac-Man** | Four-way joystick + Start | | ![Pac-Man](games/launcher/icons/ico_pacman.png) | **Pac-Man** | Four-way joystick + Start |
| ![Galaga](games/launcher/icons/ico_galaga.png) | **Galaga** | Horizontal movement + Fire + Start | | ![Galaga](games/launcher/icons/ico_galaga.png) | **Galaga** | Horizontal movement + Fire + Start |
@@ -16,314 +35,191 @@ The Arcade Escape controller is separate from Odin and does not require its code
| ![Missile Command](games/launcher/icons/ico_missilecommand.png) | **Missile Command** | Trackball + three battery buttons + Start | | ![Missile Command](games/launcher/icons/ico_missilecommand.png) | **Missile Command** | Trackball + three battery buttons + Start |
| ![Frogger](games/launcher/icons/ico_frogger.png) | **Frogger** | Four-way joystick + Start | | ![Frogger](games/launcher/icons/ico_frogger.png) | **Frogger** | Four-way joystick + Start |
| ![Space Invaders](games/launcher/icons/ico_spaceinvaders.png) | **Space Invaders** | Horizontal movement + Fire + Start | | ![Space Invaders](games/launcher/icons/ico_spaceinvaders.png) | **Space Invaders** | Horizontal movement + Fire + Start |
| ![Robotron](games/launcher/icons/ico_robotron.png) | **Robotron 2084** | Two eight-way joysticks, or one joystick + four directional fire buttons, plus Start | | ![Robotron](games/launcher/icons/ico_robotron.png) | **Robotron 2084** | Two eight-way joysticks, or movement joystick + four directional fire buttons, plus Start |
The games include attract/title screens, sound, and game-specific visual treatments. They are playable recreations under active development; arcade accuracy and individual mechanics continue to be refined. These are playable recreations with title/attract screens, sound, and game-specific rendering. Arcade accuracy and mechanics continue to be refined. Pi 4 gameplay has been tested; verify controls, audio, performance, and hardware on each installation.
## Start here ## System layout
- **Start the services with one command:** [Windows and Pi launch scripts](tools/launch/README.md)
- **Play or configure controls:** [Launcher and cabinet controls](games/launcher/README.md)
- **Deploy the room and operator:** [Controller deployment guide](tools/room_controller/README.md)
- **Connect real Pi outputs:** [GPIO setup and LED test](tools/pi_gpio/README.md)
## How it fits together
```text ```text
Godot development computer Development PC: Godot editor + saved resources
Escape Room editor + game/cabinet/scenario resources | ARM64 game/PCK + Python tools | matching catalog + operator assets
| |
| game export | exported catalog.json
v v v v
Raspberry Pi cabinets ------> Room controller on Mac mini / PC Pi cabinets <--------------------> Master controller
games + saved controls Python + persistent SQLite state | Python + SQLite
| ^ | / \
v | Local GPIO bridge Private LAN Venue LAN
Local GPIO bridge Browser operator on Mac / PC / iPad | |
| PCB inputs / RFID / outputs Staff browsers
Physical outputs
``` ```
| Component | Purpose | Runs on | The master can run on a Pi, PC, Mac mini, or another suitable Python host. For the isolated Pi arrangement, its Ethernet connects to the private cabinet switch and its Wi-Fi or second Ethernet interface connects to the venue LAN. It hosts the operator on both networks without forwarding cabinet traffic to the venue. Network isolation requires the configuration in the walkthrough; the application does not configure routing/firewalls automatically.
| Port | Service | Address |
| --- | --- | --- | | --- | --- | --- |
| Godot project and Escape Room editor | Build games, place cabinets, and author scenarios | Development computer | | **8766** | Controller API and operator webpage | Master private IP for cabinets; master venue IP for staff |
| Arcade Launcher | Select games and configure physical controls | Desktop or Pi cabinet | | **8765** | GPIO bridge | Cabinet-local `127.0.0.1` only |
| Managed cabinet client | Load assigned games and report session events | Each Pi cabinet |
| Room controller | Validate assignments, track cabinet state, and queue commands | Mac mini, PC, or suitable server |
| Browser operator | View the room, prepare scenarios, and start/reset cabinets | Staff computer or iPad |
| GPIO bridge | Drive configured local Pi outputs | Each Pi that controls hardware |
## 1. Run the project locally ## Develop and play locally
1. Clone this repository and import **project.godot** into Godot. 1. Import [project.godot](project.godot) into the project's Godot version, currently **4.7.2**, and let asset import complete. The project uses the Compatibility renderer.
2. Use the project's configured **Godot 4.7** version and **Compatibility** renderer. Allow the initial asset import to finish. 2. Run the main project or [ArcadeLauncher.tscn](games/launcher/ArcadeLauncher.tscn).
3. Run the project with **F5**, or open [ArcadeLauncher.tscn](games/launcher/ArcadeLauncher.tscn) and use **F6**. 3. Complete **Control Setup** and select a game. Review saved editor run arguments if the project starts in a test/managed mode instead.
4. Complete **Control Setup** if prompted, then select a game and press Start. 4. Install matching Godot export templates before making Windows or Linux builds.
Godot's matching export templates are needed to create builds. Python 3 is needed for the room controller and GPIO bridge, but not for standalone desktop gameplay. Standalone games do not require Python. The controller/launcher/bridge do. Pi hardware uses GPIO Zero/lgpio; the separate RFID helper is built with Rust on the Pi.
The launcher is the configured main scene. The project also has saved editor run arguments for a test scenario/cabinet; review **Project Settings → Editor → Run → Main Run Args** before testing another assignment. - **Start:** select or start; **Select:** return to the standalone launcher.
- **F1:** reopen Control Setup; **Escape:** quit the application.
- Map each machine independently: Windows and Pi USB button indices can differ.
- Saved mappings live in `user://arcade_controls.cfg` and survive game-export replacement.
### Controls ## Author rooms, cabinets, and scenarios
- **Joystick:** navigate the launcher and move in games. Open the **Escape Room** main-screen editor tab. Its plugin is enabled in the project; check Project Settings > Plugins if the tab is missing.
- **Start:** select an entry or begin play.
- **Select:** return to the launcher in standalone mode.
- **F1:** reopen Control Setup from the launcher.
- **Escape:** quit the application entirely.
Configure each machine independently. USB encoders can report different button numbers on Windows and Raspberry Pi OS. The setup wizard saves local mappings, including separate Robotron firing controls; a trackball should report as a mouse. | Resource | Purpose |
Mappings live in `user://arcade_controls.cfg` and survive replacing the game export. See the [control guide](games/launcher/README.md) for keyboard defaults, multiple-controller behavior, and reconnect handling.
## 2. Author a room and its scenarios
The **Escape Room** editor plugin is enabled in this project. Open its main-screen tab in Godot; if it is missing, check **Project Settings → Plugins**.
The existing resources provide a starting point:
| Resource | What to configure |
| --- | --- | | --- | --- |
| [Project database](data/project_database.tres) | Register cabinets, games, and scenarios | | [Project database](data/project_database.tres) | Register games, cabinets, scenarios |
| [Room layout](data/rooms/arcade_room.tres) | Boundary, walls, cabinet placements, and room devices | | [Physical room](data/rooms/arcade_room.tres) | Boundary, walls, doorways, placements, devices |
| [Cabinet profiles](data/cabinets/) | Unique cabinet IDs, available controls, and hardware configurations | | [Cabinet profiles](data/cabinets/) | Unique IDs, control capabilities, hardware actions, PCB connections |
| [Game definitions](data/games/) | Game scenes, control requirements, and exposed events | | [Game definitions](data/games/) | Scenes, required controls, game events |
| [Scenarios](data/scenarios/) | Cabinet/game assignments and event-to-hardware mappings | | [Scenarios](data/scenarios/) | Assignments, dependency gates, per-game objectives, event/output mappings |
A typical authoring workflow: Give each cabinet a unique ID, describe its actual controls, register it, and place it in the room. Assign compatible games to a scenario and map only intended events to configured hardware actions. Save all resources before exporting. **Capabilities** determine game compatibility; **input mappings** determine which physical controls activate game actions.
1. Create or duplicate a cabinet profile and give it a unique `cabinet_id`. Assignments support `game_config` overrides, including score/board objectives, Frogger home counts, and a test-only timed win. These settings survive scenario working-copy/save operations. The controller validates settings and cabinet feature support before preparation. Older example scenarios may require updating action IDs and capabilities to match the current designer-authored cabinet resources.
2. Describe its actual controls in `capabilities`, and configure its hardware actions with the correct controller ID and BCM pins.
3. Register the cabinet in the project database and place it in the room layout.
4. Create a scenario with one assignment per participating cabinet. Choose a compatible game and the `standard` variant.
5. Map game events to the cabinet's hardware action IDs. For example, `game_won` → `coin_release`.
6. Register and save the scenario. Use the editor's simulation tools before deploying real outputs.
Designer-facing physical dimensions and positioning use feet; internal room geometry remains in meters. ### Physical room editor
**Capabilities and input mappings are separate.** Capabilities determine which games a cabinet can support; Control Setup determines which physical inputs activate their actions. The sample cabinet profiles describe limited controls, so update them to match your hardware before expecting every game to be available. The **Physical** view provides Labels and Dimensions toggles to reduce clutter. Hidden labels do not prevent selecting objects; these toggles are editor-session view settings.
### Export the operator catalog Use **DRAW DOORWAY** and drag from hinge to opposite jamb. Align it with a wall to display an opening, threshold, leaf, and swing arc. Select the doorway to edit its endpoints, convert wall/doorway type, or reverse its swing; use DELETE WALL to remove it. Edits use the room undo/save/revert workflow. Designer dimensions are in feet; stored geometry is in meters.
From the project root, with your Godot executable available as `godot`: Doorways are layout annotations. They do not change game collisions or act as live door sensors. Browser room-map doorway rendering remains future work.
### Visual PCB connections
Open a cabinet profile's **PCB Connections** editor and select a connector to assign a device type, name, unique ID, binding, and wiring notes. Digital inputs expose their pull-up setting. The editor validates duplicate assignments and relevant pin/action conflicts, supports Undo/Redo, and saves the configuration with the cabinet resource.
The board includes direct GPIO connectors, J16 I2C, and multiplexed J1–J8 reader connectors. See the [pin mapping guide](docs/cabinet_pcb_setup.md). Saving this inventory does not energize outputs or install drivers. Supported entries can be exported into the optional runtime feedback configuration; I2C and Other devices remain inventory-only.
## Operator hub and managed play
The browser hub provides an overview, **Glitch: the Dark Machine** room map, per-cabinet details, hardware controls, and activity history. It shows assigned games and reported cabinet phases. Pac-Man has a custom cabinet treatment with marquee and gameplay artwork; the screen says **Waiting for operator** before play. This artwork is an illustration, not live video.
Prepare a scenario after its participating cabinets report online. Independent assignments can start through the operator or physical Start; dependency-gated assignments activate after their predecessor wins. RFID-credit assignments require a scan and physical Start. Reset before reassigning an active cabinet.
Manual game assignments do not inherit scenario rewards. Operator hardware interventions require an online prepared cabinet, configured action, and a reason; requests are logged and deduplicated. An accepted output command is not proof of physical movement or delivery.
Optional device panels show RFID identifiers, digital input activity, electrical output state, errors, and stale feedback. Activity records device changes. Generic sensor-driven puzzle rules and detailed live puzzle-step diagrams are not yet implemented; the Pac-Man RFID credit flow is the first specific input-driven scenario.
### Pac-Man RFID credit scenario
Select **Cabinet 1: Pac-Man RFID credit** (`pacman_rfid_credit_test`):
1. Prepare Cabinet 1: Pac-Man displays **SCAN CARD / CREDIT 0**.
2. A new accepted scan on **J1 / cab1_rfid1** grants **CREDIT 1** and plays the credit sound.
3. The player presses mapped Start, consumes the credit, and begins play.
4. Clear one board to win; reset/re-prepare for another session.
Credit is capped at one. Historical/stale samples, duplicate events, scans on J2, and scans during gameplay do not bank extra credits. The initial scenario accepts any compatible readable card; `rfid_allowed_uids` optionally restricts accepted identifiers. No card-writing step is required. No motor/relay reward is configured for this test.
This needs **arcade-cabinet-2.3**, real GPIO feedback, the updated bridge, and the compiled RFID helper. The helper follows Odin's 32-bit identifier representation and is not full-length UID support for every card type. Compilation and physical reader testing on the tester Pi remain to be verified.
## Configuration and credentials
Prefer the launcher INIs shown in the master walkthrough. They start the required services and provide the managed game with its connection settings.
| Role | Settings |
| --- | --- |
| Master controller | `[controller]` URL binding, catalog/database paths, operator token, cabinet-token map |
| Pi cabinet | `[cabinet]` own ID/token, master private URL, executable, real GPIO option |
| Local hardware bridge | `[gpio]` controller ID, output pin allowlist, device file, RFID helper path |
| Optional direct Godot client | `user://arcade_network.cfg`, `[network]`, role-specific token and controller URL |
Browser staff use the **operator token**. Each cabinet uses its **own cabinet token**. These are application tokens, not SSH keys. Generate distinct values, for example:
```sh ```sh
godot --headless --path . --script tools/room_controller/export_catalog.gd
```
This generates [catalog.json](tools/room_controller/catalog.json) from the saved designer resources. The controller uses it for room positions, scenarios, and game compatibility. Regenerate it after authoring changes, restart the controller after copying it, and redeploy the game export when its packaged resources change.
## 3. Export and run a Pi cabinet
1. Install matching Godot export templates on the development computer.
2. Use the **Linux** export preset, configured for **ARM64**, for a 64-bit Raspberry Pi OS installation. The **Windows Desktop** preset is available for PC testing.
3. Copy the executable and any accompanying export files to the Pi.
4. Make the Linux executable runnable, then launch it normally to configure the controls:
```sh
chmod +x ArcadeEscape.arm64
./ArcadeEscape.arm64
```
Use your actual exported filename if different. The Pi runs the exported game; it does not need the Godot editor. The project has been tested on a Pi 4; verify the target hardware, display resolution, and controls for each installation.
For gameplay-only testing, no controller service or GPIO bridge is required. Real GPIO remains opt-in through `--gpio=real`.
## 4. Start the room controller and browser operator
Copy these three files together to the controller machine:
- [server.py](tools/room_controller/server.py)
- [operator.html](tools/room_controller/operator.html)
- [catalog.json](tools/room_controller/catalog.json)
The controller uses Python's standard library; it has no pip dependencies. Generate distinct random tokens of at least 16 characters for the operator and each cabinet. Replace the placeholders below with those values.
**macOS / Linux**, from that controller folder:
```sh
export ARCADE_OPERATOR_TOKEN='your-generated-operator-token'
export ARCADE_CABINET_TOKENS='{"cabinet_01":"your-generated-cabinet-one-token","cabinet_02":"your-generated-cabinet-two-token"}'
python3 server.py --bind 0.0.0.0 --database arcade_room.sqlite3
```
**Windows PowerShell**, from the repository root:
```powershell
$env:ARCADE_OPERATOR_TOKEN = 'your-generated-operator-token'
$env:ARCADE_CABINET_TOKENS = '{"cabinet_01":"your-generated-cabinet-one-token","cabinet_02":"your-generated-cabinet-two-token"}'
py -3 tools/room_controller/server.py --bind 0.0.0.0 --database arcade_room.sqlite3
```
Keep the database at a stable location and back it up. Allow TCP **8766** through the controller machine's firewall for the room LAN. The assigned internal tokens are documented in the [cabinet setup guide](docs/cabinet_setup.md), as requested for this installation. Keep deployment databases out of source control.
Open `http://CONTROLLER_IP:8766/` on the staff computer or iPad and enter the operator token. The iPad needs only a browser. Use a trusted isolated LAN; the default HTTP connection is not encrypted. See the [deployment guide](tools/room_controller/README.md) for network and credential details.
## 5. Connect cabinets to the controller
### PC versus Pi: which configuration file?
`arcade_network.cfg` configures a **Godot client**, not the Python controller. Its contents depend on the role that Godot runs in, not just the operating system.
| Machine / role | Where settings belong | Credentials |
| --- | --- | --- |
| PC running the Python controller + browser | `tools/launch/launcher.ini`, `[controller]` | `operator_token` and JSON `cabinet_tokens` for all cabinets |
| PC running the optional Godot `--operator` app | `user://arcade_network.cfg`, `[network]` | `url` and `operator_token` |
| Pi running a managed cabinet | `user://arcade_network.cfg`, `[network]` | `url`, its own `cabinet_id`, and its own `cabinet_token` |
| PC running a game as a test cabinet | Same cabinet fields as the Pi | That test cabinet's ID/token; do not duplicate an active Pi's ID |
**For the normal browser-operator setup, the PC does not need an `arcade_network.cfg`.** Enter the operator token in the browser; the server takes its credentials from the launcher/environment.
Example Pi Godot configuration:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_01"
cabinet_token="CABINET_01_TOKEN"
```
Optional PC Godot operator configuration (only for `--operator`, with the controller on that same PC):
```ini
[network]
url="http://127.0.0.1:8766"
operator_token="OPERATOR_TOKEN"
```
The Pi's URL must use the **PC's LAN IP**, never `127.0.0.1` unless the controller actually runs on that Pi. Both client roles use controller port **8766**. GPIO port **8765** is separate and stays local to the Pi.
Godot CFG strings require double quotes. Launcher INI scalar values are unquoted; the `cabinet_tokens` JSON object still uses JSON quotes.
When using the scripts, nonempty `[cabinet] url` and `token` values in `launcher.ini` override Godot's CFG. Existing `ARCADE_ROOM_URL` / `ARCADE_CABINET_TOKEN` environment values override those launcher values. Leave the INI URL/token blank to fall back to the CFG, and clear stale environment variables if needed. The scripts always pass their configured `[cabinet] id` (default `cabinet_01`) as `--cabinet`, overriding the CFG's ID: set the correct ID on every Pi even when using the CFG for its token.
### Generate a 16-character token
These are application authentication tokens, not SSH keys. Generate a separate token for each cabinet and for the operator:
```powershell
# Windows
py -3 -c "import secrets; print(secrets.token_urlsafe(12))"
```
```sh
# Pi / macOS / Linux
python3 -c "import secrets; print(secrets.token_urlsafe(12))" python3 -c "import secrets; print(secrets.token_urlsafe(12))"
``` ```
Each command produces exactly 16 URL-safe characters. Preserve capitalization and any `-` or `_`, including a trailing hyphen. Restart the affected controller/client after changing credentials. This produces a 16-character URL-safe token; use `py -3` on Windows if appropriate. For existing installations, preserve their assigned credentials instead of silently generating replacements. The older installation-specific examples remain in [cabinet setup](docs/cabinet_setup.md); the clean-install walkthrough uses fresh placeholders.
The supplied internal token assignments, complete controller configuration, and individual Cabinet 1–7 examples are in the [cabinet setup guide](docs/cabinet_setup.md). Use those assigned tokens for the current installation; generate new ones when provisioning additional identities or deliberately replacing a token. INI scalar values are unquoted; JSON values such as `cabinet_tokens` retain JSON quotes. Godot CFG strings use double quotes. The CFG belongs in Godot's user-data directory, not beside the executable. On Linux that is normally `~/.local/share/godot/app_userdata/ArcadeEscape/`; on Windows, `%APPDATA%/Godot/app_userdata/ArcadeEscape/`.
On each cabinet, create **arcade_network.cfg** in the application's Godot user-data directory: Nonempty launcher URL/token values override the CFG; inherited `ARCADE_ROOM_URL` and `ARCADE_CABINET_TOKEN` override those launcher values. The configured cabinet ID is passed as a command-line argument. Clear stale environment overrides when troubleshooting.
```ini The cabinet controller URL is `http://MASTER_PRIVATE_IP:8766`, not the cabinet's own IP or GPIO port. The default operator connection is plain HTTP for the internal network; do not expose it through public router port forwarding.
[network]
url="http://CONTROLLER_IP:8766"
cabinet_id="cabinet_01"
cabinet_token="your-generated-cabinet-one-token"
```
Use that cabinet's own ID and token. CFG strings use **double quotes**. ## Exports, startup, and deployment
| Machine | Typical configuration directory | Use the [master walkthrough's release workflow](docs/pacman_rfid_first_setup.md#3-export-package-and-copy-one-complete-release) as the source of truth:
| --- | --- |
| Raspberry Pi / Linux | `~/.local/share/godot/app_userdata/ArcadeEscape/` |
| Windows | `%APPDATA%/Godot/app_userdata/ArcadeEscape/` |
This file goes in the user-data directory, **not beside the executable**. `ARCADE_ROOM_URL` and `ARCADE_CABINET_TOKEN` environment variables override their file values; `--cabinet=...` overrides the cabinet ID. 1. Save resources and export `catalog.json` from the same project revision.
2. Build Linux ARM64 executable + PCK into a fresh release folder outside the repository; optionally build Windows separately.
3. Package the matching Python/controller assets with `master-package` and the cabinet files with `bundle`.
4. Configure site-specific files separately; compile the RFID helper on the cabinet.
5. Validate launcher settings, start the services, and run the physical acceptance test before enabling startup.
Start the managed client: The existing `setup/pc` and `setup/pi` folders are manual staging areas, **not proof of a current build**. Their checked-in artifacts are not automatically rebuilt when source changes. Do not assume they contain the 2.3 RFID feature until explicitly exported and verified. Exclude staged exports from the game PCK.
```sh The deployment tools provide a terminal setup wizard, separate site credentials, SSH enrollment, per-cabinet preflight, versioned uploads with manifest hashes, explicit activation, and service/status/log commands. They do not install a private DHCP/firewall configuration or offer a browser provisioning wizard. RFID-only provisioning still needs the manual route described above.
./ArcadeEscape.arm64 -- --managed-cabinet --cabinet=cabinet_01
```
This bypasses the game list and waits for the operator. It uses the controls previously saved through the launcher. Deploy the current export: the fleet controller requires protocol 2 (`arcade-cabinet-2`). The Pi user-service helper captures the desktop environment and refreshes it on login. It does not automatically enable cabinet boot startup; follow the walkthrough for desktop autologin/startup, or use the master remote-start workflow. No Godot editor is needed on a deployed cabinet or browser-only master.
### Enable physical GPIO outputs Back up site configuration, controller state, input mappings, and the GPIO deduplication ledger. Keep previous release bundles for rollback. Do not mix old PCKs, new catalogs, and unrelated Python releases.
Follow the [GPIO guide](tools/pi_gpio/README.md) to install Pi dependencies and verify the wiring with an LED first. GPIO action pins use **BCM numbering**, not physical header positions.
For the Cabinet 01 GPIO17 example, run the helper and game in separate terminals:
```sh
# Terminal 1: local GPIO helper
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
```
```sh
# Terminal 2: managed game with real outputs enabled
./ArcadeEscape.arm64 -- --managed-cabinet --cabinet=cabinet_01 --gpio=real
```
Copy the bridge script from [tools/pi_gpio/](tools/pi_gpio/) to the Pi. Match its controller ID and allowed pins to that cabinet's hardware resources.
| Port | Service | Address used |
| --- | --- | --- |
| **8766** | Room controller and browser operator | Controller machine's LAN address |
| **8765** | GPIO bridge | `127.0.0.1` on the cabinet Pi |
Keep the room URL on **8766** even when the GPIO bridge reports **8765**. An accepted GPIO command confirms the software call; without sensor feedback it does not confirm physical coin delivery.
## 6. Run a room session
1. Confirm each required cabinet is **ONLINE** in the browser operator.
2. Select a scenario and choose **Prepare assigned cabinets**.
3. Wait for each cabinet to acknowledge **READY**, then start it remotely or with its physical Start button.
4. Monitor game state, event history, and hardware command acknowledgements.
5. Use **Reset cabinet…** to end its session before changing assignments.
Scenario preparation validates the complete assignment list before dispatch, including connectivity and game compatibility. Cabinets acknowledge independently. Manual game assignments intentionally run without inherited scenario rewards.
The sample [proof_of_concept](data/scenarios/proof_of_concept.tres) connects a Pac-Man win on Cabinet 01 to its `coin_release` action. The [two_cabinet_test](data/scenarios/two_cabinet_test.tres) demonstrates separate cabinet assignments and event mappings.
The older `-- --operator` Godot mode remains available for Cabinet 01 quick controls. Use the browser interface for multiple cabinets and the room map.
For a sequential two-Pi test, choose **Cabinet 1 -> Cabinet 2**: winning Pac-Man on the first cabinet loads and automatically starts Galaga on the second. See the [chain-test walkthrough](tools/room_controller/README.md#two-pi-chain-test-cabinet-1-wins---cabinet-2-starts).
## Troubleshooting
| Symptom | Check |
| --- | --- |
| Controls differ between Windows and Pi | Run Control Setup on the Pi; do not copy Windows mappings blindly. |
| Room connection token is not configured | Check the user-data CFG location, double quotes, role-specific token, and environment overrides. |
| Unauthorized | The service is reachable; check the token and cabinet ID against the controller configuration. |
| Cabinet stays offline | Check the controller IP, port 8766, firewall, running client, and export protocol version. |
| A game is missing from the compatible list | Update the cabinet's capabilities, regenerate the catalog, and redeploy the cabinet resources. |
| GPIO command fails or LED stays off | Check real mode, local helper, controller ID, pin allowlist, and the wiring test in the GPIO guide. |
| Delivery is unconfirmed | Software acknowledgement is available, but physical feedback is not; inspect the output. |
## Repository layout
```text
addons/escape_room/ Godot editor plugin
core/ Game contracts, resources, runtime, controls, networking
editor/ Room-authoring workspace and simulation tools
data/ Saved games, cabinets, rooms, scenarios, and mappings
games/ Seven games, art/audio, launcher, and control setup
operator/ Godot operator scenes
main/ Room runtime entry scene
tools/room_controller/ Python controller, browser dashboard, catalog exporter
tools/pi_gpio/ Local Raspberry Pi GPIO bridge
tests/ Godot and Python verification scripts
```
## Development checks ## Development checks
Run commands from the repository root, replacing `godot` with your executable as needed: Run from the project root; replace `godot` with the project's console executable and `python3` with `py -3` on Windows as needed:
```sh ```sh
python3 -m unittest discover -s tests/room_network -v python3 -m unittest discover -s tests/room_network -v
python3 -m unittest discover -s tests/gpio -v python3 -m unittest discover -s tests/gpio -v
godot --headless --path . --script tests/room_network/check_capabilities.gd godot --headless --path . --script res://tests/pcb/check_pcb.gd
godot --headless --path . --script tests/launcher/check_launcher.gd godot --headless --path . --script res://tests/pcb/check_rfid_credit.gd
godot --headless --path . --script tests/launcher/check_controls.gd godot --headless --path . --script res://tests/room_editor/check_room_view.gd
python3 tests/room_network/check_integration.py /path/to/godot godot --headless --path . --script res://tests/launcher/check_controls.gd
``` ```
On Windows, `py -3` can replace `python3`. The network integration test launches two headless Godot clients and temporary services on ports 8765/8766, forces test-only wins, and checks a fake GPIO output. Stop any real services using those ports before running it. These tests do not replace physical Pi, wiring, or iPad checks. Controller tests use a stable historical catalog fixture so designer changes do not alter protocol tests; the RFID scenario test also checks the actual exported catalog. Network/GPIO tests use temporary services and fake hardware, not physical reader/actuator validation.
## Current scope Last recorded verification during this work: 66 controller tests and the RFID credit checks passed; the prior GPIO suite passed 10 tests. The broader `tests/test_pacman.gd` suite reported one ghost-capture assertion failure in unchanged model code. Godot test shutdown also reported resource-leak warnings. These are known validation limitations, not a claim that every project test passes. New Pi exports, RFID compilation, card compatibility, audio, and end-to-end physical operation still need installation testing.
The project supports standalone arcade play, room authoring and simulation, multiple managed cabinets, scenario assignments, a browser room map, and real GPIO outputs. External sensor inputs, detailed live puzzle-step diagrams, coordinated finale variants, and room timers/hints remain future work. Device locations on the map currently provide layout context rather than live physical sensor state. ## Troubleshooting
| Symptom | First checks |
| --- | --- |
| Controls differ on Pi | Run Control Setup locally; do not assume Windows button indices match. |
| Unauthorized | Correct role/token and cabinet ID; check inherited environment overrides. |
| Cabinet offline | Master URL/8766, network, running client, matching export version. |
| Game unavailable | Cabinet capabilities and matching exported catalog/resources. |
| RFID scenario rejected | Cabinet 2.3, real GPIO, matching reader ID in the exported PCB inventory. |
| RFID pin conflict | Explicit empty `pins =` for RFID-only testing. BCM17/27/22/20 and SPI0 are reserved for readers. |
| UID visible but no credit | Prepare the RFID scenario, then remove/re-present at J1; check the accepted-card list. |
| Output delivery unconfirmed | GPIO acknowledgement/electrical state does not confirm physical movement; use an appropriate sensor. |
| Reader stopped or stale | Helper process, SPI/GPIO permissions, wiring, and competing Odin/bridge processes. |
## Repository layout
```text
addons/escape_room/ Editor plugin and PCB inspector registration
core/ Runtime, networking, controls, room/cabinet/scenario resources
editor/ Authoring workspace, physical room and PCB editors
data/ Saved games, cabinets, room layouts and scenarios
games/ Seven games, assets, launcher and control setup
operator/ Optional Godot operator scenes
main/ Room runtime entry scene
tools/room_controller/ Python controller, browser hub, catalog exporter
tools/pi_gpio/ Local GPIO bridge, feedback sampler, Rust RFID helper source
tools/launch/ Supervised launchers, Pi service helper, remote SSH startup
tools/deploy/ Packaging, site setup, staged deployment and activation
setup/pc/, setup/pi/ Existing manually staged exports/configuration examples
docs/ Setup, deployment and designer walkthroughs
tests/ Godot/Python checks and isolated fixtures
```
## Remaining work
Physical RFID commissioning, complete RFID support in the fleet wizard, browser doorway rendering, additional cabinet artwork, general sensor-to-scenario rules, detailed puzzle-step views, coordinated finale variants, and room timers/hints remain future work. The hub can link to other services; it is not yet a full multi-room orchestration system.
+5
View File
@@ -15,15 +15,20 @@ const ROOM_SETTING := (
) )
var pcb_inspector: EditorInspectorPlugin
var workspace: EscapeRoomWorkspace var workspace: EscapeRoomWorkspace
func _enter_tree() -> void: func _enter_tree() -> void:
pcb_inspector = preload("res://addons/escape_room/pcb_inspector.gd").new()
add_inspector_plugin(pcb_inspector)
_ensure_project_settings() _ensure_project_settings()
_create_workspace() _create_workspace()
func _exit_tree() -> void: func _exit_tree() -> void:
remove_inspector_plugin(pcb_inspector)
pcb_inspector = null
_destroy_workspace() _destroy_workspace()
+13
View File
@@ -0,0 +1,13 @@
@tool
extends EditorInspectorPlugin
const PROPERTY = preload("res://editor/hardware/pcb_editor_property.gd")
func _can_handle(object: Object) -> bool:
return object is CabinetProfile
func _parse_property(_object: Object, _type: Variant.Type, name: String, _hint: PropertyHint, _hint_string: String, _usage: int, _wide: bool) -> bool:
if name == "pcb_config":
add_property_editor(name, PROPERTY.new())
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://dxenrkrq6ko7e
+92
View File
@@ -0,0 +1,92 @@
@tool
class_name CabinetPCBConfig
extends Resource
# This resource describes wiring. It never configures or writes GPIO.
@export var connections: Array[PCBConnection] = []
const TYPES := {
"rfid": "RFID reader", "button": "Button / switch", "sensor": "Digital sensor",
"led": "LED / lighting driver", "motor": "Motorized switch / motor driver",
"relay": "Relay / MOSFET module", "i2c": "I2C peripheral", "other": "Other (document wiring)"
}
static func ports() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for index in range(8):
result.append({"id": "J%d" % (index + 1), "family": "reader", "label": "%d%d%d" % [(index >> 2) & 1, (index >> 1) & 1, index & 1], "pins": "Selector: BCM22=%d, BCM27=%d, BCM17=%d\nShared reset: BCM20. Reader connector contact pinout is not documented." % [(index >> 2) & 1, (index >> 1) & 1, index & 1], "types": ["rfid", "other"], "bcm": -1})
result.append({"id": "J9", "family": "host", "label": "Pi ribbon", "pins": "Host GPIO ribbon connector. Confirm pin-1 orientation against the physical board.", "types": [], "bcm": -1})
var signals := [5, 6, 13, 19, 26, 21]
for index in range(6):
result.append({"id": "J%d" % (10 + index), "family": "powered", "label": "BCM%d" % signals[index], "pins": "1 GND (black) | 2 3V3 (red) | 3 signal (yellow)\nBCM%d" % signals[index], "types": ["button", "sensor", "led", "motor", "relay", "other"], "bcm": signals[index]})
result.append({"id": "J16", "family": "i2c", "label": "I2C1", "pins": "1 GND (black) | 2 3V3 (red) | 3 SCL (white) | 4 SDA (yellow)", "types": ["i2c", "other"], "bcm": -1})
signals = [16, 12, 24, 23, 18, 25]
for index in range(6):
result.append({"id": "J%d" % (17 + index), "family": "signal", "label": "BCM%d" % signals[index], "pins": "1 GND (black) | 2 signal (red)\nBCM%d - red is signal, not a power supply." % signals[index], "types": ["button", "sensor", "led", "motor", "relay", "other"], "bcm": signals[index]})
return result
static func port(id: String) -> Dictionary:
for definition in ports():
if definition.id == id:
return definition
return {}
func connection(id: String) -> PCBConnection:
for item in connections:
if item != null and item.port_id == id:
return item
return null
func set_connection(item: PCBConnection) -> void:
for index in range(connections.size() - 1, -1, -1):
if connections[index] != null and connections[index].port_id == item.port_id:
connections.remove_at(index)
if not item.device_type.is_empty():
connections.append(item)
emit_changed()
func issues(hardware: HardwareConfig = null) -> PackedStringArray:
var messages := PackedStringArray()
var used_ports: Dictionary = {}
var used_ids: Dictionary = {}
for item in connections:
if item == null:
messages.append("Empty connection resource.")
continue
var definition := port(item.port_id)
if definition.is_empty() or not definition.types.has(item.device_type):
messages.append("%s: unsupported connector/device combination." % item.port_id)
if used_ports.has(item.port_id):
messages.append("%s: connector is assigned more than once." % item.port_id)
used_ports[item.port_id] = true
if item.device_id.strip_edges().is_empty():
messages.append("%s: enter a device ID." % item.port_id)
elif used_ids.has(item.device_id):
messages.append("Duplicate device ID: %s." % item.device_id)
used_ids[item.device_id] = true
if item.device_type in ["led", "motor", "relay"] and not item.binding_id.is_empty():
var action: HardwareAction = hardware.get_action(StringName(item.binding_id)) if hardware else null
if action == null:
messages.append("%s: hardware action '%s' does not exist." % [item.port_id, item.binding_id])
elif not definition.is_empty() and action.gpio_pin != definition.bcm:
messages.append("%s: action uses BCM%d, but connector uses BCM%d." % [item.port_id, action.gpio_pin, definition.bcm])
if hardware:
for action in hardware.actions:
if action != null and action.gpio_pin in [17, 27, 22, 20]:
messages.append("Action '%s' uses reserved PCB selector/reset BCM%d." % [action.action_id, action.gpio_pin])
return messages
func feedback_devices() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for item in connections:
if item == null:
continue
var kind := ""
if item.device_type == "rfid": kind = "rfid"
elif item.device_type in ["button", "sensor"]: kind = "input"
elif item.device_type in ["led", "motor", "relay"]: kind = "output"
else:
push_warning("Feedback driver not implemented for %s (%s)" % [item.device_id, item.device_type])
continue
result.append({"id": item.device_id, "name": item.display_name, "port": item.port_id, "kind": kind, "pull_up": item.input_pull_up})
return result
+1
View File
@@ -0,0 +1 @@
uid://c0g0mq4b3d547
+4
View File
@@ -10,6 +10,10 @@ extends Resource
@export var hardware_config: HardwareConfig @export var hardware_config: HardwareConfig
@export_group("PCB Connections")
@export var pcb_config: CabinetPCBConfig
@export_group("")
const FEET_TO_METERS: float = 0.3048 const FEET_TO_METERS: float = 0.3048
const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS
+12
View File
@@ -0,0 +1,12 @@
@tool
class_name PCBConnection
extends Resource
@export var port_id: String = ""
@export var device_type: String = ""
@export var device_id: String = ""
@export var display_name: String = ""
@export var binding_id: String = ""
@export_multiline var notes: String = ""
@export var input_pull_up: bool = true
+1
View File
@@ -0,0 +1 @@
uid://burui20kuayc2
+21 -2
View File
@@ -1,5 +1,5 @@
extends Node extends Node
const VERSION := "arcade-cabinet-2" const VERSION := "arcade-cabinet-2.3"
var cabinet_id := "cabinet_01" var cabinet_id := "cabinet_01"
var database: ProjectDatabase = preload("res://data/project_database.tres") var database: ProjectDatabase = preload("res://data/project_database.tres")
var cabinet: CabinetProfile var cabinet: CabinetProfile
@@ -14,6 +14,14 @@ var busy := false
var executing := false var executing := false
var event_number := 0 var event_number := 0
var notice: Label var notice: Label
var test_win_remaining := -1.0
func _process(delta: float) -> void:
if phase != "playing" or test_win_remaining < 0.0 or game == null: return
test_win_remaining -= delta
if test_win_remaining <= 0.0:
test_win_remaining = -1.0
game.win_game()
func _ready() -> void: func _ready() -> void:
var config := ConfigFile.new() var config := ConfigFile.new()
@@ -47,9 +55,13 @@ func _ready() -> void:
func poll() -> void: func poll() -> void:
if busy or executing: return if busy or executing: return
busy = true busy = true
if game != null and phase == "ready" and game.has_method("receive_device_feedback") and HardwareManager.real_gpio and HardwareManager.client != null:
game.receive_device_feedback(HardwareManager.client.get_feedback())
var sent := reports.duplicate(true) var sent := reports.duplicate(true)
var result: Dictionary = await connection.post("/cabinet/poll", { var result: Dictionary = await connection.post("/cabinet/poll", {
"cabinet": cabinet_id, "protocol": 2, "boot": boot, "version": VERSION, "cabinet": cabinet_id, "protocol": 2, "boot": boot, "version": VERSION,
"features": ["assignment_config_v1", "device_feedback_v1", "rfid_credit_v1"],
"feedback": HardwareManager.client.get_feedback() if HardwareManager.real_gpio and HardwareManager.client != null else {},
"gpio": "real" if HardwareManager.real_gpio else "simulation", "reports": sent, "gpio": "real" if HardwareManager.real_gpio else "simulation", "reports": sent,
}) })
busy = false busy = false
@@ -70,6 +82,7 @@ func poll() -> void:
notice.text = "Waiting for the room operator" if game == null else "" notice.text = "Waiting for the room operator" if game == null else ""
func unload() -> void: func unload() -> void:
test_win_remaining = -1.0
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if is_instance_valid(game): if is_instance_valid(game):
remove_child(game) remove_child(game)
@@ -77,6 +90,10 @@ func unload() -> void:
game = null game = null
func event(name: String) -> void: func event(name: String) -> void:
if name == "started" and game != null:
test_win_remaining = float(game.run_config.get("test_auto_win_seconds", -1.0))
elif name in ["won", "lost"]:
test_win_remaining = -1.0
event_number += 1 event_number += 1
reports.append({"kind": "event", "id": boot + ":" + str(event_number), "session": session, "event": name}) reports.append({"kind": "event", "id": boot + ":" + str(event_number), "session": session, "event": name})
phase = {"started": "playing", "won": "won", "lost": "lost"}.get(name, phase) phase = {"started": "playing", "won": "won", "lost": "lost"}.get(name, phase)
@@ -110,7 +127,9 @@ func execute(command: Dictionary) -> void:
if str(definition.game_id) == "missile_command": game.set("relative_mouse_aim", true) if str(definition.game_id) == "missile_command": game.set("relative_mouse_aim", true)
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if str(definition.game_id) in ["missile_command", "tempest"] else Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if str(definition.game_id) in ["missile_command", "tempest"] else Input.MOUSE_MODE_VISIBLE
add_child(game) add_child(game)
game.configure({}) game.configure(command.get("game_config", {}).duplicate(true))
if game.has_method("receive_device_feedback") and HardwareManager.client != null:
game.receive_device_feedback(HardwareManager.client.get_feedback())
game.game_started.connect(func(): event("started")) game.game_started.connect(func(): event("started"))
game.game_won.connect(func(): event("won")) game.game_won.connect(func(): event("won"))
game.game_lost.connect(func(): event("lost")) game.game_lost.connect(func(): event("lost"))
+3
View File
@@ -7,6 +7,9 @@ const FEET_TO_METERS: float = 0.3048
const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS
@export var is_doorway: bool = false
@export var swing_reversed: bool = false
@export var wall_id: StringName = &"" @export var wall_id: StringName = &""
@export var start: Vector2 = Vector2.ZERO @export var start: Vector2 = Vector2.ZERO
@export var end: Vector2 = Vector2( @export var end: Vector2 = Vector2(
+18
View File
@@ -5,6 +5,8 @@ const URL := "http://127.0.0.1:8765"
var pending: Array[Dictionary] = [] var pending: Array[Dictionary] = []
var serial := 0 var serial := 0
var busy := false var busy := false
var device_feedback: Dictionary = {}
var feedback_received_ms: int = 0
var heartbeat: HTTPRequest var heartbeat: HTTPRequest
func _ready() -> void: func _ready() -> void:
@@ -12,6 +14,7 @@ func _ready() -> void:
heartbeat = HTTPRequest.new() heartbeat = HTTPRequest.new()
heartbeat.timeout = 1.0 heartbeat.timeout = 1.0
add_child(heartbeat) add_child(heartbeat)
heartbeat.request_completed.connect(_feedback_response)
var timer := Timer.new() var timer := Timer.new()
timer.wait_time = 1.0 timer.wait_time = 1.0
timer.timeout.connect(_heartbeat) timer.timeout.connect(_heartbeat)
@@ -56,3 +59,18 @@ func _pump() -> void:
status_changed.emit("GPIO %s accepted by Pi bridge" % item.payload.pin if ok else message) status_changed.emit("GPIO %s accepted by Pi bridge" % item.payload.pin if ok else message)
completed.emit(item.id, ok) completed.emit(item.id, ok)
_pump.call_deferred() _pump.call_deferred()
func _feedback_response(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or code != 200:
return
var data = JSON.parse_string(body.get_string_from_utf8())
if data is Dictionary and data.get("feedback") is Dictionary:
device_feedback = data.feedback
feedback_received_ms = Time.get_ticks_msec()
func get_feedback() -> Dictionary:
var result := device_feedback.duplicate(true)
result["fresh"] = feedback_received_ms > 0 and Time.get_ticks_msec() - feedback_received_ms < 4000
for device in result.get("devices", []):
device["age_ms"] = int(device.get("age_ms", 999999)) + maxi(0, Time.get_ticks_msec() - feedback_received_ms)
return result
+2
View File
@@ -9,6 +9,8 @@ extends Resource
@export var variant: StringName = &"standard" @export var variant: StringName = &"standard"
## Managed controller: load and auto-start only after this cabinet wins. ## Managed controller: load and auto-start only after this cabinet wins.
@export var activate_after_win: StringName = &"" @export var activate_after_win: StringName = &""
## Per-assignment objectives; empty uses the game defaults.
@export var game_config: Dictionary = {}
@export var event_mappings: Array[ScenarioEventMapping] = [] @export var event_mappings: Array[ScenarioEventMapping] = []
+2
View File
@@ -154,6 +154,7 @@ func _create_working_copy() -> void:
) )
assignment.activate_after_win = source_assignment.activate_after_win assignment.activate_after_win = source_assignment.activate_after_win
assignment.game_config = source_assignment.game_config.duplicate(true)
assignment.event_mappings = ( assignment.event_mappings = (
_duplicate_mappings( _duplicate_mappings(
@@ -260,6 +261,7 @@ func _capture_scenario_state(
"game_id": StringName(), "game_id": StringName(),
"variant": assignment.variant, "variant": assignment.variant,
"activate_after_win": assignment.activate_after_win, "activate_after_win": assignment.activate_after_win,
"game_config": assignment.game_config.duplicate(true),
"event_mappings": [], "event_mappings": [],
} }
+2
View File
@@ -401,6 +401,8 @@ func _capture_room_state(
"start": wall.start, "start": wall.start,
"end": wall.end, "end": wall.end,
"thickness": wall.thickness, "thickness": wall.thickness,
"is_doorway": wall.is_doorway,
"swing_reversed": wall.swing_reversed,
} }
) )
+53 -2
View File
@@ -3,13 +3,64 @@
[ext_resource type="Resource" uid="uid://cxna0oimf8mop" path="res://data/cabinets/cabinet_01_hardware.tres" id="1_b4cnm"] [ext_resource type="Resource" uid="uid://cxna0oimf8mop" path="res://data/cabinets/cabinet_01_hardware.tres" id="1_b4cnm"]
[ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_hv02u"] [ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_hv02u"]
[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_v1x8f"] [ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_v1x8f"]
[ext_resource type="Script" uid="uid://burui20kuayc2" path="res://core/hardware/pcb_connection.gd" id="3_0aat6"]
[ext_resource type="Script" uid="uid://c0g0mq4b3d547" path="res://core/hardware/cabinet_pcb_config.gd" id="4_w65ol"]
[sub_resource type="Resource" id="Resource_ac1o8"]
script = ExtResource("3_0aat6")
port_id = "J17"
device_type = "button"
device_id = "cab1_btn_red"
display_name = "Red Button"
[sub_resource type="Resource" id="Resource_gto24"]
script = ExtResource("3_0aat6")
port_id = "J18"
device_type = "button"
device_id = "cab1_btn_grn"
display_name = "Green Button"
[sub_resource type="Resource" id="Resource_pqv68"]
script = ExtResource("3_0aat6")
port_id = "J19"
device_type = "button"
device_id = "cab1_btn_blue"
display_name = "Blue Button"
[sub_resource type="Resource" id="Resource_33hyq"]
script = ExtResource("3_0aat6")
port_id = "J20"
device_type = "button"
device_id = "cab1_btn_ylw"
display_name = "Yellow Btn"
[sub_resource type="Resource" id="Resource_qgibf"]
script = ExtResource("3_0aat6")
port_id = "J2"
device_type = "rfid"
device_id = "cab1_rfid2"
display_name = "RFID Reader 2"
binding_id = "credit_inserted_p2"
[sub_resource type="Resource" id="Resource_e5qub"]
script = ExtResource("3_0aat6")
port_id = "J1"
device_type = "rfid"
device_id = "cab1_rfid1"
display_name = "RFID Reader 1"
binding_id = "credit_inserted_p1"
[sub_resource type="Resource" id="Resource_npy45"]
script = ExtResource("4_w65ol")
connections = Array[ExtResource("3_0aat6")]([SubResource("Resource_ac1o8"), SubResource("Resource_gto24"), SubResource("Resource_pqv68"), SubResource("Resource_33hyq"), SubResource("Resource_qgibf"), SubResource("Resource_e5qub")])
[resource] [resource]
script = ExtResource("1_v1x8f") script = ExtResource("1_v1x8f")
cabinet_id = &"cabinet_01" cabinet_id = &"cabinet_01"
display_name = "Master Cabinet" display_name = "Cabinet 01"
capabilities = Array[StringName]([&"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"]) capabilities = Array[StringName]([&"joystick_4way", &"start_button", &"select_button"])
hardware_config = ExtResource("1_b4cnm") hardware_config = ExtResource("1_b4cnm")
pcb_config = SubResource("Resource_npy45")
footprint_width_m = 0.9144000000000001 footprint_width_m = 0.9144000000000001
footprint_depth_m = 0.9144000000000001 footprint_depth_m = 0.9144000000000001
height_m = 1.8288000000000002 height_m = 1.8288000000000002
+6 -4
View File
@@ -5,16 +5,18 @@
[sub_resource type="Resource" id="Resource_ogdj5"] [sub_resource type="Resource" id="Resource_ogdj5"]
script = ExtResource("1_w3p60") script = ExtResource("1_w3p60")
action_id = &"coin_release" action_id = &"coin_slot_1"
display_name = "Coin Release" display_name = "Coin Slot"
action_type = 0
gpio_pin = 0
pulse_duration_ms = 600 pulse_duration_ms = 600
metadata/_custom_type_script = "uid://c7eu1n5yey1x4" metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[sub_resource type="Resource" id="Resource_gu74o"] [sub_resource type="Resource" id="Resource_gu74o"]
script = ExtResource("1_w3p60") script = ExtResource("1_w3p60")
action_id = &"start_light" action_id = &"lock_released"
display_name = "Start Light" display_name = "Start Light"
gpio_pin = 20 gpio_pin = 0
metadata/_custom_type_script = "uid://c7eu1n5yey1x4" metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[resource] [resource]
+52 -1
View File
@@ -3,13 +3,64 @@
[ext_resource type="Resource" uid="uid://ddv64yg3h5vsi" path="res://data/cabinets/cabinet_02_hardware.tres" id="1_bfw68"] [ext_resource type="Resource" uid="uid://ddv64yg3h5vsi" path="res://data/cabinets/cabinet_02_hardware.tres" id="1_bfw68"]
[ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_lrfm7"] [ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_lrfm7"]
[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="2_lrfm7"] [ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="2_lrfm7"]
[ext_resource type="Script" uid="uid://burui20kuayc2" path="res://core/hardware/pcb_connection.gd" id="3_xgtuu"]
[ext_resource type="Script" uid="uid://c0g0mq4b3d547" path="res://core/hardware/cabinet_pcb_config.gd" id="4_1abnh"]
[sub_resource type="Resource" id="Resource_ac1o8"]
script = ExtResource("3_xgtuu")
port_id = "J17"
device_type = "button"
device_id = "cab1_btn_red"
display_name = "Red Button"
[sub_resource type="Resource" id="Resource_gto24"]
script = ExtResource("3_xgtuu")
port_id = "J18"
device_type = "button"
device_id = "cab1_btn_grn"
display_name = "Green Button"
[sub_resource type="Resource" id="Resource_pqv68"]
script = ExtResource("3_xgtuu")
port_id = "J19"
device_type = "button"
device_id = "cab1_btn_blue"
display_name = "Blue Button"
[sub_resource type="Resource" id="Resource_33hyq"]
script = ExtResource("3_xgtuu")
port_id = "J20"
device_type = "button"
device_id = "cab1_btn_ylw"
display_name = "Yellow Btn"
[sub_resource type="Resource" id="Resource_qgibf"]
script = ExtResource("3_xgtuu")
port_id = "J2"
device_type = "rfid"
device_id = "cab1_rfid2"
display_name = "RFID Reader 2"
binding_id = "credit_inserted_p2"
[sub_resource type="Resource" id="Resource_e5qub"]
script = ExtResource("3_xgtuu")
port_id = "J1"
device_type = "rfid"
device_id = "cab1_rfid1"
display_name = "RFID Reader 1"
binding_id = "credit_inserted_p1"
[sub_resource type="Resource" id="Resource_npy45"]
script = ExtResource("4_1abnh")
connections = Array[ExtResource("3_xgtuu")]([SubResource("Resource_ac1o8"), SubResource("Resource_gto24"), SubResource("Resource_pqv68"), SubResource("Resource_33hyq"), SubResource("Resource_qgibf"), SubResource("Resource_e5qub")])
[resource] [resource]
script = ExtResource("2_lrfm7") script = ExtResource("2_lrfm7")
cabinet_id = &"cabinet_02" cabinet_id = &"cabinet_02"
display_name = "Cabinet 02" display_name = "Cabinet 02"
capabilities = Array[StringName]([&"joystick_4way", &"button_1", &"start_button", &"joystick_horizontal"]) capabilities = Array[StringName]([&"joystick_horizontal", &"start_button", &"select_button", &"button_1"])
hardware_config = ExtResource("1_bfw68") hardware_config = ExtResource("1_bfw68")
pcb_config = SubResource("Resource_npy45")
footprint_width_m = 0.9144000000000001 footprint_width_m = 0.9144000000000001
footprint_depth_m = 0.9144000000000001 footprint_depth_m = 0.9144000000000001
height_m = 1.8288000000000002 height_m = 1.8288000000000002
+4 -12
View File
@@ -11,23 +11,15 @@ gpio_pin = 27
pulse_duration_ms = 400 pulse_duration_ms = 400
metadata/_custom_type_script = "uid://c7eu1n5yey1x4" metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[sub_resource type="Resource" id="Resource_dasqc"] [sub_resource type="Resource" id="Resource_gu74o"]
script = ExtResource("1_dasqc") script = ExtResource("1_dasqc")
action_id = &"start_light" action_id = &"lock_released"
display_name = "Start Light" display_name = "Start Light"
gpio_pin = 20 gpio_pin = 0
metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[sub_resource type="Resource" id="Resource_t81ly"]
script = ExtResource("1_dasqc")
action_id = &"glitch_code"
display_name = "Glitch Code"
gpio_pin = 21
pulse_duration_ms = 1200
metadata/_custom_type_script = "uid://c7eu1n5yey1x4" metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[resource] [resource]
script = ExtResource("2_ye2fg") script = ExtResource("2_ye2fg")
controller_id = &"cabinet_02_gpio" controller_id = &"cabinet_02_gpio"
actions = Array[ExtResource("1_dasqc")]([SubResource("Resource_ogdj5"), SubResource("Resource_dasqc"), SubResource("Resource_t81ly")]) actions = Array[ExtResource("1_dasqc")]([SubResource("Resource_ogdj5"), SubResource("Resource_gu74o")])
metadata/_custom_type_script = "uid://60whm57itpjb" metadata/_custom_type_script = "uid://60whm57itpjb"
+9 -4
View File
@@ -1,15 +1,20 @@
[gd_resource type="Resource" script_class="CabinetProfile" format=3 uid="uid://d4ns05ptr4ybw"] [gd_resource type="Resource" script_class="CabinetProfile" format=3 uid="uid://d4ns05ptr4ybw"]
[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_0s4iw"] [ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_0s4iw"]
[ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_km331"]
[ext_resource type="Resource" uid="uid://4t6n8gvu717o" path="res://data/cabinets/cabinet_03_hardware.tres" id="1_ojmvu"] [ext_resource type="Resource" uid="uid://4t6n8gvu717o" path="res://data/cabinets/cabinet_03_hardware.tres" id="1_ojmvu"]
[resource] [resource]
script = ExtResource("1_0s4iw") script = ExtResource("1_0s4iw")
cabinet_id = &"cabinet_03" cabinet_id = &"cabinet_03"
display_name = "Cabinet 03" display_name = "Cabinet 03"
capabilities = Array[StringName]([&"spinner", &"button_1", &"start_button"]) capabilities = Array[StringName]([&"spinner", &"button_1", &"start_button", &"select_button"])
hardware_config = ExtResource("1_ojmvu") hardware_config = ExtResource("1_ojmvu")
footprint_width_m = 1.0 footprint_width_m = 0.9144000000000001
footprint_depth_m = 1.0 footprint_depth_m = 0.9144000000000001
height_m = 2.0 height_m = 0.9144000000000001
footprint_width_ft = 3.0
footprint_depth_ft = 3.0
height_ft = 3.0
editor_texture = ExtResource("1_km331")
metadata/_custom_type_script = "uid://c0jj5j5loqsma" metadata/_custom_type_script = "uid://c0jj5j5loqsma"
+11 -4
View File
@@ -5,14 +5,21 @@
[sub_resource type="Resource" id="Resource_ogdj5"] [sub_resource type="Resource" id="Resource_ogdj5"]
script = ExtResource("1_2vvyl") script = ExtResource("1_2vvyl")
action_id = &"coin_release" action_id = &"coin_slot_1"
display_name = "Coin Release" display_name = "Coin Slot 1"
action_type = 0
gpio_pin = 27 gpio_pin = 27
pulse_duration_ms = 400 pulse_duration_ms = 400
metadata/_custom_type_script = "uid://c7eu1n5yey1x4" metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[sub_resource type="Resource" id="Resource_ojmvu"]
script = ExtResource("1_2vvyl")
action_id = &"lock_released"
display_name = "Lock Released"
metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[resource] [resource]
script = ExtResource("2_hx0bc") script = ExtResource("2_hx0bc")
controller_id = &"cabinet_02_gpio" controller_id = &"cabinet_03_gpio"
actions = Array[ExtResource("1_2vvyl")]([SubResource("Resource_ogdj5")]) actions = Array[ExtResource("1_2vvyl")]([SubResource("Resource_ogdj5"), SubResource("Resource_ojmvu")])
metadata/_custom_type_script = "uid://60whm57itpjb" metadata/_custom_type_script = "uid://60whm57itpjb"
+20
View File
@@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="CabinetProfile" format=3 uid="uid://cwa2ord52vdda"]
[ext_resource type="Resource" uid="uid://dbcideufl5qhi" path="res://data/cabinets/cabinet_04_hardware.tres" id="1_2m3i6"]
[ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_8nqkk"]
[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_a4ctu"]
[resource]
script = ExtResource("1_a4ctu")
cabinet_id = &"cabinet_04"
display_name = "Cabinet 4"
capabilities = Array[StringName]([&"roller", &"button_1", &"button_2", &"button_3", &"select_button", &"start_button"])
hardware_config = ExtResource("1_2m3i6")
footprint_width_m = 0.9144000000000001
footprint_depth_m = 0.9144000000000001
height_m = 0.9144000000000001
footprint_width_ft = 3.0
footprint_depth_ft = 3.0
height_ft = 3.0
editor_texture = ExtResource("1_8nqkk")
metadata/_custom_type_script = "uid://c0jj5j5loqsma"
+22
View File
@@ -0,0 +1,22 @@
[gd_resource type="Resource" script_class="HardwareConfig" format=3 uid="uid://dbcideufl5qhi"]
[ext_resource type="Script" uid="uid://c7eu1n5yey1x4" path="res://core/hardware/hardware_action.gd" id="1_lpxoo"]
[ext_resource type="Script" uid="uid://60whm57itpjb" path="res://core/hardware/hardware_config.gd" id="2_tu3fw"]
[sub_resource type="Resource" id="Resource_jt2rb"]
script = ExtResource("1_lpxoo")
action_id = &"coin_slot_1"
display_name = "Coin Slot 1"
metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[sub_resource type="Resource" id="Resource_axg2k"]
script = ExtResource("1_lpxoo")
action_id = &"lock_released"
display_name = "Lock Released"
metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[resource]
script = ExtResource("2_tu3fw")
controller_id = &"cabinet_04_gpio"
actions = Array[ExtResource("1_lpxoo")]([SubResource("Resource_jt2rb"), SubResource("Resource_axg2k")])
metadata/_custom_type_script = "uid://60whm57itpjb"
+8 -6
View File
@@ -9,20 +9,22 @@
[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="4_bfrqh"] [ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="4_bfrqh"]
[ext_resource type="Resource" uid="uid://d4ns05ptr4ybw" path="res://data/cabinets/cabinet_03.tres" id="4_gbm0a"] [ext_resource type="Resource" uid="uid://d4ns05ptr4ybw" path="res://data/cabinets/cabinet_03.tres" id="4_gbm0a"]
[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="5_b427t"] [ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="5_b427t"]
[ext_resource type="Resource" uid="uid://cqn58n5ahydil" path="res://data/scenarios/proof_of_concept.tres" id="7_gbm0a"] [ext_resource type="Resource" uid="uid://cwa2ord52vdda" path="res://data/cabinets/cabinet_04.tres" id="5_wqg7d"]
[ext_resource type="Resource" path="res://data/games/tempest.tres" id="8_0ke0w"] [ext_resource type="Resource" path="res://data/games/tempest.tres" id="8_0ke0w"]
[ext_resource type="Resource" uid="uid://tp7cvbnbhcrj" path="res://data/scenarios/galaga_proof_of_concept.tres" id="8_ib4xl"]
[ext_resource type="Resource" path="res://data/games/missile_command.tres" id="9_0ke0w"] [ext_resource type="Resource" path="res://data/games/missile_command.tres" id="9_0ke0w"]
[ext_resource type="Resource" path="res://data/games/frogger.tres" id="10_8pm0t"] [ext_resource type="Resource" path="res://data/games/frogger.tres" id="10_8pm0t"]
[ext_resource type="Resource" uid="uid://c07wbog147dml" path="res://data/scenarios/two_cabinet_test.tres" id="10_b427t"] [ext_resource type="Resource" uid="uid://c07wbog147dml" path="res://data/scenarios/two_cabinet_test.tres" id="10_b427t"]
[ext_resource type="Resource" uid="uid://yiejlr5h143m" path="res://data/games/space_invaders.tres" id="11_space_invaders"] [ext_resource type="Resource" uid="uid://yiejlr5h143m" path="res://data/games/space_invaders.tres" id="11_space_invaders"]
[ext_resource type="Resource" path="res://data/games/robotron.tres" id="12_robotron"] [ext_resource type="Resource" uid="uid://bn8vgu5vrevgx" path="res://data/games/robotron.tres" id="12_robotron"]
[ext_resource type="Resource" uid="uid://crijr0r2ikxs1" path="res://data/scenarios/eg_glitch.tres" id="18_yfaxe"]
[ext_resource type="Resource" uid="uid://cv8oknw3dlwjk" path="res://data/scenarios/cabinet_chain_test.tres" id="chain_test"]
[ext_resource type="Resource" uid="uid://coylqrwbr26tb" path="res://data/scenarios/three_cabinet_objective_test.tres" id="three_test"]
[ext_resource type="Resource" path="res://data/scenarios/cabinet_chain_test.tres" id="chain_test"] [ext_resource type="Resource" path="res://data/scenarios/pacman_rfid_credit_test.tres" id="rfid_credit_test"]
[resource] [resource]
script = ExtResource("4_algy0") script = ExtResource("4_algy0")
scenarios = Array[ExtResource("3_uw7gr")]([ExtResource("7_gbm0a"), ExtResource("8_ib4xl"), ExtResource("10_b427t"), ExtResource("chain_test")]) scenarios = Array[ExtResource("3_uw7gr")]([ExtResource("10_b427t"), ExtResource("chain_test"), ExtResource("three_test"), ExtResource("18_yfaxe"), ExtResource("rfid_credit_test")])
cabinets = Array[ExtResource("1_uu70x")]([ExtResource("2_t6ujr"), ExtResource("3_bfrqh"), ExtResource("4_gbm0a")]) cabinets = Array[ExtResource("1_uu70x")]([ExtResource("2_t6ujr"), ExtResource("3_bfrqh"), ExtResource("4_gbm0a"), ExtResource("5_wqg7d")])
games = Array[ExtResource("2_an0il")]([ExtResource("4_bfrqh"), ExtResource("5_b427t"), ExtResource("8_0ke0w"), ExtResource("9_0ke0w"), ExtResource("10_8pm0t"), ExtResource("11_space_invaders"), ExtResource("12_robotron")]) games = Array[ExtResource("2_an0il")]([ExtResource("4_bfrqh"), ExtResource("5_b427t"), ExtResource("8_0ke0w"), ExtResource("9_0ke0w"), ExtResource("10_8pm0t"), ExtResource("11_space_invaders"), ExtResource("12_robotron")])
metadata/_custom_type_script = "uid://diqnrxrhwx2ug" metadata/_custom_type_script = "uid://diqnrxrhwx2ug"
+15 -3
View File
@@ -52,13 +52,25 @@ metadata/_custom_type_script = "uid://r2rpbasss511"
[sub_resource type="Resource" id="Resource_prnev"] [sub_resource type="Resource" id="Resource_prnev"]
script = ExtResource("2_384hm") script = ExtResource("2_384hm")
cabinet_id = &"cabinet_01" cabinet_id = &"cabinet_01"
position = Vector2(-5.9484997, 0.14750004) position = Vector2(-6.5578, 1.9821999)
metadata/_custom_type_script = "uid://cwblyetugbr4d" metadata/_custom_type_script = "uid://cwblyetugbr4d"
[sub_resource type="Resource" id="Resource_dicmx"] [sub_resource type="Resource" id="Resource_dicmx"]
script = ExtResource("2_384hm") script = ExtResource("2_384hm")
cabinet_id = &"cabinet_02" cabinet_id = &"cabinet_02"
position = Vector2(-3.5153918, 0.14274521) position = Vector2(-2.8978, 1.9821999)
metadata/_custom_type_script = "uid://cwblyetugbr4d"
[sub_resource type="Resource" id="Resource_df41d"]
script = ExtResource("2_384hm")
cabinet_id = &"cabinet_03"
position = Vector2(-2.8978, 3.2022)
metadata/_custom_type_script = "uid://cwblyetugbr4d"
[sub_resource type="Resource" id="Resource_mmfj3"]
script = ExtResource("2_384hm")
cabinet_id = &"cabinet_04"
position = Vector2(-6.5578, 3.2022)
metadata/_custom_type_script = "uid://cwblyetugbr4d" metadata/_custom_type_script = "uid://cwblyetugbr4d"
[sub_resource type="Resource" id="Resource_vv75m"] [sub_resource type="Resource" id="Resource_vv75m"]
@@ -156,7 +168,7 @@ thickness_ft = 0.1
script = ExtResource("3_384hm") script = ExtResource("3_384hm")
room_id = &"arcade_room" room_id = &"arcade_room"
display_name = "Arcade Escape Room" display_name = "Arcade Escape Room"
cabinet_placements = Array[ExtResource("2_384hm")]([SubResource("Resource_prnev"), SubResource("Resource_dicmx")]) cabinet_placements = Array[ExtResource("2_384hm")]([SubResource("Resource_prnev"), SubResource("Resource_dicmx"), SubResource("Resource_df41d"), SubResource("Resource_mmfj3")])
devices = Array[ExtResource("2_hg7bw")]([SubResource("Resource_vv75m"), SubResource("Resource_12s2r"), SubResource("Resource_2f6ns"), SubResource("Resource_ks63c"), SubResource("Resource_kddy2")]) devices = Array[ExtResource("2_hg7bw")]([SubResource("Resource_vv75m"), SubResource("Resource_12s2r"), SubResource("Resource_2f6ns"), SubResource("Resource_ks63c"), SubResource("Resource_kddy2")])
bindings = Array[ExtResource("1_icsgi")]([SubResource("Resource_icsgi"), SubResource("Resource_hg7bw"), SubResource("Resource_384hm"), SubResource("Resource_t81ly"), SubResource("Resource_clsof")]) bindings = Array[ExtResource("1_icsgi")]([SubResource("Resource_icsgi"), SubResource("Resource_hg7bw"), SubResource("Resource_384hm"), SubResource("Resource_t81ly"), SubResource("Resource_clsof")])
boundary_points = PackedVector2Array(-7.3152, -0.6096, -2.1336, -0.6096, -2.1336, 7.62, -7.3152, 7.62) boundary_points = PackedVector2Array(-7.3152, -0.6096, -2.1336, -0.6096, -2.1336, 7.62, -7.3152, 7.62)
+8 -7
View File
@@ -1,11 +1,12 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3] [gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://cv8oknw3dlwjk"]
[ext_resource type="Script" path="res://core/scenarios/scenario_definition.gd" id="1"] [ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="1"]
[ext_resource type="Script" path="res://core/scenarios/scenario_assignment.gd" id="2"] [ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="2"]
[ext_resource type="Resource" path="res://data/cabinets/cabinet_01.tres" id="3"] [ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="3"]
[ext_resource type="Resource" path="res://data/cabinets/cabinet_02.tres" id="4"] [ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_mhdgo"]
[ext_resource type="Resource" path="res://data/games/pacman.tres" id="5"] [ext_resource type="Resource" uid="uid://d1gyyfaaclas8" path="res://data/cabinets/cabinet_02.tres" id="4"]
[ext_resource type="Resource" path="res://data/games/galaga.tres" id="6"] [ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="5"]
[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="6"]
[sub_resource type="Resource" id="First"] [sub_resource type="Resource" id="First"]
script = ExtResource("2") script = ExtResource("2")
+44
View File
@@ -0,0 +1,44 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://crijr0r2ikxs1"]
[ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="1_40f70"]
[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="2_nsmc3"]
[ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_4sjsw"]
[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="4_wb1pw"]
[ext_resource type="Resource" uid="uid://d1gyyfaaclas8" path="res://data/cabinets/cabinet_02.tres" id="5_cy2se"]
[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="6_r5ac2"]
[ext_resource type="Resource" uid="uid://d4ns05ptr4ybw" path="res://data/cabinets/cabinet_03.tres" id="7_4fvja"]
[ext_resource type="Resource" path="res://data/games/tempest.tres" id="8_uer0y"]
[ext_resource type="Resource" uid="uid://cwa2ord52vdda" path="res://data/cabinets/cabinet_04.tres" id="9_oms42"]
[ext_resource type="Resource" path="res://data/games/missile_command.tres" id="10_sqsy7"]
[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="11_2gwsy"]
[sub_resource type="Resource" id="Resource_p0bbs"]
script = ExtResource("1_40f70")
cabinet = ExtResource("2_nsmc3")
game = ExtResource("4_wb1pw")
metadata/_custom_type_script = "uid://ibujr0ervhhq"
[sub_resource type="Resource" id="Resource_46jx7"]
script = ExtResource("1_40f70")
cabinet = ExtResource("5_cy2se")
game = ExtResource("6_r5ac2")
metadata/_custom_type_script = "uid://ibujr0ervhhq"
[sub_resource type="Resource" id="Resource_vuaau"]
script = ExtResource("1_40f70")
cabinet = ExtResource("7_4fvja")
game = ExtResource("8_uer0y")
metadata/_custom_type_script = "uid://ibujr0ervhhq"
[sub_resource type="Resource" id="Resource_l1iuy"]
script = ExtResource("1_40f70")
cabinet = ExtResource("9_oms42")
game = ExtResource("10_sqsy7")
metadata/_custom_type_script = "uid://ibujr0ervhhq"
[resource]
script = ExtResource("11_2gwsy")
scenario_id = &"eg_glitch"
display_name = "Glitch: The Dark Machine"
assignments = Array[ExtResource("1_40f70")]([SubResource("Resource_p0bbs"), SubResource("Resource_46jx7"), SubResource("Resource_vuaau"), SubResource("Resource_l1iuy")])
metadata/_custom_type_script = "uid://d3pt6qa05kcnl"
@@ -0,0 +1,18 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3]
[ext_resource type="Script" path="res://core/scenarios/scenario_definition.gd" id="1"]
[ext_resource type="Script" path="res://core/scenarios/scenario_assignment.gd" id="2"]
[ext_resource type="Resource" path="res://data/cabinets/cabinet_01.tres" id="3"]
[ext_resource type="Resource" path="res://data/games/pacman.tres" id="4"]
[sub_resource type="Resource" id="Assignment"]
script = ExtResource("2")
cabinet = ExtResource("3")
game = ExtResource("4")
game_config = {"rfid_credit_device": "cab1_rfid1", "rfid_allowed_uids": [], "boards_to_win": 1, "score_to_win": 0}
[resource]
script = ExtResource("1")
scenario_id = &"pacman_rfid_credit_test"
display_name = "Cabinet 1: Pac-Man RFID credit"
assignments = Array[ExtResource("2")]([SubResource("Assignment")])
@@ -0,0 +1,47 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://coylqrwbr26tb"]
[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="1"]
[ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="2"]
[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="3"]
[ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_my636"]
[ext_resource type="Resource" uid="uid://d1gyyfaaclas8" path="res://data/cabinets/cabinet_02.tres" id="4"]
[ext_resource type="Resource" uid="uid://d4ns05ptr4ybw" path="res://data/cabinets/cabinet_03.tres" id="5"]
[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="6"]
[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="7"]
[ext_resource type="Resource" path="res://data/games/frogger.tres" id="8"]
[sub_resource type="Resource" id="First"]
script = ExtResource("2")
cabinet = ExtResource("3")
game = ExtResource("6")
game_config = {
"boards_to_win": 0,
"score_to_win": 0,
"test_auto_win_seconds": 5.0
}
[sub_resource type="Resource" id="Second"]
script = ExtResource("2")
cabinet = ExtResource("4")
game = ExtResource("7")
activate_after_win = &"cabinet_01"
game_config = {
"score_to_win": 500,
"stages_to_win": 0
}
[sub_resource type="Resource" id="Third"]
script = ExtResource("2")
cabinet = ExtResource("5")
game = ExtResource("8")
activate_after_win = &"cabinet_02"
game_config = {
"boards_to_win": 0,
"homes_to_win": 1
}
[resource]
script = ExtResource("1")
scenario_id = &"three_cabinet_objective_test"
display_name = "3-Cabinet Test: 5 seconds / 500 points / 1 frog"
assignments = Array[ExtResource("2")]([SubResource("First"), SubResource("Second"), SubResource("Third")])
+40
View File
@@ -0,0 +1,40 @@
# Cabinet PCB connections
Open a cabinet resource in Godot, for example `data/cabinets/cabinet_01.tres`. With the Escape Room editor plugin enabled, expand **PCB Connections** and click **Set up PCB connections…**.
1. Click a connector on the green board. The arrangement matches the supplied custom PCB diagram.
2. Choose the device type: RFID reader, button/switch, digital sensor, LED/lighting driver, motorized switch/motor driver, relay/MOSFET, I2C peripheral, or Other. Choices depend on the connector family.
3. Enter a friendly name and a device ID unique within this cabinet. Use an output's existing HardwareAction ID, or an input's intended event ID, in the optional binding field. Add driver, power, I2C address and wiring details in Notes.
4. Click **Apply connections**, then save the cabinet resource using Godot's resource save command. Apply participates in the Inspector's Undo/Redo. Cancel discards the dialog draft.
5. Reopen to review the saved connections. Choose **Empty / unplugged** to clear a slot.
Every cabinet starts unassigned. The tester's two RFID readers would be entered separately in **J1** and **J2**; these are not automatically added to every cabinet. A cabinet's new `pcb_config` is embedded in its resource by the visual editor. Editing through this dialog duplicates the draft, so another cabinet sharing the original resource is not changed.
## Fixed board mapping
| Connectors | Signal assignment | Contacts |
| --- | --- | --- |
| J15, J14, J13, J12, J11, J10 | BCM21, 26, 19, 13, 6, 5 | 1 GND black; 2 3V3 red; 3 signal yellow |
| J22, J21, J20, J19, J18, J17 | BCM25, 18, 23, 24, 12, 16 | 1 GND black; 2 signal red |
| J16 | I2C1 | 1 GND black; 2 3V3 red; 3 SCL white; 4 SDA yellow |
| J1–J8 | Selector 000–111 | Bits BCM22 / BCM27 / BCM17; reset BCM20 |
| J9 | Pi GPIO ribbon | Check physical pin-1 orientation |
J1–J8 reader contact pinouts have not been supplied; the diagram shows the verified selection bits, not an invented contact assignment. Device types describe the attached interface/driver. Motors and lighting loads are not powered directly from GPIO.
## Validation and runtime scope
The editor reports duplicate device IDs/connectors, unsupported connector/type combinations, missing output actions, GPIO/action mismatches, and existing actions using reserved selector/reset BCM17/27/22/20. Warnings remain visible but do not prevent saving an incomplete wiring plan. Existing cabinet actions using BCM17 may therefore need a separate wiring review before this PCB is installed.
The saved inventory can now be exported for optional live RFID, digital input, and electrical output feedback. Input connections include an explicit pull-up setting. Saving the diagram alone does not enable GPIO or remap HardwareActions. Follow the [live feedback setup](../tools/pi_gpio/README.md#pcb-live-feedback) to configure the bridge and reader helper. I2C and Other entries remain inventory-only; sensor events do not automatically complete puzzle steps.
## Verification
From the project root:
```text
Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://tests/pcb/check_pcb.gd
Godot_v4.7.2-stable_win64_console.exe --headless --editor --path . --import --quit
```
The resource tests cover connector mapping, validation, save/reload and deep-copy isolation. Adding `--editor` to the test command also exercises the inspector's draft, cancel/reopen and Apply behavior; Godot may report editor shutdown/scan warnings for that isolated editor harness.
+94
View File
@@ -0,0 +1,94 @@
# Master Pi installation and cabinet provisioning
Status: target architecture. A first terminal-wizard implementation now exists; see [the box-test guide](../tools/deploy/README.md). SSH staging/activation and configuration generation are implemented. Browser provisioning, automatic network isolation and arbitrary runtime cabinet profiles remain future work.
## Intended installer experience
1. Install the Arcade master package on a Raspberry Pi running 64-bit Raspberry Pi OS. The master does not need the Godot editor or a display to serve the operator website.
2. Run the local first-time setup wizard. Select the venue-facing interface and the private cabinet interface; review the network plan before applying it.
3. Add each cabinet: display name, stable cabinet ID, private IP or DHCP reservation, SSH username/port, SSH authentication, hardware profile, and GPIO settings. Prefer reserving addresses by Ethernet MAC rather than relying on hostname discovery.
4. Test access and verify the SSH host fingerprint. Use the existing login once to authorize a dedicated master deployment key; do not retain login passwords. Reuse an existing deployment key on reruns.
5. Select an ARM64 release bundle and review the proposed target/configuration changes. Deploy to selected cabinets, with per-cabinet progress and errors.
6. Verify the installed release, desktop session, local GPIO bridge availability if enabled, authenticated controller heartbeat, and expected protocol/features. Then open the normal operator page. Do not automatically start a puzzle or trigger hardware outputs during installation.
7. Later, use the same deployment inventory to update, replace, or add cabinets without repeating the room configuration.
The initial setup should run locally on the master, with privilege elevation confined to OS installation/network tasks. A browser setup interface should be loopback-only initially and must not expose an unauthenticated remote deployment endpoint. The normal operator page remains available on the venue LAN using its operator credential.
## Recommended network
```text
Venue LAN / staff Wi-Fi
| computers and iPads
| HTTP(S) operator page
Master Pi venue interface (Wi-Fi, or a second Ethernet adapter)
Master Pi private Ethernet interface
|
Private Ethernet switch -- cabinet_01
-- cabinet_02
-- cabinet_03
```
A separate Ethernet switch is the simplest cabinet network. Do not connect that switch to the venue router. Disable cabinet Wi-Fi connections to the venue network after commissioning. The master needs two interfaces: built-in Ethernet plus venue Wi-Fi, or two Ethernet interfaces when wired access is preferred.
The private network uses a subnet that does not overlap the venue LAN, VPNs, or existing routes. Example only: master 192.168.77.1/24, cabinets 192.168.77.11 onward. The wizard must check overlap instead of hard-coding this subnet for every site.
Provide DHCP reservations on the private interface, scoped only to that interface. Do not advertise a default router to cabinets. The master terminates connections on both networks; it does not bridge or forward traffic between them. Disable forwarding and enforce an IPv4/IPv6 forward-drop firewall policy for this boundary. Do not use NetworkManager shared mode as the isolation mechanism: shared mode enables forwarding. Cabinets receive bundles from the master over SSH, not through an Internet route.
This isolates cabinets from the venue LAN; cabinets on the same switch can still communicate with each other. Port-isolated switching or VLANs would be needed if cabinet-to-cabinet isolation is also required.
Allow venue staff to reach only the master's operator web port (and explicitly enabled management access). Keep the cabinet GPIO bridge bound to 127.0.0.1:8765. Cabinet polling reaches the master's private address on controller port 8766. Use a stable venue address/DHCP reservation or site DNS name for the operator bookmark. No public Internet port forwarding is needed. Venue guest-network isolation can still prevent staff devices from reaching the master and must be addressed by the venue network administrator.
Changing venue Wi-Fi/address should affect only the master connection and the staff bookmark; cabinet addresses, identities, tokens, and controller URL remain stable on the private side. If the new venue subnet overlaps, a coordinated private-network migration is required.
## Cabinet prerequisites
- Raspberry Pi OS 64-bit with a desktop, SSH enabled, and the intended login already created.
- An active desktop session with desktop autologin for unattended game startup. The SSH account and game desktop account should be the same.
- Python 3 and OpenSSH, normally available on the prepared OS image.
- GPIO Zero/lgpio packages and GPIO group access for cabinets that use real outputs.
No additional persistent management client is necessary. SSH can upload an installer and the release, register the existing user service and desktop helper, and start the application. Current pi_service.py cannot be called unchanged from a plain SSH session: it requires DISPLAY or WAYLAND_DISPLAY. Add a remote-install mode that registers the helper without pretending it has captured a desktop environment. Report waiting-for-desktop-login until the helper records the actual session. Never hard-code DISPLAY=:0 or guess Wayland socket names. A first login/reboot may be required after initial registration; do not reboot or log out a live cabinet without an explicit maintenance action.
For the least repeated setup, prepare one documented cabinet OS baseline with SSH, desktop autologin, and GPIO packages before moving onto the closed network. An offline deployment bundle must include matching OS/architecture package dependencies if these are not preinstalled; a disconnected cabinet cannot simply apt install missing packages. The game can be installed remotely without a Godot editor.
## Current code that can be reused
- tools/room_controller/server.py: Python standard-library controller, SQLite state, browser operator, per-cabinet authentication and protocol checks.
- tools/launch/launch.py: configuration generation target, process supervision, GPIO dependency checks and managed-game startup.
- tools/launch/remote_cabinets.py: strict-host-key SSH starts and separate ONLINE confirmation. An already-running service is not restarted.
- tools/launch/pi_service.py: non-root cabinet user service, display helper and systemd load-state verification.
- tools/pi_gpio/pi_gpio_bridge.py: local-only hardware bridge and durable request ledger.
Current limitation: core/network/managed_cabinet.gd looks up the cabinet ID in the exported ProjectDatabase; the server independently looks it up in catalog.json. Registering a new ID only in INI files is insufficient. The initial installer can safely bind machines to IDs/profiles already included in a release. To support arbitrary new IDs and hardware selections without rebuilding, add a validated external deployment profile consumed by both the Godot runtime and controller. It must include compatible input capabilities and the intended hardware action/controller mappings. The wizard must not silently advertise capabilities that the game export lacks.
## Configuration ownership
Maintain one private master inventory, separate from the authored game/scenario catalog. It records site network settings, cabinet addresses, SSH users/key references and verified fingerprints, cabinet profile selections, installed versions, and deployment status. Stable cabinet IDs survive IP changes and hardware replacement.
Generate the controller INI, per-cabinet INIs, and any required Godot CFG files from that inventory. Generate distinct operator/cabinet tokens for a new site; reuse them on rerun unless explicitly rotating them. Cabinets receive only their own token. Never package the existing development site's live INIs, tokens, database, SSH keys, or Odin folder into a distributable release.
Launcher INI values override the network environment supplied to the game. Keep any generated CFG consistent for direct-launch fallback, and account for existing user:// configuration precedence. Do not overwrite control mappings, player/operator state, or GPIO request ledgers as part of an application update.
Install the master controller as its own service so it survives browser closure and master reboot. Staff browsers connect to the server; they do not run the game export. The existing service binds one address, so exposure to both networks must be planned through binding/firewall rules or a reverse proxy. HTTPS can be added at the venue-facing proxy using a certificate trusted by staff devices; do not claim the current HTTP server already provides TLS.
## Release and deployment requirements
- Build one explicit ARM64 release bundle containing the executable/PCK, required Python scripts, operator assets, catalog, release metadata and SHA-256 checksums. Checksums verify transfer integrity; authenticated SSH establishes the source/channel.
- Keep credentials and persistent state outside versioned release folders.
- Validate IDs, IPs, duplicate targets, catalog compatibility, free space, CPU/OS, dependencies and desktop readiness before activation.
- Copy into staging, verify checksums, and reject unsafe archive paths/symlinks before extracting. Use a versioned release directory and an atomic current-release switch.
- Do not stop a running game to upload a bundle. Activation is a separate maintenance action. Preserve the prior release/config for rollback and avoid database downgrades that are not supported by its schema.
- Resume/report per cabinet after network failure; never mark SSH upload success as successful game startup.
- Confirm process/service health and the authenticated cabinet heartbeat independently. Preserve all GPIO deduplication state; neither retries nor rollback should replay a reward.
- Back up the master inventory, controller database and deployment key securely so a replacement master can recover the site. Losing these should not require reflashing every cabinet.
## Implementation order and acceptance checks
1. Release packaging, inventory schema, validation and deterministic INI/CFG generation; unit tests for identity/token separation and exclusion of live site data.
2. Remote preflight, SSH enrollment, staged upload, remote-safe display-helper/service installation and health reporting; test on one spare Pi before fleet rollout.
3. Local first-run wizard and master controller service; test repeat setup, interrupted upload, unavailable cabinet and reboot recovery.
4. Private-network configuration with backup/recovery instructions; verify from a venue device that the operator works but direct cabinet access does not, and from a cabinet that the venue LAN/Internet is not routed.
5. Runtime cabinet-profile support for arbitrary IDs, then integration tests for game compatibility, physical controller mappings and GPIO allowlists.
6. Move the master to a different non-overlapping venue network and verify cabinets keep the same private configuration and still complete the three-cabinet scenario.
References: [Raspberry Pi SSH setup](https://www.raspberrypi.com/documentation/computers/remote-access.html), [NetworkManager connection settings and shared-mode forwarding](https://networkmanager.pages.freedesktop.org/NetworkManager/NetworkManager/nm-settings-nmcli.html).
+542
View File
@@ -0,0 +1,542 @@
# Cabinet 1: Pac-Man RFID credit — clean setup and physical test
This guide sets up **Glitch: the Dark Machine** with a master Pi hosting the operator webpage and Cabinet 1 running Pac-Man with the custom GPIO PCB. Follow the steps in order. Commands marked **PC**, **master**, or **cabinet** run on that machine. Replace `YOUR_USER` with the actual Pi login.
The supplied scenario is **Cabinet 1: Pac-Man RFID credit** (`pacman_rfid_credit_test`). It prepares Pac-Man with CREDIT 0. A new accepted scan on **J1 / cab1_rfid1** plays `games/pacman/audio/credit.wav` and changes the display to CREDIT 1. The player presses their mapped Start button, consuming the credit. It wins after one cleared board. It has no automatic win timer and no relay/motor rewards.
The first test accepts any readable card. Credit is capped at one; scans during play do not bank credits. After a win/loss, reset and prepare the scenario again. The ordinary free-start Pac-Man assignment remains available separately.
## 1. Machines, cards, and wiring
Use Raspberry Pi OS **64-bit Desktop** on Cabinet 1. The master may use Desktop or Lite. In Raspberry Pi Imager, give them different hostnames (for example `glitch-master` and `glitch-cabinet1`), set their login details, and enable SSH. Enable desktop autologin on the cabinet so its game has a graphical session. Godot itself is not installed on either Pi.
For this baseline use the master as both the network boundary and operator server:
```text
Venue LAN / staff computers and tablets
|
master Wi-Fi (venue IP)
Master Pi: operator server on TCP 8766
master Ethernet: 192.168.77.1
|
Private Ethernet switch
|
Cabinet 1: 192.168.77.11
GPIO PCB + RFID readers on J1 / J2
```
The private switch has no direct connection to the venue router. Cabinet Wi-Fi is disabled after downloading dependencies. The master does not route or bridge traffic between the networks. Browsers communicate with the master, and cabinets communicate with that same master on its private address. This setup uses static addresses, so no DHCP server is required on the private network.
If you keep a separate existing network/router Pi, do not apply these network instructions on top of its unknown configuration. It needs its own subnet/firewall plan. The clean two-Pi arrangement above replaces that role for this test and leaves the original Odin installation untouched.
Power off before moving the PCB ribbon or reader connections. Use your documented connector orientation. J1 is selector slot 0, J2 slot 1. RFID owns BCM17/27/22 (selection), BCM20 (reset), and SPI0 BCM8/9/10/11. **Remove the old BCM17 LED test from this setup; do not run its output configuration.** This test enables no general output pins.
Cards do not need a game written onto them: this implementation reads their identifier. The MFRC522 supports ISO/IEC 14443-A / MIFARE / NTAG cards; an unspecified RFID card is not automatically compatible (for example 125 kHz cards will not work). Start by reading one. The helper currently follows Odin's **32-bit identifier representation**; it is not full-length UID support for every card, a balance stored on the card, or an anti-cloning/payment system.
## 2. Install prerequisites while Internet access is available
**Master:**
```bash
sudo apt update
sudo apt install -y python3 openssh-client nftables
```
**Cabinet:**
```bash
sudo apt update
sudo apt install -y python3 python3-gpiozero python3-lgpio build-essential curl ca-certificates
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
cargo --version
sudo usermod -aG spi,gpio "$USER"
sudo raspi-config
```
Choose the default Rust installation. In raspi-config enable **Interface Options > SPI**, and desktop autologin if not already enabled. Reboot the cabinet and log into its desktop. Check:
```bash
ls -l /dev/spidev0.0
groups
```
The SPI device must exist and your user should have `spi` and `gpio` membership. Download/build the reader helper in step 5 before removing cabinet Internet access.
## 3. Export, package, and copy one complete release
This is the release-building part of the master walkthrough. Build everything from the same saved project before copying anything to a Pi. For the first RFID installation, use the manual configuration in steps 5–9 below; the fleet deployment wizard does not yet preserve all RFID settings (see 3.7).
### 3.1 Save and check the source project — Windows PC
Open `D:\Projects\escape-gallery\room_glitch` in the project's Godot version (currently **4.7.2**). Save scenes and resources before running command-line exports. Check:
- Cabinet 1 has ID `cabinet_01`, and its PCB identifies J1 as `cab1_rfid1` and J2 as `cab1_rfid2`.
- `data/project_database.tres` includes **Cabinet 1: Pac-Man RFID credit**. Its assignment uses Pac-Man and `rfid_credit_device = cab1_rfid1`.
- The assignment has no output mappings for this initial reader test. `rfid_allowed_uids = []` accepts any readable card.
- The Linux preset is **ARM64**, with **Embed PCK disabled**. The existing Windows Desktop preset is x86_64.
- Export templates matching the editor version are installed (Editor > Manage Export Templates). An editor executable alone is not sufficient to export.
Use a release folder **outside the Godot project**. Do not export back into the project or mix old `setup/pi` exports with current source. In each preset's Resources tab, exclude the existing `setup/*` folder from the game export: it contains old exported artifacts and copied tools that do not belong inside a game PCK. Keep the actual `core`, `data`, `games`, `ui`, and runtime resources included. Do not add INIs, private keys, or SQLite files to the export's non-resource include filter.
The runtime for this scenario must advertise **arcade-cabinet-2.3** and `rfid_credit_v1`. Python files or a fresh catalog cannot add that feature to an old game PCK.
### 3.2 Create a fresh release directory — Windows PowerShell
The example release name is `glitch-rfid-001`. Pick a new name for every build you intend to deploy. These commands are examples to run, not artifacts already built by this walkthrough.
```powershell
Set-Location 'D:\Projects\escape-gallery\room_glitch'
$releaseName = 'glitch-rfid-001'
$releaseDir = "D:\ArcadeReleases\$releaseName"
if (Test-Path -LiteralPath $releaseDir) { throw 'Choose a new release name; this directory already exists.' }
New-Item -ItemType Directory -Path "$releaseDir\pi", "$releaseDir\windows" | Out-Null
```
Use this same PowerShell session for the following commands so the variables remain available. If you return later, set them again to the intended existing release; do not rerun the directory-creation check.
### 3.3 Export the catalog and game — Windows PowerShell
First export the controller catalog from the saved resources:
```powershell
.\Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://tools/room_controller/export_catalog.gd
if ($LASTEXITCODE -ne 0) { throw 'Catalog export failed.' }
```
Then export the actual Pi game, using the preset name exactly as shown:
```powershell
.\Godot_v4.7.2-stable_win64_console.exe --headless --path . --export-release 'Linux' "$releaseDir\pi\ArcadeEscape.arm64"
if ($LASTEXITCODE -ne 0) { throw 'Pi export failed.' }
```
Check the export log for errors, not just whether an old file exists. The folder must contain both `ArcadeEscape.arm64` and `ArcadeEscape.pck`. Godot may also create `ArcadeEscape.sh`; the Python launcher runs the ARM64 executable directly, so that shell wrapper is not required by our deployment package.
Optionally export Windows for PC testing:
```powershell
.\Godot_v4.7.2-stable_win64_console.exe --headless --path . --export-release 'Windows Desktop' "$releaseDir\windows\ArcadeEscape.exe"
if ($LASTEXITCODE -ne 0) { throw 'Windows export failed.' }
```
Keep each platform's executable and accompanying PCK together. Do not swap the Windows and Pi PCKs between release folders. The Windows export can test games and controls with GPIO disabled; it cannot validate the physical SPI reader. A browser-only master does **not** need either Godot game export or the Godot editor.
If you prefer the Godot UI, use Project > Export > the corresponding preset > Export Project, disable Export With Debug, and use the same filenames and separate release folders above. Still run the catalog export before packaging.
### 3.4 Package the master software and cabinet bundle — Windows PowerShell
From the project root, run:
```powershell
py -3 tools/deploy/deploy.py master-package --output "$releaseDir\master"
if ($LASTEXITCODE -ne 0) { throw 'Master packaging failed.' }
py -3 tools/deploy/deploy.py bundle --export "$releaseDir\pi" --output "$releaseDir\$releaseName.tar.gz" --release $releaseName
if ($LASTEXITCODE -ne 0) { throw 'Cabinet packaging failed.' }
Get-FileHash -Algorithm SHA256 -LiteralPath "$releaseDir\$releaseName.tar.gz" | Format-List
```
The master directory and archive must not already exist. Do not edit project code, game resources, or the catalog between exporting and packaging. If something changes, build a new release from the beginning.
The packager uses a file allowlist. It checks the ARM64 executable header and PCK header and writes SHA-256 hashes into the cabinet manifest. It does **not** prove that the PCK was built from the current source; keeping the build steps together is essential.
Your deliverables are now:
| Item | Destination / purpose |
| --- | --- |
| `master/` | Master Pi: controller, browser operator, matching catalog, launch/deployment scripts. |
| `glitch-rfid-001.tar.gz` | Cabinet software: ARM64 game + PCK, bridge, helper source, launch scripts, catalog and manifest. |
| `windows/` | Optional Windows game test. Not needed on the master Pi. |
| Release notes and archive SHA-256 | Keep with your release records for verification and rollback. |
The packages intentionally omit live tokens/INIs, SSH keys, databases, Godot saved controls, and Odin. They also omit the **compiled RFID executable** and each cabinet's **devices.json**. Build/create those on the cabinet in step 5. Custom cabinet artwork is embedded in the packaged operator HTML; you do not need to copy the source PNG directory separately for this page.
Record the release name, editor version, source commit (if committed), any uncommitted source changes included, export date, and tests performed in a small release-notes file. Keep the original archive unchanged. Retain `Cargo.lock` produced by the successful Pi helper build with your records as well.
### 3.5 Copy to clean installations
Use a USB drive or SFTP/SCP. The target folder is `~/arcade` under the actual Pi user, not `/root/arcade`. This subsection is for **fresh installs**; do not extract over a running or previously configured installation. Updates are covered in 3.8.
**Master:** create `~/arcade` and copy the **contents** of the release's `master` folder into it. The result must be:
```text
~/arcade/tools/room_controller/server.py
~/arcade/tools/room_controller/operator.html
~/arcade/tools/room_controller/catalog.json
~/arcade/tools/launch/launch.py
~/arcade/tools/deploy/deploy.py
```
Other allowlisted files will also be present. Do not accidentally end up with `~/arcade/master/tools/...`; the remaining walkthrough assumes `~/arcade/tools/...`.
**Cabinet:** copy the archive into the cabinet user's home. Before extraction, compare its hash with the Windows result (letter case does not matter):
```bash
sha256sum ~/glitch-rfid-001.tar.gz
mkdir -p ~/arcade
tar -tzf ~/glitch-rfid-001.tar.gz
```
Confirm `~/arcade` is empty on this fresh installation, and that this is the archive you just built. Extract:
```bash
tar -xzf ~/glitch-rfid-001.tar.gz -C ~/arcade
chmod +x ~/arcade/ArcadeEscape.arm64
```
The resulting cabinet layout includes:
```text
~/arcade/manifest.json
~/arcade/ArcadeEscape.arm64
~/arcade/ArcadeEscape.pck
~/arcade/tools/launch/launch.py
~/arcade/tools/launch/pi_service.py
~/arcade/tools/pi_gpio/pi_gpio_bridge.py
~/arcade/tools/pi_gpio/device_feedback.py
~/arcade/tools/pi_gpio/rfid_helper/Cargo.toml
~/arcade/tools/pi_gpio/rfid_helper/src/main.rs
```
The extra controller files inside the cabinet bundle are harmless; do not start a second controller there. Continue with network setup in step 4, RFID build/devices.json in step 5, and the separate master/cabinet INIs in step 6. The `.sh` file, source repository, and Godot editor are not required on the cabinet.
### 3.6 Preflight before calling the release ready
After completing steps 4–6, validate each machine without starting it:
**Master:**
```bash
cd ~/arcade
python3 tools/launch/launch.py controller --config controller.ini --check
```
**Cabinet:**
```bash
cd ~/arcade
python3 tools/launch/launch.py cabinet --config cabinet.ini --check
```
These checks validate configuration/dependencies/port availability. They do not prove card communication, display rendering, audio, or successful gameplay. Stop any existing stack first if the check reports its ports are occupied.
Use step 7's physical test as the acceptance gate: correct version, fresh J1 readings, CREDIT 0 -> scan/sound -> CREDIT 1 -> physical Start -> CREDIT 0, and no unintended outputs. Verify again after the reboot/startup test in step 9. Only then label the release ready for the room.
### 3.7 Optional fleet deployment: current RFID limitation
For later multi-cabinet rollout, the generated master package includes `tools/deploy/deploy.py`; the [fleet deployment guide](../tools/deploy/README.md) explains setup, enrollment, preflight, stage, activate, and rollback. Staging verifies each manifest hash before accepting a release. It does not start a game by itself.
**The current fleet configuration generator is not yet a complete RFID installer.** It rejects real-GPIO cabinets with an empty output pin list and does not write `devices` or `rfid_helper`. Do not add a fake output pin to bypass that check, and do not run fleet activation over the working RFID installation expecting those settings to be preserved. For this RFID-only first test, follow the manual `~/arcade` installation and service steps in this walkthrough. The bundle remains useful for consistent, checked packaging even when copied manually.
The deployment wizard also uses its own site credentials/configuration directory. Do not mix its generated `controller.ini` with the manual one below or install two competing master services. Choose one configuration owner. Until RFID provisioning is supported by the generator, use this walkthrough's manual path for Cabinet 1.
### 3.8 Updating and rolling back the manual installation
Do updates between room sessions. Preserve the previous complete release and configuration, not just the executable.
1. Build a new versioned master package and cabinet archive on the PC using 3.1–3.4. Do not overwrite/reuse a release archive.
2. Back up the master's `controller.ini`, controller database/state directory, and any SSH/site credentials. Back up the cabinet's `cabinet.ini`, `devices.json`, working RFID binary/lockfile, and Godot control mappings. Keep the GPIO request ledger at `~/.local/state/arcade_gpio/requests.sqlite3`; do not erase it to retry an uncertain physical action.
3. Extract/copy the new software into a **new sibling directory**, such as `~/arcade-next`, on each machine. Build the helper there if its source/dependencies changed. Copy your existing site-specific INIs/devices.json into that new installation and inspect their paths. The guide's relative paths remain valid inside the new root; any custom absolute paths need adjustment.
4. Stop the old services with `systemctl --user stop arcade-master.service` on the master and `systemctl --user stop arcade-cabinet.service` on the cabinet, or Ctrl+C their manual launchers. Copy the stopped master's database into the new installation's matching state location before switching. Avoid opening the live database with two controllers.
5. Keep the old directory as `~/arcade-previous-RELEASE_NAME`, and move the new directory into `~/arcade`. Use previously unused backup names; do not merge two release directories. Because the service paths still point at `~/arcade`, the manual services in step 9 continue to use the intended location. Recheck config paths, helper permissions, and the same operator/cabinet credentials.
6. Start the master and cabinet and repeat the preflight/physical acceptance test. Re-prepare the scenario for the new session. Do not resume an uncertain old room state automatically.
7. If the release fails, stop both services, keep the failed installation separately for diagnosis, restore the previous full software/configuration/database set at `~/arcade`, and start again. Keep the GPIO deduplication ledger intact. Future database format changes may require release-specific migration/rollback steps; never point an older controller at an unreviewed newer database.
Do not overwrite saved input mappings or re-image the SD card just to update game code. The manual path does not provide atomic activation; the fleet tool's staged activation can replace this workflow once its RFID configuration support is completed.
## 4. Configure the private network
Do this from each Pi's **local terminal**, not an SSH connection that you are about to disconnect. These commands assume a clean Raspberry Pi OS installation with NetworkManager and `eth0` as the wired interface. Check first:
```bash
nmcli device status
nmcli connection show
```
Use the actual Ethernet connection name in place of `Wired connection 1` if different. Verify that `192.168.77.0/24` is not the venue subnet; if it overlaps, choose another private subnet and change every corresponding address in this guide.
**Master:**
```bash
sudo nmcli connection modify "Wired connection 1" ipv4.method manual ipv4.addresses 192.168.77.1/24 ipv4.gateway "" ipv4.dns "" ipv4.never-default yes ipv6.method disabled
sudo nmcli connection up "Wired connection 1"
```
Keep the master's Wi-Fi on the venue LAN, with its normal Internet route. Reserve that Wi-Fi address in the venue router so the operator bookmark stays stable. Do not choose NetworkManager's `shared` mode.
**Cabinet (after step 5 has downloaded the Rust dependencies):**
```bash
sudo nmcli connection modify "Wired connection 1" ipv4.method manual ipv4.addresses 192.168.77.11/24 ipv4.gateway "" ipv4.dns "" ipv4.never-default yes ipv6.method disabled
sudo nmcli connection up "Wired connection 1"
sudo nmcli radio wifi off
```
Cabinet 2 can later use `.12`, Cabinet 3 `.13`, etc. Leave gateway/DNS blank on all private wired connections. Verify from the master:
```bash
ping -c 3 192.168.77.11
ssh YOUR_CABINET_USER@192.168.77.11
```
### Enforce no forwarding on the clean master
These steps are for the fresh master described above, not an existing managed router/firewall. They do not restrict connections *to* the master, including SSH and the operator webpage; they block forwarding *through* it.
Create `/etc/sysctl.d/90-arcade-private.conf` with `sudo nano`:
```ini
net.ipv4.ip_forward=0
net.ipv6.conf.all.forwarding=0
```
Apply it:
```bash
sudo sysctl --system
```
On the fresh master, inspect `/etc/nftables.conf`. In its default `table inet filter`, change the `forward` chain's policy from `accept` to `drop`. Leave the input/output policies alone for this first test. If the file contains an existing custom policy, stop here and have it reviewed rather than replacing it. Validate and enable the policy:
```bash
sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftables
sudo systemctl restart nftables
sudo nft list ruleset
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
```
Expected: forward policy `drop` and both forwarding values `0`. Do not bridge the two interfaces or set up Internet sharing. Do not add router port forwarding. Staff browsers will use `http://MASTER_VENUE_IP:8766`; cabinets use `http://192.168.77.1:8766`. The GPIO bridge stays local to the cabinet at `127.0.0.1:8765`.
## 5. Compile and test the RFID readers on the cabinet
Do this before disabling its Internet connection:
```bash
cd ~/arcade
chmod +x ArcadeEscape.arm64
cargo build --release --manifest-path tools/pi_gpio/rfid_helper/Cargo.toml
```
A successful build creates `tools/pi_gpio/rfid_helper/target/release/arcade-rfid`. The new helper has not yet been built or physically verified on our tester Pi; if compilation fails, retain the exact output for troubleshooting. Do not assume a successful Windows test proves hardware communication.
Stop the game service, GPIO bridge, and any Odin reader process before testing. On a fresh Pi there should be none. On an existing Arcade installation:
```bash
systemctl --user stop arcade-cabinet.service
```
Then:
```bash
cd ~/arcade
./tools/pi_gpio/rfid_helper/target/release/arcade-rfid 0,1
```
Present a card separately to J1 and J2. Success resembles:
```json
{"slot":0,"uid":"12AB34CD","error":""}
```
The example UID is not a real enrolled card. Write down the exact value reported for each of your cards. No card-writing/programming step is required. A blank `uid` with `No tag read (absent or unreadable)` is not an accepted scan. `Reader did not respond` indicates reader communication trouble, not simply an unprogrammed card. Press Ctrl+C before starting the bridge.
Create `~/arcade/devices.json` with this minimal RFID-only inventory:
```json
[
{"id":"cab1_rfid1","name":"Credit reader","port":"J1","kind":"rfid"},
{"id":"cab1_rfid2","name":"Second reader","port":"J2","kind":"rfid"}
]
```
J2 is visible for diagnostics but grants no credit in this scenario. This minimal inventory intentionally excludes existing buttons/outputs so the first test owns only the readers. Later you can extract the saved PCB catalog using `device_feedback.py` as documented in the GPIO guide.
## 6. Generate credentials and create the two INIs
**Master:** generate two different 16-character tokens (first operator, second cabinet):
```bash
python3 -c 'import secrets; print("Operator:", secrets.token_urlsafe(12)); print("Cabinet 1:", secrets.token_urlsafe(12))'
```
Create `~/arcade/controller.ini` on the master:
```ini
[controller]
bind = 0.0.0.0
port = 8766
catalog = tools/room_controller/catalog.json
database = state/arcade_room.sqlite3
operator_token = REPLACE_WITH_OPERATOR_TOKEN
cabinet_tokens = {"cabinet_01":"REPLACE_WITH_CABINET_TOKEN"}
```
Create `~/arcade/cabinet.ini` on Cabinet 1:
```ini
[cabinet]
id = cabinet_01
url = http://192.168.77.1:8766
token = REPLACE_WITH_CABINET_TOKEN
executable = ArcadeEscape.arm64
gpio = true
[gpio]
controller = cabinet_01_gpio
pins =
devices = devices.json
rfid_helper = tools/pi_gpio/rfid_helper/target/release/arcade-rfid
```
Use matching cabinet tokens on both sides; the operator token belongs only on the master and in the browser login. INI token values have **no surrounding quotes**; the cabinet_tokens JSON string values **do** have JSON double quotes. All relative file paths above are relative to the containing INI at `~/arcade`.
Leave `pins =` explicitly empty. Omitting it defaults to the old BCM17 output, which conflicts with RFID and will be rejected. The scenario has no output actions, so there is no reason to enable output pins for this test.
With this launcher approach, no hand-written `arcade_network.cfg` is needed: the launcher supplies the URL and token. Old environment variables can override INIs. In a reused terminal, clear them before launching:
```bash
unset ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS
```
Protect the INIs on their respective machines:
```bash
chmod 600 ~/arcade/controller.ini
```
On the cabinet use `chmod 600 ~/arcade/cabinet.ini` instead.
## 7. Start and run the test
**Master:**
```bash
cd ~/arcade
python3 tools/launch/launch.py controller --config controller.ini
```
**Cabinet, in a terminal on its desktop:**
```bash
cd ~/arcade
python3 tools/launch/launch.py cabinet --config cabinet.ini
```
The cabinet launcher starts both the local GPIO bridge/reader helper and the game. Do not also start the standalone reader or another bridge. In a staff browser open `http://MASTER_VENUE_IP:8766` and log in with the **operator** token.
1. Confirm Cabinet 1 is online, reports `arcade-cabinet-2.3`, and GPIO is real.
2. In Room control select **Cabinet 1: Pac-Man RFID credit** and prepare it. Reset an already active cabinet first if requested.
3. Wait for the game to show **SCAN CARD / CREDIT 0** and the operator Hardware view to receive fresh readings. Historical scans at preparation are deliberately ignored. Keep the card away until ready.
4. Press the cabinet Start button without a card: nothing should start.
5. Scan J1: hear the credit sound and see **CREDIT 1 / PRESS START**. Hold/re-scan the card: it must stay at one credit and not repeatedly sound for the same accepted credit.
6. Press the mapped Start button: Pac-Man starts and shows **CREDIT 0**. The operator changes to Playing. Operator Start is disabled for this scenario because the player must start it.
7. Scan during gameplay: no credit is banked. Clear one board to win. No physical reward is configured.
8. Reset/re-prepare to test again. Scanning J2 must never grant credit. Disconnecting the reader/bridge must not create credits; feedback becomes stale/error.
The game uses the `arcade_start` input mapping. If the physical button is wrong, use the launcher Controls setup to map it and save, then return to managed mode. Keyboard/controller mapping is independent of the RFID reader.
The operator's Pac-Man image is an illustration, not a streamed screen; CREDIT 0/1 is rendered on the actual cabinet display. The Hardware view shows reader feedback, not a claim that a credit was granted. The physical test above verifies both.
## 8. Optionally restrict accepted cards
After recording the helper's reported identifier, edit the scenario assignment's `game_config` in Godot:
```text
rfid_credit_device: cab1_rfid1
rfid_allowed_uids: ["12AB34CD", "5678ABCD"]
boards_to_win: 1
score_to_win: 0
```
Replace the examples with your actual uppercase eight-character values. An empty array accepts any readable card. Changing this list does not write to the card. Keep a separate list of card labels and identifiers for your staff.
Save, re-export the controller catalog, and copy the updated catalog to the master. Restart the controller between sessions and re-prepare the scenario. For changes only to this assignment's allowlist/objective, the compatible 2.3 cabinet runtime accepts the new configuration without another game export. Game code, cabinet profiles, and new assets still require exporting the game.
## 9. Enable startup after the manual test passes
**Cabinet, on its local desktop:**
```bash
cd ~/arcade
python3 tools/launch/pi_service.py install --config "$HOME/arcade/cabinet.ini"
systemctl --user start arcade-cabinet.service
```
Stop the manually launched cabinet stack with Ctrl+C before installing. The installer captures the current desktop environment. The service intentionally has no boot enable target because the display must be ready first. For automatic start after desktop login, create `~/arcade/start-cabinet.sh`:
```bash
#!/bin/sh
/usr/bin/python3 "$HOME/arcade/tools/launch/pi_service.py" refresh-session || exit 1
exec systemctl --user start arcade-cabinet.service
```
Run `chmod +x ~/arcade/start-cabinet.sh`. Create `~/.config/autostart/glitch-cabinet-start.desktop`:
```ini
[Desktop Entry]
Type=Application
Name=Start Glitch Cabinet
Exec=/home/YOUR_USER/arcade/start-cabinet.sh
Terminal=false
```
Replace `YOUR_USER` in the Exec path. This explicitly refreshes the display environment before starting the game. Desktop autologin must remain enabled. Do not use `sudo` for the game service.
**Master:** stop the manually launched controller, then create `~/.config/systemd/user/arcade-master.service` (create the directory first):
```ini
[Unit]
Description=Glitch room operator controller
[Service]
Type=simple
WorkingDirectory=%h/arcade
ExecStart=/usr/bin/python3 %h/arcade/tools/launch/launch.py controller --config %h/arcade/controller.ini
UnsetEnvironment=ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
```
Then:
```bash
systemctl --user daemon-reload
systemctl --user enable --now arcade-master.service
sudo loginctl enable-linger "$USER"
```
After a reboot verify both services and the web page. Preparing a scenario remains an operator action; a cabinet reboot invalidates the old session and requires preparation again.
Logs:
```bash
journalctl --user -u arcade-master.service -n 80 --no-pager
journalctl --user -u arcade-cabinet.service -n 80 --no-pager
```
Run each command on the corresponding Pi. If startup is already managed by the deployment tools, use their service/config instead of installing a second master service.
## Troubleshooting and acceptance
| Symptom | Check |
| --- | --- |
| Build fails | Keep the first Rust compiler error; confirm Cargo installed and Internet available. |
| SPI device missing | Enable SPI, reboot, check `/dev/spidev0.0`. |
| GPIO/SPI permission denied | `spi`/`gpio` groups; log out/back in after adding membership. |
| Reader unresponsive | Ribbon orientation, reader wiring/power, correct J1/J2 slots, no competing Odin process. |
| Blank/unprogrammed cards not reading | Check card technology; no card data-writing is required by this test. |
| Pin conflict on startup | Explicit blank output `pins`; RFID reserves BCM17/27/22/20 and SPI0. |
| Scenario rejected | Updated master catalog, cabinet 2.3 export, real GPIO, matching `cab1_rfid1` inventory. |
| Live UID but no credit | Prepare this scenario, remove/re-present card after initial feedback, use J1, verify allowed UID. |
| Credit but no start | Verify `arcade_start` mapping and physical button. |
| Unauthorized | Operator token in browser; matching Cabinet 1 token on master/cabinet; check inherited environment. |
| Cabinet offline | Cabinet URL uses private master IP and `http://...:8766`; 8765 is only local GPIO. |
| Staff page inaccessible | Master venue address, venue client isolation/firewall, server bound to 0.0.0.0. |
Software regression tests cover credit gating, fresh versus historical/stale feedback, UID/reader filtering, duplicates, credit consumption, and non-RFID behavior. Physical reader/audio/controller/network behavior must still be checked on this installation. No Pi, GPIO, network, or live deployment was changed while preparing this guide.
References: [Raspberry Pi networking/configuration](https://www.raspberrypi.com/documentation/computers/configuration.html), [Rust installation](https://doc.rust-lang.org/book/ch01-01-installation.html), [NXP MFRC522 supported card families](https://www.nxp.com/docs/en/data-sheet/MFRC522.pdf), [project GPIO guide](../tools/pi_gpio/README.md), [fleet deployment guide](../tools/deploy/README.md).
+118
View File
@@ -0,0 +1,118 @@
# Start the Pi cabinets from the controller PC
After one-time setup, your usual command starts the Windows controller, opens the browser, and asks each configured Pi to start its cabinet service:
```powershell
.\Start-Arcade.ps1 -Mode controller -Browser
```
The Pi service runs the existing cabinet launcher, which starts its GPIO bridge when enabled and then the managed game. SSH disconnects after the start request; the service stays running on the Pi. Already-running services are not restarted. Starting the apps does not automatically prepare or start a scenario; continue doing that in the operator.
The Pis must be powered on, reachable, and logged into their graphical desktop. This is remote application startup, not remote power-on. Desktop autologin is useful for cabinets; configure it on each Pi if desired.
## 1. Install the cabinet service on each Pi
Copy the updated `tools/launch/` scripts to the Pi's deployment folder. Stop the manually running cabinet launcher with Ctrl+C first so it releases the GPIO bridge port.
From a terminal **on the Pi desktop**, as the normal account that runs the game (without sudo):
```sh
python3 tools/launch/pi_service.py install --config tools/launch/launcher.ini
```
Use your actual INI filename, such as `launcher-pi.ini`, if different. It must contain that Pi's executable path, cabinet ID, controller URL/token (or blank URL/token to use its existing Godot CFG), and GPIO settings. The installer records the absolute config path; rerun it if the deployment/config moves.
This creates:
- `~/.config/systemd/user/arcade-cabinet.service`: runs your existing managed launcher as the desktop user.
- `~/.config/autostart/arcade-cabinet-display.desktop`: refreshes display settings at each desktop login, without launching the game.
- A temporary display environment file under the user's runtime directory, refreshed immediately during installation.
The installer does not start the game, enable boot startup, configure SSH, or change OS autologin. It captures only display-related variables, not application tokens. The service clears inherited `ARCADE_*` credential/URL overrides, so keep its intended settings in the configured INI or Godot CFG rather than relying on a terminal's environment.
Test locally:
```sh
systemctl --user start arcade-cabinet.service
systemctl --user status arcade-cabinet.service
```
Confirm the game appears on the cabinet monitor and becomes ONLINE. To stop it:
```sh
systemctl --user stop arcade-cabinet.service
```
If display settings change, run `python3 tools/launch/pi_service.py refresh-session` from the current Pi desktop terminal before starting it again.
## 2. Set up SSH access from Windows
SSH must be enabled on each Pi. Use the same SSH username as the desktop account where you installed the service.
These are **SSH keys**, separate from the 16-character application tokens in the cabinet setup guide. The tokens cannot authenticate SSH.
Generate a dedicated key on the PC (do not overwrite an existing one):
```powershell
ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\arcade_cabinets" -C "arcade-controller"
```
If you use a passphrase, load the key into your SSH agent before launching the room. Automated startup uses batch mode and cannot ask for passwords or passphrases.
For each Pi, first connect interactively and verify its host fingerprint before trusting it:
```powershell
ssh PI_USER@PI_HOST
```
Log out with `exit`. To authorize the public key on that Pi, run from PowerShell (substitute its real user and host):
```powershell
Get-Content "$env:USERPROFILE\.ssh\arcade_cabinets.pub" | ssh PI_USER@PI_HOST 'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys'
```
Only copy the `.pub` key; the private key stays on the PC. Test unattended access:
```powershell
ssh -i "$env:USERPROFILE\.ssh\arcade_cabinets" -o BatchMode=yes -o StrictHostKeyChecking=yes PI_USER@PI_HOST "systemctl --user status arcade-cabinet.service"
```
An inactive service may return a nonzero status; confirm SSH succeeded and systemd found the installed unit. The launcher keeps host-key checking enabled and never automatically accepts a new or changed host key.
## 3. Add the Pi list to the PC launcher INI
Merge this into the controller INI you actually launch with. Replace each host and username:
```ini
[remote]
enabled = true
cabinets = [{"id":"cabinet_01","host":"PI_1_HOST","user":"PI_USER","identity_file":"~/.ssh/arcade_cabinets"},{"id":"cabinet_02","host":"PI_2_HOST","user":"PI_USER","identity_file":"~/.ssh/arcade_cabinets"},{"id":"cabinet_03","host":"PI_3_HOST","user":"PI_USER","identity_file":"~/.ssh/arcade_cabinets"}]
```
`identity_file` is optional if your default SSH identity or agent is already configured. `port` is optional and defaults to 22. Each ID must exist in the controller catalog and its cabinet-token map. The Pi's own launcher must use the same cabinet ID; this list does not remotely rewrite its identity or credentials.
Validate, then launch:
```powershell
.\Start-Arcade.ps1 -Mode controller -Check
.\Start-Arcade.ps1 -Mode controller -Browser
```
Use `-Config tools/launch/launcher-pc.ini` if that is your actual config filename. `-Check` validates local settings only; it does not contact or start a Pi.
The controller starts first. The launcher sends the SSH requests concurrently, then checks controller heartbeats. It prints separate **SSH OK/FAILED** and **ONLINE/OFFLINE** results, so a successful service command is not mistaken for a working game connection. A failed/offline Pi leaves the controller running for troubleshooting.
## Operating behavior
- Starting the controller again leaves active Pi services running; it does not interrupt their games with `restart`.
- Closing the browser or stopping the PC controller leaves the Pi services running. They reconnect when the controller returns.
- Closing the Pi game with Escape stops that Pi's launcher/bridge. It will not restart automatically; the next controller launch can start it again.
- A crash is not automatically restarted, avoiding unexpected game resets during a room session.
- Use `ssh PI_USER@PI_HOST "systemctl --user stop arcade-cabinet.service"` for an intentional remote stop.
- Application logs remain in the Pi launcher's configured log folder. Service startup errors are available through `journalctl --user -u arcade-cabinet.service -n 60 --no-pager`.
- If the PC launches before the Pi desktop is ready, startup can fail. Wait for desktop login and launch the controller again, or issue the same `systemctl --user start` command over SSH. There is no automatic power-on or infinite retry loop.
- After a desktop restart/logout, confirm the display helper has run before starting games. If your desktop does not honor XDG autostart entries, run `pi_service.py refresh-session` locally and check the desktop's startup configuration.
The service can be removed by stopping it, removing the two managed files listed above, and running `systemctl --user daemon-reload`. Game resources, saved control mappings, tokens, and GPIO ledger are unaffected.
Implementation references: [OpenSSH client configuration](https://man.openbsd.org/ssh_config), [systemd execution environment](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html), and [systemd desktop integration](https://github.com/systemd/systemd/blob/main/docs/DESKTOP_ENVIRONMENTS.md).
+92
View File
@@ -0,0 +1,92 @@
# Three-cabinet objective test
Choose **3-Cabinet Test: 5 seconds / 500 points / 1 frog** in the browser operator.
| Cabinet | Game | Starts when | Win objective |
| --- | --- | --- | --- |
| `cabinet_01` | Pac-Man | Operator or physical Start after preparation | Automatic win five seconds after Start |
| `cabinet_02` | Galaga | Cabinet 1 reports a win | Score reaches **500 or more** |
| `cabinet_03` | Frogger | Cabinet 2 reports a win | **One frog** reaches a valid home |
Cabinets 2 and 3 remain unloaded while waiting. They load and automatically start after their predecessor wins. The five-second clock begins with Cabinet 1's game-start event, including its intro; it does not count time spent waiting in READY. Galaga keeps its normal brief victory transition before reporting completion. Frogger reports completion after the home-arrival pause.
## What was configured
The resource is [three_cabinet_objective_test.tres](../data/scenarios/three_cabinet_objective_test.tres), registered in [project_database.tres](../data/project_database.tres).
Each `ScenarioAssignment` now has a `game_config` Dictionary, in addition to its cabinet, game, variant, dependency, and event mappings. These values are exported to the controller catalog, included in the prepare command, and passed to the game's `configure()` method. The editor's working copy preserves these dictionaries independently.
The assignments are:
```text
Cabinet 1 / Pac-Man
activate_after_win: empty
game_config:
test_auto_win_seconds: 5.0
boards_to_win: 0
score_to_win: 0
Cabinet 2 / Galaga
activate_after_win: cabinet_01
game_config:
score_to_win: 500
stages_to_win: 0
Cabinet 3 / Frogger
activate_after_win: cabinet_02
game_config:
homes_to_win: 1
boards_to_win: 0
```
Setting board/stage targets to zero disables those alternate win conditions for this scenario. Galaga already supported score targets. Frogger now supports the individual-home target; its default remains a full board. The managed agent's test timer is enabled only by `test_auto_win_seconds`; ordinary scenarios and manual assignments do not inherit it. Reset/unload cancels the timer.
Cabinet 3's profile now includes `joystick_4way`, alongside its existing controls, so Frogger passes compatibility checks. Map its actual joystick using Control Setup. This test has no hardware event mappings, so no GPIO reward or additional wiring is required.
## How to change it in Godot
1. Open `data/scenarios/three_cabinet_objective_test.tres` in the Inspector.
2. Expand **Assignments**, then the assignment to edit.
3. Choose its Cabinet and Game references. Keep Variant as `standard`.
4. Set **Activate After Win** to the preceding cabinet's ID (blank for Cabinet 1).
5. Expand **Game Config** and set the objective keys and numeric values shown above.
6. Save the resource. The sample is already registered in the project database; register any newly created scenario there too.
7. Regenerate the controller catalog from the project root:
```sh
godot --headless --path . --script tools/room_controller/export_catalog.gd
```
Supported network objective keys currently include `test_auto_win_seconds`; Pac-Man board/score targets; Galaga stage/score targets; and Frogger board/home targets. Invalid values and unsupported keys are rejected before scenario dispatch. Chain execution belongs to the managed controller; the editor's local simulation does not run this network handoff.
## Build and deployment checklist
**This update needs fresh game exports on all three Pis.** Agents now advertise `arcade-cabinet-2.1` and the `assignment_config_v1` feature. The controller rejects this objective scenario on older agents so a cabinet cannot silently ignore the five-second or one-frog objective.
1. In Godot, build **Windows Desktop** for PC testing and **Linux** (ARM64) for the Pis. Use the configured engine version and matching export templates. The current presets write one directory above the project; check the output paths before exporting.
2. Copy the new ARM64 executable and any accompanying export files to all three Pis. The same build serves every cabinet; identity and tokens remain machine-specific.
3. Update the Windows controller's `server.py`, `catalog.json`, and `operator.html` together from `tools/room_controller/`.
4. Preserve each machine's local `launcher.ini`, Godot CFG, input mappings, controller database, and GPIO ledger. Confirm its unique ID/token using the [cabinet setup guide](cabinet_setup.md).
5. Restart Windows in **controller-only** mode and restart each Pi cabinet. Do not run an additional PC game with an ID already used by a Pi.
No builds or live controller restarts are performed by editing these resources. The exported binaries must be rebuilt and copied before the hardware test.
## Run the test
1. Confirm Cabinets 1, 2, and 3 are ONLINE with agent version `arcade-cabinet-2.1`.
2. Reset any active sessions. Select **3-Cabinet Test: 5 seconds / 500 points / 1 frog** and prepare it.
3. Confirm Cabinet 1 is READY and Cabinets 2/3 are WAITING.
4. Start Cabinet 1. After roughly five seconds plus the next network poll, Cabinet 2 loads and starts Galaga.
5. Play Galaga until the score reaches at least 500. Cabinet 3 then loads and starts Frogger.
6. Get one frog into an open home. Cabinet 3 becomes WON, completing the test.
7. To repeat, reset all three and prepare the scenario again. Resetting or restarting an upstream cabinet invalidates its dependent sessions. A lost game does not unlock the next cabinet.
## Verification
The automated integration test runs three actual headless Godot clients against a temporary controller, with no real GPIO. Cabinet 1 uses the real five-second timer. Test-only code adds Galaga points at 499 and 500, then places Frogger at a valid home; it exercises normal score/home completion paths rather than directly forcing their wins.
```sh
python3 -m unittest discover -s tests/room_network -v
python3 tests/room_network/check_objective_integration.py /path/to/godot
godot --headless --path . --script tests/room_network/check_objective_defaults.gd
```
+209
View File
@@ -0,0 +1,209 @@
@tool
extends EditorProperty
var open_button: Button
var dialog: ConfirmationDialog
var draft: CabinetPCBConfig
var chosen := "J1"
var buttons: Dictionary = {}
var type_select: OptionButton
var input_pull: CheckBox
var device_name: LineEdit
var device_id: LineEdit
var binding: LineEdit
var notes: TextEdit
var pin_label: Label
var warning_label: Label
var summary: Label
var loading := false
func _init() -> void:
open_button = Button.new()
open_button.text = "Open PCB connections..."
open_button.pressed.connect(_open)
add_child(open_button)
add_focusable(open_button)
func _update_property() -> void:
var config: CabinetPCBConfig = get_edited_object().get(get_edited_property())
open_button.text = "PCB connections (%d)..." % config.connections.size() if config else "Set up PCB connections..."
func _open() -> void:
var source: CabinetPCBConfig = get_edited_object().get(get_edited_property())
draft = source.duplicate(true) if source else CabinetPCBConfig.new()
# Rebuild static labels after editor script reloads as well as dynamic fields.
if is_instance_valid(dialog):
dialog.hide()
dialog.queue_free()
buttons.clear()
_build_dialog()
dialog.title = "%s - Custom GPIO routing PCB" % get_edited_object().display_name
_select(chosen)
dialog.popup_centered_clamped(Vector2i(1120, 760), 0.9)
func _label(text: String, parent: Node) -> Label:
var label := Label.new()
label.text = text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
parent.add_child(label)
return label
func _build_dialog() -> void:
dialog = ConfirmationDialog.new()
dialog.ok_button_text = "Apply connections"
dialog.min_size = Vector2i(700, 550)
dialog.confirmed.connect(_apply)
add_child(dialog)
var scroll := ScrollContainer.new()
dialog.add_child(scroll)
var content := VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(content)
_label("Select a socket, then describe the device. Apply supports Undo/Redo; save the cabinet resource to keep changes.", content)
var panel := PanelContainer.new()
var style := StyleBoxFlat.new()
style.bg_color = Color("164d3a")
style.content_margin_left = 16
style.content_margin_right = 16
style.content_margin_top = 16
style.content_margin_bottom = 16
panel.add_theme_stylebox_override("panel", style)
content.add_child(panel)
var board := VBoxContainer.new()
board.add_theme_constant_override("separation", 18)
panel.add_child(board)
var header := Button.new()
header.text = "J9 | Raspberry Pi GPIO ribbon | verify pin 1 orientation"
header.disabled = true
board.add_child(header)
var middle := HBoxContainer.new()
board.add_child(middle)
var group_index := 0
for group in [["J15", "J14", "J13", "J12", "J11", "J10"], ["J22", "J21", "J20", "J19", "J18", "J17"], ["J16"]]:
var column := VBoxContainer.new()
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
middle.add_child(column)
_label(["SIGNAL + 3V3", "SIGNAL / GND", "I2C"][group_index], column)
group_index += 1
var grid := GridContainer.new()
grid.columns = group.size()
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
column.add_child(grid)
for id in group:
_port_button(id, grid)
_label("READER CONNECTORS | selector BCM22 / BCM27 / BCM17 | shared reset BCM20", board)
var readers := GridContainer.new()
readers.columns = 8
board.add_child(readers)
for index in range(1, 9):
_port_button("J%d" % index, readers)
summary = _label("", content)
var details := HBoxContainer.new()
details.add_theme_constant_override("separation", 24)
content.add_child(details)
var left := VBoxContainer.new()
left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
details.add_child(left)
pin_label = _label("", left)
_label("GPIO is a control signal. Motors, relays and lighting need an appropriate driver and supply. This editor does not write GPIO or install reader drivers.", left)
var form := VBoxContainer.new()
form.size_flags_horizontal = Control.SIZE_EXPAND_FILL
details.add_child(form)
_label("Connected device", form)
type_select = OptionButton.new()
type_select.item_selected.connect(_type_changed)
form.add_child(type_select)
input_pull = CheckBox.new()
input_pull.text = "Input pull-up (active when connected to GND)"
input_pull.toggled.connect(func(_value: bool): _store())
form.add_child(input_pull)
device_name = _field("Device name", form)
device_id = _field("Unique device ID (within this cabinet)", form)
binding = _field("Hardware action ID (outputs) / event ID (inputs)", form)
_label("Wiring notes / driver / supply / I2C address", form)
notes = TextEdit.new()
notes.custom_minimum_size.y = 80
notes.text_changed.connect(_store)
form.add_child(notes)
warning_label = _label("", content)
warning_label.modulate = Color("ffd07a")
func _field(title: String, parent: Node) -> LineEdit:
_label(title, parent)
var field := LineEdit.new()
field.text_changed.connect(func(_text: String): _store())
parent.add_child(field)
return field
func _port_button(id: String, parent: Node) -> void:
var button := Button.new()
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(64, 82)
button.toggle_mode = true
button.pressed.connect(_select.bind(id))
parent.add_child(button)
buttons[id] = button
func _select(id: String) -> void:
chosen = id
loading = true
var definition := CabinetPCBConfig.port(id)
var item := draft.connection(id)
pin_label.text = "%s | %s\n\n%s" % [id, definition.label, definition.pins]
type_select.clear()
type_select.add_item("Empty / unplugged")
type_select.set_item_metadata(0, "")
for kind in definition.types:
type_select.add_item(CabinetPCBConfig.TYPES[kind])
type_select.set_item_metadata(type_select.item_count - 1, kind)
type_select.select(0)
if item:
for index in range(type_select.item_count):
if type_select.get_item_metadata(index) == item.device_type:
type_select.select(index)
device_name.text = item.display_name if item else ""
device_id.text = item.device_id if item else ""
binding.text = item.binding_id if item else ""
notes.text = item.notes if item else ""
input_pull.set_pressed_no_signal(item.input_pull_up if item else true)
loading = false
_refresh_board()
func _type_changed(_index: int) -> void:
_store()
func _store() -> void:
if loading:
return
var item := PCBConnection.new()
item.port_id = chosen
item.device_type = str(type_select.get_selected_metadata())
item.display_name = device_name.text.strip_edges()
item.device_id = device_id.text.strip_edges()
item.binding_id = binding.text.strip_edges()
item.notes = notes.text
item.input_pull_up = input_pull.button_pressed
draft.set_connection(item)
_refresh_board()
func _refresh_board() -> void:
for id in buttons:
var definition := CabinetPCBConfig.port(id)
var item := draft.connection(id)
var button: Button = buttons[id]
button.text = "%s\n%s\n%s" % [id, definition.label, item.device_type.to_upper() if item else "Empty"]
button.set_pressed_no_signal(id == chosen)
button.tooltip_text = "%s\n%s" % [definition.pins, item.display_name if item else "Unassigned"]
var disabled := str(type_select.get_selected_metadata()).is_empty()
device_name.editable = not disabled
device_id.editable = not disabled
binding.editable = not disabled
notes.editable = not disabled
input_pull.visible = str(type_select.get_selected_metadata()) in ["button", "sensor"]
summary.text = "%d connections configured | %s selected" % [draft.connections.size(), chosen]
var issues := draft.issues(get_edited_object().hardware_config)
warning_label.text = "Review wiring:\n- " + "\n- ".join(issues) if not issues.is_empty() else "Inventory is consistent. Runtime wiring and drivers are configured separately."
func _apply() -> void:
emit_changed(get_edited_property(), draft.duplicate(true))
_update_property()
@@ -0,0 +1 @@
uid://pqhm21y6t7c6
+80 -1
View File
@@ -231,7 +231,12 @@ var simulation_driver := EditorSimulationDriver.new()
var editor_selection := EscapeRoomEditorSelection.new() var editor_selection := EscapeRoomEditorSelection.new()
var doorway_tool: Button
var doorway_check: CheckBox
var swing_check: CheckBox
func _ready() -> void: func _ready() -> void:
_setup_view_controls()
select_tool_button.button_pressed = true select_tool_button.button_pressed = true
@@ -1503,6 +1508,9 @@ func _on_show_connections_check_box_toggled(
) )
func _clear_property_panel() -> void: func _clear_property_panel() -> void:
if doorway_check != null:
doorway_check.disabled = true
swing_check.disabled = true
selected_label.text = ( selected_label.text = (
"Nothing selected" "Nothing selected"
@@ -1668,6 +1676,7 @@ func _on_wall_creation_requested(
wall.start = start wall.start = start
wall.end = end wall.end = end
wall.thickness = 0.10 wall.thickness = 0.10
wall.is_doorway = doorway_tool.button_pressed
if undo_redo == null: if undo_redo == null:
_add_wall( _add_wall(
@@ -1828,10 +1837,15 @@ func _refresh_wall_property_panel(
return return
selected_label.text = ( selected_label.text = (
"Wall: %s" ("Doorway: %s" if wall.is_doorway else "Wall: %s")
% wall.wall_id % wall.wall_id
) )
doorway_check.disabled = false
doorway_check.set_pressed_no_signal(wall.is_doorway)
swing_check.set_pressed_no_signal(wall.swing_reversed)
swing_check.disabled = not wall.is_doorway
wall_start_x_spin_box.set_value_no_signal( wall_start_x_spin_box.set_value_no_signal(
wall.start.x * METERS_TO_FEET wall.start.x * METERS_TO_FEET
) )
@@ -3001,3 +3015,68 @@ func _on_room_layout_refresh_requested() -> void:
status_label.text = ( status_label.text = (
"Physical room refreshed." "Physical room refreshed."
) )
func _setup_view_controls() -> void:
var toolbar := draw_wall_button.get_parent()
doorway_tool = Button.new()
doorway_tool.text = "DRAW DOORWAY"
doorway_tool.toggle_mode = true
doorway_tool.button_group = draw_wall_button.button_group
doorway_tool.tooltip_text = "Drag from hinge to opposite jamb. Align with a wall to create an opening."
toolbar.add_child(doorway_tool)
toolbar.move_child(doorway_tool, draw_wall_button.get_index() + 1)
doorway_tool.pressed.connect(func(): room_layout.set_edit_mode(RoomLayout.EditMode.DRAW_WALL))
var view_bar := HBoxContainer.new()
toolbar.get_parent().add_child(view_bar)
toolbar.get_parent().move_child(view_bar, toolbar.get_index() + 1)
for title in ["Labels", "Dimensions"]:
var toggle := CheckBox.new()
toggle.text = title
toggle.button_pressed = true
view_bar.add_child(toggle)
if title == "Labels":
toggle.toggled.connect(room_layout.set_labels_visible)
else:
toggle.toggled.connect(room_layout.set_dimensions_visible)
doorway_check = CheckBox.new()
doorway_check.text = "Doorway opening"
doorway_check.disabled = true
doorway_check.toggled.connect(func(value: bool): _commit_door_setting("is_doorway", value))
swing_check = CheckBox.new()
swing_check.text = "Reverse door swing"
swing_check.disabled = true
swing_check.toggled.connect(func(value: bool): _commit_door_setting("swing_reversed", value))
var properties := delete_wall_button.get_parent()
properties.add_child(doorway_check)
properties.add_child(swing_check)
properties.move_child(doorway_check, delete_wall_button.get_index())
properties.move_child(swing_check, delete_wall_button.get_index())
func _commit_door_setting(property: String, value: bool) -> void:
if context == null:
return
var target := context.get_room()
var wall := target.get_wall(room_layout.selected_wall_id)
if wall == null:
doorway_check.set_pressed_no_signal(false)
swing_check.set_pressed_no_signal(false)
return
var old: bool = wall.get(property)
if old == value:
return
if undo_redo == null:
_apply_door_setting(target, wall, property, value)
return
undo_redo.create_action("Edit doorway", UndoRedo.MERGE_DISABLE, target)
undo_redo.add_do_method(self, "_apply_door_setting", target, wall, property, value)
undo_redo.add_undo_method(self, "_apply_door_setting", target, wall, property, old)
undo_redo.commit_action()
func _apply_door_setting(target: RoomDefinition, wall: RoomWallDefinition, property: String, value: bool) -> void:
if not target.walls.has(wall):
return
wall.set(property, value)
context.room_edit_session.mark_dirty()
room_layout.queue_redraw()
_refresh_wall_property_panel(wall.wall_id)
+7 -2
View File
@@ -1,6 +1,8 @@
@tool @tool
extends ArcadeGame extends ArcadeGame
@export_range(0,99) var boards_to_win: int = 1 @export_range(0,99) var boards_to_win: int = 1
## Zero disables the individual-home objective; normal play uses board completion.
@export_range(0,5) var homes_to_win: int = 0
@export_range(1,9) var starting_lives: int = 3 @export_range(1,9) var starting_lives: int = 3
@export_range(15,120) var crossing_time: float = 60.0 @export_range(15,120) var crossing_time: float = 60.0
@export_range(.5,2) var difficulty: float = 1.0 @export_range(.5,2) var difficulty: float = 1.0
@@ -139,7 +141,10 @@ func _physics_process(delta: float) -> void:
phase = "play" phase = "play"
if music_enabled and not demo_active: $Audio.music() if music_enabled and not demo_active: $Audio.music()
"home": "home":
if not homes.has(false): var target := int(run_config.get("homes_to_win", homes_to_win))
if not demo_active and target > 0 and homes.count(true) >= target:
finish(true)
elif not homes.has(false):
add_score(1000) add_score(1000)
phase = "clear" phase = "clear"
phase_time = 2.0 phase_time = 2.0
@@ -149,7 +154,7 @@ func _physics_process(delta: float) -> void:
"clear": "clear":
if demo_active: if demo_active:
end_demo() end_demo()
elif boards_to_win > 0 and board_number >= boards_to_win: elif int(run_config.get("boards_to_win", boards_to_win)) > 0 and board_number >= int(run_config.get("boards_to_win", boards_to_win)):
finish(true) finish(true)
else: else:
board_number += 1 board_number += 1
+45
View File
@@ -13,6 +13,40 @@ var model = Model.new()
var view var view
var audio var audio
var accumulator := 0.0 var accumulator := 0.0
var credits := 0
var rfid_seen: Dictionary = {}
var rfid_initialized := false
func receive_device_feedback(feedback: Dictionary) -> void:
var events: Array = feedback.get("events", [])
if not rfid_initialized:
if not bool(feedback.get("fresh", false)): return
for entry in events: rfid_seen[str(entry.get("id", ""))] = true
rfid_initialized = true
return
if not bool(feedback.get("fresh", false)): return
var device_id := str(run_config.get("rfid_credit_device", ""))
for entry in events:
var ident := str(entry.get("id", ""))
if ident.is_empty() or rfid_seen.has(ident): continue
rfid_seen[ident] = true
if device_id.is_empty() or is_running or credits > 0 or model.state != "idle": continue
if entry.get("device") != device_id or entry.get("kind") != "rfid" or not str(entry.get("error", "")).is_empty(): continue
var uid := str(entry.get("value", "")).to_upper()
if uid.is_empty(): continue
var allowed: Array = run_config.get("rfid_allowed_uids", [])
if not allowed.is_empty() and not allowed.has(uid): continue
for sample in feedback.get("devices", []):
if sample.get("id") == device_id and int(sample.get("age_ms", 999999)) < 2000 and sample.get("value") == entry.get("value") and str(sample.get("error", "")).is_empty():
credits = 1
view.credits = credits
audio.play_effect("credit")
break
# The bridge retains only its last 64 events. Keep deduplication bounded too.
if rfid_seen.size() > 256:
rfid_seen.clear()
for entry in events: rfid_seen[str(entry.get("id", ""))] = true
func _ready() -> void: func _ready() -> void:
view = View.new() view = View.new()
@@ -26,9 +60,15 @@ func _ready() -> void:
func configure(config: Dictionary = {}) -> void: func configure(config: Dictionary = {}) -> void:
super.configure(config) super.configure(config)
credits = 0
rfid_seen.clear()
rfid_initialized = false
_apply_config() _apply_config()
func _apply_config() -> void: func _apply_config() -> void:
if view != null:
view.credit_required = not str(run_config.get("rfid_credit_device", "")).is_empty()
view.credits = credits
model.boards_to_win = maxi(0, int(run_config.get("boards_to_win", boards_to_win))) model.boards_to_win = maxi(0, int(run_config.get("boards_to_win", boards_to_win)))
model.score_to_win = maxi(0, int(run_config.get("score_to_win", score_to_win))) model.score_to_win = maxi(0, int(run_config.get("score_to_win", score_to_win)))
model.initial_lives = clampi(int(run_config.get("starting_lives", starting_lives)), 1, 9) model.initial_lives = clampi(int(run_config.get("starting_lives", starting_lives)), 1, 9)
@@ -36,6 +76,9 @@ func _apply_config() -> void:
func start_game() -> bool: func start_game() -> bool:
if is_running: return false if is_running: return false
if not str(run_config.get("rfid_credit_device", "")).is_empty():
if credits < 1: return false
credits -= 1
_apply_config() _apply_config()
audio.stop_all() audio.stop_all()
model.start() model.start()
@@ -43,6 +86,8 @@ func start_game() -> bool:
return super.start_game() return super.start_game()
func reset_game() -> void: func reset_game() -> void:
credits = 0
if view != null: view.credits = 0
audio.stop_all() audio.stop_all()
model.reset() model.reset()
accumulator = 0.0 accumulator = 0.0
+5 -2
View File
@@ -10,6 +10,8 @@ const DEATH_ANIMATION_SECONDS := 1.55
const DEATH_POP_SECONDS := 0.24 const DEATH_POP_SECONDS := 0.24
var model var model
var debug_grid := false var debug_grid := false
var credit_required := false
var credits := 0
func _ready() -> void: func _ready() -> void:
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
@@ -50,12 +52,13 @@ func _draw() -> void:
_text("HIGH SCORE", Vector2(80, 0)) _text("HIGH SCORE", Vector2(80, 0))
_text(str(model.score).pad_zeros(2), Vector2(8, 8)) _text(str(model.score).pad_zeros(2), Vector2(8, 8))
_text(str(model.high_score).pad_zeros(2), Vector2(88, 8)) _text(str(model.high_score).pad_zeros(2), Vector2(88, 8))
if credit_required: _text("CREDIT %d" % credits, Vector2(120, 280))
for i in range(mini(model.lives - 1, 5)): for i in range(mini(model.lives - 1, 5)):
_sprite(Rect2(600, 16, 16, 16), Vector2(16 + i * 16, 280)) _sprite(Rect2(600, 16, 16, 16), Vector2(16 + i * 16, 280))
_text("L%d" % model.level, Vector2(192, 276), Color("ffb852")) if not credit_required: _text("L%d" % model.level, Vector2(192, 276), Color("ffb852"))
match model.state: match model.state:
"idle": "idle":
_banner("PRESS START", Color.YELLOW) _banner("SCAN CARD" if credit_required and credits == 0 else "PRESS START", Color.YELLOW)
_text("ARROWS TO MOVE", Vector2(56, 256)) _text("ARROWS TO MOVE", Vector2(56, 256))
"ready": "ready":
_banner("READY!", Color.YELLOW) _banner("READY!", Color.YELLOW)
Binary file not shown.
Binary file not shown.
File renamed without changes.
+4
View File
@@ -0,0 +1,4 @@
[network]
url="http://10.0.0.57:8766"
cabinet_id="cabinet_03"
cabinet_token="qZSD9eM2dmeRtmVA"
+46
View File
@@ -0,0 +1,46 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
from test_bridge import bridge
source=Path(__file__).resolve().parents[2]/'tools/pi_gpio/device_feedback.py'
spec=importlib.util.spec_from_file_location('feedback',source)
feedback=importlib.util.module_from_spec(spec);spec.loader.exec_module(feedback)
class Pin:
def __init__(self,pin,**kwargs): self.value=False;self.closed=False
def on(self):self.value=True
def off(self):self.value=False
def close(self):self.closed=True
class FeedbackTests(unittest.TestCase):
def config(self,data,pins):
with tempfile.TemporaryDirectory() as folder:
path=Path(folder)/'devices.json';path.write_text(json.dumps(data))
return feedback.load_devices(path,pins)
def test_pin_ownership(self):
with self.assertRaises(ValueError):self.config([dict(id='r',port='J1',kind='rfid')],[17])
with self.assertRaises(ValueError):self.config([dict(id='b',port='J17',kind='input',pull_up=True)],[16])
with self.assertRaises(ValueError):self.config([dict(id='b',port='J17',kind='input')],[25])
with self.assertRaises(ValueError):self.config([dict(id='x',port='J22',kind='output')],[18])
result=self.config([dict(id='r',port='J2',kind='rfid'),dict(id='b',port='J17',kind='input',pull_up=True)],[25])
self.assertEqual(result[1]['pin'],16)
def test_live_values_transitions_age_and_cleanup(self):
now=[100]
definitions=self.config([dict(id='button',port='J17',kind='input',pull_up=True),dict(id='lamp',port='J22',kind='output')],[25])
outputs=bridge.Outputs(Pin,'test',[25],clock=lambda:now[0])
monitor=feedback.Feedback(definitions,Pin,outputs,clock=lambda:now[0])
monitor.tick();first=monitor.snapshot();monitor.tick()
self.assertEqual(len(first['events']),len(monitor.snapshot()['events']))
monitor.inputs[0][1].value=True
outputs.command(dict(controller='test',pin=25,mode='pulse',duration_ms=200,active_high=True))
monitor.tick()
self.assertTrue(all(d['value'] for d in monitor.snapshot()['devices']))
now[0]+=.3;outputs.tick();monitor.tick()
self.assertFalse(monitor.snapshot()['devices'][1]['value'])
now[0]+=7
self.assertGreater(monitor.snapshot()['devices'][0]['age_ms'],6000)
pin=monitor.inputs[0][1];monitor.close();outputs.close();self.assertTrue(pin.closed)
def test_rfid_requires_helper(self):
outputs=bridge.Outputs(Pin,'test',[25])
with self.assertRaises(ValueError):feedback.Feedback([dict(id='r',kind='rfid',port='J1')],Pin,outputs)
outputs.close()
+98
View File
@@ -0,0 +1,98 @@
extends SceneTree
var failures := 0
func check(condition: bool, message: String) -> void:
if not condition:
failures += 1
push_error(message)
func _initialize() -> void:
run.call_deferred()
func run() -> void:
if "--preview" in OS.get_cmdline_user_args():
root.gui_embed_subwindows = true
root.size = Vector2i(1200, 850)
var board := CabinetPCBConfig.new()
check(board.ports().size() == 22, "All 22 connectors must be present")
check(board.port("J15").bcm == 21 and board.port("J10").bcm == 5, "Three pin order")
check(board.port("J22").bcm == 25 and board.port("J17").bcm == 16, "Two pin order")
check(board.port("J2").label == "001" and board.port("J8").label == "111", "Reader selector order")
var reader := PCBConnection.new()
reader.port_id = "J1"
reader.device_type = "rfid"
reader.device_id = "reader_left"
reader.display_name = "Left RFID reader"
board.set_connection(reader)
var reader2: PCBConnection = reader.duplicate(true)
reader2.port_id = "J2"
reader2.device_id = "reader_right"
board.set_connection(reader2)
check(board.issues().is_empty(), "Valid readers")
var cabinet := CabinetProfile.new()
cabinet.display_name = "PCB editor test"
cabinet.pcb_config = board
var copy: CabinetProfile = cabinet.duplicate(true)
copy.pcb_config.connection("J1").display_name = "Other cabinet"
check(board.connection("J1").display_name == "Left RFID reader", "Cabinet deep-copy isolation")
var path := "user://pcb_test_roundtrip.tres"
check(ResourceSaver.save(cabinet, path) == OK, "Save cabinet")
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) as CabinetProfile
check(loaded.pcb_config.connection("J2").device_id == "reader_right", "Save/reload connector data")
DirAccess.remove_absolute(path)
reader2.device_id = reader.device_id
check(not board.issues().is_empty(), "Duplicate IDs reported")
reader2.device_id = "reader_right"
var hardware := HardwareConfig.new()
var action := HardwareAction.new()
action.action_id = &"lamp"
action.gpio_pin = 17
hardware.actions.append(action)
check(not board.issues(hardware).is_empty(), "Reserved selector pin reported")
var output := PCBConnection.new()
output.port_id = "J22"
output.device_type = "led"
output.device_id = "lamp"
output.binding_id = "lamp"
board.set_connection(output)
action.gpio_pin = 18
check(not board.issues(hardware).is_empty(), "Output BCM mismatch reported")
action.gpio_pin = 25
check(board.issues(hardware).is_empty(), "Matching output action accepted")
cabinet.hardware_config = hardware
if not Engine.is_editor_hint():
print("PCB resource tests: ", "PASS" if failures == 0 else "FAIL")
quit(failures)
return
var property := preload("res://editor/hardware/pcb_editor_property.gd").new()
root.add_child(property)
property.set_object_and_property(cabinet, "pcb_config")
property._update_property()
property._open()
property._select("J2")
check(property.device_id.text == "reader_right", "Editor selects correct reader")
property.device_name.text = "Pending edit"
property._store()
check(board.connection("J2").display_name != "Pending edit", "Draft cannot change source before Apply")
property.dialog.hide()
property._open()
check(property.device_name.text != "Pending edit", "Cancel/reopen discards draft")
property._select("J22")
property.device_name.text = "Reward lamp"
property._store()
property.property_changed.connect(func(_key: StringName, value: Variant, _field: StringName, _changing: bool): cabinet.pcb_config = value)
property._apply()
check(cabinet.pcb_config.connection("J22").display_name == "Reward lamp", "Apply emits updated configuration")
if "--preview" in OS.get_cmdline_user_args():
root.size = Vector2i(1200, 850)
property.dialog.popup_centered(Vector2i(1120, 760))
await process_frame
await process_frame
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("user://pcb_editor_preview.png")
print("Preview: ", OS.get_user_data_dir(), "/pcb_editor_preview.png")
property.queue_free()
await process_frame
print("PCB tests: ", "PASS" if failures == 0 else "FAIL")
quit(failures)
+1
View File
@@ -0,0 +1 @@
uid://cbtyukgnhlhts
+42
View File
@@ -0,0 +1,42 @@
extends SceneTree
var failures := 0
func check(ok: bool, message: String) -> void:
if not ok:
failures += 1
push_error(message)
func sample(id: String, uid := "1234ABCD", device := "cab1_rfid1", fresh := true, age := 0) -> Dictionary:
return {"fresh": fresh, "events": [{"id": id, "device": device, "kind": "rfid", "value": uid, "error": ""}], "devices": [{"id": device, "value": uid, "age_ms": age, "error": ""}]}
func _initialize() -> void:
call_deferred("run")
func run() -> void:
var game = load("res://games/pacman/pacman.gd").new()
root.add_child(game)
game.configure({"rfid_credit_device": "cab1_rfid1", "rfid_allowed_uids": ["1234ABCD"]})
check(not game.start_game(), "Start must require a credit")
game.receive_device_feedback({})
game.receive_device_feedback(sample("old"))
check(game.credits == 0, "First fresh snapshot must baseline historical scans")
game.receive_device_feedback(sample("wrong-reader", "1234ABCD", "other"))
game.receive_device_feedback(sample("wrong-card", "99999999"))
game.receive_device_feedback(sample("stale", "1234ABCD", "cab1_rfid1", true, 5000))
game.receive_device_feedback(sample("offline", "1234ABCD", "cab1_rfid1", false))
check(game.credits == 0, "Wrong reader/card, stale and offline samples must not credit")
game.receive_device_feedback(sample("scan"))
check(game.credits == 1 and game.view.credits == 1, "New accepted scan grants one visible credit")
game.receive_device_feedback(sample("scan"))
game.receive_device_feedback(sample("scan2"))
check(game.credits == 1, "Duplicate and additional scans must not stack credits")
check(game.start_game() and game.credits == 0, "Start consumes one credit")
game.receive_device_feedback(sample("during-play"))
check(game.credits == 0, "Playing scans must not bank credits")
game.reset_game()
game.receive_device_feedback(sample("scan"))
check(game.credits == 0, "Reset must not replay a prior event")
game.receive_device_feedback(sample("new-scan"))
check(game.credits == 1, "New scan works after reset")
game.configure({})
check(game.start_game(), "Ordinary non-RFID Pac-Man remains free start")
game.queue_free()
await process_frame
print("RFID credit tests: ", "PASS" if failures == 0 else "FAIL")
quit(1 if failures else 0)
+64
View File
@@ -0,0 +1,64 @@
extends SceneTree
var failures := 0
func check(value: bool, message: String) -> void:
if not value:
failures += 1
push_error(message)
func _initialize() -> void:
run.call_deferred()
func run() -> void:
var room := RoomDefinition.new()
var wall := RoomWallDefinition.new()
wall.wall_id = &"wall"
wall.end = Vector2(4, 0)
var door := RoomWallDefinition.new()
door.wall_id = &"door"
door.start = Vector2(1, 0)
door.end = Vector2(2, 0)
door.is_doorway = true
room.walls.assign([wall, door])
var path := "user://room_door_test.tres"
check(ResourceSaver.save(room, path) == OK, "Initial room save")
room.take_over_path(path)
var session := RoomEditSession.new()
session.begin(room)
session.working_room.get_wall(&"door").swing_reversed = true
session.mark_dirty()
check(session.is_dirty, "Door swing participates in dirty tracking")
check(not door.swing_reversed, "Source isolated from edits")
check(session.save(), "Save room")
var loaded := ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE) as RoomDefinition
check(loaded.get_wall(&"door").is_doorway and loaded.get_wall(&"door").swing_reversed, "Door save/reload")
session.working_room.get_wall(&"door").is_doorway = false
session.mark_dirty()
check(session.is_dirty, "Door type participates in dirty tracking")
session.revert()
check(session.working_room.get_wall(&"door").is_doorway, "Revert restores doorway")
DirAccess.remove_absolute(path)
room.walls.assign([wall, door])
var layout := RoomLayout.new()
layout.item_scene = load("res://ui/RoomLayoutItem.tscn")
root.add_child(layout)
var device := RoomDeviceDefinition.new()
device.device_id = &"lamp"
device.display_name = "Reward lamp"
room.devices.append(device)
layout.configure(room, null)
var ranges := layout._wall_solid_ranges(wall)
check(ranges == [Vector2(0, 0.25), Vector2(0.5, 1)], "Door cuts wall opening")
door.start = Vector2(2, 0)
door.end = Vector2(1, 0)
check(layout._wall_solid_ranges(wall) == ranges, "Reversed doorway endpoints")
check(layout._find_wall_at_screen_position(layout.world_to_screen(Vector2(1.5, 0))) == door, "Door selected ahead of underlying wall")
layout.set_labels_visible(false)
check(not layout.item_nodes[&"lamp"].title_label.visible, "Hide item label")
layout.configure(room, null)
check(not layout.item_nodes[&"lamp"].title_label.visible, "Label preference survives rebuild")
layout.set_labels_visible(true)
check(layout.item_nodes[&"lamp"].title_label.visible, "Restore item label")
layout.set_dimensions_visible(false)
check(not layout.show_dimensions, "Dimension visibility")
layout.queue_free()
await process_frame
print("Room editor tests: ", "PASS" if failures == 0 else "FAIL")
quit(failures)
+1
View File
@@ -0,0 +1 @@
uid://0p3i2u8rm2x4
+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): class FleetTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.now = 100 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) self.fleet = server.Fleet(':memory:', self.catalog, lambda: self.now)
for cabinet in self.fleet.rooms: self.poll(cabinet) for cabinet in self.fleet.rooms: self.poll(cabinet)
@@ -54,6 +55,9 @@ class FleetTests(unittest.TestCase):
self.assertFalse(self.poll('cabinet_01')['commands']) self.assertFalse(self.poll('cabinet_01')['commands'])
def test_compatibility_rejects_missing_controls_and_accepts_alternatives(self): 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'): with self.assertRaisesRegex(ValueError, 'hardware'):
self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'missile_command'}) self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'missile_command'})
cabinet = self.catalog['cabinets']['cabinet_03'] 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())
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
site/
*.tar.gz
+134
View File
@@ -0,0 +1,134 @@
# Master Pi deployment: first box test
This first implementation provides a **terminal setup wizard**, portable master software, versioned cabinet uploads, configuration generation and SSH service controls. The normal browser operator remains the game-master interface. There is not yet a browser provisioning wizard or automatic network reconfiguration.
It does not connect to any Pi until you explicitly run an enrollment/deployment command. It does not pulse GPIO, trigger a puzzle, reset controller mappings, or copy the development site's credentials. Use one spare cabinet first.
## 1. Prepare clean deployment packages on the development PC
From the project root (replace the release label for each build):
```powershell
py -3 tools/deploy/deploy.py master-package --output D:/ArcadeMaster-test1
py -3 tools/deploy/deploy.py bundle --export setup/pi --output D:/arcade-test1.tar.gz --release test1
```
The output directory and bundle must not already exist. The master package is an explicit allowlist of Python/scripts, operator assets and catalog. The cabinet bundle contains the current ARM64 executable/PCK plus scripts and a checksum manifest. No live INIs, tokens, SQLite files, SSH keys or Odin files are copied. Package an export built from the same game/cabinet catalog: the packager validates ARM64/PCK headers, but cannot prove a PCK was exported from the matching source revision.
Copy `ArcadeMaster-test1` and `arcade-test1.tar.gz` to the master Pi, for example under its desktop user's home. No Godot editor is needed on the master.
## 2. Network for the box
First confirm the physical arrangement: master Ethernet to the box's private switch, cabinet Ethernet to that switch, and master Wi-Fi (or a second Ethernet adapter) to the venue LAN. Do not also connect the private switch to the venue router. Cabinet Wi-Fi must not bypass the private network.
A plain switch does not supply DHCP. For the initial box test, manually configure **unique static wired addresses** through each Pi's desktop network settings. Choose a subnet that does not overlap the venue network; an example is master `192.168.77.1/24`, cabinet 1 `.11/24`, cabinet 2 `.12/24`, cabinet 3 `.13/24`. Leave the private connection's gateway and DNS blank, and keep the master's default Internet route on its venue connection. Record the real addresses in setup below. A later installer can provide private DHCP reservations.
The deployment tool does not install a firewall or enforce network isolation. The master must not bridge the interfaces or enable IP forwarding/connection sharing. For an enforced deployment, apply a firewall policy that drops forwarding in both IPv4 and IPv6, permits cabinet controller traffic to the master on TCP 8766, and permits venue browsers to the operator port. This needs the actual interface/subnet information; do not blindly replace an existing firewall. Staff on an isolated guest Wi-Fi may not be allowed to reach the master.
You may test the software on the existing LAN first; that is **not** a closed cabinet network. The browser connects to `http://MASTER_VENUE_IP:8766`; cabinets use `http://MASTER_PRIVATE_IP:8766`. Game-master commands travel through the master. No direct staff-browser-to-cabinet route is required.
## 3. Pi prerequisites
Use Raspberry Pi OS **64-bit with a desktop** on the cabinets. Enable SSH and desktop autologin for the same ordinary user who runs the game. The master needs Python 3 and the OpenSSH client; it can be headless after initial setup.
On the master, if the commands are missing:
```sh
sudo apt install python3 openssh-client
```
On cabinets using real GPIO, install dependencies before disconnecting Internet access:
```sh
sudo apt install python3-gpiozero python3-lgpio
```
The cabinet user needs GPIO device permission (normally the Raspberry Pi OS `gpio` group). The first test may use GPIO disabled. Do not guess MOSFET/NFC pins: enable only the BCM outputs already defined and wired for that cabinet. NFC input integration is separate from deployment.
## 4. Register the site on the master
From the copied master folder:
```sh
python3 tools/deploy/deploy.py setup
```
The wizard asks for the private controller URL, each cabinet's existing catalog ID, its address and SSH user, and GPIO settings. It generates distinct site credentials and configurations in `~/.local/share/arcade-master/`, with private permissions. It never asks for or stores the Pi login password. Re-running setup edits entries and preserves tokens. Press Enter at the cabinet-ID prompt when finished.
For this release choose `cabinet_01`, `cabinet_02`, or `cabinet_03`, matching the existing exported hardware profiles. Arbitrary new IDs require a matching export/catalog; dynamic runtime profiles are not implemented yet. The GPIO controller ID must match the project's hardware action definitions, not merely a newly invented name.
To inspect/edit the inventory later, use `inventory.json` in that site directory and run `python3 tools/deploy/deploy.py generate`. Never rotate or hand out another cabinet's token accidentally. Changing an active site's master credentials/config requires a controller restart and matching cabinet reactivation. Do maintenance between sessions.
## 5. Enroll, check and upload ONE cabinet
```sh
python3 tools/deploy/deploy.py enroll cabinet_01
python3 tools/deploy/deploy.py preflight cabinet_01
python3 tools/deploy/deploy.py stage cabinet_01 --bundle ~/arcade-test1.tar.gz
```
Enrollment creates a dedicated master SSH key if none exists, asks you to verify the target host fingerprint, and uses `ssh-copy-id` with the cabinet's existing login. It preserves other authorized keys. The key has no passphrase so startup can be unattended; protect the master account and its site backup. Subsequent commands require the verified host key and key-based authentication without prompts.
Preflight reports architecture, Python version, GPIO-package availability, free space and whether the display helper has captured a desktop session. Uploads use SSH and verify an exact file allowlist and every SHA-256 checksum. Staging does not stop or start a game. An interrupted upload leaves the previous release untouched; rerun stage. Reusing a release ID with different contents is rejected.
## 6. Activate and initialize the desktop helper
Stop any manually launched game/bridge first. If the cabinet service is running, explicitly stop it between tests:
```sh
python3 tools/deploy/deploy.py stop cabinet_01
python3 tools/deploy/deploy.py activate cabinet_01 --release test1
```
Skip the stop command on a fresh cabinet with no service. Activation refuses to replace a running service and refuses to overwrite unrelated service files. It writes that cabinet's INI and installs the normal user service through SSH. The master/operator token is never sent to the cabinet. If registration fails, prior service files/config are restored.
On a **fresh cabinet**, log out and back into its desktop once after activation, or run this on its local desktop terminal:
```sh
python3 ~/.local/share/arcade-deploy/releases/test1/tools/launch/pi_service.py refresh-session
```
The desktop autostart helper refreshes this display environment at future logins. SSH cannot safely guess the active display. The first remote install therefore reports waiting for desktop readiness instead of pretending the game has started. No root process renders the game.
## 7. Start the master and verify
On the master, from its software folder:
```sh
python3 tools/deploy/deploy.py install-master
sudo loginctl enable-linger "$USER"
systemctl --user start arcade-master.service
python3 tools/deploy/deploy.py health
```
The installer enables the master user service, but does not start it until the start command. Lingering starts the master service at boot even without a desktop login. The master launcher tries to start the registered cabinet services and reports authenticated ONLINE status separately from SSH success. A cabinet must already have its display environment ready. If it logs in later, issue `start` below; startup has bounded waits, not an endless retry loop.
Open `http://MASTER_VENUE_IP:8766` in a staff browser. Read the operator token from the master site's `controller.ini`; it is separate from SSH credentials and cabinet tokens. Existing scenario controls, overrides and hardware actions remain available in the operator page. Deployment does not automatically prepare a scenario or test a hardware output.
```sh
python3 tools/deploy/deploy.py start cabinet_01
python3 tools/deploy/deploy.py status cabinet_01
python3 tools/deploy/deploy.py logs cabinet_01
python3 tools/deploy/deploy.py health
```
To diagnose the master: `journalctl --user -u arcade-master.service -n 60 --no-pager`.
## Updates, rollback and state
Use a new release label and stage it while the old game is running. Between sessions, stop the cabinet, activate the new release and start it. To roll back, stop it and activate the previous release label again. Previous releases are retained; no automatic cleanup deletes them. Reusing the same release for another cabinet identity on the same device overwrites only that release's generated INI after the service is stopped.
Game user data/control mappings stay in Godot's existing user-data directory. GPIO deduplication stays at `~/.local/state/arcade_gpio/requests.sqlite3`. Cabinet logs are under `~/.local/state/arcade_cabinet/logs`. The new master site has its own controller database under the site's `state/`; it does not silently import or overwrite the development controller database.
Back up the master site folder (inventory, tokens, SSH key and controller database) securely. After moving to a non-overlapping venue network, retain the private addresses and update the master venue connection/operator bookmark. This version does not reconfigure network interfaces automatically.
## Box acceptance checklist
- One cabinet enrolls, stages and activates; a failed upload does not stop the existing game.
- A fresh desktop login produces display readiness and the game appears on the cabinet monitor.
- Operator sees the expected cabinet ONLINE and can prepare/start/reset the existing scenario.
- Existing manual progression/hardware controls still work through the master; only test a known wired output deliberately.
- Restart the master; it comes back without a login and cabinets reconnect without replaying a reward.
- Restart a cabinet; after desktop login, explicitly start it if the master's initial startup attempt has already passed.
- Verify staff can reach the operator and, once isolation is configured, cannot directly reach cabinet addresses.
- Repeat with remaining cabinets, then run the three-cabinet test.
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""First-run master setup and explicit SSH deployment for Arcade Escape."""
import argparse
import configparser
import hashlib
import io
import ipaddress
import json
import os
from pathlib import Path
import re
import secrets
import shlex
import shutil
import subprocess
import sys
import tarfile
import time
import urllib.request
from receiver import FILES, private_write, release_id, render_config
ROOT = Path(__file__).resolve().parents[2]
DEFAULT_SITE = Path.home() / '.local/share/arcade-master'
def catalog():
return json.loads((ROOT / 'tools/room_controller/catalog.json').read_text(encoding='utf-8'))
def validate(site):
if site.get('schema') != 1 or not site.get('cabinets'):
raise ValueError('Expected schema 1 and at least one cabinet')
tokens = [site['operator_token']]
seen, targets = set(), set()
for item in site['cabinets']:
for key, pattern in [('id', r'[A-Za-z0-9_]+'), ('host', r'[A-Za-z0-9][A-Za-z0-9.-]*'),
('user', r'[A-Za-z_][A-Za-z0-9_-]*')]:
if not isinstance(item.get(key), str) or not re.fullmatch(pattern, item[key]):
raise ValueError('Invalid cabinet ' + key)
if item['id'] not in catalog()['cabinets']:
raise ValueError('Cabinet ID is not in the exported catalog: ' + item['id'])
target = (item['host'].lower(), item.get('port', 22))
if item['id'] in seen or target in targets:
raise ValueError('Duplicate cabinet ID or SSH target')
seen.add(item['id'])
targets.add(target)
if type(target[1]) is not int or not 1 <= target[1] <= 65535:
raise ValueError('Invalid SSH port')
tokens.append(item['token'])
render_config(dict(item, url=site['controller_url']))
if any(not isinstance(t, str) or not re.fullmatch(r'[A-Za-z0-9_-]{16,128}', t) for t in tokens) or len(set(tokens)) != len(tokens):
raise ValueError('Operator and cabinet tokens must be distinct, 16+ character URL-safe strings')
from urllib.parse import urlsplit
parsed = urlsplit(site['controller_url'])
# This launcher hosts HTTP locally; a TLS proxy would require additional configuration.
if parsed.scheme != 'http' or parsed.port != 8766:
raise ValueError('Use http://MASTER_PRIVATE_IP:8766 for the cabinet controller URL')
address = ipaddress.ip_address(parsed.hostname)
if address.version != 4 or address.is_loopback or address.is_unspecified or address.is_multicast:
raise ValueError('Use the master private-interface IPv4 address, not localhost')
if any(item['host'] == str(address) for item in site['cabinets']):
raise ValueError('A cabinet target cannot be the master controller IP')
def load(folder):
site = json.loads((folder / 'inventory.json').read_text(encoding='utf-8'))
validate(site)
return site
def generate(folder, site):
validate(site)
key = folder / 'ssh/arcade_cabinets'
ini = configparser.ConfigParser(interpolation=None)
ini['controller'] = dict(bind='0.0.0.0', port='8766', operator_token=site['operator_token'],
cabinet_tokens=json.dumps({c['id']: c['token'] for c in site['cabinets']}),
catalog=str(ROOT / 'tools/room_controller/catalog.json'), database=str(folder / 'state/arcade_room.sqlite3'))
ini['launcher'] = dict(logs=str(folder / 'logs'))
ini['remote'] = dict(enabled='true', cabinets=json.dumps([
{k: c[k] for k in ('id', 'host', 'user', 'port')} | {'identity_file': str(key)}
for c in site['cabinets']]))
output = io.StringIO()
ini.write(output)
private_write(folder / 'controller.ini', output.getvalue())
for c in site['cabinets']:
private_write(folder / 'cabinets' / (c['id'] + '.ini'), render_config(dict(c, url=site['controller_url'])))
def prompt(label, default=''):
value = input(label + (f' [{default}]' if default else '') + ': ').strip()
return value or default
def setup(folder):
path = folder / 'inventory.json'
if path.exists():
site = load(folder)
print('Existing site loaded; credentials will be preserved.')
else:
site = dict(schema=1, operator_token=secrets.token_urlsafe(24), controller_url='', cabinets=[])
site['controller_url'] = prompt('Master PRIVATE URL', site['controller_url'] or 'http://192.168.77.1:8766')
print('Available cabinet profiles: ' + ', '.join(catalog()['cabinets']))
existing = {c['id']: c for c in site['cabinets']}
while True:
cabinet = prompt('Cabinet ID to add/edit (Enter when done)')
if not cabinet:
break
if cabinet not in catalog()['cabinets']:
print('Choose an ID in the list. New IDs require an updated game export and catalog.')
continue
c = dict(existing.get(cabinet, dict(id=cabinet, token=secrets.token_urlsafe(24), port=22)))
c['host'] = prompt('Cabinet IP or hostname', c.get('host', ''))
c['user'] = prompt('Pi desktop / SSH username', c.get('user', ''))
c['port'] = int(prompt('SSH port', str(c.get('port', 22))))
c['gpio'] = prompt('Enable real GPIO? yes/no', 'yes' if c.get('gpio') else 'no').lower() == 'yes'
pins = prompt('Allowed BCM pins, comma-separated', ','.join(map(str, c.get('pins', []))))
c['pins'] = [int(p.strip()) for p in pins.split(',') if p.strip()]
c['gpio_controller'] = prompt('GPIO controller ID from cabinet hardware profile', c.get('gpio_controller', cabinet + '_gpio'))
existing[cabinet] = c
site['cabinets'] = list(existing.values())
validate(site)
private_write(path, json.dumps(site, indent=2))
generate(folder, site)
print('Saved site inventory and generated configs at ' + str(folder))
print('Operator credential is in controller.ini. No cabinet contacted; no network settings changed.')
def ssh_args(folder, c, batch=True):
key = folder / 'ssh/arcade_cabinets'
if not key.is_file():
raise ValueError('Run enroll first to generate and authorize the master key')
return ['ssh', '-T', '-p', str(c.get('port', 22)), '-i', str(key),
'-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'BatchMode=' + ('yes' if batch else 'no'),
'-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=10',
'-o', 'ServerAliveCountMax=3', c['user'] + '@' + c['host']]
def enroll(folder, c):
if not sys.platform.startswith('linux'):
raise ValueError('Run enrollment on the master Pi (requires ssh-copy-id)')
if not shutil.which('ssh-copy-id'):
raise ValueError('Install openssh-client on the master')
key = folder / 'ssh/arcade_cabinets'
key.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if not key.exists():
subprocess.run(['ssh-keygen', '-t', 'ed25519', '-N', '', '-f', str(key), '-C', 'arcade-master'], check=True)
if not key.with_suffix('.pub').is_file():
raise ValueError('Private key exists but .pub is missing; refusing to replace the key')
print('Verify the Pi host fingerprint when prompted. Enter its existing SSH password if requested.')
subprocess.run(['ssh-copy-id', '-i', str(key) + '.pub', '-p', str(c.get('port', 22)),
'-o', 'StrictHostKeyChecking=ask', c['user'] + '@' + c['host']], check=True)
subprocess.run(ssh_args(folder, c) + ['true'], check=True, timeout=30)
print(c['id'] + ': key authentication verified')
def remote(folder, c, action, source=None, release=None):
code = Path(__file__).with_name('receiver.py').read_text(encoding='utf-8')
command = 'python3 -c ' + shlex.quote(code) + ' ' + shlex.quote(action)
if release is not None:
command += ' --release ' + shlex.quote(release_id(release))
subprocess.run(ssh_args(folder, c) + [command], stdin=source or subprocess.DEVNULL, check=True)
def bundle(export, output, release):
release_id(release)
paths = {name: (export / name if name.startswith('ArcadeEscape.') else ROOT / name) for name in FILES}
with paths['ArcadeEscape.arm64'].open('rb') as binary:
header = binary.read(20)
if header[:6] != b'\x7fELF\x02\x01' or header[18:20] != b'\xb7\x00':
raise ValueError('Expected a little-endian ARM64 Linux game export')
with paths['ArcadeEscape.pck'].open('rb') as pack:
if pack.read(4) != b'GDPC':
raise ValueError('Invalid Godot PCK')
manifest = dict(release=release, files={name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in paths.items()})
encoded = json.dumps(manifest).encode()
# Exclusive creation prevents silently replacing a released bundle.
with output.open('xb') as raw, tarfile.open(fileobj=raw, mode='w:gz') as archive:
info = tarfile.TarInfo('manifest.json')
info.size = len(encoded)
info.mode = 0o600
archive.addfile(info, io.BytesIO(encoded))
for name, path in paths.items():
info = archive.gettarinfo(str(path), arcname=name)
info.uid = info.gid = 0
info.uname = info.gname = ''
info.mode = 0o700 if name.endswith('.arm64') else 0o600
with path.open('rb') as content:
archive.addfile(info, content)
print('Created release bundle: ' + str(output))
def master_package(output):
# Explicit allowlist: never ship a developer's INI, credentials, database or keys.
output.mkdir(parents=True, exist_ok=False)
names = [name for name in FILES if not name.startswith('ArcadeEscape.')]
names += ['tools/deploy/deploy.py', 'tools/deploy/receiver.py', 'tools/deploy/README.md']
for name in names:
target = output / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / name, target)
print('Portable master software: ' + str(output))
def install_master(folder):
if not sys.platform.startswith('linux') or os.getuid() == 0:
raise ValueError('Install the master service as the normal master Pi user, without sudo')
sys.path.insert(0, str(ROOT / 'tools/launch'))
from pi_service import quoted, working_directory
path = Path.home() / '.config/systemd/user/arcade-master.service'
marker = '# Managed by Arcade Escape deploy.py'
if path.exists() and not path.read_text(encoding='utf-8').startswith(marker):
raise ValueError('Refusing to overwrite an unrelated master service')
unit = f'''{marker}
[Unit]
Description=Arcade room master controller
[Service]
Type=exec
WorkingDirectory={working_directory(ROOT)}
ExecStart=:{quoted(sys.executable)} {quoted(ROOT / 'tools/launch/launch.py')} controller --config {quoted(folder / 'controller.ini')}
UnsetEnvironment=ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS
Restart=on-failure
RestartSec=10
KillMode=mixed
TimeoutStopSec=15
[Install]
WantedBy=default.target
'''
private_write(path, unit)
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
result = subprocess.check_output(['systemctl', '--user', 'show', 'arcade-master.service', '--property=LoadState', '--value'], text=True)
if result.strip() != 'loaded':
raise ValueError('systemd rejected the master unit; inspect systemctl --user status arcade-master.service')
subprocess.run(['systemctl', '--user', 'enable', 'arcade-master.service'], check=True)
print('Master service installed/enabled, not started. For startup before login run:')
print('sudo loginctl enable-linger ' + shlex.quote(os.environ.get('USER', 'YOUR_MASTER_USERNAME')))
print('Then: systemctl --user start arcade-master.service')
def health(site):
request = urllib.request.Request('http://127.0.0.1:8766/operator/state', data=b'{}',
headers={'Authorization': 'Bearer ' + site['operator_token']})
with urllib.request.urlopen(request, timeout=5) as response:
state = json.load(response)
for c in site['cabinets']:
result = state.get('cabinets', {}).get(c['id'], {})
print(c['id'] + ': ' + ('ONLINE' if result.get('online') else 'OFFLINE'))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--site', type=Path, default=DEFAULT_SITE)
sub = parser.add_subparsers(dest='action', required=True)
sub.add_parser('setup', help='Interactive inventory/configuration wizard; no remote changes')
sub.add_parser('generate', help='Validate edited inventory and regenerate configs')
sub.add_parser('install-master')
sub.add_parser('health')
master = sub.add_parser('master-package')
master.add_argument('--output', type=Path, required=True)
pack = sub.add_parser('bundle')
pack.add_argument('--export', type=Path, required=True)
pack.add_argument('--output', type=Path, required=True)
pack.add_argument('--release', required=True)
for action in ('enroll', 'preflight', 'stage', 'activate', 'start', 'stop', 'status', 'logs'):
command = sub.add_parser(action)
command.add_argument('cabinet', help='One registered cabinet ID; no implicit fleet-wide mutations')
if action == 'stage': command.add_argument('--bundle', type=Path, required=True)
if action == 'activate': command.add_argument('--release', required=True)
args = parser.parse_args()
folder = args.site.expanduser().resolve()
try:
if args.action == 'master-package':
master_package(args.output.resolve())
return 0
if args.action == 'bundle':
bundle(args.export.resolve(), args.output.resolve(), args.release)
return 0
if args.action == 'setup':
setup(folder)
return 0
site = load(folder)
if args.action == 'generate': generate(folder, site)
elif args.action == 'install-master': install_master(folder)
elif args.action == 'health': health(site)
else:
c = next((c for c in site['cabinets'] if c['id'] == args.cabinet), None)
if c is None: raise ValueError('Cabinet not registered in this site')
if args.action == 'enroll': enroll(folder, c)
elif args.action == 'preflight': remote(folder, c, 'preflight')
elif args.action == 'stage':
with args.bundle.open('rb') as source: remote(folder, c, 'stage', source)
elif args.action == 'activate':
import tempfile
with tempfile.TemporaryFile() as source:
source.write(json.dumps(dict(c, url=site['controller_url'])).encode())
source.seek(0)
remote(folder, c, 'activate', source, args.release)
else:
command = ['journalctl', '--user', '-u', 'arcade-cabinet.service', '-n', '60', '--no-pager'] if args.action == 'logs' else ['systemctl', '--user', args.action, 'arcade-cabinet.service', '--no-pager']
subprocess.run(ssh_args(folder, c) + command, check=True, timeout=45)
return 0
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc:
# CalledProcessError's command can contain configuration input only on stdin,
# but suppress the huge receiver source from diagnostics as well.
if isinstance(exc, subprocess.CalledProcessError):
print('Command failed (exit ' + str(exc.returncode) + '); see diagnostics above.', file=sys.stderr)
else:
print('Setup failed: ' + str(exc), file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())
+213
View File
@@ -0,0 +1,213 @@
"""Uploaded over authenticated SSH; no daemon and no root required."""
import argparse
import configparser
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
FILES = (
'ArcadeEscape.arm64', 'ArcadeEscape.pck',
'tools/launch/launch.py', 'tools/launch/pi_service.py',
'tools/launch/remote_cabinets.py', 'tools/pi_gpio/pi_gpio_bridge.py',
'tools/room_controller/server.py', 'tools/room_controller/operator.html',
'tools/room_controller/catalog.json',
'tools/pi_gpio/device_feedback.py',
'tools/pi_gpio/rfid_helper/Cargo.toml', 'tools/pi_gpio/rfid_helper/src/main.rs',
)
MAX_BYTES = 1024 * 1024 * 1024
SERVICE = 'arcade-cabinet.service'
MARKER = '# Managed by Arcade Escape pi_service.py'
def release_id(value):
if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,63}', value):
raise ValueError('Invalid release ID')
return value
def private_write(path, text):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as output:
output.write(text)
path.chmod(0o600)
def unpack(stream, destination):
"""Read only the exact regular files named by our bounded manifest."""
with tarfile.open(fileobj=stream, mode='r|gz') as archive:
first = next(iter(archive), None)
if first is None or first.name != 'manifest.json' or not first.isfile() or first.size > 65536:
raise ValueError('Release must begin with a small regular manifest.json')
manifest = json.load(archive.extractfile(first))
release_id(manifest['release'])
expected = manifest['files']
if set(expected) != set(FILES):
raise ValueError('Release contains an unexpected file set')
if any(not isinstance(v, str) or not re.fullmatch('[0-9a-f]{64}', v) for v in expected.values()):
raise ValueError('Invalid release checksums')
seen, total = set(), 0
# TarFile iteration begins at its cached first member; skip the manifest once.
for member in archive:
if member is first:
continue
if member.name not in expected or member.name in seen or not member.isfile():
raise ValueError('Unexpected, duplicate, or nonregular archive entry')
total += member.size
if total > MAX_BYTES:
raise ValueError('Release exceeds size limit')
target = destination / member.name
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
with archive.extractfile(member) as source, target.open('xb') as output:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != expected[member.name]:
raise ValueError('Checksum mismatch: ' + member.name)
target.chmod(0o700 if member.name == 'ArcadeEscape.arm64' else 0o600)
seen.add(member.name)
if seen != set(expected):
raise ValueError('Incomplete release')
private_write(destination / 'manifest.json', json.dumps(manifest, indent=2))
return manifest
def verify_release(folder):
manifest = json.loads((folder / 'manifest.json').read_text(encoding='utf-8'))
if set(manifest['files']) != set(FILES):
raise ValueError('Unexpected release contents')
for name, expected in manifest['files'].items():
target = folder / name
if target.is_symlink() or hashlib.sha256(target.read_bytes()).hexdigest() != expected:
raise ValueError('Release verification failed: ' + name)
return manifest
def preflight():
runtime = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/' + str(os.getuid())))
return dict(architecture=platform.machine(), python=list(sys.version_info[:3]),
gpio_packages=all(importlib.util.find_spec(m) is not None for m in ('gpiozero', 'lgpio')),
display_ready=(runtime / 'arcade-cabinet-display.env').is_file(),
free_bytes=shutil.disk_usage(Path.home()).free)
def render_config(settings):
cabinet, token, url = (settings[k] for k in ('id', 'token', 'url'))
if not re.fullmatch(r'[A-Za-z0-9_]+', cabinet):
raise ValueError('Invalid cabinet ID')
if not re.fullmatch(r'[A-Za-z0-9_-]{16,128}', token):
raise ValueError('Invalid cabinet token')
from urllib.parse import urlsplit
parsed = urlsplit(url)
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.path not in ('', '/') or parsed.query or parsed.fragment or any(c.isspace() for c in url):
raise ValueError('Invalid controller URL')
if type(settings.get('gpio')) is not bool:
raise ValueError('GPIO must be true or false')
pins = settings.get('pins', [])
if not isinstance(pins, list) or any(type(p) is not int or not 0 <= p <= 27 for p in pins) or len(set(pins)) != len(pins) or (settings['gpio'] and not pins):
raise ValueError('Invalid BCM pin allowlist')
controller = settings.get('gpio_controller', cabinet + '_gpio')
if not re.fullmatch(r'[A-Za-z0-9_]+', controller):
raise ValueError('Invalid GPIO controller ID')
config = configparser.ConfigParser(interpolation=None)
config['cabinet'] = dict(executable='../../ArcadeEscape.arm64', id=cabinet,
url=url.rstrip('/'), token=token, gpio=str(settings['gpio']).lower())
config['gpio'] = dict(controller=controller, pins=','.join(map(str, pins)),
ledger='~/.local/state/arcade_gpio/requests.sqlite3')
config['launcher'] = dict(logs='~/.local/state/arcade_cabinet/logs')
output = io.StringIO()
config.write(output)
return output.getvalue()
def stage(root, stream):
releases = root / 'releases'
releases.mkdir(parents=True, exist_ok=True, mode=0o700)
with tempfile.TemporaryDirectory(prefix='.incoming-', dir=releases) as temp:
folder = Path(temp)
manifest = unpack(stream, folder)
target = releases / manifest['release']
if target.exists():
if verify_release(target) != manifest:
raise ValueError('Release ID already exists with different contents')
print('Release already staged and verified: ' + manifest['release'])
return
folder.rename(target)
print('Staged and verified: ' + manifest['release'] + '; not activated')
def activate(root, release, settings):
folder = root / 'releases' / release_id(release)
verify_release(folder)
text = render_config(settings)
catalog = json.loads((folder / 'tools/room_controller/catalog.json').read_text(encoding='utf-8'))
if settings['id'] not in catalog['cabinets']:
raise ValueError('Cabinet ID is not included in this release catalog')
if subprocess.run(['systemctl', '--user', 'is-active', '--quiet', SERVICE]).returncode == 0:
raise ValueError('Cabinet is running. Stop it explicitly before activating a release.')
home = Path.home() / '.config'
managed = [home / 'systemd/user' / SERVICE, home / 'autostart/arcade-cabinet-display.desktop']
previous = {}
for path in managed:
previous[path] = path.read_bytes() if path.exists() else None
if previous[path] is not None and not previous[path].decode('utf-8').startswith(MARKER):
raise ValueError('Refusing to replace an unrelated service/helper')
config = folder / 'tools/launch/launcher.ini'
old_config = config.read_bytes() if config.exists() else None
private_write(config, text)
try:
subprocess.run([sys.executable, str(folder / 'tools/launch/pi_service.py'), 'install',
'--remote', '--config', str(config)], check=True)
# Informational receipt; service refers directly to the immutable release path.
private_write(root / 'active.json', json.dumps(dict(release=release, cabinet=settings['id'])))
except Exception:
for path, content in previous.items():
if content is None:
path.unlink(missing_ok=True)
else:
private_write(path, content.decode('utf-8'))
if old_config is None:
config.unlink(missing_ok=True)
else:
private_write(config, old_config.decode('utf-8'))
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=False)
raise
print('Activated ' + release + '. Service is stopped; start after desktop readiness is confirmed.')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['preflight', 'stage', 'activate'])
parser.add_argument('--release')
args = parser.parse_args()
try:
if not sys.platform.startswith('linux') or os.getuid() == 0:
raise ValueError('Use the normal Pi desktop account, not root')
if platform.machine() not in ('aarch64', 'arm64'):
raise ValueError('A 64-bit ARM Raspberry Pi OS is required')
root = Path.home() / '.local/share/arcade-deploy'
if args.action == 'preflight':
print(json.dumps(preflight()))
elif args.action == 'stage':
stage(root, sys.stdin.buffer)
else:
activate(root, args.release, json.loads(sys.stdin.read(65536)))
return 0
except (OSError, ValueError, KeyError, TypeError, tarfile.TarError, subprocess.CalledProcessError) as exc:
print('Deployment failed: ' + str(exc), file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())
+2
View File
@@ -1,5 +1,7 @@
# Start the Arcade stack # Start the Arcade stack
To start the Pi applications automatically from the PC controller command, follow the [remote Pi startup guide](../../docs/remote_pi_start.md).
For the assigned internal operator/Cabinet 1–7 tokens and per-machine Godot CFG examples, see the [cabinet setup guide](../../docs/cabinet_setup.md). 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). 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).
-12
View File
@@ -1,12 +0,0 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
; Paths are relative to THIS file. Reuse your existing database path when upgrading.
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
; Use your existing tokens. Leave blank if supplied via environment variables.
operator_token =
cabinet_tokens = {}
+32 -4
View File
@@ -70,6 +70,10 @@ def plan(config_path, mode):
url_host = '127.0.0.1' if bind == '0.0.0.0' else bind url_host = '127.0.0.1' if bind == '0.0.0.0' else bind
url = f'http://{url_host}:{port}' url = f'http://{url_host}:{port}'
jobs.append(dict(name='controller', command=[sys.executable, '-u', str(script), '--bind', bind, '--port', str(port), '--database', str(database), '--catalog', str(catalog)], env=server_env, port=(bind, port), ready=url+'/', directory=database.parent)) jobs.append(dict(name='controller', command=[sys.executable, '-u', str(script), '--bind', bind, '--port', str(port), '--database', str(database), '--catalog', str(catalog)], env=server_env, port=(bind, port), ready=url+'/', directory=database.parent))
if value('controller', 'hub_config'):
hub_path = path('controller', 'hub_config', '')
require_file(hub_path)
jobs[-1]['command'] += ['--hub-config', str(hub_path)]
if mode in ('cabinet', 'all'): if mode in ('cabinet', 'all'):
executable = path('cabinet', 'executable', '../../ArcadeEscape.exe' if os.name == 'nt' else '../../ArcadeEscape.arm64') executable = path('cabinet', 'executable', '../../ArcadeEscape.exe' if os.name == 'nt' else '../../ArcadeEscape.arm64')
require_file(executable) require_file(executable)
@@ -92,13 +96,21 @@ def plan(config_path, mode):
if not sys.platform.startswith('linux'): raise ValueError('Real GPIO requires the Linux Pi; set gpio = false on Windows') if not sys.platform.startswith('linux'): raise ValueError('Real GPIO requires the Linux Pi; set gpio = false on Windows')
if importlib.util.find_spec('gpiozero') is None or importlib.util.find_spec('lgpio') is None: if importlib.util.find_spec('gpiozero') is None or importlib.util.find_spec('lgpio') is None:
raise ValueError('Install Pi dependencies first: sudo apt install python3-gpiozero python3-lgpio') raise ValueError('Install Pi dependencies first: sudo apt install python3-gpiozero python3-lgpio')
pins = [int(p.strip()) for p in value('gpio', 'pins', '17').split(',')] pins = [int(p.strip()) for p in value('gpio', 'pins', '17').split(',') if p.strip()]
if not pins or len(set(pins)) != len(pins) or any(p < 0 or p > 27 for p in pins): raise ValueError('GPIO pins must be unique BCM numbers 0..27') if len(set(pins)) != len(pins) or any(p < 0 or p > 27 for p in pins): raise ValueError('GPIO pins must be unique BCM numbers 0..27')
script = ROOT / 'tools/pi_gpio/pi_gpio_bridge.py' script = ROOT / 'tools/pi_gpio/pi_gpio_bridge.py'
require_file(script) require_file(script)
ledger = path('gpio', 'ledger', '~/.local/state/arcade_gpio/requests.sqlite3') ledger = path('gpio', 'ledger', '~/.local/state/arcade_gpio/requests.sqlite3')
bridge_env = dict(env, GPIOZERO_PIN_FACTORY='lgpio') bridge_env = dict(env, GPIOZERO_PIN_FACTORY='lgpio')
jobs.append(dict(name='gpio', command=[sys.executable, '-u', str(script), '--controller', value('gpio', 'controller', cabinet_id+'_gpio'), '--pins', ','.join(map(str,pins)), '--ledger', str(ledger)], env=bridge_env, port=('127.0.0.1', 8765), ready='http://127.0.0.1:8765/heartbeat', directory=ledger.parent)) jobs.append(dict(name='gpio', command=[sys.executable, '-u', str(script), '--controller', value('gpio', 'controller', cabinet_id+'_gpio'), '--pins', ','.join(map(str,pins)), '--ledger', str(ledger)], env=bridge_env, port=('127.0.0.1', 8765), ready='http://127.0.0.1:8765/heartbeat', directory=ledger.parent))
if value('gpio', 'devices'):
devices_path = path('gpio', 'devices', '')
require_file(devices_path)
jobs[-1]['command'] += ['--devices', str(devices_path)]
if value('gpio', 'rfid_helper'):
helper_path = path('gpio', 'rfid_helper', '')
require_file(helper_path)
jobs[-1]['command'] += ['--rfid-helper', str(helper_path)]
args = [str(executable), '--', '--managed-cabinet', '--cabinet='+cabinet_id] args = [str(executable), '--', '--managed-cabinet', '--cabinet='+cabinet_id]
if real_gpio: args.append('--gpio=real') if real_gpio: args.append('--gpio=real')
jobs.append(dict(name='cabinet', command=args, env=cabinet_env, port=None, ready=None, directory=executable.parent)) jobs.append(dict(name='cabinet', command=args, env=cabinet_env, port=None, ready=None, directory=executable.parent))
@@ -127,7 +139,7 @@ def wait_ready(job, process):
raise RuntimeError(f"{job['name']} did not become ready; check its log") raise RuntimeError(f"{job['name']} did not become ready; check its log")
def run(jobs, logs, open_browser=False): def run(jobs, logs, open_browser=False, remote_targets=None):
check_ports(jobs) check_ports(jobs)
logs.mkdir(parents=True, exist_ok=True) logs.mkdir(parents=True, exist_ok=True)
children, files = [], [] children, files = [], []
@@ -144,6 +156,9 @@ def run(jobs, logs, open_browser=False):
if job['name'] == 'controller': if job['name'] == 'controller':
print('Operator: '+job['ready'], flush=True) print('Operator: '+job['ready'], flush=True)
if open_browser: webbrowser.open(job['ready']) if open_browser: webbrowser.open(job['ready'])
if remote_targets:
from remote_cabinets import start_and_check
start_and_check(remote_targets, job)
print('Running. Ctrl+C stops this stack. Exiting the game also stops services started here.', flush=True) print('Running. Ctrl+C stops this stack. Exiting the game also stops services started here.', flush=True)
while True: while True:
for job, process in children: for job, process in children:
@@ -178,12 +193,25 @@ def main():
try: try:
print('Launcher configuration: '+str(Path(args.config).resolve()), flush=True) print('Launcher configuration: '+str(Path(args.config).resolve()), flush=True)
jobs, logs = plan(args.config, args.mode) jobs, logs = plan(args.config, args.mode)
from remote_cabinets import load_targets
remote_targets = load_targets(args.config, args.mode)
if remote_targets:
controller = next(j for j in jobs if j['name'] == 'controller')
catalog_path = controller['command'][controller['command'].index('--catalog')+1]
known = json.loads(Path(catalog_path).read_text(encoding='utf-8'))['cabinets']
tokens = json.loads(controller['env']['ARCADE_CABINET_TOKENS'])
for target in remote_targets:
if target['id'] not in known or target['id'] not in tokens:
raise ValueError('Remote cabinet must be registered and have a controller token: '+target['id'])
if any(j['name']=='cabinet' and '--cabinet='+target['id'] in j['command'] for j in jobs):
raise ValueError('Cannot launch the same cabinet identity locally and remotely')
if args.check: if args.check:
check_ports(jobs) check_ports(jobs)
print('Configuration and ports OK. Planned services: '+', '.join(j['name'] for j in jobs)) print('Configuration and ports OK. Planned services: '+', '.join(j['name'] for j in jobs))
print('Logs: '+str(logs)) print('Logs: '+str(logs))
print('Remote start targets (not contacted by --check): '+', '.join(t['id'] for t in remote_targets))
return 0 return 0
return run(jobs, logs, args.browser) return run(jobs, logs, args.browser, remote_targets)
except (ValueError, OSError, RuntimeError, configparser.Error) as exc: except (ValueError, OSError, RuntimeError, configparser.Error) as exc:
print('Startup failed: '+str(exc), file=sys.stderr) print('Startup failed: '+str(exc), file=sys.stderr)
return 1 return 1
+10
View File
@@ -0,0 +1,10 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
operator_token = Yu1qH7nz_aMUTlkk
cabinet_tokens = {"cabinet_01":"K32qg0lYioXmzWAK","cabinet_02":"LY9ATvM3wkTxJg0x","cabinet_03":"qZSD9eM2dmeRtmVA","cabinet_04":"2xXcj0sUbOzQCn2j","cabinet_05":"weISFvFBOt6pxfk-","cabinet_06":"h-lP-SfQMXT_dLtc","cabinet_07":"pUT4GdBYtW3slhOq"}
+7
View File
@@ -0,0 +1,7 @@
[launcher]
logs = logs
[cabinet]
id = cabinet_03
url = 10.0.0.57
token = qZSD9eM2dmeRtmVA
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Install a user service from a Pi desktop terminal; no root or SSH changes."""
import argparse
import os
from pathlib import Path
import subprocess
import sys
HERE = Path(__file__).resolve().parent
MARKER = '# Managed by Arcade Escape pi_service.py'
def quoted(value):
value=str(value)
if any(c in value for c in ('\n','\r','\0')): raise ValueError('Paths must not contain control characters')
return '"'+value.replace('\\','\\\\').replace('"','\\"').replace('%','%%')+'"'
def working_directory(value):
# Unlike ExecStart arguments, systemd reads this as a single unquoted path.
value = str(value)
if any(ord(c) < 32 or ord(c) == 127 for c in value):
raise ValueError('Paths must not contain control characters')
return value.replace('%', '%%')
def reload_service():
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
result = subprocess.run(
['systemctl', '--user', 'show', 'arcade-cabinet.service',
'--property=LoadState', '--value'],
check=True, capture_output=True, text=True)
if result.stdout.strip() != 'loaded':
raise ValueError(
'systemd rejected arcade-cabinet.service (LoadState: '
+ result.stdout.strip() + '). Inspect it with: '
'systemctl --user status arcade-cabinet.service --no-pager -l')
def render_unit(python, launcher, config):
return f"""{MARKER}
[Unit]
Description=Arcade Escape managed cabinet
PartOf=graphical-session.target
[Service]
Type=exec
EnvironmentFile=%t/arcade-cabinet-display.env
UnsetEnvironment=ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS
WorkingDirectory={working_directory(Path(launcher).resolve().parents[2])}
ExecStart=:{quoted(python)} {quoted(launcher)} cabinet --config {quoted(config)}
Restart=no
KillMode=mixed
TimeoutStopSec=15
"""
def render_desktop(python, script):
# Desktop Exec quoting also needs escaping of $ and backticks inside quotes.
def desktop_quote(value):
return quoted(value).replace('$','\\$').replace('`','\\`')
return f"""{MARKER}
[Desktop Entry]
Type=Application
Name=Arcade cabinet display setup
Exec={desktop_quote(python)} {desktop_quote(script)} refresh-session
Terminal=false
NoDisplay=true
"""
def refresh_session():
if not os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'):
raise ValueError('Run this from a terminal on the Pi desktop, not a plain SSH session')
runtime=Path(os.environ.get('XDG_RUNTIME_DIR',f'/run/user/{os.getuid()}'))
if not runtime.is_dir(): raise ValueError('The Pi desktop user runtime directory is unavailable')
values={k:os.environ[k] for k in ('DISPLAY','WAYLAND_DISPLAY','XAUTHORITY','XDG_RUNTIME_DIR','XDG_SESSION_TYPE') if os.environ.get(k)}
text=''
for key,value in values.items():
if any(c in value for c in ('\n','\r','\0')): raise ValueError('Invalid desktop environment value')
text+=key+'="'+value.replace('\\','\\\\').replace('"','\\"')+'"\n'
target=runtime/'arcade-cabinet-display.env'
temporary=target.with_suffix('.tmp')
temporary.write_text(text,encoding='utf-8')
temporary.chmod(0o600)
temporary.replace(target)
print('Saved display environment for the current desktop login.')
def write_managed(path, text):
if path.exists() and not path.read_text(encoding='utf-8').startswith(MARKER):
raise ValueError('Refusing to overwrite an unrelated file: '+str(path))
path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(text,encoding='utf-8')
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('action',choices=['install','refresh-session'])
parser.add_argument('--config',default=str(HERE/'launcher.ini'))
parser.add_argument('--remote', action='store_true', help='Register from SSH; wait for the desktop helper if no display is available')
args=parser.parse_args()
try:
if not sys.platform.startswith('linux') or os.getuid()==0:
raise ValueError('Run as the normal Pi desktop user on Linux, without sudo')
if args.action=='refresh-session':
refresh_session()
return 0
from launch import plan
config=Path(args.config).resolve()
# Validate against the user's actual config; do not start games or GPIO here.
plan(config,'cabinet')
if not args.remote or os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY'):
refresh_session()
else:
print('Remote installation: display helper will capture settings at the next desktop login.')
home=Path.home()/'.config'
unit=home/'systemd/user/arcade-cabinet.service'
desktop=home/'autostart/arcade-cabinet-display.desktop'
unit_text=render_unit(sys.executable,HERE/'launch.py',config)
desktop_text=render_desktop(sys.executable,Path(__file__).resolve())
for target in (unit,desktop):
if target.exists() and not target.read_text(encoding='utf-8').startswith(MARKER):
raise ValueError('Refusing to overwrite an unrelated file: '+str(target))
write_managed(unit,unit_text)
write_managed(desktop,desktop_text)
reload_service()
print('Installed arcade-cabinet.service (not started and not enabled at boot).')
print('Start locally: systemctl --user start arcade-cabinet.service')
print('The display helper will refresh settings at future desktop logins.')
return 0
except (ValueError,OSError,subprocess.CalledProcessError) as exc:
print('Pi service setup failed: '+str(exc),file=sys.stderr)
return 1
if __name__=='__main__': sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
; Merge this section into the PC controller's existing launcher INI.
; Replace the hosts and usernames before setting enabled = true.
[remote]
enabled = false
cabinets = [{"id":"cabinet_01","host":"PI_1_HOST","user":"PI_USER"},{"id":"cabinet_02","host":"PI_2_HOST","user":"PI_USER"},{"id":"cabinet_03","host":"PI_3_HOST","user":"PI_USER"}]
+73
View File
@@ -0,0 +1,73 @@
"""Explicit SSH service starts; no remote shell interpolation or automatic restarts."""
import concurrent.futures
import configparser
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
import urllib.request
def load_targets(config_path, mode):
if mode not in ('controller', 'all'): return []
config=configparser.ConfigParser(interpolation=None)
config.read(config_path, encoding='utf-8-sig')
if not config.getboolean('remote', 'enabled', fallback=False): return []
raw=json.loads(config.get('remote', 'cabinets', fallback='[]'))
if not isinstance(raw,list) or not raw: raise ValueError('remote.cabinets must be a nonempty JSON list')
ssh=shutil.which('ssh')
if not ssh: raise ValueError('OpenSSH client (ssh) is required for remote cabinet startup')
targets=[]
seen=set()
for item in raw:
if not isinstance(item,dict): raise ValueError('Each remote cabinet must be an object')
cabinet,host,user=(item.get(k,'') for k in ('id','host','user'))
for value,pattern,label in [(cabinet,r'[A-Za-z0-9_]+','cabinet ID'),(host,r'[A-Za-z0-9][A-Za-z0-9.-]*','host'),(user,r'[A-Za-z_][A-Za-z0-9_-]*','SSH user')]:
if not isinstance(value,str) or not re.fullmatch(pattern,value): raise ValueError('Invalid remote '+label)
if cabinet in seen: raise ValueError('Duplicate remote cabinet ID')
seen.add(cabinet)
port=item.get('port',22)
if type(port) is not int or not 1<=port<=65535: raise ValueError('Invalid SSH port')
command=[ssh,'-T','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=5','-o','ConnectionAttempts=1','-p',str(port)]
if item.get('identity_file'):
key=(Path(config_path).resolve().parent / Path(item['identity_file']).expanduser()).resolve()
if not key.is_file(): raise ValueError('SSH identity file not found: '+str(key))
command+=['-i',str(key),'-o','IdentitiesOnly=yes']
command += [user+'@'+host,'systemctl','--user','start','arcade-cabinet.service']
targets.append(dict(id=cabinet,host=host,command=command))
return targets
def start_one(target):
try:
result=subprocess.run(target['command'],stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,timeout=15,creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)
if result.returncode:
return target['id'],False,(result.stderr or result.stdout).strip()[-1500:]
return target['id'],True,'Service start accepted (already-running services are left running)'
except (OSError,subprocess.TimeoutExpired) as exc:
return target['id'],False,str(exc)
def start_and_check(targets,controller):
if not targets: return
print('Starting configured Pi services over SSH...',flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
for cabinet,ok,detail in executor.map(start_one,targets):
print(f"{cabinet}: {'SSH OK' if ok else 'SSH FAILED'} - {detail}",flush=True)
pending={t['id'] for t in targets}
deadline=time.monotonic()+15
while pending and time.monotonic()<deadline:
try:
request=urllib.request.Request(controller['ready'].rstrip('/')+'/operator/state',data=b'{}',headers={'Authorization':'Bearer '+controller['env']['ARCADE_OPERATOR_TOKEN']})
with urllib.request.urlopen(request,timeout=2) as response: state=json.load(response)
for cabinet in list(pending):
if state.get('cabinets',{}).get(cabinet,{}).get('online'):
print(cabinet+': ONLINE in room controller',flush=True)
pending.remove(cabinet)
except (OSError,ValueError): pass
if pending: time.sleep(.5)
for cabinet in sorted(pending):
print(cabinet+': OFFLINE - check SSH, Pi desktop login, service logs, and cabinet URL/token. Controller remains running.',flush=True)
-18
View File
@@ -1,18 +0,0 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
operator_token =
cabinet_tokens = {}
[cabinet]
executable = ../../ArcadeEscape.exe
id = cabinet_01
; In all mode, blank URL/token use the local controller and this cabinet's token.
url =
token =
gpio = false
+36 -1
View File
@@ -40,7 +40,7 @@ If this fails, fix the reported GPIO permissions/package error or check the resi
## 4. Start the GPIO helper ## 4. Start the GPIO helper
Copy **pi_gpio_bridge.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal: Copy **pi_gpio_bridge.py** and **device_feedback.py** from this folder alongside the exported game on the Pi. It is a separate Python program, not an embedded Godot resource. Keep it running in a terminal:
```sh ```sh
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17 GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
@@ -81,3 +81,38 @@ Keep the helper running while restarting games with Escape. For unattended boot,
Bridge tests cover pulse timing/retriggering, active-low configuration, digital off, watchdog expiry, rejected pins/controllers, and shutdown. The Godot integration test sends a real HTTP request from a Pac-Man win to a test bridge with fake GPIO devices. Physical voltage and your wiring must still be verified on the Pi. Bridge tests cover pulse timing/retriggering, active-low configuration, digital off, watchdog expiry, rejected pins/controllers, and shutdown. The Godot integration test sends a real HTTP request from a Pac-Man win to a test bridge with fake GPIO devices. Physical voltage and your wiring must still be verified on the Pi.
References: [GPIO Zero installation](https://gpiozero.readthedocs.io/en/stable/installing.html), [output device API](https://gpiozero.readthedocs.io/en/stable/api_output.html). References: [GPIO Zero installation](https://gpiozero.readthedocs.io/en/stable/installing.html), [output device API](https://gpiozero.readthedocs.io/en/stable/api_output.html).
## PCB live feedback
This optional integration requires the updated controller, launcher, bridge plus `device_feedback.py`, and a new Godot cabinet export (`arcade-cabinet-2.2`). No installation is performed by editing the PCB diagram. Odin remains separate.
1. Save each cabinet's PCB connections in the Godot editor. Give every device a unique ID. For buttons/sensors, check **Input pull-up** when the switch activates by connecting the signal to GND; verify the actual circuit first.
2. Export the catalog from the project root:
```sh
godot --headless --path . --script res://tools/room_controller/export_catalog.gd
```
3. Extract that cabinet's inventory (the destination must not already exist):
```sh
python3 tools/pi_gpio/device_feedback.py --catalog tools/room_controller/catalog.json --cabinet cabinet_01 --output devices.json
```
4. Copy the inventory and updated software to that cabinet Pi. For RFID, enable SPI using Raspberry Pi configuration and build the separate helper on a Pi with a Rust toolchain:
```sh
cargo build --release --manifest-path tools/pi_gpio/rfid_helper/Cargo.toml
```
The binary is `tools/pi_gpio/rfid_helper/target/release/arcade-rfid`. The source follows Odin's MFRC522 library and selector sequence, but this new helper has **not yet been compiled or tested on the tester Pi**. It requires access to GPIO and SPI. Do not run Odin's reader service simultaneously.
5. Add these optional settings to the existing launcher's `[gpio]` section, with paths relative to the launcher INI (or absolute paths):
```ini
devices = /home/YOUR_USER/arcade/devices.json
rfid_helper = /home/YOUR_USER/arcade/tools/pi_gpio/rfid_helper/target/release/arcade-rfid
pins =
```
An empty `pins` list is valid for input/RFID-only testing. If outputs are configured, list only their actual BCM pins and keep the corresponding HardwareActions aligned. Omit `rfid_helper` if no RFID readers are configured. Keep the existing real-GPIO launcher setting enabled.
**Pin ownership:** RFID uses BCM17/27/22 for selection, BCM20 for reset, and SPI0 (BCM8/9/10/11). These cannot also be general outputs. The earlier standalone LED example on BCM17 and start-light example on BCM20 must not be used with this RFID setup. Inputs cannot also be in the output allowlist. The bridge rejects these conflicts before starting.
The operator's cabinet detail and Hardware views display per-device readings. Activity records changes. Disconnected or old samples become stale. J1/J2 are scanned as selector slots 0/1; additional configured readers are scanned in sequence. RFID values currently use Odin's 32-bit UID representation and are not a promise of a complete UID for every card type. A failed tag read is shown as absent **or unreadable**, rather than proof that a card was removed.
Outputs report the GPIO device's logical electrical state after initialization, not proof that a lock or motor moved. Physical confirmation needs a separate sensor. I2C/Other devices are not sampled yet. Feedback is monitoring only; automatic scenario rules for these events are not enabled by this change.
Start with a button and the two readers, then test an appropriately driven output. Confirm real readings and stale/disconnected behavior on the Pi before relying on this for room operation. Windows tests use fake devices; no physical pins were exercised during development.
+114
View File
@@ -0,0 +1,114 @@
"""Read-only feedback inventory; output writes remain in pi_gpio_bridge.Outputs."""
import json
import re
import subprocess
import threading
import time
import uuid
from collections import deque
from pathlib import Path
SIGNALS = dict(zip(['J10','J11','J12','J13','J14','J15','J17','J18','J19','J20','J21','J22'], [5,6,13,19,26,21,16,12,24,23,18,25]))
RESERVED = {17,27,22,20,8,9,10,11}
def load_devices(path, output_pins):
data = json.loads(Path(path).read_text(encoding='utf-8-sig'))
if not isinstance(data, list) or len(data)>32: raise ValueError('Device config must be a list of up to 32 devices')
ids, ports, inputs, readers = set(), set(), set(), False
for d in data:
if not isinstance(d,dict) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}',d.get('id','')): raise ValueError('Invalid device ID')
if d['id'] in ids or d.get('port') in ports: raise ValueError('Duplicate device ID or port')
ids.add(d['id']); ports.add(d.get('port'))
if not isinstance(d.get('name',''),str) or len(d.get('name',''))>100: raise ValueError('Invalid name')
kind, port = d.get('kind'), d.get('port')
if kind=='rfid':
if port not in ['J'+str(i) for i in range(1,9)]: raise ValueError('RFID requires J1-J8')
readers=True
elif kind in ('input','output'):
if port not in SIGNALS: raise ValueError('Digital devices require a signal connector')
d['pin']=SIGNALS[port]
if kind=='input':
if type(d.get('pull_up')) is not bool: raise ValueError('Inputs require explicit pull_up true/false')
inputs.add(d['pin'])
elif d['pin'] not in output_pins: raise ValueError('Output connector not enabled in output pin allowlist')
else: raise ValueError('Supported kinds: input, output, rfid')
if inputs & set(output_pins): raise ValueError('A GPIO cannot be both an input and an output')
if readers and RESERVED & (set(output_pins)|inputs): raise ValueError('RFID selector/reset/SPI pins cannot be configured as general GPIO')
return data
class Feedback:
def __init__(self, definitions, input_factory, outputs, reader_binary=None, clock=time.monotonic):
self.definitions, self.outputs, self.clock = definitions, outputs, clock
self.lock=threading.RLock(); self.stop=threading.Event(); self.values={}; self.events=deque(maxlen=64)
self.boot=uuid.uuid4().hex; self.sequence=0; self.inputs=[]; self.worker=None; self.reader=None
try:
for d in definitions:
self.values[d['id']] = dict(id=d['id'],name=d.get('name',d['id']),port=d['port'],kind=d['kind'],value=None,error='Awaiting first sample',stamp=clock())
if d['kind']=='input': self.inputs.append((d,input_factory(d['pin'],pull_up=d['pull_up'],bounce_time=.05)))
readers=[d for d in definitions if d['kind']=='rfid']
if readers:
if not reader_binary: raise ValueError('RFID devices require --rfid-helper executable')
self.reader=subprocess.Popen([str(Path(reader_binary).resolve()),','.join(str(int(d['port'][1:])-1) for d in readers)],stdout=subprocess.PIPE,stderr=None,text=True,bufsize=1)
self.worker=threading.Thread(target=self._readers,args=(readers,),daemon=True); self.worker.start()
except Exception:
self.close(); raise
def update(self, ident, value, error=''):
with self.lock:
old=self.values[ident]
changed=old['value']!=value or old['error']!=error
old.update(value=value,error=error,stamp=self.clock())
if changed:
self.sequence+=1
self.events.append(dict(id=self.boot+':'+str(self.sequence),device=ident,value=value,error=error,kind=old['kind']))
def _readers(self, readers):
slots={int(d['port'][1:])-1:d['id'] for d in readers}
try:
for line in self.reader.stdout:
if self.stop.is_set(): break
if len(line)>2048: continue
value=json.loads(line)
ident=slots.get(value.get('slot'))
if ident:
self.update(ident,str(value.get('uid',''))[:32],str(value.get('error',''))[:120])
except Exception as exc:
for ident in slots.values(): self.update(ident,None,'Reader helper error: '+str(exc)[:80])
finally:
if not self.stop.is_set():
for ident in slots.values(): self.update(ident,None,'Reader helper stopped')
def tick(self):
for d,device in self.inputs:
try: self.update(d['id'],bool(device.value))
except Exception as exc: self.update(d['id'],None,str(exc)[:120])
with self.outputs.lock:
for d in self.definitions:
if d['kind']!='output': continue
entry=self.outputs.pins.get(d['pin'])
try: self.update(d['id'],bool(entry['device'].value) if entry else None,'' if entry else 'Output not initialized')
except Exception as exc: self.update(d['id'],None,str(exc)[:120])
def snapshot(self):
with self.lock:
now=self.clock()
return dict(devices=[{k:v for k,v in d.items() if k!='stamp'}|{'age_ms':int(max(0,now-d['stamp'])*1000)} for d in self.values.values()],events=list(self.events))
def close(self):
self.stop.set()
if self.reader:
self.reader.terminate()
try:self.reader.wait(timeout=2)
except subprocess.TimeoutExpired:self.reader.kill();self.reader.wait()
if self.worker:self.worker.join(timeout=2)
self.reader.stdout.close()
for _,device in self.inputs: device.close()
if __name__ == '__main__':
import argparse
parser=argparse.ArgumentParser(description='Extract saved PCB feedback inventory from the exported room catalog')
parser.add_argument('--catalog',required=True)
parser.add_argument('--cabinet',required=True)
parser.add_argument('--output',required=True)
args=parser.parse_args()
catalog=json.loads(Path(args.catalog).read_text(encoding='utf-8-sig'))
devices=catalog['cabinets'][args.cabinet].get('devices',[])
with Path(args.output).open('x',encoding='utf-8') as file:
json.dump(devices,file,indent=2)
print('Wrote',len(devices),'device definitions. Review input polarity and output allowlist before running the bridge.')
+13 -6
View File
@@ -103,7 +103,7 @@ class Outputs:
self.ledger.close() self.ledger.close()
def handler_for(outputs): def handler_for(outputs, feedback=None):
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
try: try:
@@ -115,7 +115,7 @@ def handler_for(outputs):
data = json.loads(self.rfile.read(length) or b"{}") data = json.loads(self.rfile.read(length) or b"{}")
if self.path == "/heartbeat": if self.path == "/heartbeat":
outputs.heartbeat() outputs.heartbeat()
result = {"ok": True} result = {"ok": True, "feedback": feedback.snapshot() if feedback else {"devices": [], "events": []}}
elif self.path == "/output": elif self.path == "/output":
result = outputs.command(data) result = outputs.command(data)
print("GPIO:", data, flush=True) print("GPIO:", data, flush=True)
@@ -142,14 +142,19 @@ def main():
parser.add_argument("--controller", default="cabinet_01_gpio") parser.add_argument("--controller", default="cabinet_01_gpio")
parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated") parser.add_argument("--pins", default="17", help="BCM pin allowlist, comma separated")
parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3")) parser.add_argument("--ledger", default=str(Path.home() / ".local/state/arcade_gpio/requests.sqlite3"))
parser.add_argument("--devices", help="Explicit PCB feedback JSON inventory")
parser.add_argument("--rfid-helper", help="Built arcade-rfid helper executable")
args = parser.parse_args() args = parser.parse_args()
pins = [int(n) for n in args.pins.split(",")] pins = [int(n) for n in args.pins.split(",") if n.strip()]
if not pins or any(pin < 0 or pin > 27 for pin in pins): if len(set(pins)) != len(pins) or any(pin < 0 or pin > 27 for pin in pins):
parser.error("Use BCM header GPIO numbers 0..27") parser.error("Use BCM header GPIO numbers 0..27")
from gpiozero import OutputDevice from gpiozero import OutputDevice, Button
from device_feedback import Feedback, load_devices
definitions = load_devices(args.devices, pins) if args.devices else []
Path(args.ledger).parent.mkdir(parents=True, exist_ok=True) Path(args.ledger).parent.mkdir(parents=True, exist_ok=True)
outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger) outputs = Outputs(OutputDevice, args.controller, pins, ledger=args.ledger)
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs)) feedback = Feedback(definitions, Button, outputs, args.rfid_helper)
server = HTTPServer(("127.0.0.1", 8765), handler_for(outputs, feedback))
server.timeout = .05 server.timeout = .05
stop = threading.Event() stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set()) signal.signal(signal.SIGTERM, lambda *_: stop.set())
@@ -158,6 +163,7 @@ def main():
def watchdog(): def watchdog():
while not stop.wait(.02): while not stop.wait(.02):
outputs.tick() outputs.tick()
feedback.tick()
worker = threading.Thread(target=watchdog, daemon=True) worker = threading.Thread(target=watchdog, daemon=True)
worker.start() worker.start()
@@ -168,6 +174,7 @@ def main():
finally: finally:
stop.set() stop.set()
worker.join() worker.join()
feedback.close()
outputs.close() outputs.close()
server.server_close() server.server_close()
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "arcade-rfid"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
rppal = "0.22.1"
rppal_mfrc522 = "0.4.1"
serde_json = "1"
+43
View File
@@ -0,0 +1,43 @@
// Uses the same MFRC522 library and selector sequence as Odin; no Odin room logic.
use rppal::{gpio::Gpio, spi::{Bus, Mode, SlaveSelect, Spi}};
use rppal_mfrc522::Mfrc522;
use std::{thread, time::Duration, io::{self, Write}};
fn main() -> anyhow::Result<()> {
let arg = std::env::args().nth(1).unwrap_or_else(|| "0,1".into());
let slots: Vec<usize> = arg.split(',').map(str::parse).collect::<Result<_,_>>()?;
anyhow::ensure!(!slots.is_empty() && slots.len() <= 8 && slots.iter().all(|x| *x < 8), "Slots must be 0..7");
let gpio = Gpio::new()?;
let mut a = gpio.get(17)?.into_output_low();
let mut b = gpio.get(27)?.into_output_low();
let mut c = gpio.get(22)?.into_output_low();
let mut reset = gpio.get(20)?.into_output_low();
thread::sleep(Duration::from_millis(100));
let mut spi = Spi::new(Bus::Spi0, SlaveSelect::Ss0, 1_000_000, Mode::Mode0)?;
let mut reader = Mfrc522::new(&mut spi);
loop {
for slot in &slots {
reset.set_low();
if slot & 1 != 0 {a.set_high()} else {a.set_low()}
if slot & 2 != 0 {b.set_high()} else {b.set_low()}
if slot & 4 != 0 {c.set_high()} else {c.set_low()}
reset.set_high();
thread::sleep(Duration::from_millis(50));
let sample = (|| -> anyhow::Result<serde_json::Value> {
reader.reset()?;
thread::sleep(Duration::from_millis(100));
let version = reader.version()?;
anyhow::ensure!(version != 0 && version != 255, "Reader did not respond");
Ok(match reader.uid(Duration::from_millis(250)) {
Ok(uid) => serde_json::json!({"slot":slot,"uid":format!("{:08X}",uid.to_u32()),"error":""}),
Err(_) => serde_json::json!({"slot":slot,"uid":"","error":"No tag read (absent or unreadable)"}),
})
})();
reset.set_low();
thread::sleep(Duration::from_millis(50));
let value = sample.unwrap_or_else(|e| serde_json::json!({"slot":slot,"uid":"","error":e.to_string()}));
println!("{}",value);
io::stdout().flush()?;
}
}
}
+37
View File
@@ -165,3 +165,40 @@ Automated two-client check (no GPIO; wins forced only by the test harness):
```sh ```sh
python3 tests/room_network/check_chain_integration.py /path/to/godot python3 tests/room_network/check_chain_integration.py /path/to/godot
``` ```
## Three-cabinet objective test
See the [three-cabinet walkthrough](../../docs/three_cabinet_test.md) for the separate Pac-Man five-second auto-win → Galaga 500-point objective → Frogger one-home scenario. This scenario requires updated `arcade-cabinet-2.1` game exports on all three cabinets.
## Escape Gallery browser hub
The browser now opens an **Escape Gallery** overview, with **Glitch: the Dark Machine** as the current room. Navigation provides Room control, Cabinets, Hardware, and Activity. Update the controller Python files and `operator.html`, restart the controller, and reload the browser. These controls use the existing cabinet protocol; this UI update alone does not require a new Godot export.
Room control uses the boundary, walls, device references and cabinet positions exported in `catalog.json`. Cabinets without a position still appear in the cabinet list. Selecting a cabinet opens an original generic cabinet illustration: its marquee follows the assigned game and its screen shows reported phase. It is not gameplay video. Custom cabinet artwork and game-specific screen/marquee assets can replace this illustration later.
Cabinet state refreshes approximately every 1.5 seconds. Device markers are reference locations until sensor integration is implemented. Output rings show pending or failed/unconfirmed commands, not physical device activation. Per-cabinet RFID, digital input, and electrical output feedback is available through the optional [PCB feedback bridge](../pi_gpio/README.md#pcb-live-feedback). Cabinet and Hardware views show readings, errors, and stale status; Activity records changes. The Pac-Man cabinet uses assigned-game artwork, not a live video feed.
Hardware offers only each cabinet's configured actions. An operator reviews the action and supplies a reason. The controller requires real GPIO mode, an online prepared cabinet, the current session, and no pending commands. Requests are deduplicated and reasons appear in Activity. An accepted acknowledgement still does not establish physical delivery. A timeout is not retried automatically. Tokens remain in browser memory and Lock clears access on that device.
Optional hub branding and links to other rooms/services can be set in a separate JSON file:
```json
{
"name": "Escape Gallery",
"room_name": "Glitch: the Dark Machine",
"services": [
{
"id": "trivia",
"name": "Trivia",
"kind": "trivia",
"url": "http://TRIVIA_HOST:8080",
"description": "Open the trivia operator application"
}
]
}
```
Replace the example host before using it, or keep `services` empty. Entries are links, not aggregated live status or shared authentication. Allowed kinds are `room`, `trivia`, and `service`. URLs cannot contain credentials or token query strings.
Pass `--hub-config /path/to/hub.json` to `server.py`, or add `hub_config = /path/to/hub.json` under `[controller]` in the launcher INI. Restart after changes. Keep this optional setting when regenerating deployment configurations. Current authentication remains the shared operator token; individual staff identities are not implemented.
Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bqb2ndoni3hqb"
path="res://.godot/imported/pacman_marquee.png-385e4e7d4a7acf48156ae5dff9cd6ab8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/room_controller/art/pacman_marquee.png"
dest_files=["res://.godot/imported/pacman_marquee.png-385e4e7d4a7acf48156ae5dff9cd6ab8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b5u7udnl0fhnw"
path="res://.godot/imported/pacman_screen.png-7852643f5de365f46bdcadee22b5b971.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/room_controller/art/pacman_screen.png"
dest_files=["res://.godot/imported/pacman_screen.png-7852643f5de365f46bdcadee22b5b971.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+217 -48
View File
@@ -2,59 +2,162 @@
"cabinets": { "cabinets": {
"cabinet_01": { "cabinet_01": {
"actions": [ "actions": [
"coin_release", "coin_slot_1",
"start_light" "lock_released"
], ],
"capabilities": [ "capabilities": [
"joystick_4way", "joystick_4way",
"button_1",
"start_button", "start_button",
"joystick_horizontal", "select_button"
"button_2",
"button_3",
"button_4",
"button_5",
"button_6",
"select_button",
"trackball",
"joystick_8way",
"joystick_dual"
], ],
"name": "Master Cabinet", "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
}
],
"name": "Cabinet 01",
"position": [ "position": [
-5.94849967956543, -6.55779981613159,
0.147500038146973 1.98219990730286
] ]
}, },
"cabinet_02": { "cabinet_02": {
"actions": [ "actions": [
"coin_release", "coin_release",
"start_light", "lock_released"
"glitch_code"
], ],
"capabilities": [ "capabilities": [
"joystick_4way", "joystick_horizontal",
"button_1",
"start_button", "start_button",
"joystick_horizontal" "select_button",
"button_1"
],
"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
}
], ],
"name": "Cabinet 02", "name": "Cabinet 02",
"position": [ "position": [
-3.51539182662964, -2.89779996871948,
0.142745211720467 1.98219990730286
] ]
}, },
"cabinet_03": { "cabinet_03": {
"actions": [ "actions": [
"coin_release" "coin_slot_1",
"lock_released"
], ],
"capabilities": [ "capabilities": [
"spinner", "spinner",
"button_1", "button_1",
"start_button",
"select_button"
],
"devices": [],
"name": "Cabinet 03",
"position": [
-2.89779996871948,
3.20219993591309
]
},
"cabinet_04": {
"actions": [
"coin_slot_1",
"lock_released"
],
"capabilities": [
"roller",
"button_1",
"button_2",
"button_3",
"select_button",
"start_button" "start_button"
], ],
"name": "Cabinet 03", "devices": [],
"position": null "name": "Cabinet 4",
"position": [
-6.55779981613159,
3.20219993591309
]
} }
}, },
"games": { "games": {
@@ -252,6 +355,7 @@
"activate_after_win": "", "activate_after_win": "",
"cabinet": "cabinet_01", "cabinet": "cabinet_01",
"game": "pacman", "game": "pacman",
"game_config": {},
"mappings": {}, "mappings": {},
"variant": "standard" "variant": "standard"
}, },
@@ -259,43 +363,106 @@
"activate_after_win": "cabinet_01", "activate_after_win": "cabinet_01",
"cabinet": "cabinet_02", "cabinet": "cabinet_02",
"game": "galaga", "game": "galaga",
"game_config": {},
"mappings": {}, "mappings": {},
"variant": "standard" "variant": "standard"
} }
], ],
"name": "Cabinet 1 -> Cabinet 2" "name": "Cabinet 1 -> Cabinet 2"
}, },
"galaga_proof_of_concept": { "eg_glitch": {
"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": [ "assignments": [
{ {
"activate_after_win": "", "activate_after_win": "",
"cabinet": "cabinet_01", "cabinet": "cabinet_01",
"game": "pacman", "game": "pacman",
"mappings": { "game_config": {},
"game_won": [ "mappings": {},
"coin_release" "variant": "standard"
]
}, },
{
"activate_after_win": "",
"cabinet": "cabinet_02",
"game": "galaga",
"game_config": {},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "",
"cabinet": "cabinet_03",
"game": "tempest",
"game_config": {},
"mappings": {},
"variant": "standard"
},
{
"activate_after_win": "",
"cabinet": "cabinet_04",
"game": "missile_command",
"game_config": {},
"mappings": {},
"variant": "standard" "variant": "standard"
} }
], ],
"name": "Proof of Concept" "name": "Glitch: The Dark Machine"
},
"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"
},
"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"
}, },
"two_cabinet_test": { "two_cabinet_test": {
"assignments": [ "assignments": [
@@ -303,6 +470,7 @@
"activate_after_win": "", "activate_after_win": "",
"cabinet": "cabinet_01", "cabinet": "cabinet_01",
"game": "pacman", "game": "pacman",
"game_config": {},
"mappings": { "mappings": {
"game_started": [ "game_started": [
"start_light" "start_light"
@@ -317,6 +485,7 @@
"activate_after_win": "", "activate_after_win": "",
"cabinet": "cabinet_02", "cabinet": "cabinet_02",
"game": "galaga", "game": "galaga",
"game_config": {},
"mappings": { "mappings": {
"game_activated": [ "game_activated": [
"glitch_code" "glitch_code"
+2 -1
View File
@@ -14,6 +14,7 @@ func run() -> void:
for action in cabinet.hardware_config.actions: actions.append(str(action.action_id)) for action in cabinet.hardware_config.actions: actions.append(str(action.action_id))
var placement := room.get_cabinet_placement(cabinet.cabinet_id) var placement := room.get_cabinet_placement(cabinet.cabinet_id)
catalog.cabinets[str(cabinet.cabinet_id)] = {"name": cabinet.display_name, "capabilities": Array(cabinet.capabilities), "actions": actions, "position": [placement.position.x, placement.position.y] if placement else null} catalog.cabinets[str(cabinet.cabinet_id)] = {"name": cabinet.display_name, "capabilities": Array(cabinet.capabilities), "actions": actions, "position": [placement.position.x, placement.position.y] if placement else null}
catalog.cabinets[str(cabinet.cabinet_id)]["devices"] = cabinet.pcb_config.feedback_devices() if cabinet.pcb_config else []
for scenario in database.scenarios: for scenario in database.scenarios:
var assignments: Array = [] var assignments: Array = []
for assignment in scenario.assignments: for assignment in scenario.assignments:
@@ -22,7 +23,7 @@ func run() -> void:
for mapping in assignment.event_mappings: for mapping in assignment.event_mappings:
if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = [] if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = []
mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions)) 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), "activate_after_win": str(assignment.activate_after_win), "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), "game_config": assignment.game_config, "mappings": mappings})
catalog.scenarios[str(scenario.scenario_id)] = {"name": scenario.display_name, "assignments": assignments} 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 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]]) for wall in room.walls: catalog.layout.walls.append([[wall.start.x, wall.start.y], [wall.end.x, wall.end.y]])
+44 -26
View File
@@ -1,28 +1,46 @@
<!doctype html> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Escape Gallery · Operations</title>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Arcade · Room operator</title>
<style> <style>
:root{color-scheme:dark;font:16px system-ui,sans-serif;background:#0b111a;color:#e3ebf5}*{box-sizing:border-box}body{margin:0}header{padding:24px 4vw;border-bottom:1px solid #293749;display:flex;align-items:center;justify-content:space-between;gap:20px}h1{font-size:24px;margin:0}h2{font-size:18px;margin:0 0 16px}p{line-height:1.5}small,.muted{color:#9eb0c6}main{max-width:1500px;margin:auto;padding:28px 4vw}.panel{background:#121c29;border:1px solid #293749;border-radius:14px;padding:22px;margin-bottom:20px}.row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px}button,input,select{font:inherit;border-radius:7px;border:1px solid #45576c;padding:12px;background:#1c2a3b;color:inherit;min-height:46px}button{cursor:pointer}button.primary{background:#69ddcd;color:#092923;border-color:#69ddcd;font-weight:650}button:disabled{opacity:.4;cursor:default}button:focus-visible,select:focus-visible,input:focus-visible{outline:3px solid #69ddcd;outline-offset:3px}select{max-width:100%}#login{max-width:550px;margin:8vh auto}#login input{width:100%;margin:10px 0}#status{color:#69ddcd}#error{color:#ffce96;white-space:pre-wrap}#error:empty{display:none}#cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-top:16px}.card{text-align:left;padding:16px;background:#152335}.card[aria-pressed=true]{border-color:#69ddcd;box-shadow:inset 0 0 0 1px #69ddcd}.card span{display:block;margin-top:9px;font-size:14px}svg{width:100%;height:340px;background:#0b131e;border-radius:8px}.steps{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.steps span{padding:8px 10px;border-radius:5px;background:#223043;font-size:13px}.steps .current{background:#69ddcd;color:#092923}table{width:100%;border-collapse:collapse;font-size:14px}td,th{text-align:left;padding:10px 6px;border-bottom:1px solid #293749;overflow-wrap:anywhere}#events{max-height:270px;overflow:auto;padding-left:22px;font-size:14px}#events li{padding:6px}#assignment{white-space:pre-line}#scenarioSummary{font-size:14px;color:#b9c8d9}#output{overflow-x:auto}#signout{font-size:14px} [hidden]{display:none!important}@media(max-width:850px){.columns{grid-template-columns:1fr}header{align-items:flex-start}main{padding:18px}svg{height:280px}} :root{font:16px/1.5 system-ui,sans-serif;color:#25342e;background:#f3f4ef;--muted:#627168;--line:#d7dfd5;--accent:#21644e}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 16px;min-height:46px;border:1px solid #bdccc0;border-radius:8px;background:white;color:inherit;font-weight:600;cursor:pointer;text-decoration:none}button:hover,.button:hover{background:#edf3eb}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button.danger{color:#a44031}button:disabled{opacity:.4;cursor:not-allowed}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #d29932;outline-offset:3px}input,select,textarea{padding:11px;border:1px solid #b8c7ba;border-radius:8px;min-height:46px;background:white;color:inherit;max-width:100%}h1,h2,h3,p{margin-top:0}h1{font-size:clamp(26px,3vw,36px);line-height:1.2;letter-spacing:-1px;margin-bottom:12px}h2{font-size:20px;margin-bottom:14px}h3{font-size:16px;margin-bottom:8px}p{margin-bottom:14px}small,.muted{color:var(--muted)}small{font-size:13px}label{display:block;font-size:14px;font-weight:600;margin-bottom:6px}[hidden]{display:none!important}.eyebrow{font-size:11px;font-weight:750;letter-spacing:1.7px;color:var(--muted);text-transform:uppercase;margin-bottom:10px}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.between{justify-content:space-between}.panel{background:white;border:1px solid var(--line);border-radius:12px;padding:24px}.columns{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,235px),1fr));gap:16px}.badge{display:inline-flex;gap:6px;align-items:center;border-radius:25px;padding:4px 9px;font-size:12px;font-weight:700;background:#edf0e9;color:#53634f}.badge:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.good{background:#e0f0e3;color:#28623a}.warn{background:#fff0cd;color:#78550e}.bad{background:#fbe8e2;color:#9c3d29}.blue{background:#e4edf9;color:#2c6095}.notice{background:#fff6e1;border:1px solid #dec18b;color:#775115;padding:14px 18px;border-radius:9px;margin-bottom:18px;white-space:pre-line}.notice:empty{display:none}#error{background:#ffede7;border-color:#dcb3a5;color:#8b3727}#toast{background:#e5f1e4;border-color:#b5d1b6;color:#2a5737}#login{max-width:470px;margin:10vh auto}#login input{width:100%;margin-bottom:16px}#login .primary{width:100%}.mark{display:grid;place-items:center;width:42px;height:42px;border-radius:10px;background:#dfb75b;color:#233c32;font-weight:850}#shell{display:grid;grid-template-columns:222px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;background:#1c322c;color:#e1e9df;padding:28px 18px}.brand{display:flex;gap:10px;align-items:center;margin-bottom:35px}.brand strong{font-size:17px}.brand small{display:block;font-size:10px;letter-spacing:2px;color:#afc4b5}.nav-label{font-size:10px;letter-spacing:1.6px;color:#a6bfac;margin:22px 12px 8px}.nav{width:100%;justify-content:flex-start;background:transparent;border:0;color:#c4d4c4;padding:12px;margin-bottom:4px;text-align:left;font-size:14px}.nav:hover{background:#2a4537}.nav[aria-current=page]{background:#3c5944;color:white}.nav span{color:#d8bb75;width:20px;font-size:18px}.sidebar footer{margin-top:auto;border-top:1px solid #405742;padding-top:20px}.sidebar footer p{font-size:12px;color:#b8c9b5}.sidebar footer button{background:transparent;color:#e2eadb;border-color:#688267;width:100%}.content{min-width:0}.topbar{padding:16px 30px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px;background:#fafbf6}.topbar strong,.topbar small{display:block}.topbar strong{font-size:14px}.topbar button{font-size:13px;padding:8px 12px;min-height:40px}main{max-width:1600px;margin:auto;padding:30px}.heading{margin-bottom:25px}.heading p{color:var(--muted);max-width:750px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:22px 0}.stat{padding:19px;background:white;border:1px solid var(--line);border-radius:10px}.stat strong{display:block;font-size:30px;line-height:1.2}.stat span{font-size:12px;color:var(--muted)}.experience{border-top:4px solid #c9a75d}.experience h2{font-size:25px;margin-top:16px}.experience p{color:var(--muted)}.experience .button,.experience button{margin-top:12px}.link{border:0;background:none;padding:0;min-height:32px;color:var(--accent);font-size:14px}.attention{display:flex;gap:14px;align-items:center;padding:14px 0;border-bottom:1px solid var(--line)}.attention:last-child{border:0}.attention div{flex:1}.attention strong,.attention small{display:block}.card{display:block;padding:18px;text-align:left;border:1px solid var(--line);border-radius:10px;background:white}.card[aria-pressed=true]{border:2px solid var(Line truncated
</style></head><body> .cabinet-visual{margin:auto;width:min(100%,270px);padding:14px 16px 22px;background:#252f2c;border:5px solid #17201c;border-radius:10px 10px 5px 5px;color:#eff5e8;box-shadow:7px 10px 0 #00000010}.marquee{border:3px solid #101813;background:#ecce7e;color:#233420;font-weight:850;text-align:center;padding:9px 6px;letter-spacing:1px;text-transform:uppercase;font-size:15px}.screen{margin:15px 0 0;background:#101d17;border:9px solid #414b42;border-radius:5px;aspect-ratio:1.1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:8px;padding:12px}.screen .screen-game{font-size:20px;font-weight:750;color:#e7c965}.screen .screen-state{font-size:12px;letter-spacing:1px;text-transform:uppercase;color:#b8ddad}.screen .screen-detail{font-size:11px;color:#9aafa0}.deck{background:#3b473d;padding:14px 10px;display:flex;justify-content:space-around;align-items:center;transform:perspective(100px) rotateX(12deg);border-bottom:4px solid #18271d}.joystick{width:18px;height:18px;background:#d8ba60;border-radius:50%;box-shadow:0 6px 0 #142318}.buttons{display:flex;gap:6px}.buttons i{height:9px;width:9px;background:#cce0ba;border-radius:50%}.coin-door{width:58px;height:55px;border:3px solid #414d40;border-radius:3px;margin:25px auto 8px;display:grid;place-items:center}.coin-slot{width:20px;height:5px;background:#101a12}.cabinet-caption{text-align:center;font-size:12px;color:var(--muted);margin-top:14px}.visual-panel{background:#f4f6ee;padding:22px;border-radius:9px}
<header><div><small>ARCADE ESCAPE / OPERATIONS</small><h1>Room control</h1></div><div class="row"><span id="status" role="status">Not connected</span><button id="signout" hidden>Lock</button></div></header> @media(max-width:1050px){#shell{grid-template-columns:188px 1fr}.sidebar{padding:24px 12px}.brand strong{font-size:15px}.columns{grid-template-columns:1fr}main{padding:24px}.topbar{padding:16px 24px}}@media(max-width:680px){#shell{display:block}.sidebar{height:auto;position:static;display:block;padding:16px}.brand{margin-bottom:14px}.sidebar nav{display:flex;overflow-x:auto;gap:5px}.nav-label,.sidebar footer{display:none}.nav{width:auto;white-space:nowrap;margin:0;padding:10px}.nav span{display:none}.topbar{padding:12px 16px}main{padding:22px 16px}.panel{padding:18px}.stats{grid-template-columns:repeat(2,1fr)}.activity li{grid-template-columns:70px 1fr;gap:4px 10px}.activity .message{grid-column:2}#map{height:270px}}@media(min-width:681px){#mobileLock{display:none}}
<main><div id="error" class="panel" role="alert"></div> .marquee img{width:100%;height:100%;object-fit:contain;background:white}.marquee:has(img){padding:0;height:82px}.screen{aspect-ratio:224/288;position:relative}.screen-game:has(img){width:100%;height:100%}.screen img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}.screen:has(img){padding:0}.screen:has(img) .screen-state{position:absolute;bottom:4px;background:#000b;padding:3px 8px}.screen:has(img) .screen-detail{display:none}.cabinet-visual{border-left:12px solid #24392b;border-right:12px solid #24392b;box-shadow:8px 10px 0 #172a251a}.feedback-list{display:grid;gap:10px;margin-top:16px}.feedback-item{border-left:4px solid #89918d;padding:12px;background:#f4f6ee}.feedback-item.active{border-color:#287c58}.feedback-item.stale{opacity:.65}.cabinet-visual[data-game="pacman"]{border-color:#e8bf21;background:#e8bf21}.cabinet-visual[data-game="pacman"] .deck{background:#303334}.cabinet-visual[data-game="pacman"] .coin-door{background:#26292a;border-color:#171c1a}</style></head><body>
<form id="login" class="panel"><h2>Connect to this room</h2><p class="muted">Use the operator token configured on this controller. It stays in memory until this page closes or locks.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button class="primary">Connect</button></form> <form id="login" class="panel"><div class="row" style="margin-bottom:26px"><div class="mark">EG</div><div><strong>Escape Gallery</strong><div class="eyebrow" style="margin:0">Operations hub</div></div></div><h1>Ready when you are.</h1><p class="muted">Connect to the controller to follow the room and manage the experience.</p><label for="token">Operator token</label><input id="token" type="password" autocomplete="off" required><button type="button" id="connect" class="primary">Connect to operations</button><p id="loginError" class="notice" role="alert" style="margin-top:16px"></p><p class="small muted" style="margin:18px 0 0">Your token stays in this tab’s memory. Lock the hub when you leave this device.</p></form>
<div id="workspace" hidden> <div id="shell" hidden><aside class="sidebar"><div class="brand"><div class="mark">EG</div><div><strong id="siteName">Escape Gallery</strong><small>OPERATIONS</small></div></div><nav aria-label="Main navigation"><div class="nav-label">WORKSPACE</div><button class="nav" data-view="home"><span>◈</span>Hub overview</button><div class="nav-label">CURRENT ROOM</div><button class="nav" data-view="room"><span>▤</span>Room control</button><button class="nav" data-view="cabinets"><span>▣</span>Cabinets</button><button class="nav" data-view="hardware"><span>⌘</span>Hardware</button><button class="nav" data-view="activity"><span>≡</span>Activity</button></nav><footer><p id="sidebarRoom">Glitch: the Dark Machine</p><p>Building network · Operator access</p><button class="lock">Lock this device</button></footer></aside><div class="content"><header class="topbar"><div><small id="breadcrumb">Operations / Overview</small><strong id="roomName">Glitch: the Dark Machine</strong></div><div class="row"><span id="status" class="badge" role="status">Connecting</span><button id="refresh">Refresh</button><button class="lock" id="mobileLock">Lock</button></div></header><main><div id="error" class="notice" role="alert"></div><div id="toast" class="notice" role="status"></div><div id="stale" class="notice" hidden>Connection lost. Showing last received state; commands are disabled.</div>
<section class="panel"><h2>Prepare a scenario</h2><div class="row"><select id="scenario" aria-label="Scenario"></select><button id="prepareScenario" class="primary">Prepare assigned cabinets</button></div><p id="scenarioSummary"></p><small>Preparation loads games and their event-to-hardware mappings. Start each cabinet when ready. Reset active cabinets before changing assignments.</small></section> <section data-page="home"><div class="heading"><div class="eyebrow">Your operations, together</div><h1>Keep the experience moving.</h1><p>Open a room, check what needs attention, and get back to your players.</p></div><div class="row between"><h2>Experiences</h2><span class="badge">Local network</span></div><div id="experiences" class="grid"></div><div class="stats"><div class="stat"><strong id="onlineCount">—</strong><span>Cabinets online</span></div><div class="stat"><strong id="playingCount">—</strong><span>Games in progress</span></div><div class="stat"><strong id="readyCount">—</strong><span>Ready to start</span></div><div class="stat"><strong id="attentionCount">—</strong><span>Need attention</span></div></div><div class="columns"><section class="panel"><div class="row between"><h2>Needs attention</h2><button class="link" data-go="cabinets">View cabinets →</button></div><div id="attention"></div></section><section class="panel"><div class="row between"><h2>Recent activity</h2><button class="link" data-go="activity">View all →</button></div><ol id="recent" class="activity"></ol></section></div></section>
<div class="columns"><section class="panel"><h2>Room overview</h2><svg id="map" role="img" aria-label="Room layout and cabinet status"></svg><p class="muted"><small>Cabinet colors show runtime state. Room devices are reference locations; physical sensor feedback is not connected.</small></p><div id="cards"></div></section> <section data-page="room" hidden><div class="heading"><div class="eyebrow">Game-master workspace</div><h1 id="roomHeading">Glitch: the Dark Machine</h1><p>Follow the room layout and cabinet sequence. Select a station for its live reported state and controls.</p></div><div class="columns"><section class="panel"><h2>Scenario preparation</h2><label for="scenario">Scenario</label><select id="scenario" style="width:100%"></select><ol id="scenarioPlan" class="scenario-plan"></ol><button id="prepareScenario" class="primary" style="width:100%">Prepare assigned cabinets…</button><p class="small muted" style="margin-top:14px">Preparation can run configured activation outputs. Linked cabinets wait for the preceding win; independent cabinets are started individually.</p><div id="scenarioBlockers" class="notice"></div></section><section class="panel"><h2>Room layout</h2><svg id="map" role="group" aria-label="Room layout; select a cabinet for controls"></svg><div class="legend"><span><i style="background:#287c58"></i>Ready / won</span><span><i style="background:#426ca4"></i>Playing</span><span><i style="background:#9a762d"></i>Waiting / idle</span><span><i style="background:#89918d"></i>Offline</span></div><p class="small muted">Device markers are reference locations, not live sensor readings. Cabinet rings indicate pending or unconfirmed output commands.</p></section></div><div class="row between" style="margin:24px 0 14px"><h2 style="margin:0">Cabinet status</h2><button class="link" data-go="cabinets">Open controls →</button></div><div class="grid" id="roomCards"></div></section>
<section class="panel"><h2 id="cabinetTitle">Select a cabinet</h2><p id="connection" class="muted"></p><p id="assignment"></p><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset">Reset cabinet…</button></div><hr style="border:0;border-top:1px solid #293749;margin:24px 0"><h2>Manual game assignment</h2><div class="row"><select id="game" aria-label="Compatible game"></select><button id="prepare">Prepare game</button></div><p><small>Only compatible games are listed. Manual assignments run without scenario hardware rewards.</small></p><h2>Hardware commands</h2><p id="reward" class="muted"></p><div id="output"></div><h2 style="margin-top:24px">Activity</h2><ol id="events"></ol></section></div> <section data-page="cabinets" hidden><div class="heading"><div class="eyebrow">Connected stations</div><h1>Cabinets</h1><p>The marquee follows the assigned game. The screen represents reported state; it is not a live video feed.</p></div><div class="grid" id="cards"></div><section class="panel detail" id="detail"><div class="row between" style="margin-bottom:20px"><div><h2 id="cabinetTitle">Select a cabinet</h2><small id="connection"></small></div><span class="badge" id="phaseBadge"></span></div><div class="columns"><div><dl id="assignment" class="kv"></dl><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset" class="danger">Reset cabinet…</button><button id="openHardware">Hardware →</button></div><p id="controlHint" class="small muted" style="margin-top:14px"></p><hr><h3>Manual game assignment</h3><p class="small muted">Runs a compatible game without scenario hardware rewards.</p><label for="game">Compatible game</label><div class="row"><select id="game"></select><button id="prepare">Prepare game…</button></div><h3 style="margin-top:24px">Win output</h3><p id="reward" class="small muted"></p><h3>Device feedback</h3><div id="cabinetFeedback" class="feedback-list"></div></div><div class="visual-panel"><div class="cabinet-visual" aria-label="Cabinet state illustration"><div class="marquee" id="marquee">GAME</div><div class="screen"><div class="screen-game" id="screenGame"></div><div class="screen-state" id="screenState"></div><div class="screen-detail" id="screenDetail"></div></div><div class="deck" aria-hidden="true"><div class="joystick"></div><div class="buttons"><i></i><i></i><i></i></div><div class="joystick"></div></div><div class="coin-door" aria-hidden="true"><div class="coin-slot"></div></div></div><p class="cabinet-caption">Assigned-game artwork · reported cabinet state</p></div></div></section></section>
</div></main><script> <section data-page="hardware" hidden><div class="heading"><div class="eyebrow">Physical devices</div><h1>Hardware &amp; outputs</h1><p>Trigger configured actions and review acknowledgements. Accepted does not prove that a lock moved or a reward was delivered.</p></div><section class="panel"><label for="hardwareCabinet">Cabinet</label><select id="hardwareCabinet"></select><p id="hardwareHint" class="small muted" style="margin-top:14px"></p><div class="grid" id="actions"></div><h2 style="margin-top:26px">Command history</h2><div class="table-wrap" id="output"></div><h2 style="margin-top:24px">Live device feedback</h2><div id="feedback" class="feedback-list"></div></section><section class="panel" style="margin-top:20px"><div class="row between"><h2>Room devices</h2><span class="badge">Integration pending</span></div><p class="small muted">Device feedback is listed above when a configured Pi bridge reports it. These room markers remain reference locations until mapped to sensor IDs.</p><div id="roomDevices" class="grid"></div></section></section>
'use strict'; <section data-page="activity" hidden><div class="heading"><div class="eyebrow">Room history</div><h1>Activity</h1><p>Cabinet events, operator interventions, and command acknowledgements in one place.</p></div><section class="panel"><div class="row filters"><label class="visually-hidden" for="activityCabinet">Filter cabinet</label><select id="activityCabinet"></select><label class="visually-hidden" for="activitySearch">Search activity</label><input type="search" id="activitySearch" placeholder="Search events or actions…"><span id="eventCount" class="small muted"></span></div><ol class="activity" id="events"></ol><p class="small muted" style="margin-top:20px">Latest retained history: up to 100 events per cabinet. Operator entries record actions and reasons; individual staff accounts are not configured.</p></section></section>
const $=id=>document.getElementById(id);let token='',state=null,selected='',pending=false,connected=false,refreshing=false; </main></div></div><dialog id="confirmDialog" aria-labelledby="confirmTitle"><form id="confirmForm"><div class="eyebrow">Review action</div><h2 id="confirmTitle"></h2><p id="confirmText" class="muted"></p><div id="confirmReason" hidden><label for="reason">Reason for this intervention</label><textarea id="reason" rows="3" maxlength="200"></textarea></div><div class="row"><button type="button" id="cancelConfirm">Cancel</button><button type="submit" class="primary" id="acceptConfirm">Confirm</button></div></form></dialog>
const colors={ready:'#69ddcd',playing:'#7bbaff',won:'#b4ec86',lost:'#ffae9e'}; <script>
function node(tag,text,cls){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(cls)e.className=cls;return e} const $=id=>document.getElementById(id), phases={idle:'Idle',waiting:'Waiting',preparing:'Preparing',ready:'Ready',starting:'Starting',playing:'Playing',won:'Won',lost:'Lost',resetting:'Resetting',needs_prepare:'Needs preparation'};
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} let token='',state=null,selected='',view='home',connected=false,pending=false,refreshing=false,epoch=0,confirmation=null;
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} const node=(tag,text='',cls='')=>{const n=document.createElement(tag);n.textContent=text;n.className=cls;return n};
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} const name=id=>state?.cabinets[id]?.definition.name||id, gameName=id=>state?.catalog.games[id]?.name||id||'No game assigned';
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'} const busy=c=>c.commands.some(x=>x.status==='queued'), prep=c=>c.online&&!busy(c)&&['idle','won','lost','needs_prepare'].includes(c.phase);
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)}} const hardware=c=>connected&&!pending&&c?.online&&c.gpio==='real'&&c.session&&!busy(c)&&['ready','playing','won','lost'].includes(c.phase);
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()} function options(id,items){const s=$(id),old=s.value,key=JSON.stringify(items);if(s.dataset.options===key)return;s.dataset.options=key;s.replaceChildren(...items.map(([v,t])=>{const o=node('option',t);o.value=v;return o}));if(items.some(x=>x[0]===old))s.value=old}
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}} function cached(id,key,fn){const e=$(id),s=JSON.stringify(key);if(e.dataset.key!==s){e.dataset.key=s;fn(e)}}
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()}} function navigate(next){view=['home','room','cabinets','hardware','activity'].includes(next)?next:'home';document.querySelectorAll('[data-page]').forEach(e=>e.hidden=e.dataset.page!==view);document.querySelectorAll('[data-view]').forEach(e=>e.setAttribute('aria-current',e.dataset.view===view?'page':'false'));$('breadcrumb').textContent='Operations / '+({home:'Overview',room:'Room control',cabinets:'Cabinets',hardware:'Hardware',activity:'Activity'}[view]);if(state)render()}
$('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); function select(id,next='cabinets'){selected=id;navigate(next)}
async function api(path,data={}){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),6000);try{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:controller.signal});const b=await r.json();if(!r.ok)throw Error(b.error||'Request failed');return b}finally{clearTimeout(timer)}}
function enable(){const c=state?.cabinets[selected],ok=connected&&!pending;['start','reset','prepare','prepareScenario'].forEach(id=>$(id).disabled=true);if(c){$('start').disabled=!(ok&&c.online&&c.phase==='ready'&&!c.gate&&!c.game_config?.rfid_credit_device&&!busy(c));$('reset').disabled=!(ok&&c.online&&!busy(c));$('prepare').disabled=!(ok&&prep(c)&&$('game').value)}const plan=state?.catalog.scenarios[$('scenario').value];$('prepareScenario').disabled=!(ok&&plan?.assignments.length&&plan.assignments.every(a=>prep(state.cabinets[a.cabinet])));document.querySelectorAll('[data-output]').forEach(b=>b.disabled=!hardware(c));$('acceptConfirm').disabled=!ok;$('refresh').disabled=pending||refreshing}
async function refresh(){if(!token||pending||refreshing)return;refreshing=true;const generation=epoch;try{const s=await api('/operator/state');if(generation!==epoch)return;state=s;connected=true;$('login').hidden=true;$('shell').hidden=false;$('loginError').textContent='';render()}catch(e){if(generation!==epoch)return;connected=false;$('loginError').textContent=e.message;enable()}finally{refreshing=false;$('status').textContent=connected?'Connected':'Disconnected';$('status').className='badge '+(connected?'good':'bad');$('stale').hidden=connected;enable()}}
async function command(action,data){if(!connected||pending)return;pending=true;const generation=++epoch;enable();$('error').textContent='';$('toast').textContent='';try{const result=await api('/operator/command',{action,...data});if(generation!==epoch)return;state=result;render();$('toast').textContent='Command submitted. Follow its acknowledgement in Activity.'}catch(e){if(generation!==epoch)return;$('error').textContent=e.name==='AbortError'?'Reply timed out. Check Activity before repeating the action.':e.message}finally{pending=false;enable();refresh()}}
function review(title,text,fn,reason=false){confirmation=fn;$('confirmTitle').textContent=title;$('confirmText').textContent=text;$('confirmReason').hidden=!reason;$('reason').value='';$('reason').required=reason;$('reason').minLength=reason?3:0;$('confirmDialog').showModal()}
function allEvents(){return Object.entries(state.cabinets).flatMap(([id,c])=>c.history.map((e,i)=>({...e,id,i}))).sort((a,b)=>b.time-a.time||b.i-a.i)}
function events(id,list){cached(id,list,e=>{e.replaceChildren(...list.map(x=>{const li=node('li');li.append(node('time',new Date(x.time*1000).toLocaleTimeString()),node('strong',name(x.id)),node('span',x.message,'message'));return li}));if(!list.length)e.append(node('li','No events yet.','empty'))})}
function cards(id){const root=$(id);for(const [key,c] of Object.entries(state.cabinets)){let b=Array.from(root.children).find(e=>e.dataset.cabinet===key);if(!b){b=node('button','','card');b.dataset.cabinet=key;b.onclick=()=>select(key);root.append(b)}b.setAttribute('aria-pressed',String(key===selected));const signature=JSON.stringify([c.online,c.phase,c.game,c.definition.name]);if(b.dataset.signature!==signature){b.dataset.signature=signature;b.replaceChildren(node('strong',name(key)),node('span',c.online?(phases[c.phase]||c.phase):'Offline','badge '+(c.online?'good':'')),node('p',gameName(c.game),'game'))}}}
function home(){const cs=Object.values(state.cabinets);$('onlineCount').textContent=cs.filter(c=>c.online).length+' / '+cs.length;$('playingCount').textContent=cs.filter(c=>c.online&&c.phase==='playing').length;$('readyCount').textContent=cs.filter(c=>c.online&&c.phase==='ready').length;const attention=Object.entries(state.cabinets).filter(([,c])=>!c.online||c.phase==='needs_prepare'||c.commands.some(x=>['failed','unknown'].includes(x.status)));$('attentionCount').textContent=attention.length;cached('experiences',state.hub,e=>{const b=node('button','','experience');b.append(node('span','ARCADE ROOM','eyebrow'),node('h2',state.hub.room_name),node('p','Room layout, cabinet controls and hardware activity'),node('span','Open room →','link'));b.onclick=()=>navigate('room');e.replaceChildren(b);for(const s of state.hub.services){const a=node('a','','experience');a.href=s.url;a.target='_blank';a.rel='noopener noreferrer';a.append(node('span',s.kind.toUpperCase(),'eyebrow'),node('h2',s.name),node('p',s.description),node('small','External service · status not monitored'));e.append(a)}});cached('attention',attention.map(([id,c])=>[id,c.online,c.phase]),e=>{e.replaceChildren(...attention.map(([id,c])=>{const b=node('button',name(id)+' — '+(!c.online?'Offline':c.phase==='needs_prepare'?'Needs preparation':'Review command history'),'attention');b.onclick=()=>select(id);return b}));if(!attention.length)e.append(node('p','All cabinets are reporting normally.','muted'))});events('recent',allEvents().slice(0,6))}
function scenario(){const s=state.catalog.scenarios[$('scenario').value];$('scenarioPlan').replaceChildren(...(s?.assignments||[]).map(a=>{const li=node('li');li.append(node('strong',name(a.cabinet)+' · '+gameName(a.game)),node('p',a.activate_after_win?'After '+name(a.activate_after_win)+' wins':'Ready for operator start'),node('small',Object.entries(a.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(' · ')));return li}));$('scenarioBlockers').textContent=(s?.assignments||[]).filter(a=>!prep(state.cabinets[a.cabinet])).map(a=>name(a.cabinet)+': must be online, reset, and free of pending commands').join('. ')}
function map(){const layout=state.catalog.layout||{},cs=Object.entries(state.cabinets);cached('map',[layout,cs.map(([id,c])=>[id,c.online,c.phase,c.commands])],svg=>{svg.replaceChildren();const pts=[...(layout.boundary||[]),...(layout.walls||[]).flat(),...(layout.devices||[]).map(d=>d.position),...cs.map(([,c])=>c.definition.position)].filter(Boolean);if(!pts.length)return;const xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]),minX=Math.min(...xs)-.7,minY=Math.min(...ys)-.7,w=Math.max(...xs)-minX+.7,h=Math.max(...ys)-minY+.7;svg.setAttribute('viewBox',[minX,minY,w,h].join(' '));const draw=(tag,attrs,parent=svg)=>{const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const[k,v]of Object.entries(attrs))e.setAttribute(k,v);parent.append(e);return e};if(layout.boundary?.length)draw('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#edf0e9',stroke:'#b6beb5','stroke-width':.04});for(const wall of layout.walls||[])draw('polyline',{points:wall.map(p=>p.join(',')).join(' '),fill:'none',stroke:'#728176','stroke-width':.07});for(const d of layout.devices||[]){if(!d.position)continue;const e=draw('rect',{x:d.position[0]-.05,y:d.position[1]-.05,width:.1,height:.1,fill:'#555'});draw('title',{},e).textContent=d.name+' — reference location; feedback unavailable'}for(const[id,c]of cs){const p=c.definition.position;if(!p)continue;const g=draw('g',{role:'button',tabindex:0,'aria-label':name(id)+': '+(c.online?c.phase:'offline')});g.style.cursor='pointer';g.onclick=()=>select(id);g.onkeydown=e=>{if(['Enter',' '].includes(e.key)){e.preventDefault();select(id)}};draw('circle',{cx:p[0],cy:p[1],r:.17,fill:!c.online?'#89918d':c.phase==='playing'?'#426ca4':['ready','won'].includes(c.phase)?'#287c58':'#9a762d'},g);const output=c.commands.filter(x=>['output','reward'].includes(x.type)).at(-1);if(output&&['queued','unknown','failed'].includes(output.status)){const ring=draw('circle',{cx:p[0],cy:p[1],r:.23,fill:'none',stroke:output.status==='queued'?'#b28934':'#bd4a3e','stroke-width':.035},g);draw('title',{},ring).textContent='Output command '+output.status+'; physical state unverified'}draw('text',{x:p[0],y:p[1]-.3,'text-anchor':'middle','font-size':.16,fill:'#263f32'},g).textContent=name(id)}})}
function detail(){renderFeedback('cabinetFeedback');const c=state.cabinets[selected];$('detail').hidden=!c;if(!c)return;$('cabinetTitle').textContent=name(selected);$('connection').textContent=selected+' · '+c.version+' · GPIO: '+c.gpio;$('phaseBadge').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('assignment').replaceChildren(...Object.entries({Game:gameName(c.game),Assignment:c.scenario||'None',Activation:c.gate?'Waiting for '+name(c.gate.cabinet)+' to win':'Independent',Objectives:Object.entries(c.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(', ')||'Game defaults'}).flatMap(([k,v])=>[node('dt',k),node('dd',v)]));$('steps').replaceChildren(...['preparing','ready','playing','won'].map(p=>node('span',phases[p],c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,gameName(id)]));$('reward').textContent=c.reward||'No win output reported.';$('controlHint').textContent=!c.online?'Reconnect this cabinet to send commands.':busy(c)?'Waiting for command acknowledgement.':c.gate?'This cabinet starts automatically after its dependency wins.':c.game_config?.rfid_credit_device?'Scan a card, then press Start on the cabinet.':'Start a ready game, or reset before changing its assignment.';renderCabinetArt(c);$('screenState').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('screenDetail').textContent=c.gate?'Waiting for '+name(c.gate.cabinet):busy(c)?'Command pending':'Reported state · '+selected}
function hardwareView(){renderFeedback();const c=state.cabinets[selected];$('hardwareCabinet').value=selected;$('hardwareHint').textContent=c?name(selected)+' · GPIO: '+c.gpio+' · '+(hardware(c)?'Configured outputs available.':'Requires an online, prepared cabinet with real GPIO and no pending commands.'):'No cabinet selected';cached('actions',[selected,c?.definition.actions],e=>{e.replaceChildren(...(c?.definition.actions||[]).map(action=>{const box=node('div','','panel'),b=node('button','Trigger…');b.dataset.output=action;b.onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Trigger '+action+'?',name(id)+' will run this configured physical action. Check the connected device before proceeding.',reason=>{const bytes=crypto.getRandomValues(new Uint8Array(16));command('trigger_output',{cabinet:id,expected_session:session,action_id:action,reason,request_id:Array.from(bytes,x=>x.toString(16).padStart(2,'0')).join('')})},true)};box.append(node('h3',action),node('p','Configured cabinet action','small muted'),b);return box}));if(!e.children.length)e.append(node('p','No actions configured.','empty'))});cached('output',[selected,c?.commands],e=>{const list=(c?.commands||[]).filter(x=>['reward','output'].includes(x.type)).slice().reverse();e.replaceChildren(...list.map(x=>node('p',(x.action_id||x.type)+' · '+(x.source==='operator'?'Operator':'Scenario / win output')+' · '+x.status+(x.status==='accepted'?' — physical delivery unconfirmed':''))));if(!list.length)e.append(node('p','No hardware commands yet.','muted'))});cached('roomDevices',state.catalog.layout?.devices,e=>e.replaceChildren(...(state.catalog.layout?.devices||[]).map(d=>{const n=node('div','','panel');n.append(node('strong',d.name),node('p',d.id+' · feedback unavailable','small muted'));return n})))}
function activity(){const id=$('activityCabinet').value,q=$('activitySearch').value.toLowerCase();const list=allEvents().filter(e=>(!id||e.id===id)&&(!q||(e.message+' '+name(e.id)).toLowerCase().includes(q)));$('eventCount').textContent=list.length+' events';events('events',list)}
function render(){if(!state?.cabinets)return;if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];const hub=state.hub||{name:'Escape Gallery',room_name:'Glitch: the Dark Machine',services:[]};state.hub=hub;$('siteName').textContent=hub.name;for(const id of ['sidebarRoom','roomName','roomHeading'])$(id).textContent=hub.room_name;document.title=hub.name+' · Operations';options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name||id]));options('hardwareCabinet',Object.keys(state.cabinets).map(id=>[id,name(id)]));options('activityCabinet',[['','All cabinets'],...Object.keys(state.cabinets).map(id=>[id,name(id)])]);if(view==='home')home();if(view==='room'){scenario();map();cards('roomCards')}if(view==='cabinets'){cards('cards');detail()}if(view==='hardware')hardwareView();if(view==='activity')activity();enable()}
$('login').onsubmit=e=>e.preventDefault();$('connect').onclick=()=>{token=$('token').value.trim();if(token)refresh()};$('token').onkeydown=e=>{if(e.key==='Enter')$('connect').click()};$('refresh').onclick=refresh;document.querySelectorAll('.lock').forEach(b=>b.onclick=()=>{++epoch;token='';state=null;connected=false;$('token').value='';$('shell').hidden=true;$('login').hidden=false;$('confirmDialog').close();$('token').focus()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>navigate(b.dataset.view));document.querySelectorAll('[data-go]').forEach(b=>b.onclick=()=>navigate(b.dataset.go));$('scenario').onchange=()=>{scenario();enable()};$('game').onchange=enable;$('hardwareCabinet').onchange=()=>select($('hardwareCabinet').value,'hardware');$('activityCabinet').onchange=activity;$('activitySearch').oninput=activity;$('openHardware').onclick=()=>navigate('hardware');$('start').onclick=()=>command('start',{cabinet:selected,expected_session:state.cabinets[selected].session});$('reset').onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Reset '+name(id)+'?','This ends its current game and clears its activation dependency.',()=>command('reset',{cabinet:id,expected_session:session}))};$('prepare').onclick=()=>{const id=selected,session=state.cabinets[id].session,game=$('game').value;review('Prepare '+gameName(game)+'?',name(id)+' will receive a manual assignment without scenario hardware rewards.',()=>command('prepare',{cabinet:id,expected_session:session,game}))};$('prepareScenario').onclick=()=>{const scenario=$('scenario').value;review('Prepare this scenario?','Assignments will be sent to its cabinets. Configured activation outputs may run. Linked cabinets wait for the preceding win.',()=>command('prepare_scenario',{scenario}))};$('cancelConfirm').onclick=()=>{$('confirmDialog').close();confirmation=null};$('confirmForm').onsubmit=e=>{e.preventDefault();if(!connected||pending)return;const reason=$('reason').value.trim();if(!$('confirmReason').hidden&&reason.length<3){$('reason').setCustomValidity('Enter at least three characters.');$('reason').reportValidity();return}const fn=confirmation;confirmation=null;$('confirmDialog').close();fn?.(reason)};$('reason').oninput=()=>$('reason').setCustomValidity('');navigate('home');setInterval(refresh,1500);
const PACMAN_ART={"screen": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAOAAAAEgCAIAAAAi0Oi2AAAQAElEQVR4AeydTX7kthHFu+cmPYtcxBtvnVvkRL6FvfUmF8lCfRTl31MSVAII4pMgSFZ+z1ChUF94fFZr6JnJj9t5//f29g7c/bCBbDE8iJ819IuHI4G3Fae3SoysOUcS6VZJcVtnxPwEyNH51jMLdOVp/fx5BxKAAcSWJ83WQfxFqy5ComwxgNhS3G21kyPnxwB4BNgSiQHE6VY8p8RFBbr4LOXx88gXT+ucVAOS69XHD+TI1hgD5xcoshDEKFj0SwqrO8UWOE/MENlJsKyxyHw/dbxgPA7e0Zm25xcochEUPTZSvHg8As+/uJVIWQlASax1IBeQ66phA9nKyvasOL9A858cD5tgUQNGNagAwnSvPjEgDPM8koUzJ5iwk6FeoIcmgocN5AoYQGxRA1sgnopVF5E64qGUGDiB22qn9hMgkABsOcUA2Bp4TokzC5TnCtxjwwayxfAgftbQLx6OBN5WnN4qMW7Vp86J4fzYGuIXj7bxsGUNgf+UOLNAT/nArnapzgLVHzrYmk22DtqPLX4MDXGyaudsNuNp6PFifmLkCENDnKzaaXZPgQq57tMHcsXjDH2EU+BiZCsrTglmi806IWQwmZOVCcXjDJwCPA4uxnkwcLpIbDwGYaCnQB3FlNYsi80p/hChX+IlUk61R/wzrMwGZBI9odjuSALcGvolXgLkVHvEf9m1p0CFRMgFYgvdYuP8wNu7eM6xyqXkLle4r9x02NpZoDwtN7p+WjjZCrB1GNvjQl+E2+mLsBXg1GFsDfkM9BSoewzuweTPoSNJZ+uqYYsHYyq4CRkPVM8mua4adcSDYegpUMcmXDvgFLpDD0fixADa1iliEzAtZHJZGVIGli2r82CwBRgAA2AAnSI2TgMM9BQozIagB/D8eASen634WbEF2HNCxvNWGXXRyZHnZ4tTgC2Qra3CQE+BSkVbjYGODBxLoB0vbqWOwYAJ9BjP6bJTmkAv++iPcXET6DGe02WnNIFe9tEf4+Im0GM8p8tO+U2gj8f7WWH3OhAD+t/GD4HK9M/n3WAM7M6AqFFk+hIoe5lJXLYaA/syIGpElozxEihfHPAaihhw1C0aRaUsGAY8Gn/gQrDiFZutIZ8BSBP2wpWj/DoWKQxAmjDJFvvrOygbXHJmaz4DkAZ1YTxOjkK/edYZgDSoczFfAnUuM74xYJtdGTCB7kq/NU8xYAJNMWTnuzJgAt2VfmueYsAEmmLIzndlIC3Q93/+AuGQOMFx/eHkYzyQBsJeOIH5PQbSAvUSbJvNgAV2YCAt0Pvv/wZhK5zguP5w8jEeSANhL5zA/B4DaYF6CbY1BkYyYAIdybb1KmbABFpMmSWMZMAEOpJt61XMgAm0mLIBCdbCMZAWKC/ngEsYZtAUDGs3rBGXAsPaJRsxDEiGdQ+gKUiWTQs0WeLR8CeZksVXAlr6xnJX2g07is3W4m8Zfq++MnNaoLycAxIdrkz/bPiTTKSHNcVDUyB2uJLY0jeWS9mwV18PlwKxmgwQm63FT9lYR4YBsVMSd+nr5kkL1IWGhkwf+vM9XJ4i+fESSQqJYvddKUvxvjXzq9GaAfLj8yMpS/H8eIkkhUSx61bSKVKXK1lNApUSthoD2zGQECg/xjpsN8TpK7+/34B3TTzAc7ZuD5LvRIWxPvKaQL1kb7te104dA+sqXD91Rc5keELytt5NowJdTFt0ehVtG2NAa1HbsfhT+hcltOiU60cFKsc7rgwNdhygS+scIebEdBlmsQgkg8WjGZzzCnQGdmyG3RmYV6C8nAO7E9R3APlmKWvfytXVIBlUp2+dGBXo4tCLzq1HtPpDGBjUZFFCi04ZKCpQjr00b0uAoYKBqb59VszfnuIJydt69dcESijJDmwNpQzc7+mMnJh0lUNFOFFhrA+eEOh6sp0aA1sz0CTQ9v/Syn+opUjpJUkhsTQrJ56yFM+J1DGkkKg9bHGKZ/0bpD4lhUTJ6rtSluKlNUkhsTRLx5NOEe0ptdMC5SUZiNWlPUNUg/RYZZqC2CmJ1U1XEikb67juJ1GXZavjUSHQHmw8AEODRF2nl01Z3UXbkAy0R9sktsxAuq6mbZoC7Vm00wJdTNNOhqiGrlNqVzddSSydQcfrstrvbOSo4fyeoev0sr0WRdvkDCsBRY0Wg9MC5cdYsJi8qZOmoFsLfvH8iffbzaFb/Xih93/eHeJRu51AMhjfnqYg2Tct0GSJ2QNEl7+mfL/fwS/zYxGlfmx6f5EPR1019OhTs0MGzi5Q1Pl5aU+an+7XV2T6+tL1n7e3j6rPf33UdYY7+jiwL3EGTi3QT3W+PW9Onc+3d6AJYQs+1KQPGmw+08n+739vAMOBLWArARiGdQZOLdBfV0edv76+FoT4+nK7rRgSYOskDJxfoCHRv/12A9rPFmhPu/3430eNFeMj4rJfMi6eFigvq0BYCieY3x9OuO7hUmA9JvMUaQIdzBZoz7Q2JIBwPJxgO79XOS1QL+Ew288fQH8+bo+fH/9FXAx+Cnx7Lnh6/Ria+fNlZthhCN9m0LRAeVkFwu44wfx+b0I0CrSTLXAeLgXc9rIGJIDw+jjBdn6vclqgXoJtjYGRDJhAR7JtvYoZMIEWU2YJIxmICHTkCGN73T9/8SRtva04O6733z9+NSY1va04bV1h4LwCvX8q437/tG4vOd7v0e0KTyVHToViyEoBMWR1WwzDCgNpgfLSC6yU2OiIpqCpOFK83ykCECW44flVERv4219HXRZUCFwpbBDbOn+RwaVAUcpUwQwPkiOlBZosIb9Dp25NFl8J0B1XwpJHus5sdnL4lQB9l5Ww5JGuU2oniycD0gLlpReIFWLild+vmjwiPVaZpiB2SqIuzjYWSREQOyVR15nNZrzY5FwKxE5J1HdhG4tc95Oo65TapMfqMzyInTp/WqAuNDRoz8ShP99DOkXy4yWSFBLFlpUtTrHzV1JIzI8fH8l4DFnalxQSdRZbnNqTY5NCYk5kLIZ0isROc/xNAl1qYD5joCcDJtCebFqt7gyYQLtTagV7MtAk0PafMPgBhSI9L1RSi9YMUJIxOpbxGHJ0189+tGaAz13NV9IpUpP5mZMWKC+rwGe8/5X2DFEN0v2Kn3uags9d/VeKgFg+A1QPL4mxyuKXmOqV8aROuHIpEPr7ehigengSSY/Nw/Agdur8aYG60JjBENWI1Rzprx5eEnkMsWk5kpjqNVZ5pL96eBLb50wLlJdVoL1TsoIXQFPgOSu2FAHrie///OGwHhme8hgQYujHyVHo7+XhUqBXtfF1GB4k+6YFmixx6ADRpb5C6NGnZg9m4NICRYsxuleOYinm34KB6wrUSfD5r7+AJpctcAH6yOzBDFxXoEI0QkwaEmDrLgxcXaBC+m9D/qS89LK1iIG0QHlZBcKiOMF4f9jxtupiSLAaknVIEZAVuhpEERCG4ATt/rBCnYdhQJiLE2zn9yqnBeolnGPrfr58/O/f3Oj1J+X/fBnY2uPC8Bt2YSAtUF5WgXA4nGC8P+y47mFIsBKDIoEOYAu0B5siAKMRFAFhEZyg3R9WqPMwDAhzcYLt/F7ltEC9BNsaAyMZMIGOZNt6FTNgAi2mzBJGMnBxgb6ovv/+9+vL5z/e9tNtX/dh4KICdSoUQ1aegBiyui2GYS8GvgT6fN4fj15/A2GH6/CyDeQXKp0fFQJXHxvEts4/v8FDhIr554xN6M3/g8vgkmix2Q6D9O21ts/fa5KWOo3kQ0JL9zC3cZ7SdDc/idhf30FlMlwjwRDSN1x52QZC/7qncfiVedb79jplgMYr9JpE6rTPU3od6evWl0ApwRzAeYcZ0npYu2SjfefhETBAcshhAXvNQ18gVLwEyoXZALzDQNND42rDDxMGjZAiEIY/BCobvMPAHNLU1vkZ4GENEwaNNCHfBKoPzDYGZmDABPrtKci3im+ujA3/0pOoA9ni1J4cmxQScyKvEzOvQHkJCkqfBA+4BUiktKPEk6j7shV/6UqirlNhl3acPH5egVYQx+PkAbegoqlL0X2ds8LQdSpsSKhoOm3KvALlJSjIJ44Hw+PMj98tcuPGkAAVGzcZV35egY7jwDpNzIAJdOKHY6PdbiZQU8HUDJhAp348NpwJ1DQwNQPzCpSXoGBq8kYPd8V+8wr0ik/D7hwwMK9AeQkKgoHNcS0G5hXotZ6D3TbCgAk0Qoy552DABDrHc7ApIgyYQCPEnMt93NuYQI/77C4x+bwC5SUouMRDsEvGGZhXoPGZu508Hu/VKB2iuhGJpb3OFD+vQHkJCrbm+vm8V4Cp+O5eBFIqGpFC4pUxr0Cv/FTs7o4BE+iLCj5Gi0AO392LQEpRC4JJ2R97T/ADInbBbB9ezFOE0gdXVJzg0vqbxjPPLiKh6esvD6P9eGxKqBXvzsB4hUjHnT/i+VeEOUrZJIVEncUWp/Zc2YYKCNEMsMWpPTk2KSTmRG4Xs9tHPDcHUBC7m/waOXZKIukObGOR1/RDiCMHg20dDySSviN2+4jn5qCONcki3UE8tmoGHDkY2l9qk74jdv6IXyFLfo28EmBHczPQZ7p5Bdrnflbl4AyYQA/+AM8+vgn07E/44PczgR78AZ59fBPo2Z/wwe83r0DX34MenHYbf40BfTavQPWUZl+WgXkFau9BLytKffF5BaqnNPuyDJhAL/voj3FxE+gxntNlpzSBXvbRH+PiZQI9xp1syhMxMK9A7T3oiWRWf5Wdf8Ny/eC3m/5dtC11zprbix9dZ7y9829Y5sIxfay/ByVR/y5atrE61/RDSBd+vDq65hh75494LgkFpRoihUSdxRan9lzZhgoI0Qywxak9OTYpJOZEbhez20f8dleyylswgFh3waiP+OBvmOG2W/BYXZN5pkL1RbZIhBm+le6CnT/it2CzLine truncated
function renderCabinetArt(c){document.querySelector('.cabinet-visual').dataset.game=c.game;const active=c.online&&c.phase==='playing';cached('marquee',[c.game],e=>{e.replaceChildren();if(c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.marquee;img.alt='Pac-Man marquee';e.append(img)}else e.textContent=gameName(c.game)});cached('screenGame',[c.game,c.phase,c.online],e=>{e.replaceChildren();if(active&&c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.screen;img.alt='Pac-Man gameplay illustration (not live video)';e.append(img)}else e.textContent=!c.online?'Cabinet offline':active?gameName(c.game):['won','lost'].includes(c.phase)?phases[c.phase]:'Waiting for operator'});}
function renderFeedback(target='feedback'){const c=state.cabinets[selected];const f=c?.feedback;cached(target,[selected,f,c?.online],e=>{e.replaceChildren();if(!f?.devices?.length){e.append(node('p',f?.error||'No configured device feedback received.','muted'));return}for(const d of f.devices){const box=node('div','','feedback-item'+(d.value===true?' active':'')+(!c.online||!f.fresh||d.age_ms>6000?' stale':''));box.append(node('strong',d.name||d.id),node('p',(d.port||'')+' · '+d.kind+' · '+(!c.online||!f.fresh||d.age_ms>6000?'STALE / unavailable':d.error||(typeof d.value==='boolean'?(d.value?'Active':'Inactive'):String(d.value))),'small'));if(d.kind==='output')box.append(node('small','Electrical output state; physical movement is unconfirmed.'));e.append(box)}})}
</script></body></html> </script></body></html>
+170 -8
View File
@@ -3,7 +3,9 @@
import argparse import argparse
import hmac import hmac
import json import json
import math
import os import os
import re
from pathlib import Path from pathlib import Path
import sqlite3 import sqlite3
import time import time
@@ -12,6 +14,42 @@ from urllib.parse import urlparse
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
def hub_config(path=None):
default = dict(name="Escape Gallery", room_name="Glitch: the Dark Machine", services=[])
if path is None:
return default
data = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("Hub configuration must be an object")
for key in ("name", "room_name"):
value = data.get(key, default[key])
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 80:
raise ValueError("Invalid hub " + key)
default[key] = value.strip()
entries = data.get("services", [])
if not isinstance(entries, list) or len(entries) > 40:
raise ValueError("Hub services must be a list of up to 40 entries")
seen = set()
for entry in entries:
if not isinstance(entry, dict): raise ValueError("Invalid hub service")
ident, name, kind, url = (entry.get(k, "") for k in ("id", "name", "kind", "url"))
if not isinstance(ident, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", ident) or ident in seen:
raise ValueError("Invalid or duplicate hub service ID")
seen.add(ident)
if not isinstance(name, str) or not 1 <= len(name) <= 80 or kind not in ("room", "trivia", "service"):
raise ValueError("Invalid hub service name or kind")
if not isinstance(url, str): raise ValueError("Invalid hub service URL")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password or parsed.fragment or parsed.query or any(c.isspace() or ord(c) < 32 for c in url) or "\\" in url:
raise ValueError("Use an HTTP(S) service URL without credentials, query or fragment")
try: parsed.port
except ValueError: raise ValueError("Invalid hub service port")
description = entry.get("description", "")
if not isinstance(description, str) or len(description) > 240: raise ValueError("Invalid service description")
default["services"].append(dict(id=ident, name=name, kind=kind, url=url, description=description))
return default
class Room: class Room:
def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"): def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"):
self.clock = clock self.clock = clock
@@ -25,9 +63,13 @@ class Room:
self.state = json.loads(row[0]) if row else dict( self.state = json.loads(row[0]) if row else dict(
session="", phase="idle", reward="not requested", commands=[], seen=[], history=[]) session="", phase="idle", reward="not requested", commands=[], seen=[], history=[])
# Never carry live connectivity across a controller restart. # Never carry live connectivity across a controller restart.
self.feedback = {}
self.feedback_at = 0
self.feedback_seen = []
self.last_seen = 0 self.last_seen = 0
self.boot = self.state.get("boot", "") self.boot = self.state.get("boot", "")
self.version = "unknown" self.version = "unknown"
self.features = []
self.gpio = "unknown" self.gpio = "unknown"
self.state.setdefault("game", "pacman") self.state.setdefault("game", "pacman")
self.state.setdefault("mappings", {"game_won": ["coin_release"]}) self.state.setdefault("mappings", {"game_won": ["coin_release"]})
@@ -47,8 +89,42 @@ class Room:
def snapshot(self): def snapshot(self):
self.expire() self.expire()
return dict(self.state, online=self.online(), cabinet=self.cabinet_id, return dict(self.state, online=self.online(), cabinet=self.cabinet_id,
version=self.version, gpio=self.gpio, version=self.version, gpio=self.gpio, features=self.features,
last_seen=self.last_seen, protocol=2) last_seen=self.last_seen, protocol=2, feedback=self.feedback_snapshot())
def feedback_snapshot(self):
result = dict(self.feedback)
age = max(0, self.clock() - self.feedback_at)
result['fresh'] = bool(result.get('fresh') and self.online() and age < 4)
result['devices'] = [dict(d, age_ms=d['age_ms'] + int(age*1000)) for d in result.get('devices', [])]
return result
def receive_feedback(self, payload):
if not isinstance(payload, dict): return
devices, events = payload.get('devices', []), payload.get('events', [])
if not isinstance(devices, list) or len(devices) > 32 or not isinstance(events, list) or len(events) > 64: return
clean = []
for d in devices:
if not isinstance(d, dict) or d.get('kind') not in ('input', 'output', 'rfid'): continue
ident = d.get('id')
if not isinstance(ident, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', ident): continue
value = d.get('value')
if value is not None and type(value) is not bool and not isinstance(value, str): continue
age = d.get('age_ms', 999999)
if type(age) not in (int, float) or not math.isfinite(age) or age < 0: continue
clean.append(dict(id=ident, name=str(d.get('name',ident))[:100], port=str(d.get('port',''))[:8], kind=d['kind'], value=value[:64] if isinstance(value,str) else value, error=str(d.get('error',''))[:120], age_ms=min(int(age),999999)))
fresh = payload.get('fresh') is True and self.gpio == 'real'
self.feedback = dict(devices=clean, fresh=fresh)
self.feedback_at = self.clock()
if not fresh: return
known = {d['id'] for d in clean}
for event in events:
if not isinstance(event, dict): continue
ident = event.get('id')
if not isinstance(ident,str) or len(ident)>100 or ident in self.feedback_seen or event.get('device') not in known: continue
self.feedback_seen.append(ident)
self.feedback_seen = self.feedback_seen[-256:]
self.log('Device '+event['device']+': '+str(event.get('error') or event.get('value'))[:120])
def expire(self): def expire(self):
for command in self.state["commands"]: for command in self.state["commands"]:
@@ -65,7 +141,7 @@ class Room:
command = dict(id=str(uuid.uuid4()), session=self.state["session"], type=kind, command = dict(id=str(uuid.uuid4()), session=self.state["session"], type=kind,
status="queued", expires=self.clock() + 15) status="queued", expires=self.clock() + 15)
if kind == "prepare": if kind == "prepare":
command.update(game=self.state["game"], variant=self.state.get("variant", "standard")) command.update(game=self.state["game"], variant=self.state.get("variant", "standard"), game_config=self.state.get("game_config", {}))
self.state["commands"].append(command) self.state["commands"].append(command)
self.log("Queued " + kind) self.log("Queued " + kind)
return command return command
@@ -82,6 +158,8 @@ class Room:
reward="not requested", commands=[], seen=[]) reward="not requested", commands=[], seen=[])
self.command(action) self.command(action)
elif action == "start": elif action == "start":
if self.state.get("game_config", {}).get("rfid_credit_device"):
raise ValueError("Scan a card and press Start on the cabinet for this RFID scenario")
if self.state["phase"] != "ready": raise ValueError("Prepare the cabinet first") if self.state["phase"] != "ready": raise ValueError("Prepare the cabinet first")
self.state["phase"] = "starting" self.state["phase"] = "starting"
self.command("start") self.command("start")
@@ -103,7 +181,9 @@ class Room:
self.state["boot"] = boot self.state["boot"] = boot
self.last_seen = self.clock() self.last_seen = self.clock()
self.version = str(data.get("version", "unknown"))[:80] self.version = str(data.get("version", "unknown"))[:80]
self.features = data.get("features", []) if isinstance(data.get("features", []), list) else []
self.gpio = str(data.get("gpio", "unknown"))[:100] self.gpio = str(data.get("gpio", "unknown"))[:100]
self.receive_feedback(data.get("feedback", {}))
self.expire() self.expire()
reports = data.get("reports", []) reports = data.get("reports", [])
if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports") if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports")
@@ -170,7 +250,8 @@ class Room:
class Fleet: class Fleet:
"""Editor catalog plus independently acknowledged cabinet sessions.""" """Editor catalog plus independently acknowledged cabinet sessions."""
def __init__(self, database, catalog, clock=time.time): def __init__(self, database, catalog, clock=time.time, hub=None):
self.hub = hub if hub is not None else hub_config()
self.catalog = catalog self.catalog = catalog
self.rooms = {key: Room(database, clock, key) for key in catalog["cabinets"]} self.rooms = {key: Room(database, clock, key) for key in catalog["cabinets"]}
@@ -187,7 +268,7 @@ class Fleet:
compatible_games=[g for g in self.catalog["games"] if self.compatible(key, g)]) compatible_games=[g for g in self.catalog["games"] if self.compatible(key, g)])
# Retain the single-cabinet desktop UI response while the browser offers the fleet view. # Retain the single-cabinet desktop UI response while the browser offers the fleet view.
first = cabinets.get("cabinet_01", next(iter(cabinets.values()))) first = cabinets.get("cabinet_01", next(iter(cabinets.values())))
return dict(first, cabinets=cabinets, catalog=self.catalog) return dict(first, cabinets=cabinets, catalog=self.catalog, hub=self.hub)
def validate_assignment(self, item): def validate_assignment(self, item):
cabinet, game = item.get("cabinet"), item.get("game") cabinet, game = item.get("cabinet"), item.get("game")
@@ -195,6 +276,39 @@ class Fleet:
raise ValueError("Unknown cabinet or game") raise ValueError("Unknown cabinet or game")
if not self.compatible(cabinet, game): raise ValueError(f"{game} cannot run on {cabinet}: hardware requirements not met") if not self.compatible(cabinet, game): raise ValueError(f"{game} cannot run on {cabinet}: hardware requirements not met")
if item.get("variant", "standard") != "standard": raise ValueError("Only standard variants are supported by this runtime") if item.get("variant", "standard") != "standard": raise ValueError("Only standard variants are supported by this runtime")
settings = item.get("game_config", {})
if not isinstance(settings, dict): raise ValueError("game_config must be an object")
limits = {"test_auto_win_seconds": (0.1, 300)}
limits.update({"galaga": {"score_to_win": (0, 1000000), "stages_to_win": (0, 100)},
"frogger": {"homes_to_win": (0, 5), "boards_to_win": (0, 99)},
"pacman": {"boards_to_win": (0, 100), "score_to_win": (0, 1000000)}}.get(game, {}))
credit_device = settings.get("rfid_credit_device")
if credit_device is not None:
if game != "pacman" or not isinstance(credit_device, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", credit_device):
raise ValueError("Invalid RFID credit device")
if not any(d.get("id") == credit_device and d.get("kind") == "rfid" for d in self.catalog["cabinets"][cabinet].get("devices", [])):
raise ValueError("RFID credit device is missing from the cabinet PCB inventory")
if "rfid_credit_v1" not in self.rooms[cabinet].features:
raise ValueError("Update the game export: RFID credits require arcade-cabinet-2.3")
if self.rooms[cabinet].gpio != "real":
raise ValueError("RFID credits require real GPIO feedback")
if item.get("activate_after_win"):
raise ValueError("RFID credit scenarios require player Start, not automatic start")
if "rfid_allowed_uids" in settings:
uids = settings["rfid_allowed_uids"]
if credit_device is None or not isinstance(uids, list) or len(uids) > 100 or any(not isinstance(uid, str) or not re.fullmatch(r"[0-9A-F]{8}", uid) for uid in uids):
raise ValueError("RFID allowed UIDs must be a list of uppercase 8-digit hexadecimal IDs")
for key, value in settings.items():
if key in ("rfid_credit_device", "rfid_allowed_uids"): continue
if key not in limits or type(value) not in (int, float) or not math.isfinite(value):
raise ValueError("Unsupported game objective: " + str(key))
low, high = limits[key]
if not low <= value <= high or (key != "test_auto_win_seconds" and value != int(value)):
raise ValueError("Invalid game objective: " + key)
if settings and not self.rooms[cabinet].online():
raise ValueError(f"{cabinet} is offline; check its controller URL, cabinet ID and token before preparing objectives")
if settings and "assignment_config_v1" not in self.rooms[cabinet].features:
raise ValueError(f"Update the game export on {cabinet}: connected agent {self.rooms[cabinet].version} does not advertise assignment objectives (requires arcade-cabinet-2.1)")
allowed = set(self.catalog["cabinets"][cabinet]["actions"]) allowed = set(self.catalog["cabinets"][cabinet]["actions"])
for event, actions in item.get("mappings", {}).items(): for event, actions in item.get("mappings", {}).items():
if event not in ("game_activated", "game_started", "game_won", "game_lost"): if event not in ("game_activated", "game_started", "game_won", "game_lost"):
@@ -210,7 +324,13 @@ class Fleet:
def operator(self, action, data=None): def operator(self, action, data=None):
data = data or {} data = data or {}
if action == "prepare_scenario": if "expected_session" in data:
cabinet = data.get("cabinet")
if cabinet not in self.rooms or self.rooms[cabinet].state["session"] != data["expected_session"]:
raise ValueError("Cabinet session changed; review its current state before sending a command")
if action == "trigger_output":
self.trigger_output(data)
elif action == "prepare_scenario":
scenario_id = data.get("scenario") scenario_id = data.get("scenario")
scenario = self.catalog["scenarios"].get(scenario_id) scenario = self.catalog["scenarios"].get(scenario_id)
if not scenario: raise ValueError("Unknown scenario") if not scenario: raise ValueError("Unknown scenario")
@@ -256,10 +376,47 @@ class Fleet:
self.advance() self.advance()
return self.snapshot() return self.snapshot()
def trigger_output(self, data):
cabinet, action_id = data.get("cabinet"), data.get("action_id")
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
room = self.rooms[cabinet]
request_id = data.get("request_id", "")
reason = data.get("reason", "")
if not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{16,100}", request_id):
raise ValueError("Manual hardware command requires a unique request ID")
if not isinstance(reason, str) or not 3 <= len(reason.strip()) <= 200:
raise ValueError("Enter a reason (3–200 characters) for the hardware action")
if data.get("expected_session") != room.state["session"]:
raise ValueError("Cabinet session changed; review before triggering hardware")
if action_id not in self.catalog["cabinets"][cabinet]["actions"]:
raise ValueError("Hardware action is not configured for this cabinet")
receipts = room.state.setdefault("manual_requests", {})
if request_id in receipts:
if receipts[request_id] != [action_id, room.state["session"]]:
raise ValueError("Request ID was already used for another action/session")
return # A lost HTTP reply must not cause a second physical action.
if len(receipts) >= 10000:
raise ValueError("Manual action history limit reached; contact the administrator")
room.expire()
if not room.online(): raise ValueError("Cabinet is offline; no command sent")
if room.gpio != "real": raise ValueError("Cabinet must report real GPIO mode")
if not room.state["session"] or room.state["phase"] not in ("ready", "playing", "won", "lost"):
raise ValueError("Prepare the cabinet before triggering its hardware")
if any(c["status"] == "queued" for c in room.state["commands"]):
raise ValueError("Wait for the pending command or its timeout")
command = room.command("output")
command["action_id"] = action_id
command["source"] = "operator"
room.state["last_output"] = "requested"
receipts[request_id] = [action_id, room.state["session"]]
# Refuse new requests rather than evict deduplication receipts during a session.
room.log("Operator triggered " + str(action_id) + ": " + reason.strip())
room.save()
def prepare(self, item, scenario): def prepare(self, item, scenario):
room = self.rooms[item["cabinet"]] room = self.rooms[item["cabinet"]]
room.state.update(game=item["game"], mappings=item.get("mappings", {}), room.state.update(game=item["game"], mappings=item.get("mappings", {}),
variant=item.get("variant", "standard"), scenario=scenario) variant=item.get("variant", "standard"), game_config=item.get("game_config", {}), scenario=scenario)
room.state.pop("gate", None) room.state.pop("gate", None)
room.state.pop("auto_start", None) room.state.pop("auto_start", None)
room.operator("reset" if item.get("activate_after_win") else "prepare") room.operator("reset" if item.get("activate_after_win") else "prepare")
@@ -363,6 +520,7 @@ def main():
parser.add_argument("--port", type=int, default=8766) parser.add_argument("--port", type=int, default=8766)
parser.add_argument("--database", default="arcade_room.sqlite3") parser.add_argument("--database", default="arcade_room.sqlite3")
parser.add_argument("--catalog", default=str(Path(__file__).with_name("catalog.json"))) parser.add_argument("--catalog", default=str(Path(__file__).with_name("catalog.json")))
parser.add_argument("--hub-config", help="Optional operations hub names and service links")
args = parser.parse_args() args = parser.parse_args()
operator = os.environ.get("ARCADE_OPERATOR_TOKEN", "") operator = os.environ.get("ARCADE_OPERATOR_TOKEN", "")
cabinet = os.environ.get("ARCADE_CABINET_TOKEN", "") cabinet = os.environ.get("ARCADE_CABINET_TOKEN", "")
@@ -375,7 +533,11 @@ def main():
if not isinstance(tokens, dict) or not tokens or len(operator) < 16 or any(not isinstance(t, str) or len(t) < 16 or t == operator for t in tokens.values()) or len(set(tokens.values())) != len(tokens): if not isinstance(tokens, dict) or not tokens or len(operator) < 16 or any(not isinstance(t, str) or len(t) < 16 or t == operator for t in tokens.values()) or len(set(tokens.values())) != len(tokens):
parser.error("Set an operator token and unique cabinet tokens (16+ characters); use ARCADE_CABINET_TOKEN for cabinet_01 or ARCADE_CABINET_TOKENS JSON for multiple cabinets") parser.error("Set an operator token and unique cabinet tokens (16+ characters); use ARCADE_CABINET_TOKEN for cabinet_01 or ARCADE_CABINET_TOKENS JSON for multiple cabinets")
catalog = json.loads(Path(args.catalog).read_text(encoding="utf-8")) catalog = json.loads(Path(args.catalog).read_text(encoding="utf-8"))
room = Fleet(Path(args.database), catalog) try:
hub = hub_config(args.hub_config)
except (OSError, ValueError) as exc:
parser.error(str(exc))
room = Fleet(Path(args.database), catalog, hub=hub)
server = HTTPServer((args.bind, args.port), handler_for(room, operator, tokens)) server = HTTPServer((args.bind, args.port), handler_for(room, operator, tokens))
print(f"Arcade controller ready at http://{args.bind}:{args.port}/ — {len(room.rooms)} cabinets", flush=True) print(f"Arcade controller ready at http://{args.bind}:{args.port}/ — {len(room.rooms)} cabinets", flush=True)
try: server.serve_forever() try: server.serve_forever()
+79 -11
View File
@@ -82,6 +82,8 @@ var highlighted_bindings: Dictionary = {}
var binding_labels: Array[Label] = [] var binding_labels: Array[Label] = []
var show_connections: bool = true var show_connections: bool = true
var show_labels: bool = true
var show_dimensions: bool = true
var selected_binding_key: String = "" var selected_binding_key: String = ""
@@ -178,6 +180,7 @@ func _rebuild() -> void:
_build_cabinets() _build_cabinets()
_build_devices() _build_devices()
_build_binding_labels() _build_binding_labels()
set_labels_visible(show_labels)
queue_redraw() queue_redraw()
@@ -976,7 +979,7 @@ func set_connections_visible(
show_connections = visible show_connections = visible
for label in binding_labels: for label in binding_labels:
label.visible = visible label.visible = visible and show_labels
queue_redraw() queue_redraw()
@@ -1703,7 +1706,7 @@ func _draw_dimension_label(
text: String text: String
) -> void: ) -> void:
if text.is_empty(): if not show_dimensions or text.is_empty():
return return
var font := ( var font := (
@@ -2075,7 +2078,7 @@ func _draw_walls() -> void:
if room == null: if room == null:
return return
for wall in room.walls: for wall in _ordered_walls():
if wall == null: if wall == null:
continue continue
@@ -2111,13 +2114,7 @@ func _draw_walls() -> void:
width += 3.0 width += 3.0
draw_line( _draw_wall_or_door(wall, start, end, wall_color, width)
start,
end,
wall_color,
width,
true
)
# #
# Wall dimension. # Wall dimension.
@@ -2867,7 +2864,9 @@ func _find_wall_at_screen_position(
var hit_radius_pixels: float = 10.0 var hit_radius_pixels: float = 10.0
for wall in room.walls: var ordered := _ordered_walls()
ordered.reverse()
for wall in ordered:
if wall == null: if wall == null:
continue continue
@@ -3987,3 +3986,72 @@ func _get_cabinet_profile(
return assignment.cabinet return assignment.cabinet
return null return null
func set_labels_visible(value: bool) -> void:
show_labels = value
for item in item_nodes.values():
item.title_label.visible = value
item.subtitle_label.visible = value
item.tooltip_text = item.base_title + "\n" + item.base_subtitle
for label in binding_labels:
label.visible = value and show_connections
queue_redraw()
func set_dimensions_visible(value: bool) -> void:
show_dimensions = value
queue_redraw()
func _ordered_walls() -> Array[RoomWallDefinition]:
var ordered: Array[RoomWallDefinition] = []
for wall in room.walls:
if wall != null and not wall.is_doorway:
ordered.append(wall)
for wall in room.walls:
if wall != null and wall.is_doorway:
ordered.append(wall)
return ordered
# Openings are projected onto collinear wall segments without destroying geometry.
func _wall_solid_ranges(wall: RoomWallDefinition) -> Array[Vector2]:
var ranges: Array[Vector2] = [Vector2(0, 1)]
var delta := wall.end - wall.start
if delta.length_squared() < 0.000001:
return ranges
for door in room.walls:
if door == null or not door.is_doorway:
continue
var tolerance := maxf(wall.thickness * 0.5, 0.02)
if absf(delta.cross(door.start - wall.start)) / delta.length() > tolerance or absf(delta.cross(door.end - wall.start)) / delta.length() > tolerance:
continue
var a := delta.dot(door.start - wall.start) / delta.length_squared()
var b := delta.dot(door.end - wall.start) / delta.length_squared()
var low := minf(a, b)
var high := maxf(a, b)
var next: Array[Vector2] = []
for segment in ranges:
if high <= segment.x or low >= segment.y:
next.append(segment)
else:
if low > segment.x:
next.append(Vector2(segment.x, low))
if high < segment.y:
next.append(Vector2(high, segment.y))
ranges = next
return ranges
func _draw_wall_or_door(wall: RoomWallDefinition, start: Vector2, end: Vector2, color: Color, width: float) -> void:
if not wall.is_doorway:
for segment in _wall_solid_ranges(wall):
draw_line(start.lerp(end, segment.x), start.lerp(end, segment.y), color, width, true)
return
var direction := end - start
if direction.length() < 1.0:
return
var angle := direction.angle()
var swing := -PI / 2.0 if wall.swing_reversed else PI / 2.0
var door_color := Color("67d5c5") if wall.wall_id != selected_wall_id else color
draw_dashed_line(start, end, door_color, 1.5, 5.0)
draw_line(start, start + direction.rotated(swing), door_color, 2.0, true)
draw_arc(start, direction.length(), minf(angle, angle + swing), maxf(angle, angle + swing), 32, door_color, 1.5, true)
draw_circle(start, 4.0, door_color)