diff --git a/README.md b/README.md index 1b9ed53..0c69518 100644 --- a/README.md +++ b/README.md @@ -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 -| | Game | Cabinet controls | +| | Game | Controls | | --- | --- | --- | | ![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 | @@ -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 | | ![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 | -| ![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 - -- **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 +## System layout ```text -Godot development computer - Escape Room editor + game/cabinet/scenario resources - | | - | game export | exported catalog.json - v v -Raspberry Pi cabinets ------> Room controller on Mac mini / PC - games + saved controls Python + persistent SQLite state - | ^ - v | -Local GPIO bridge Browser operator on Mac / PC / iPad - | -Physical outputs +Development PC: Godot editor + saved resources + | ARM64 game/PCK + Python tools | matching catalog + operator assets + v v +Pi cabinets <--------------------> Master controller + | Python + SQLite + | / \ +Local GPIO bridge Private LAN Venue LAN + | | +PCB inputs / RFID / outputs Staff browsers ``` -| 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 | -| Arcade Launcher | Select games and configure physical controls | Desktop or Pi cabinet | -| 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 | +| **8766** | Controller API and operator webpage | Master private IP for cabinets; master venue IP for staff | +| **8765** | GPIO bridge | Cabinet-local `127.0.0.1` only | -## 1. Run the project locally +## Develop and play locally -1. Clone this repository and import **project.godot** into Godot. -2. Use the project's configured **Godot 4.7** version and **Compatibility** renderer. Allow the initial asset import to finish. -3. Run the project with **F5**, or open [ArcadeLauncher.tscn](games/launcher/ArcadeLauncher.tscn) and use **F6**. -4. Complete **Control Setup** if prompted, then select a game and press Start. +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. Run the main project or [ArcadeLauncher.tscn](games/launcher/ArcadeLauncher.tscn). +3. Complete **Control Setup** and select a game. Review saved editor run arguments if the project starts in a test/managed mode instead. +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. -- **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. +Open the **Escape Room** main-screen editor tab. Its plugin is enabled in the project; check Project Settings > Plugins if the tab is missing. -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. - -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 | +| Resource | Purpose | | --- | --- | -| [Project database](data/project_database.tres) | Register cabinets, games, and scenarios | -| [Room layout](data/rooms/arcade_room.tres) | Boundary, walls, cabinet placements, and room devices | -| [Cabinet profiles](data/cabinets/) | Unique cabinet IDs, available controls, and hardware configurations | -| [Game definitions](data/games/) | Game scenes, control requirements, and exposed events | -| [Scenarios](data/scenarios/) | Cabinet/game assignments and event-to-hardware mappings | +| [Project database](data/project_database.tres) | Register games, cabinets, scenarios | +| [Physical room](data/rooms/arcade_room.tres) | Boundary, walls, doorways, placements, devices | +| [Cabinet profiles](data/cabinets/) | Unique IDs, control capabilities, hardware actions, PCB connections | +| [Game definitions](data/games/) | Scenes, required controls, game events | +| [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`. -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. +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. -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 -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))" ``` -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 -[network] -url="http://CONTROLLER_IP:8766" -cabinet_id="cabinet_01" -cabinet_token="your-generated-cabinet-one-token" -``` +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. -Use that cabinet's own ID and token. CFG strings use **double quotes**. +## Exports, startup, and deployment -| Machine | Typical configuration directory | -| --- | --- | -| Raspberry Pi / Linux | `~/.local/share/godot/app_userdata/ArcadeEscape/` | -| Windows | `%APPDATA%/Godot/app_userdata/ArcadeEscape/` | +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: -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 -./ArcadeEscape.arm64 -- --managed-cabinet --cabinet=cabinet_01 -``` +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. -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 - -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 -``` +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. ## 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 python3 -m unittest discover -s tests/room_network -v python3 -m unittest discover -s tests/gpio -v -godot --headless --path . --script tests/room_network/check_capabilities.gd -godot --headless --path . --script tests/launcher/check_launcher.gd -godot --headless --path . --script tests/launcher/check_controls.gd -python3 tests/room_network/check_integration.py /path/to/godot +godot --headless --path . --script res://tests/pcb/check_pcb.gd +godot --headless --path . --script res://tests/pcb/check_rfid_credit.gd +godot --headless --path . --script res://tests/room_editor/check_room_view.gd +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. diff --git a/addons/escape_room/escape_room_plugin.gd b/addons/escape_room/escape_room_plugin.gd index 4a93631..a314aee 100644 --- a/addons/escape_room/escape_room_plugin.gd +++ b/addons/escape_room/escape_room_plugin.gd @@ -15,15 +15,20 @@ const ROOM_SETTING := ( ) +var pcb_inspector: EditorInspectorPlugin var workspace: EscapeRoomWorkspace func _enter_tree() -> void: + pcb_inspector = preload("res://addons/escape_room/pcb_inspector.gd").new() + add_inspector_plugin(pcb_inspector) _ensure_project_settings() _create_workspace() func _exit_tree() -> void: + remove_inspector_plugin(pcb_inspector) + pcb_inspector = null _destroy_workspace() diff --git a/addons/escape_room/pcb_inspector.gd b/addons/escape_room/pcb_inspector.gd new file mode 100644 index 0000000..40278bb --- /dev/null +++ b/addons/escape_room/pcb_inspector.gd @@ -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 diff --git a/addons/escape_room/pcb_inspector.gd.uid b/addons/escape_room/pcb_inspector.gd.uid new file mode 100644 index 0000000..5cdf569 --- /dev/null +++ b/addons/escape_room/pcb_inspector.gd.uid @@ -0,0 +1 @@ +uid://dxenrkrq6ko7e diff --git a/core/hardware/cabinet_pcb_config.gd b/core/hardware/cabinet_pcb_config.gd new file mode 100644 index 0000000..5cc3efa --- /dev/null +++ b/core/hardware/cabinet_pcb_config.gd @@ -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 diff --git a/core/hardware/cabinet_pcb_config.gd.uid b/core/hardware/cabinet_pcb_config.gd.uid new file mode 100644 index 0000000..827f111 --- /dev/null +++ b/core/hardware/cabinet_pcb_config.gd.uid @@ -0,0 +1 @@ +uid://c0g0mq4b3d547 diff --git a/core/hardware/cabinet_profile.gd b/core/hardware/cabinet_profile.gd index 5842e74..f38792c 100644 --- a/core/hardware/cabinet_profile.gd +++ b/core/hardware/cabinet_profile.gd @@ -10,6 +10,10 @@ extends Resource @export var hardware_config: HardwareConfig +@export_group("PCB Connections") +@export var pcb_config: CabinetPCBConfig +@export_group("") + const FEET_TO_METERS: float = 0.3048 const METERS_TO_FEET: float = 1.0 / FEET_TO_METERS diff --git a/core/hardware/pcb_connection.gd b/core/hardware/pcb_connection.gd new file mode 100644 index 0000000..46690cc --- /dev/null +++ b/core/hardware/pcb_connection.gd @@ -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 diff --git a/core/hardware/pcb_connection.gd.uid b/core/hardware/pcb_connection.gd.uid new file mode 100644 index 0000000..098605f --- /dev/null +++ b/core/hardware/pcb_connection.gd.uid @@ -0,0 +1 @@ +uid://burui20kuayc2 diff --git a/core/network/managed_cabinet.gd b/core/network/managed_cabinet.gd index a6a9f68..91a68e5 100644 --- a/core/network/managed_cabinet.gd +++ b/core/network/managed_cabinet.gd @@ -1,5 +1,5 @@ extends Node -const VERSION := "arcade-cabinet-2" +const VERSION := "arcade-cabinet-2.3" var cabinet_id := "cabinet_01" var database: ProjectDatabase = preload("res://data/project_database.tres") var cabinet: CabinetProfile @@ -14,6 +14,14 @@ var busy := false var executing := false var event_number := 0 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: var config := ConfigFile.new() @@ -47,9 +55,13 @@ func _ready() -> void: func poll() -> void: if busy or executing: return 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 result: Dictionary = await connection.post("/cabinet/poll", { "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, }) busy = false @@ -70,6 +82,7 @@ func poll() -> void: notice.text = "Waiting for the room operator" if game == null else "" func unload() -> void: + test_win_remaining = -1.0 Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if is_instance_valid(game): remove_child(game) @@ -77,6 +90,10 @@ func unload() -> void: game = null 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 reports.append({"kind": "event", "id": boot + ":" + str(event_number), "session": session, "event": name}) 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) Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if str(definition.game_id) in ["missile_command", "tempest"] else Input.MOUSE_MODE_VISIBLE 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_won.connect(func(): event("won")) game.game_lost.connect(func(): event("lost")) diff --git a/core/rooms/room_wall_definition.gd b/core/rooms/room_wall_definition.gd index 4e4d391..19a110f 100644 --- a/core/rooms/room_wall_definition.gd +++ b/core/rooms/room_wall_definition.gd @@ -7,6 +7,9 @@ const FEET_TO_METERS: float = 0.3048 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 start: Vector2 = Vector2.ZERO @export var end: Vector2 = Vector2( diff --git a/core/runtime/pi_gpio_client.gd b/core/runtime/pi_gpio_client.gd index 858c918..66f4292 100644 --- a/core/runtime/pi_gpio_client.gd +++ b/core/runtime/pi_gpio_client.gd @@ -5,6 +5,8 @@ const URL := "http://127.0.0.1:8765" var pending: Array[Dictionary] = [] var serial := 0 var busy := false +var device_feedback: Dictionary = {} +var feedback_received_ms: int = 0 var heartbeat: HTTPRequest func _ready() -> void: @@ -12,6 +14,7 @@ func _ready() -> void: heartbeat = HTTPRequest.new() heartbeat.timeout = 1.0 add_child(heartbeat) + heartbeat.request_completed.connect(_feedback_response) var timer := Timer.new() timer.wait_time = 1.0 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) completed.emit(item.id, ok) _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 diff --git a/core/scenarios/scenario_assignment.gd b/core/scenarios/scenario_assignment.gd index 69142f1..8b6f6d7 100644 --- a/core/scenarios/scenario_assignment.gd +++ b/core/scenarios/scenario_assignment.gd @@ -9,6 +9,8 @@ extends Resource @export var variant: StringName = &"standard" ## Managed controller: load and auto-start only after this cabinet wins. @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] = [] diff --git a/core/scenarios/scenario_edit_session.gd b/core/scenarios/scenario_edit_session.gd index 0d05c6d..e284ffd 100644 --- a/core/scenarios/scenario_edit_session.gd +++ b/core/scenarios/scenario_edit_session.gd @@ -154,6 +154,7 @@ func _create_working_copy() -> void: ) assignment.activate_after_win = source_assignment.activate_after_win + assignment.game_config = source_assignment.game_config.duplicate(true) assignment.event_mappings = ( _duplicate_mappings( @@ -260,6 +261,7 @@ func _capture_scenario_state( "game_id": StringName(), "variant": assignment.variant, "activate_after_win": assignment.activate_after_win, + "game_config": assignment.game_config.duplicate(true), "event_mappings": [], } diff --git a/core/simulation/room_edit_session.gd b/core/simulation/room_edit_session.gd index cd96f66..d7cd56d 100644 --- a/core/simulation/room_edit_session.gd +++ b/core/simulation/room_edit_session.gd @@ -401,6 +401,8 @@ func _capture_room_state( "start": wall.start, "end": wall.end, "thickness": wall.thickness, + "is_doorway": wall.is_doorway, + "swing_reversed": wall.swing_reversed, } ) diff --git a/data/cabinets/cabinet_01.tres b/data/cabinets/cabinet_01.tres index 9b0730a..7edef58 100644 --- a/data/cabinets/cabinet_01.tres +++ b/data/cabinets/cabinet_01.tres @@ -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="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://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] script = ExtResource("1_v1x8f") cabinet_id = &"cabinet_01" -display_name = "Master Cabinet" -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"]) +display_name = "Cabinet 01" +capabilities = Array[StringName]([&"joystick_4way", &"start_button", &"select_button"]) hardware_config = ExtResource("1_b4cnm") +pcb_config = SubResource("Resource_npy45") footprint_width_m = 0.9144000000000001 footprint_depth_m = 0.9144000000000001 height_m = 1.8288000000000002 diff --git a/data/cabinets/cabinet_01_hardware.tres b/data/cabinets/cabinet_01_hardware.tres index 0d3aefb..182c124 100644 --- a/data/cabinets/cabinet_01_hardware.tres +++ b/data/cabinets/cabinet_01_hardware.tres @@ -5,16 +5,18 @@ [sub_resource type="Resource" id="Resource_ogdj5"] script = ExtResource("1_w3p60") -action_id = &"coin_release" -display_name = "Coin Release" +action_id = &"coin_slot_1" +display_name = "Coin Slot" +action_type = 0 +gpio_pin = 0 pulse_duration_ms = 600 metadata/_custom_type_script = "uid://c7eu1n5yey1x4" [sub_resource type="Resource" id="Resource_gu74o"] script = ExtResource("1_w3p60") -action_id = &"start_light" +action_id = &"lock_released" display_name = "Start Light" -gpio_pin = 20 +gpio_pin = 0 metadata/_custom_type_script = "uid://c7eu1n5yey1x4" [resource] diff --git a/data/cabinets/cabinet_02.tres b/data/cabinets/cabinet_02.tres index 6031849..2c4d3c7 100644 --- a/data/cabinets/cabinet_02.tres +++ b/data/cabinets/cabinet_02.tres @@ -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="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://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] script = ExtResource("2_lrfm7") cabinet_id = &"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") +pcb_config = SubResource("Resource_npy45") footprint_width_m = 0.9144000000000001 footprint_depth_m = 0.9144000000000001 height_m = 1.8288000000000002 diff --git a/data/cabinets/cabinet_02_hardware.tres b/data/cabinets/cabinet_02_hardware.tres index abde4a9..7bf86bc 100644 --- a/data/cabinets/cabinet_02_hardware.tres +++ b/data/cabinets/cabinet_02_hardware.tres @@ -11,23 +11,15 @@ gpio_pin = 27 pulse_duration_ms = 400 metadata/_custom_type_script = "uid://c7eu1n5yey1x4" -[sub_resource type="Resource" id="Resource_dasqc"] +[sub_resource type="Resource" id="Resource_gu74o"] script = ExtResource("1_dasqc") -action_id = &"start_light" +action_id = &"lock_released" display_name = "Start Light" -gpio_pin = 20 -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 +gpio_pin = 0 metadata/_custom_type_script = "uid://c7eu1n5yey1x4" [resource] script = ExtResource("2_ye2fg") 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" diff --git a/data/cabinets/cabinet_03.tres b/data/cabinets/cabinet_03.tres index a6c75e1..7f492d7 100644 --- a/data/cabinets/cabinet_03.tres +++ b/data/cabinets/cabinet_03.tres @@ -1,15 +1,20 @@ [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="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"] [resource] script = ExtResource("1_0s4iw") cabinet_id = &"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") -footprint_width_m = 1.0 -footprint_depth_m = 1.0 -height_m = 2.0 +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_km331") metadata/_custom_type_script = "uid://c0jj5j5loqsma" diff --git a/data/cabinets/cabinet_03_hardware.tres b/data/cabinets/cabinet_03_hardware.tres index c414537..f788e13 100644 --- a/data/cabinets/cabinet_03_hardware.tres +++ b/data/cabinets/cabinet_03_hardware.tres @@ -5,14 +5,21 @@ [sub_resource type="Resource" id="Resource_ogdj5"] script = ExtResource("1_2vvyl") -action_id = &"coin_release" -display_name = "Coin Release" +action_id = &"coin_slot_1" +display_name = "Coin Slot 1" +action_type = 0 gpio_pin = 27 pulse_duration_ms = 400 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] script = ExtResource("2_hx0bc") -controller_id = &"cabinet_02_gpio" -actions = Array[ExtResource("1_2vvyl")]([SubResource("Resource_ogdj5")]) +controller_id = &"cabinet_03_gpio" +actions = Array[ExtResource("1_2vvyl")]([SubResource("Resource_ogdj5"), SubResource("Resource_ojmvu")]) metadata/_custom_type_script = "uid://60whm57itpjb" diff --git a/data/cabinets/cabinet_04.tres b/data/cabinets/cabinet_04.tres new file mode 100644 index 0000000..bbf172a --- /dev/null +++ b/data/cabinets/cabinet_04.tres @@ -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" diff --git a/data/cabinets/cabinet_04_hardware.tres b/data/cabinets/cabinet_04_hardware.tres new file mode 100644 index 0000000..65be608 --- /dev/null +++ b/data/cabinets/cabinet_04_hardware.tres @@ -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" diff --git a/data/project_database.tres b/data/project_database.tres index 6e1f791..a0a10aa 100644 --- a/data/project_database.tres +++ b/data/project_database.tres @@ -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://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://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" 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/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://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] script = ExtResource("4_algy0") -scenarios = Array[ExtResource("3_uw7gr")]([ExtResource("7_gbm0a"), ExtResource("8_ib4xl"), ExtResource("10_b427t"), ExtResource("chain_test")]) -cabinets = Array[ExtResource("1_uu70x")]([ExtResource("2_t6ujr"), ExtResource("3_bfrqh"), ExtResource("4_gbm0a")]) +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"), 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")]) metadata/_custom_type_script = "uid://diqnrxrhwx2ug" diff --git a/data/rooms/arcade_room.tres b/data/rooms/arcade_room.tres index 2b9743a..681b057 100644 --- a/data/rooms/arcade_room.tres +++ b/data/rooms/arcade_room.tres @@ -52,13 +52,25 @@ metadata/_custom_type_script = "uid://r2rpbasss511" [sub_resource type="Resource" id="Resource_prnev"] script = ExtResource("2_384hm") cabinet_id = &"cabinet_01" -position = Vector2(-5.9484997, 0.14750004) +position = Vector2(-6.5578, 1.9821999) metadata/_custom_type_script = "uid://cwblyetugbr4d" [sub_resource type="Resource" id="Resource_dicmx"] script = ExtResource("2_384hm") 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" [sub_resource type="Resource" id="Resource_vv75m"] @@ -156,7 +168,7 @@ thickness_ft = 0.1 script = ExtResource("3_384hm") room_id = &"arcade_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")]) 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) diff --git a/data/scenarios/cabinet_chain_test.tres b/data/scenarios/cabinet_chain_test.tres index 667f859..d582e0c 100644 --- a/data/scenarios/cabinet_chain_test.tres +++ b/data/scenarios/cabinet_chain_test.tres @@ -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" 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/cabinets/cabinet_02.tres" id="4"] -[ext_resource type="Resource" path="res://data/games/pacman.tres" id="5"] -[ext_resource type="Resource" path="res://data/games/galaga.tres" id="6"] +[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_mhdgo"] +[ext_resource type="Resource" uid="uid://d1gyyfaaclas8" path="res://data/cabinets/cabinet_02.tres" id="4"] +[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"] script = ExtResource("2") diff --git a/data/scenarios/eg_glitch.tres b/data/scenarios/eg_glitch.tres new file mode 100644 index 0000000..0577cd2 --- /dev/null +++ b/data/scenarios/eg_glitch.tres @@ -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" diff --git a/data/scenarios/pacman_rfid_credit_test.tres b/data/scenarios/pacman_rfid_credit_test.tres new file mode 100644 index 0000000..7f74674 --- /dev/null +++ b/data/scenarios/pacman_rfid_credit_test.tres @@ -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")]) diff --git a/data/scenarios/three_cabinet_objective_test.tres b/data/scenarios/three_cabinet_objective_test.tres new file mode 100644 index 0000000..f56fdb4 --- /dev/null +++ b/data/scenarios/three_cabinet_objective_test.tres @@ -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")]) diff --git a/docs/cabinet_pcb_setup.md b/docs/cabinet_pcb_setup.md new file mode 100644 index 0000000..ce3a585 --- /dev/null +++ b/docs/cabinet_pcb_setup.md @@ -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. diff --git a/docs/master_pi_deployment_design.md b/docs/master_pi_deployment_design.md new file mode 100644 index 0000000..52478fa --- /dev/null +++ b/docs/master_pi_deployment_design.md @@ -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). diff --git a/docs/pacman_rfid_first_setup.md b/docs/pacman_rfid_first_setup.md new file mode 100644 index 0000000..0fb2867 --- /dev/null +++ b/docs/pacman_rfid_first_setup.md @@ -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). diff --git a/docs/remote_pi_start.md b/docs/remote_pi_start.md new file mode 100644 index 0000000..69ef411 --- /dev/null +++ b/docs/remote_pi_start.md @@ -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). diff --git a/docs/three_cabinet_test.md b/docs/three_cabinet_test.md new file mode 100644 index 0000000..5e8e625 --- /dev/null +++ b/docs/three_cabinet_test.md @@ -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 +``` diff --git a/editor/hardware/pcb_editor_property.gd b/editor/hardware/pcb_editor_property.gd new file mode 100644 index 0000000..c0ab213 --- /dev/null +++ b/editor/hardware/pcb_editor_property.gd @@ -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() diff --git a/editor/hardware/pcb_editor_property.gd.uid b/editor/hardware/pcb_editor_property.gd.uid new file mode 100644 index 0000000..3ed135f --- /dev/null +++ b/editor/hardware/pcb_editor_property.gd.uid @@ -0,0 +1 @@ +uid://pqhm21y6t7c6 diff --git a/editor/workspace/escape_room_workspace.gd b/editor/workspace/escape_room_workspace.gd index aee7ee0..221dd64 100644 --- a/editor/workspace/escape_room_workspace.gd +++ b/editor/workspace/escape_room_workspace.gd @@ -231,7 +231,12 @@ var simulation_driver := EditorSimulationDriver.new() var editor_selection := EscapeRoomEditorSelection.new() +var doorway_tool: Button +var doorway_check: CheckBox +var swing_check: CheckBox + func _ready() -> void: + _setup_view_controls() select_tool_button.button_pressed = true @@ -1503,6 +1508,9 @@ func _on_show_connections_check_box_toggled( ) func _clear_property_panel() -> void: + if doorway_check != null: + doorway_check.disabled = true + swing_check.disabled = true selected_label.text = ( "Nothing selected" @@ -1668,6 +1676,7 @@ func _on_wall_creation_requested( wall.start = start wall.end = end wall.thickness = 0.10 + wall.is_doorway = doorway_tool.button_pressed if undo_redo == null: _add_wall( @@ -1828,10 +1837,15 @@ func _refresh_wall_property_panel( return selected_label.text = ( - "Wall: %s" + ("Doorway: %s" if wall.is_doorway else "Wall: %s") % 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 * METERS_TO_FEET ) @@ -3001,3 +3015,68 @@ func _on_room_layout_refresh_requested() -> void: status_label.text = ( "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) diff --git a/games/frogger/frogger.gd b/games/frogger/frogger.gd index 758afae..a2335c1 100644 --- a/games/frogger/frogger.gd +++ b/games/frogger/frogger.gd @@ -1,6 +1,8 @@ @tool extends ArcadeGame @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(15,120) var crossing_time: float = 60.0 @export_range(.5,2) var difficulty: float = 1.0 @@ -139,7 +141,10 @@ func _physics_process(delta: float) -> void: phase = "play" if music_enabled and not demo_active: $Audio.music() "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) phase = "clear" phase_time = 2.0 @@ -149,7 +154,7 @@ func _physics_process(delta: float) -> void: "clear": if demo_active: 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) else: board_number += 1 diff --git a/games/pacman/pacman.gd b/games/pacman/pacman.gd index 2d2306c..f0240c7 100644 --- a/games/pacman/pacman.gd +++ b/games/pacman/pacman.gd @@ -13,6 +13,40 @@ var model = Model.new() var view var audio 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: view = View.new() @@ -26,9 +60,15 @@ func _ready() -> void: func configure(config: Dictionary = {}) -> void: super.configure(config) + credits = 0 + rfid_seen.clear() + rfid_initialized = false _apply_config() 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.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) @@ -36,6 +76,9 @@ func _apply_config() -> void: func start_game() -> bool: 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() audio.stop_all() model.start() @@ -43,6 +86,8 @@ func start_game() -> bool: return super.start_game() func reset_game() -> void: + credits = 0 + if view != null: view.credits = 0 audio.stop_all() model.reset() accumulator = 0.0 diff --git a/games/pacman/pacman_view.gd b/games/pacman/pacman_view.gd index ebde2f5..9e4ec0e 100644 --- a/games/pacman/pacman_view.gd +++ b/games/pacman/pacman_view.gd @@ -10,6 +10,8 @@ const DEATH_ANIMATION_SECONDS := 1.55 const DEATH_POP_SECONDS := 0.24 var model var debug_grid := false +var credit_required := false +var credits := 0 func _ready() -> void: texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST @@ -50,12 +52,13 @@ func _draw() -> void: _text("HIGH SCORE", Vector2(80, 0)) _text(str(model.score).pad_zeros(2), Vector2(8, 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)): _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: "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)) "ready": _banner("READY!", Color.YELLOW) diff --git a/setup/pc/ArcadeEscape.pck b/setup/pc/ArcadeEscape.pck index 6cdf308..a69b707 100644 Binary files a/setup/pc/ArcadeEscape.pck and b/setup/pc/ArcadeEscape.pck differ diff --git a/setup/pi/ArcadeEscape.pck b/setup/pi/ArcadeEscape.pck index 6cdf308..a69b707 100644 Binary files a/setup/pi/ArcadeEscape.pck and b/setup/pi/ArcadeEscape.pck differ diff --git a/setup/pi/arcade_network.cfg b/setup/pi/arcade_network_c1.cfg similarity index 100% rename from setup/pi/arcade_network.cfg rename to setup/pi/arcade_network_c1.cfg diff --git a/setup/pi/arcade_network_c3.cfg b/setup/pi/arcade_network_c3.cfg new file mode 100644 index 0000000..4f2be01 --- /dev/null +++ b/setup/pi/arcade_network_c3.cfg @@ -0,0 +1,4 @@ +[network] +url="http://10.0.0.57:8766" +cabinet_id="cabinet_03" +cabinet_token="qZSD9eM2dmeRtmVA" \ No newline at end of file diff --git a/tests/gpio/test_feedback.py b/tests/gpio/test_feedback.py new file mode 100644 index 0000000..fac18fe --- /dev/null +++ b/tests/gpio/test_feedback.py @@ -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() diff --git a/tests/pcb/check_pcb.gd b/tests/pcb/check_pcb.gd new file mode 100644 index 0000000..a279f79 --- /dev/null +++ b/tests/pcb/check_pcb.gd @@ -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) diff --git a/tests/pcb/check_pcb.gd.uid b/tests/pcb/check_pcb.gd.uid new file mode 100644 index 0000000..732e881 --- /dev/null +++ b/tests/pcb/check_pcb.gd.uid @@ -0,0 +1 @@ +uid://cbtyukgnhlhts diff --git a/tests/pcb/check_rfid_credit.gd b/tests/pcb/check_rfid_credit.gd new file mode 100644 index 0000000..8fa477a --- /dev/null +++ b/tests/pcb/check_rfid_credit.gd @@ -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) diff --git a/tests/room_editor/check_room_view.gd b/tests/room_editor/check_room_view.gd new file mode 100644 index 0000000..21fcf01 --- /dev/null +++ b/tests/room_editor/check_room_view.gd @@ -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) diff --git a/tests/room_editor/check_room_view.gd.uid b/tests/room_editor/check_room_view.gd.uid new file mode 100644 index 0000000..deaefc1 --- /dev/null +++ b/tests/room_editor/check_room_view.gd.uid @@ -0,0 +1 @@ +uid://0p3i2u8rm2x4 diff --git a/tests/room_network/catalog_fixture.json b/tests/room_network/catalog_fixture.json new file mode 100644 index 0000000..3a9e395 --- /dev/null +++ b/tests/room_network/catalog_fixture.json @@ -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 +} \ No newline at end of file diff --git a/tests/room_network/check_objective_defaults.gd b/tests/room_network/check_objective_defaults.gd new file mode 100644 index 0000000..bc7c050 --- /dev/null +++ b/tests/room_network/check_objective_defaults.gd @@ -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() diff --git a/tests/room_network/check_objective_defaults.gd.uid b/tests/room_network/check_objective_defaults.gd.uid new file mode 100644 index 0000000..49b43e7 --- /dev/null +++ b/tests/room_network/check_objective_defaults.gd.uid @@ -0,0 +1 @@ +uid://di2ndju42rh4t diff --git a/tests/room_network/check_objective_integration.py b/tests/room_network/check_objective_integration.py new file mode 100644 index 0000000..09c1781 --- /dev/null +++ b/tests/room_network/check_objective_integration.py @@ -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() 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() diff --git a/tests/room_network/objective_agent.gd b/tests/room_network/objective_agent.gd new file mode 100644 index 0000000..8f9db6b --- /dev/null +++ b/tests/room_network/objective_agent.gd @@ -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) diff --git a/tests/room_network/objective_agent.gd.uid b/tests/room_network/objective_agent.gd.uid new file mode 100644 index 0000000..147b32d --- /dev/null +++ b/tests/room_network/objective_agent.gd.uid @@ -0,0 +1 @@ +uid://drmhfkifu2igv diff --git a/tests/room_network/test_deploy.py b/tests/room_network/test_deploy.py new file mode 100644 index 0000000..72a952c --- /dev/null +++ b/tests/room_network/test_deploy.py @@ -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() diff --git a/tests/room_network/test_feedback.py b/tests/room_network/test_feedback.py new file mode 100644 index 0000000..13843a8 --- /dev/null +++ b/tests/room_network/test_feedback.py @@ -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() diff --git a/tests/room_network/test_fleet.py b/tests/room_network/test_fleet.py index dfd3cc2..4a4baa4 100644 --- a/tests/room_network/test_fleet.py +++ b/tests/room_network/test_fleet.py @@ -13,7 +13,8 @@ import unittest class FleetTests(unittest.TestCase): def setUp(self): self.now = 100 - self.catalog = json.loads((ROOT / 'tools/room_controller/catalog.json').read_text()) + # Fixed historical test room: designer edits must not alter protocol assertions. + self.catalog = json.loads(Path(__file__).with_name('catalog_fixture.json').read_text(encoding='utf-8')) self.fleet = server.Fleet(':memory:', self.catalog, lambda: self.now) for cabinet in self.fleet.rooms: self.poll(cabinet) @@ -54,6 +55,9 @@ class FleetTests(unittest.TestCase): self.assertFalse(self.poll('cabinet_01')['commands']) def test_compatibility_rejects_missing_controls_and_accepts_alternatives(self): + # Explicit fixture: actual cabinet hardware can evolve independently. + self.catalog['cabinets']['cabinet_01']['capabilities'] = ['joystick_4way', 'button_1', 'start_button'] + self.catalog['cabinets']['cabinet_03']['capabilities'] = ['spinner', 'button_1', 'start_button'] with self.assertRaisesRegex(ValueError, 'hardware'): self.fleet.operator('prepare', {'cabinet': 'cabinet_01', 'game': 'missile_command'}) cabinet = self.catalog['cabinets']['cabinet_03'] diff --git a/tests/room_network/test_hub.py b/tests/room_network/test_hub.py new file mode 100644 index 0000000..f1ccc08 --- /dev/null +++ b/tests/room_network/test_hub.py @@ -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') diff --git a/tests/room_network/test_objectives.py b/tests/room_network/test_objectives.py new file mode 100644 index 0000000..5a3f743 --- /dev/null +++ b/tests/room_network/test_objectives.py @@ -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') diff --git a/tests/room_network/test_remote_start.py b/tests/room_network/test_remote_start.py new file mode 100644 index 0000000..660549c --- /dev/null +++ b/tests/room_network/test_remote_start.py @@ -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()) diff --git a/tools/deploy/.gitignore b/tools/deploy/.gitignore new file mode 100644 index 0000000..d84309b --- /dev/null +++ b/tools/deploy/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +site/ +*.tar.gz diff --git a/tools/deploy/README.md b/tools/deploy/README.md new file mode 100644 index 0000000..c6cdf64 --- /dev/null +++ b/tools/deploy/README.md @@ -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. diff --git a/tools/deploy/deploy.py b/tools/deploy/deploy.py new file mode 100644 index 0000000..062dc45 --- /dev/null +++ b/tools/deploy/deploy.py @@ -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()) diff --git a/tools/deploy/receiver.py b/tools/deploy/receiver.py new file mode 100644 index 0000000..e00de70 --- /dev/null +++ b/tools/deploy/receiver.py @@ -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()) diff --git a/tools/launch/README.md b/tools/launch/README.md index 7cb0135..7d989b7 100644 --- a/tools/launch/README.md +++ b/tools/launch/README.md @@ -1,5 +1,7 @@ # 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). 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). diff --git a/tools/launch/controller.example.ini b/tools/launch/controller.example.ini deleted file mode 100644 index 561246d..0000000 --- a/tools/launch/controller.example.ini +++ /dev/null @@ -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 = {} diff --git a/tools/launch/launch.py b/tools/launch/launch.py index 14df4e6..e6e635b 100644 --- a/tools/launch/launch.py +++ b/tools/launch/launch.py @@ -70,6 +70,10 @@ def plan(config_path, mode): url_host = '127.0.0.1' if bind == '0.0.0.0' else bind 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)) + 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'): executable = path('cabinet', 'executable', '../../ArcadeEscape.exe' if os.name == 'nt' else '../../ArcadeEscape.arm64') 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 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') - pins = [int(p.strip()) for p in value('gpio', 'pins', '17').split(',')] - 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') + pins = [int(p.strip()) for p in value('gpio', 'pins', '17').split(',') if p.strip()] + 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' require_file(script) ledger = path('gpio', 'ledger', '~/.local/state/arcade_gpio/requests.sqlite3') 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)) + 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] if real_gpio: args.append('--gpio=real') 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") -def run(jobs, logs, open_browser=False): +def run(jobs, logs, open_browser=False, remote_targets=None): check_ports(jobs) logs.mkdir(parents=True, exist_ok=True) children, files = [], [] @@ -144,6 +156,9 @@ def run(jobs, logs, open_browser=False): if job['name'] == 'controller': print('Operator: '+job['ready'], flush=True) 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) while True: for job, process in children: @@ -178,12 +193,25 @@ def main(): try: print('Launcher configuration: '+str(Path(args.config).resolve()), flush=True) 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: check_ports(jobs) print('Configuration and ports OK. Planned services: '+', '.join(j['name'] for j in jobs)) print('Logs: '+str(logs)) + print('Remote start targets (not contacted by --check): '+', '.join(t['id'] for t in remote_targets)) return 0 - return run(jobs, logs, args.browser) + return run(jobs, logs, args.browser, remote_targets) except (ValueError, OSError, RuntimeError, configparser.Error) as exc: print('Startup failed: '+str(exc), file=sys.stderr) return 1 diff --git a/tools/launch/launcher-pc.ini b/tools/launch/launcher-pc.ini new file mode 100644 index 0000000..bcf5ada --- /dev/null +++ b/tools/launch/launcher-pc.ini @@ -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"} diff --git a/tools/launch/launcher-pi.ini b/tools/launch/launcher-pi.ini new file mode 100644 index 0000000..377db83 --- /dev/null +++ b/tools/launch/launcher-pi.ini @@ -0,0 +1,7 @@ +[launcher] +logs = logs + +[cabinet] +id = cabinet_03 +url = 10.0.0.57 +token = qZSD9eM2dmeRtmVA \ No newline at end of file diff --git a/tools/launch/pi_service.py b/tools/launch/pi_service.py new file mode 100644 index 0000000..ff4a281 --- /dev/null +++ b/tools/launch/pi_service.py @@ -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()) diff --git a/tools/launch/remote.example.ini b/tools/launch/remote.example.ini new file mode 100644 index 0000000..efe4256 --- /dev/null +++ b/tools/launch/remote.example.ini @@ -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"}] diff --git a/tools/launch/remote_cabinets.py b/tools/launch/remote_cabinets.py new file mode 100644 index 0000000..3ea4bdc --- /dev/null +++ b/tools/launch/remote_cabinets.py @@ -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()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.') diff --git a/tools/pi_gpio/pi_gpio_bridge.py b/tools/pi_gpio/pi_gpio_bridge.py index c2a66c5..b5941b9 100644 --- a/tools/pi_gpio/pi_gpio_bridge.py +++ b/tools/pi_gpio/pi_gpio_bridge.py @@ -103,7 +103,7 @@ class Outputs: self.ledger.close() -def handler_for(outputs): +def handler_for(outputs, feedback=None): class Handler(BaseHTTPRequestHandler): def do_POST(self): try: @@ -115,7 +115,7 @@ def handler_for(outputs): data = json.loads(self.rfile.read(length) or b"{}") if self.path == "/heartbeat": outputs.heartbeat() - result = {"ok": True} + result = {"ok": True, "feedback": feedback.snapshot() if feedback else {"devices": [], "events": []}} elif self.path == "/output": result = outputs.command(data) print("GPIO:", data, flush=True) @@ -142,14 +142,19 @@ def main(): parser.add_argument("--controller", default="cabinet_01_gpio") 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("--devices", help="Explicit PCB feedback JSON inventory") + parser.add_argument("--rfid-helper", help="Built arcade-rfid helper executable") args = parser.parse_args() - pins = [int(n) for n in args.pins.split(",")] - if not pins or any(pin < 0 or pin > 27 for pin in pins): + pins = [int(n) for n in args.pins.split(",") if n.strip()] + 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") - 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) 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 stop = threading.Event() signal.signal(signal.SIGTERM, lambda *_: stop.set()) @@ -158,6 +163,7 @@ def main(): def watchdog(): while not stop.wait(.02): outputs.tick() + feedback.tick() worker = threading.Thread(target=watchdog, daemon=True) worker.start() @@ -168,6 +174,7 @@ def main(): finally: stop.set() worker.join() + feedback.close() outputs.close() server.server_close() diff --git a/tools/pi_gpio/rfid_helper/Cargo.toml b/tools/pi_gpio/rfid_helper/Cargo.toml new file mode 100644 index 0000000..c1ae7a5 --- /dev/null +++ b/tools/pi_gpio/rfid_helper/Cargo.toml @@ -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" diff --git a/tools/pi_gpio/rfid_helper/src/main.rs b/tools/pi_gpio/rfid_helper/src/main.rs new file mode 100644 index 0000000..57eeee3 --- /dev/null +++ b/tools/pi_gpio/rfid_helper/src/main.rs @@ -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 = arg.split(',').map(str::parse).collect::>()?; + 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 { + 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()?; + } + } +} diff --git a/tools/room_controller/README.md b/tools/room_controller/README.md index ce1df38..646b442 100644 --- a/tools/room_controller/README.md +++ b/tools/room_controller/README.md @@ -165,3 +165,40 @@ Automated two-client check (no GPIO; wins forced only by the test harness): ```sh python3 tests/room_network/check_chain_integration.py /path/to/godot ``` + + +## 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. diff --git a/tools/room_controller/art/pacman_marquee.png b/tools/room_controller/art/pacman_marquee.png new file mode 100644 index 0000000..fb6b18e Binary files /dev/null and b/tools/room_controller/art/pacman_marquee.png differ diff --git a/tools/room_controller/art/pacman_marquee.png.import b/tools/room_controller/art/pacman_marquee.png.import new file mode 100644 index 0000000..5f9bb2c --- /dev/null +++ b/tools/room_controller/art/pacman_marquee.png.import @@ -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 diff --git a/tools/room_controller/art/pacman_screen.png b/tools/room_controller/art/pacman_screen.png new file mode 100644 index 0000000..75cee18 Binary files /dev/null and b/tools/room_controller/art/pacman_screen.png differ diff --git a/tools/room_controller/art/pacman_screen.png.import b/tools/room_controller/art/pacman_screen.png.import new file mode 100644 index 0000000..14b76e7 --- /dev/null +++ b/tools/room_controller/art/pacman_screen.png.import @@ -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 diff --git a/tools/room_controller/catalog.json b/tools/room_controller/catalog.json index b6781c9..ac606c5 100644 --- a/tools/room_controller/catalog.json +++ b/tools/room_controller/catalog.json @@ -2,59 +2,162 @@ "cabinets": { "cabinet_01": { "actions": [ - "coin_release", - "start_light" + "coin_slot_1", + "lock_released" ], "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" + "select_button" ], - "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": [ - -5.94849967956543, - 0.147500038146973 + -6.55779981613159, + 1.98219990730286 ] }, "cabinet_02": { "actions": [ "coin_release", - "start_light", - "glitch_code" + "lock_released" ], "capabilities": [ - "joystick_4way", - "button_1", + "joystick_horizontal", "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", "position": [ - -3.51539182662964, - 0.142745211720467 + -2.89779996871948, + 1.98219990730286 ] }, "cabinet_03": { "actions": [ - "coin_release" + "coin_slot_1", + "lock_released" ], "capabilities": [ "spinner", "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" ], - "name": "Cabinet 03", - "position": null + "devices": [], + "name": "Cabinet 4", + "position": [ + -6.55779981613159, + 3.20219993591309 + ] } }, "games": { @@ -252,6 +355,7 @@ "activate_after_win": "", "cabinet": "cabinet_01", "game": "pacman", + "game_config": {}, "mappings": {}, "variant": "standard" }, @@ -259,43 +363,106 @@ "activate_after_win": "cabinet_01", "cabinet": "cabinet_02", "game": "galaga", + "game_config": {}, "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": { + "eg_glitch": { "assignments": [ { "activate_after_win": "", "cabinet": "cabinet_01", "game": "pacman", - "mappings": { - "game_won": [ - "coin_release" - ] - }, + "game_config": {}, + "mappings": {}, + "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" } ], - "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": { "assignments": [ @@ -303,6 +470,7 @@ "activate_after_win": "", "cabinet": "cabinet_01", "game": "pacman", + "game_config": {}, "mappings": { "game_started": [ "start_light" @@ -317,6 +485,7 @@ "activate_after_win": "", "cabinet": "cabinet_02", "game": "galaga", + "game_config": {}, "mappings": { "game_activated": [ "glitch_code" diff --git a/tools/room_controller/export_catalog.gd b/tools/room_controller/export_catalog.gd index e48c2d9..58457e3 100644 --- a/tools/room_controller/export_catalog.gd +++ b/tools/room_controller/export_catalog.gd @@ -14,6 +14,7 @@ func run() -> void: for action in cabinet.hardware_config.actions: actions.append(str(action.action_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)]["devices"] = cabinet.pcb_config.feedback_devices() if cabinet.pcb_config else [] for scenario in database.scenarios: var assignments: Array = [] for assignment in scenario.assignments: @@ -22,7 +23,7 @@ func run() -> void: for mapping in assignment.event_mappings: if not mappings.has(str(mapping.game_event)): mappings[str(mapping.game_event)] = [] mappings[str(mapping.game_event)].append_array(Array(mapping.hardware_actions)) - assignments.append({"cabinet": str(assignment.cabinet.cabinet_id), "game": str(assignment.game.game_id), "variant": str(assignment.variant), "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} 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]]) diff --git a/tools/room_controller/operator.html b/tools/room_controller/operator.html index 5b6aaf7..8c5a02c 100644 --- a/tools/room_controller/operator.html +++ b/tools/room_controller/operator.html @@ -1,28 +1,46 @@ - - -Arcade · Room operator +Escape Gallery · Operations -
ARCADE ESCAPE / OPERATIONS

Room control

Not connected
-
-

Connect to this room

Use the operator token configured on this controller. It stays in memory until this page closes or locks.

-
diff --git a/tools/room_controller/server.py b/tools/room_controller/server.py index 1e0735a..0c07974 100644 --- a/tools/room_controller/server.py +++ b/tools/room_controller/server.py @@ -3,7 +3,9 @@ import argparse import hmac import json +import math import os +import re from pathlib import Path import sqlite3 import time @@ -12,6 +14,42 @@ from urllib.parse import urlparse 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: def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"): self.clock = clock @@ -25,9 +63,13 @@ class Room: self.state = json.loads(row[0]) if row else dict( session="", phase="idle", reward="not requested", commands=[], seen=[], history=[]) # Never carry live connectivity across a controller restart. + self.feedback = {} + self.feedback_at = 0 + self.feedback_seen = [] self.last_seen = 0 self.boot = self.state.get("boot", "") self.version = "unknown" + self.features = [] self.gpio = "unknown" self.state.setdefault("game", "pacman") self.state.setdefault("mappings", {"game_won": ["coin_release"]}) @@ -47,8 +89,42 @@ class Room: def snapshot(self): self.expire() return dict(self.state, online=self.online(), cabinet=self.cabinet_id, - version=self.version, gpio=self.gpio, - last_seen=self.last_seen, protocol=2) + version=self.version, gpio=self.gpio, features=self.features, + 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): for command in self.state["commands"]: @@ -65,7 +141,7 @@ class Room: command = dict(id=str(uuid.uuid4()), session=self.state["session"], type=kind, status="queued", expires=self.clock() + 15) 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.log("Queued " + kind) return command @@ -82,6 +158,8 @@ class Room: reward="not requested", commands=[], seen=[]) self.command(action) 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") self.state["phase"] = "starting" self.command("start") @@ -103,7 +181,9 @@ class Room: self.state["boot"] = boot self.last_seen = self.clock() 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.receive_feedback(data.get("feedback", {})) self.expire() reports = data.get("reports", []) if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports") @@ -170,7 +250,8 @@ class Room: class Fleet: """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.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)]) # Retain the single-cabinet desktop UI response while the browser offers the fleet view. 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): cabinet, game = item.get("cabinet"), item.get("game") @@ -195,6 +276,39 @@ class Fleet: 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 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"]) for event, actions in item.get("mappings", {}).items(): if event not in ("game_activated", "game_started", "game_won", "game_lost"): @@ -210,7 +324,13 @@ class Fleet: def operator(self, action, data=None): 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 = self.catalog["scenarios"].get(scenario_id) if not scenario: raise ValueError("Unknown scenario") @@ -256,10 +376,47 @@ class Fleet: self.advance() 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): room = self.rooms[item["cabinet"]] 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("auto_start", None) 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("--database", default="arcade_room.sqlite3") 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() operator = os.environ.get("ARCADE_OPERATOR_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): 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")) - 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)) print(f"Arcade controller ready at http://{args.bind}:{args.port}/ — {len(room.rooms)} cabinets", flush=True) try: server.serve_forever() diff --git a/ui/room_layout.gd b/ui/room_layout.gd index 92dfca0..d373dd5 100644 --- a/ui/room_layout.gd +++ b/ui/room_layout.gd @@ -82,6 +82,8 @@ var highlighted_bindings: Dictionary = {} var binding_labels: Array[Label] = [] var show_connections: bool = true +var show_labels: bool = true +var show_dimensions: bool = true var selected_binding_key: String = "" @@ -178,6 +180,7 @@ func _rebuild() -> void: _build_cabinets() _build_devices() _build_binding_labels() + set_labels_visible(show_labels) queue_redraw() @@ -976,7 +979,7 @@ func set_connections_visible( show_connections = visible for label in binding_labels: - label.visible = visible + label.visible = visible and show_labels queue_redraw() @@ -1703,7 +1706,7 @@ func _draw_dimension_label( text: String ) -> void: - if text.is_empty(): + if not show_dimensions or text.is_empty(): return var font := ( @@ -2075,7 +2078,7 @@ func _draw_walls() -> void: if room == null: return - for wall in room.walls: + for wall in _ordered_walls(): if wall == null: continue @@ -2111,13 +2114,7 @@ func _draw_walls() -> void: width += 3.0 - draw_line( - start, - end, - wall_color, - width, - true - ) + _draw_wall_or_door(wall, start, end, wall_color, width) # # Wall dimension. @@ -2867,7 +2864,9 @@ func _find_wall_at_screen_position( 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: continue @@ -3987,3 +3986,72 @@ func _get_cabinet_profile( return assignment.cabinet 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)