Compare commits

..
5 Commits
Author SHA1 Message Date
Jeff Luke e58ceeb192 Added chatterbox connections 2026-10-18 10:54:38 -07:00
Jeff Luke 41ef12f954 Add Pac-Man power-on flow and document Odin integration
- Add a four-second arcade-style boot sequence followed by the
  Character / Nickname introduction.
- Separate cabinet power-on from RFID credit and player Start.
- Add operator Power on control and cabinet power-state reporting.
- Prevent repeated power commands from restarting boot or gameplay.
- Ignore RFID scans received before the cabinet is ready.
- Add power-state, RFID, controller, and GPIO feedback regression tests.

- Expand the master walkthrough with export, packaging, file transfer,
  fresh-Pi setup, RFID compilation, and end-to-end testing instructions.
- Correct tester hardware documentation: BCM23/J20 drives an LED;
  physical buttons connect through a separate I2C1 input board.
- Document operator-triggered power-on as the current test workflow
  and retain the temporary 2.4 configuration compatibility notes.
- Record successful physical testing of power-on, RFID credit,
  and player Start.

- Add an Odin integration README describing the proposed Rust/NATS
  bridge, shared messages, Flutter GM interface, autopilot sequencing,
  hardware ownership, and Starfish SSH-management role.
- Define migration milestones and information needed from the
  framework owner; link the integration guide from the root README.

Validation: 69 controller tests, 10 GPIO tests, Pac-Man power/RFID
checks, managed-cabinet lifecycle checks, and visual screen inspection.
2026-10-17 18:47:26 -07:00
Jeff Luke c1b2195834 Expand Glitch operations hub, cabinet authoring, deployment, and RFID support
Operator hub
- Introduce Escape Gallery branding and Glitch: the Dark Machine room views
- Add cabinet illustrations, Pac-Man marquee/gameplay artwork, and waiting states
- Add device feedback panels with RFID readings, input/output states, and stale status
- Add logged operator hardware interventions with request deduplication

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

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

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

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

Known limitations
- RFID helper compilation and physical Pi commissioning remain unverified
- Fleet provisioning does not yet configure RFID-only installations completely
- Broader Pac-Man tests retain a ghost-capture assertion failure
- Staged exports must be rebuilt and checked before deploying runtime 2.3
2026-10-17 14:53:54 -07:00
Jeff Luke 705ec31e0b updated gitignore to ignore the godot executables 2026-10-07 06:06:26 -07:00
Jeff Luke 0dcada4db2 Added setup folder with needed launch files - Update when new version is ready 2026-10-07 06:06:03 -07:00
194 changed files with 18084 additions and 375 deletions

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
# Godot 4+ specific ignores
.godot/
/android/
Godot_v4.7.2-stable_win64_console.exe
Godot_v4.7.2-stable_win64.exe
+158 -196
View File
@@ -1,14 +1,34 @@
# 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 |
| --- | --- |
| Plan integration with Rust/NATS, Odin autopilot, and Flutter | [Target architecture and next requirements](docs/odin_integration/README.md) |
| 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,258 +36,200 @@ 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.
```sh
godot --headless --path . --script tools/room_controller/export_catalog.gd
```
### Visual PCB connections
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.
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.
## 3. Export and run a Pi cabinet
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.
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:
## Operator hub and managed play
```sh
chmod +x ArcadeEscape.arm64
./ArcadeEscape.arm64
```
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.
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.
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.
For gameplay-only testing, no controller service or GPIO bridge is required. Real GPIO remains opt-in through `--gpio=real`.
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.
## 4. Start the room controller and browser operator
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.
Copy these three files together to the controller machine:
### Pac-Man RFID credit scenario
- [server.py](tools/room_controller/server.py)
- [operator.html](tools/room_controller/operator.html)
- [catalog.json](tools/room_controller/catalog.json)
Select **Cabinet 1: Pac-Man RFID credit** (`pacman_rfid_credit_test`):
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.
1. Prepare Cabinet 1: Pac-Man displays **WAITING FOR POWER**.
2. Press **J20 / BCM23** (`cab1_btn_ylw`) or operator **Power on**. After the four-second ROM-check recreation, Pac-Man shows its Character / Nickname introduction.
3. A new accepted scan on **J1 / cab1_rfid1** grants **CREDIT 1** and plays the credit sound.
4. The player presses mapped Start, consumes the credit, and begins play.
5. Clear one board to win; reset/re-prepare for another session.
**macOS / Linux**, from that controller folder:
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.
```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
```
This needs **arcade-cabinet-2.4**, 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.
**Windows PowerShell**, from the repository root:
## Configuration and credentials
```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
```
Prefer the launcher INIs shown in the master walkthrough. They start the required services and provide the managed game with its connection settings.
Keep the database at a stable location and back it up. Allow TCP **8766** through the controller machine's firewall for the room LAN. Keep real tokens and 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
On each cabinet, create **arcade_network.cfg** in the application's Godot user-data directory:
```ini
[network]
url="http://CONTROLLER_IP:8766"
cabinet_id="cabinet_01"
cabinet_token="your-generated-cabinet-one-token"
```
Use that cabinet's own ID and token. CFG strings use **double quotes**.
| Machine | Typical configuration directory |
| Role | Settings |
| --- | --- |
| Raspberry Pi / Linux | `~/.local/share/godot/app_userdata/ArcadeEscape/` |
| Windows | `%APPDATA%/Godot/app_userdata/ArcadeEscape/` |
| 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 |
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.
Start the managed client:
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
./ArcadeEscape.arm64 -- --managed-cabinet --cabinet=cabinet_01
python3 -c "import secrets; print(secrets.token_urlsafe(12))"
```
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`).
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.
### Enable physical GPIO outputs
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/`.
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.
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.
For the Cabinet 01 GPIO17 example, run the helper and game in separate terminals:
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.
```sh
# Terminal 1: local GPIO helper
GPIOZERO_PIN_FACTORY=lgpio python3 pi_gpio_bridge.py --controller cabinet_01_gpio --pins 17
```
## Exports, startup, and deployment
```sh
# Terminal 2: managed game with real outputs enabled
./ArcadeEscape.arm64 -- --managed-cabinet --cabinet=cabinet_01 --gpio=real
```
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:
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.
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.
| 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 |
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.4 power/RFID features until explicitly exported and verified. Exclude staged exports from the game PCK.
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.
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.
## 6. Run a room session
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.
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.
## 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.4, 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.
Pac-Man power-button wiring, the `devices.json` input entry, and the complete power → boot → RFID → Start acceptance test are included in the [master walkthrough](docs/pacman_rfid_first_setup.md).
## Glitch / Odin integration lab
The isolated [Rust, NATS, and Flutter lab](labs/glitch_odin/README.md) provides an experimental GM screen and simulated Pac-Man cabinet for testing the power, boot, RFID credit, Start, and win flow. It leaves Odin and the current Pi deployment untouched. Begin with the lab prerequisite check; Rust/Flutter checks and the headless real-Godot integration test now pass; physical RFID testing remains pending.
The [whole-room simulation walkthrough](labs/glitch_odin/ROOM_SIMULATION.md) covers the seven-cabinet Glitch story, discovery puzzles, ship cells, party-scaled calibration, launch and Teddy narration. This is an isolated Rust/NATS/Flutter prototype with virtual time and replayable traces; production durable recovery and hardware integration remain separate gates.
+5
View File
@@ -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()
+13
View File
@@ -0,0 +1,13 @@
@tool
extends EditorInspectorPlugin
const PROPERTY = preload("res://editor/hardware/pcb_editor_property.gd")
func _can_handle(object: Object) -> bool:
return object is CabinetProfile
func _parse_property(_object: Object, _type: Variant.Type, name: String, _hint: PropertyHint, _hint_string: String, _usage: int, _wide: bool) -> bool:
if name == "pcb_config":
add_property_editor(name, PROPERTY.new())
return true
return false
+1
View File
@@ -0,0 +1 @@
uid://dxenrkrq6ko7e
+92
View File
@@ -0,0 +1,92 @@
@tool
class_name CabinetPCBConfig
extends Resource
# This resource describes wiring. It never configures or writes GPIO.
@export var connections: Array[PCBConnection] = []
const TYPES := {
"rfid": "RFID reader", "button": "Button / switch", "sensor": "Digital sensor",
"led": "LED / lighting driver", "motor": "Motorized switch / motor driver",
"relay": "Relay / MOSFET module", "i2c": "I2C peripheral", "other": "Other (document wiring)"
}
static func ports() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for index in range(8):
result.append({"id": "J%d" % (index + 1), "family": "reader", "label": "%d%d%d" % [(index >> 2) & 1, (index >> 1) & 1, index & 1], "pins": "Selector: BCM22=%d, BCM27=%d, BCM17=%d\nShared reset: BCM20. Reader connector contact pinout is not documented." % [(index >> 2) & 1, (index >> 1) & 1, index & 1], "types": ["rfid", "other"], "bcm": -1})
result.append({"id": "J9", "family": "host", "label": "Pi ribbon", "pins": "Host GPIO ribbon connector. Confirm pin-1 orientation against the physical board.", "types": [], "bcm": -1})
var signals := [5, 6, 13, 19, 26, 21]
for index in range(6):
result.append({"id": "J%d" % (10 + index), "family": "powered", "label": "BCM%d" % signals[index], "pins": "1 GND (black) | 2 3V3 (red) | 3 signal (yellow)\nBCM%d" % signals[index], "types": ["button", "sensor", "led", "motor", "relay", "other"], "bcm": signals[index]})
result.append({"id": "J16", "family": "i2c", "label": "I2C1", "pins": "1 GND (black) | 2 3V3 (red) | 3 SCL (white) | 4 SDA (yellow)", "types": ["i2c", "other"], "bcm": -1})
signals = [16, 12, 24, 23, 18, 25]
for index in range(6):
result.append({"id": "J%d" % (17 + index), "family": "signal", "label": "BCM%d" % signals[index], "pins": "1 GND (black) | 2 signal (red)\nBCM%d - red is signal, not a power supply." % signals[index], "types": ["button", "sensor", "led", "motor", "relay", "other"], "bcm": signals[index]})
return result
static func port(id: String) -> Dictionary:
for definition in ports():
if definition.id == id:
return definition
return {}
func connection(id: String) -> PCBConnection:
for item in connections:
if item != null and item.port_id == id:
return item
return null
func set_connection(item: PCBConnection) -> void:
for index in range(connections.size() - 1, -1, -1):
if connections[index] != null and connections[index].port_id == item.port_id:
connections.remove_at(index)
if not item.device_type.is_empty():
connections.append(item)
emit_changed()
func issues(hardware: HardwareConfig = null) -> PackedStringArray:
var messages := PackedStringArray()
var used_ports: Dictionary = {}
var used_ids: Dictionary = {}
for item in connections:
if item == null:
messages.append("Empty connection resource.")
continue
var definition := port(item.port_id)
if definition.is_empty() or not definition.types.has(item.device_type):
messages.append("%s: unsupported connector/device combination." % item.port_id)
if used_ports.has(item.port_id):
messages.append("%s: connector is assigned more than once." % item.port_id)
used_ports[item.port_id] = true
if item.device_id.strip_edges().is_empty():
messages.append("%s: enter a device ID." % item.port_id)
elif used_ids.has(item.device_id):
messages.append("Duplicate device ID: %s." % item.device_id)
used_ids[item.device_id] = true
if item.device_type in ["led", "motor", "relay"] and not item.binding_id.is_empty():
var action: HardwareAction = hardware.get_action(StringName(item.binding_id)) if hardware else null
if action == null:
messages.append("%s: hardware action '%s' does not exist." % [item.port_id, item.binding_id])
elif not definition.is_empty() and action.gpio_pin != definition.bcm:
messages.append("%s: action uses BCM%d, but connector uses BCM%d." % [item.port_id, action.gpio_pin, definition.bcm])
if hardware:
for action in hardware.actions:
if action != null and action.gpio_pin in [17, 27, 22, 20]:
messages.append("Action '%s' uses reserved PCB selector/reset BCM%d." % [action.action_id, action.gpio_pin])
return messages
func feedback_devices() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for item in connections:
if item == null:
continue
var kind := ""
if item.device_type == "rfid": kind = "rfid"
elif item.device_type in ["button", "sensor"]: kind = "input"
elif item.device_type in ["led", "motor", "relay"]: kind = "output"
else:
push_warning("Feedback driver not implemented for %s (%s)" % [item.device_id, item.device_type])
continue
result.append({"id": item.device_id, "name": item.display_name, "port": item.port_id, "kind": kind, "pull_up": item.input_pull_up})
return result
+1
View File
@@ -0,0 +1 @@
uid://c0g0mq4b3d547
+4
View File
@@ -10,6 +10,10 @@ extends Resource
@export var hardware_config: HardwareConfig
@export_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
+12
View File
@@ -0,0 +1,12 @@
@tool
class_name PCBConnection
extends Resource
@export var port_id: String = ""
@export var device_type: String = ""
@export var device_id: String = ""
@export var display_name: String = ""
@export var binding_id: String = ""
@export_multiline var notes: String = ""
@export var input_pull_up: bool = true
+1
View File
@@ -0,0 +1 @@
uid://burui20kuayc2
+33 -4
View File
@@ -1,5 +1,5 @@
extends Node
const VERSION := "arcade-cabinet-2"
const VERSION := "arcade-cabinet-2.4"
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 in ["ready", "powered_off", "booting"] 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", "pacman_power_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,12 +127,19 @@ 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({})
if game.has_signal("power_state_changed"):
game.power_state_changed.connect(_power_changed)
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"))
phase = "ready"
acknowledge(command, true, definition.display_name + " loaded and ready")
phase = {"off": "powered_off", "booting": "booting", "ready": "ready"}.get(str(game.get("power_state")), "ready") if game.has_signal("power_state_changed") else "ready"
acknowledge(command, true, definition.display_name + " loaded: " + phase)
"power_on":
var ok: bool = game != null and phase == "powered_off" and game.has_method("power_on") and game.power_on()
acknowledge(command, ok, "Power-on requested" if ok else "Cabinet is not powered off")
"start":
var ok: bool = game != null and phase == "ready" and game.start_game()
acknowledge(command, ok, "Game started" if ok else "Cabinet was not ready")
@@ -139,3 +163,8 @@ func execute(command: Dictionary) -> void:
acknowledge(command, ok, "GPIO bridge accepted pulse; delivery unconfirmed" if ok else "GPIO failed or acknowledgement lost; inspect cabinet")
_:
acknowledge(command, false, "Unsupported command")
func _power_changed(state: String) -> void:
if state == "off": return # prepare acknowledgement establishes this state
phase = "booting" if state == "booting" else "ready"
event("power_booting" if state == "booting" else "power_ready")
+3
View File
@@ -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(
+20
View File
@@ -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,20 @@ 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)
for entry in result.get("events", []):
entry["age_ms"] = int(entry.get("age_ms", 999999)) + maxi(0, Time.get_ticks_msec() - feedback_received_ms)
return result
+4
View File
@@ -7,6 +7,10 @@ extends Resource
@export var game: GameDefinition
@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] = []
+5
View File
@@ -153,6 +153,9 @@ func _create_working_copy() -> void:
source_assignment.variant
)
assignment.activate_after_win = source_assignment.activate_after_win
assignment.game_config = source_assignment.game_config.duplicate(true)
assignment.event_mappings = (
_duplicate_mappings(
source_assignment.event_mappings
@@ -257,6 +260,8 @@ func _capture_scenario_state(
"cabinet_id": StringName(),
"game_id": StringName(),
"variant": assignment.variant,
"activate_after_win": assignment.activate_after_win,
"game_config": assignment.game_config.duplicate(true),
"event_mappings": [],
}
+2
View File
@@ -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,
}
)
+53 -2
View File
@@ -3,13 +3,64 @@
[ext_resource type="Resource" uid="uid://cxna0oimf8mop" path="res://data/cabinets/cabinet_01_hardware.tres" id="1_b4cnm"]
[ext_resource type="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
+6 -4
View File
@@ -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]
+52 -1
View File
@@ -3,13 +3,64 @@
[ext_resource type="Resource" uid="uid://ddv64yg3h5vsi" path="res://data/cabinets/cabinet_02_hardware.tres" id="1_bfw68"]
[ext_resource type="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
+4 -12
View File
@@ -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"
+9 -4
View File
@@ -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"
+11 -4
View File
@@ -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"
+20
View File
@@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="CabinetProfile" format=3 uid="uid://cwa2ord52vdda"]
[ext_resource type="Resource" uid="uid://dbcideufl5qhi" path="res://data/cabinets/cabinet_04_hardware.tres" id="1_2m3i6"]
[ext_resource type="Texture2D" uid="uid://cistuspr5bf8h" path="res://art/devices/tex_cabinet.png" id="1_8nqkk"]
[ext_resource type="Script" uid="uid://c0jj5j5loqsma" path="res://core/hardware/cabinet_profile.gd" id="1_a4ctu"]
[resource]
script = ExtResource("1_a4ctu")
cabinet_id = &"cabinet_04"
display_name = "Cabinet 4"
capabilities = Array[StringName]([&"roller", &"button_1", &"button_2", &"button_3", &"select_button", &"start_button"])
hardware_config = ExtResource("1_2m3i6")
footprint_width_m = 0.9144000000000001
footprint_depth_m = 0.9144000000000001
height_m = 0.9144000000000001
footprint_width_ft = 3.0
footprint_depth_ft = 3.0
height_ft = 3.0
editor_texture = ExtResource("1_8nqkk")
metadata/_custom_type_script = "uid://c0jj5j5loqsma"
+22
View File
@@ -0,0 +1,22 @@
[gd_resource type="Resource" script_class="HardwareConfig" format=3 uid="uid://dbcideufl5qhi"]
[ext_resource type="Script" uid="uid://c7eu1n5yey1x4" path="res://core/hardware/hardware_action.gd" id="1_lpxoo"]
[ext_resource type="Script" uid="uid://60whm57itpjb" path="res://core/hardware/hardware_config.gd" id="2_tu3fw"]
[sub_resource type="Resource" id="Resource_jt2rb"]
script = ExtResource("1_lpxoo")
action_id = &"coin_slot_1"
display_name = "Coin Slot 1"
metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[sub_resource type="Resource" id="Resource_axg2k"]
script = ExtResource("1_lpxoo")
action_id = &"lock_released"
display_name = "Lock Released"
metadata/_custom_type_script = "uid://c7eu1n5yey1x4"
[resource]
script = ExtResource("2_tu3fw")
controller_id = &"cabinet_04_gpio"
actions = Array[ExtResource("1_lpxoo")]([SubResource("Resource_jt2rb"), SubResource("Resource_axg2k")])
metadata/_custom_type_script = "uid://60whm57itpjb"
+9 -5
View File
@@ -9,18 +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/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")])
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"
+15 -3
View File
@@ -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)
+26
View File
@@ -0,0 +1,26 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://cv8oknw3dlwjk"]
[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")
cabinet = ExtResource("3")
game = ExtResource("5")
[sub_resource type="Resource" id="Second"]
script = ExtResource("2")
cabinet = ExtResource("4")
game = ExtResource("6")
activate_after_win = &"cabinet_01"
[resource]
script = ExtResource("1")
scenario_id = &"cabinet_chain_test"
display_name = "Cabinet 1 -> Cabinet 2"
assignments = Array[ExtResource("2")]([SubResource("First"), SubResource("Second")])
+44
View File
@@ -0,0 +1,44 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://crijr0r2ikxs1"]
[ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="1_40f70"]
[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="2_nsmc3"]
[ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_4sjsw"]
[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="4_wb1pw"]
[ext_resource type="Resource" uid="uid://d1gyyfaaclas8" path="res://data/cabinets/cabinet_02.tres" id="5_cy2se"]
[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="6_r5ac2"]
[ext_resource type="Resource" uid="uid://d4ns05ptr4ybw" path="res://data/cabinets/cabinet_03.tres" id="7_4fvja"]
[ext_resource type="Resource" path="res://data/games/tempest.tres" id="8_uer0y"]
[ext_resource type="Resource" uid="uid://cwa2ord52vdda" path="res://data/cabinets/cabinet_04.tres" id="9_oms42"]
[ext_resource type="Resource" path="res://data/games/missile_command.tres" id="10_sqsy7"]
[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="11_2gwsy"]
[sub_resource type="Resource" id="Resource_p0bbs"]
script = ExtResource("1_40f70")
cabinet = ExtResource("2_nsmc3")
game = ExtResource("4_wb1pw")
metadata/_custom_type_script = "uid://ibujr0ervhhq"
[sub_resource type="Resource" id="Resource_46jx7"]
script = ExtResource("1_40f70")
cabinet = ExtResource("5_cy2se")
game = ExtResource("6_r5ac2")
metadata/_custom_type_script = "uid://ibujr0ervhhq"
[sub_resource type="Resource" id="Resource_vuaau"]
script = ExtResource("1_40f70")
cabinet = ExtResource("7_4fvja")
game = ExtResource("8_uer0y")
metadata/_custom_type_script = "uid://ibujr0ervhhq"
[sub_resource type="Resource" id="Resource_l1iuy"]
script = ExtResource("1_40f70")
cabinet = ExtResource("9_oms42")
game = ExtResource("10_sqsy7")
metadata/_custom_type_script = "uid://ibujr0ervhhq"
[resource]
script = ExtResource("11_2gwsy")
scenario_id = &"eg_glitch"
display_name = "Glitch: The Dark Machine"
assignments = Array[ExtResource("1_40f70")]([SubResource("Resource_p0bbs"), SubResource("Resource_46jx7"), SubResource("Resource_vuaau"), SubResource("Resource_l1iuy")])
metadata/_custom_type_script = "uid://d3pt6qa05kcnl"
@@ -0,0 +1,25 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://bnn41ffo1lhvy"]
[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_ut8lr"]
[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" 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 = {
"boards_to_win": 1,
"power_on_device": "cab1_btn_ylw",
"rfid_allowed_uids": [],
"rfid_credit_device": "cab1_rfid1",
"score_to_win": 0
}
[resource]
script = ExtResource("1")
scenario_id = &"pacman_rfid_credit_test"
display_name = "Cabinet 1: Pac-Man RFID credit"
assignments = Array[ExtResource("2")]([SubResource("Assignment")])
@@ -0,0 +1,47 @@
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://coylqrwbr26tb"]
[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="1"]
[ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="2"]
[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="3"]
[ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_my636"]
[ext_resource type="Resource" uid="uid://d1gyyfaaclas8" path="res://data/cabinets/cabinet_02.tres" id="4"]
[ext_resource type="Resource" uid="uid://d4ns05ptr4ybw" path="res://data/cabinets/cabinet_03.tres" id="5"]
[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="6"]
[ext_resource type="Resource" uid="uid://cnfhiky5834pw" path="res://data/games/galaga.tres" id="7"]
[ext_resource type="Resource" path="res://data/games/frogger.tres" id="8"]
[sub_resource type="Resource" id="First"]
script = ExtResource("2")
cabinet = ExtResource("3")
game = ExtResource("6")
game_config = {
"boards_to_win": 0,
"score_to_win": 0,
"test_auto_win_seconds": 5.0
}
[sub_resource type="Resource" id="Second"]
script = ExtResource("2")
cabinet = ExtResource("4")
game = ExtResource("7")
activate_after_win = &"cabinet_01"
game_config = {
"score_to_win": 500,
"stages_to_win": 0
}
[sub_resource type="Resource" id="Third"]
script = ExtResource("2")
cabinet = ExtResource("5")
game = ExtResource("8")
activate_after_win = &"cabinet_02"
game_config = {
"boards_to_win": 0,
"homes_to_win": 1
}
[resource]
script = ExtResource("1")
scenario_id = &"three_cabinet_objective_test"
display_name = "3-Cabinet Test: 5 seconds / 500 points / 1 frog"
assignments = Array[ExtResource("2")]([SubResource("First"), SubResource("Second"), SubResource("Third")])
+40
View File
@@ -0,0 +1,40 @@
# Cabinet PCB connections
Open a cabinet resource in Godot, for example `data/cabinets/cabinet_01.tres`. With the Escape Room editor plugin enabled, expand **PCB Connections** and click **Set up PCB connections…**.
1. Click a connector on the green board. The arrangement matches the supplied custom PCB diagram.
2. Choose the device type: RFID reader, button/switch, digital sensor, LED/lighting driver, motorized switch/motor driver, relay/MOSFET, I2C peripheral, or Other. Choices depend on the connector family.
3. Enter a friendly name and a device ID unique within this cabinet. Use an output's existing HardwareAction ID, or an input's intended event ID, in the optional binding field. Add driver, power, I2C address and wiring details in Notes.
4. Click **Apply connections**, then save the cabinet resource using Godot's resource save command. Apply participates in the Inspector's Undo/Redo. Cancel discards the dialog draft.
5. Reopen to review the saved connections. Choose **Empty / unplugged** to clear a slot.
Every cabinet starts unassigned. The tester's two RFID readers would be entered separately in **J1** and **J2**; these are not automatically added to every cabinet. A cabinet's new `pcb_config` is embedded in its resource by the visual editor. Editing through this dialog duplicates the draft, so another cabinet sharing the original resource is not changed.
## Fixed board mapping
| Connectors | Signal assignment | Contacts |
| --- | --- | --- |
| J15, J14, J13, J12, J11, J10 | BCM21, 26, 19, 13, 6, 5 | 1 GND black; 2 3V3 red; 3 signal yellow |
| J22, J21, J20, J19, J18, J17 | BCM25, 18, 23, 24, 12, 16 | 1 GND black; 2 signal red |
| J16 | I2C1 | 1 GND black; 2 3V3 red; 3 SCL white; 4 SDA yellow |
| J1–J8 | Selector 000–111 | Bits BCM22 / BCM27 / BCM17; reset BCM20 |
| J9 | Pi GPIO ribbon | Check physical pin-1 orientation |
J1–J8 reader contact pinouts have not been supplied; the diagram shows the verified selection bits, not an invented contact assignment. Device types describe the attached interface/driver. Motors and lighting loads are not powered directly from GPIO.
## Validation and runtime scope
The editor reports duplicate device IDs/connectors, unsupported connector/type combinations, missing output actions, GPIO/action mismatches, and existing actions using reserved selector/reset BCM17/27/22/20. Warnings remain visible but do not prevent saving an incomplete wiring plan. Existing cabinet actions using BCM17 may therefore need a separate wiring review before this PCB is installed.
The saved inventory can now be exported for optional live RFID, digital input, and electrical output feedback. Input connections include an explicit pull-up setting. Saving the diagram alone does not enable GPIO or remap HardwareActions. Follow the [live feedback setup](../tools/pi_gpio/README.md#pcb-live-feedback) to configure the bridge and reader helper. I2C and Other entries remain inventory-only; sensor events do not automatically complete puzzle steps.
## Verification
From the project root:
```text
Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://tests/pcb/check_pcb.gd
Godot_v4.7.2-stable_win64_console.exe --headless --editor --path . --import --quit
```
The resource tests cover connector mapping, validation, save/reload and deep-copy isolation. Adding `--editor` to the test command also exercises the inspector's draft, cancel/reopen and Apply behavior; Godot may report editor shutdown/scan warnings for that isolated editor harness.
+243
View File
@@ -0,0 +1,243 @@
# Internal cabinet setup and assigned tokens
This guide records the supplied credentials for the internal Arcade Escape installation. It documents configuration only: adding a row here does not update a running controller, create cabinet resources, or deploy a Pi.
## Assigned tokens
| Role | Cabinet ID | Token |
| --- | --- | --- |
| Operator | — | `Yu1qH7nz_aMUTlkk` |
| Cabinet 1 | `cabinet_01` | `K32qg0lYioXmzWAK` |
| Cabinet 2 | `cabinet_02` | `LY9ATvM3wkTxJg0x` |
| Cabinet 3 | `cabinet_03` | `qZSD9eM2dmeRtmVA` |
| Cabinet 4 | `cabinet_04` | `2xXcj0sUbOzQCn2j` |
| Cabinet 5 | `cabinet_05` | `weISFvFBOt6pxfk-` |
| Cabinet 6 | `cabinet_06` | `h-lP-SfQMXT_dLtc` |
| Cabinet 7 | `cabinet_07` | `pUT4GdBYtW3slhOq` |
All eight tokens are distinct and exactly 16 characters. Copy them exactly, including capitalization, underscores, and hyphens. Cabinet 5's token ends with a hyphen.
## Windows controller and browser
The Python controller does **not** read `arcade_network.cfg`. Merge these values into the existing `tools/launch/launcher.ini`; preserve the current database/catalog paths and other settings:
```ini
[controller]
bind = 0.0.0.0
port = 8766
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"}
```
Stop the existing stack, then launch controller-only mode so the PC does not compete with a Pi for its cabinet identity:
```powershell
.\Start-Arcade.ps1 -Mode controller -Browser
```
Enter the **Operator** token from the table in the browser. The PC can browse `http://127.0.0.1:8766/`; other devices use `http://WINDOWS_PC_IP:8766/`. No Godot CFG is required for this browser workflow.
If old environment variables override the INI, clear them in the launching PowerShell session before restarting:
```powershell
Remove-Item Env:ARCADE_OPERATOR_TOKEN -ErrorAction SilentlyContinue
Remove-Item Env:ARCADE_CABINET_TOKENS -ErrorAction SilentlyContinue
Remove-Item Env:ARCADE_CABINET_TOKEN -ErrorAction SilentlyContinue
```
## Optional Godot operator on the PC
Only when running the exported app with `-- --operator`, create `%APPDATA%/Godot/app_userdata/ArcadeEscape/arcade_network.cfg`:
```ini
[network]
url="http://127.0.0.1:8766"
operator_token="Yu1qH7nz_aMUTlkk"
```
Use the controller's LAN IP instead if it runs on another machine. This CFG supplies the Godot operator client; it does not change the Python server's accepted tokens.
## Pi configuration
Create `~/.local/share/godot/app_userdata/ArcadeEscape/arcade_network.cfg` under the account running the game. It is not stored beside the executable. Replace `WINDOWS_PC_IP` in the examples below with the controller's actual LAN address.
For each Pi, also set the matching ID in `tools/launch/launcher.ini`. The launcher always passes this ID and it overrides the CFG ID. Leave its `url` and `token` empty to use the Godot CFG. Retain your executable path, `gpio` setting, and hardware pin allowlist.
### Cabinet 1
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_01"
cabinet_token="K32qg0lYioXmzWAK"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_01
url =
token =
```
### Cabinet 2
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_02"
cabinet_token="LY9ATvM3wkTxJg0x"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_02
url =
token =
```
### Cabinet 3
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_03"
cabinet_token="qZSD9eM2dmeRtmVA"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_03
url =
token =
```
### Cabinet 4
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_04"
cabinet_token="2xXcj0sUbOzQCn2j"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_04
url =
token =
```
### Cabinet 5
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_05"
cabinet_token="weISFvFBOt6pxfk-"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_05
url =
token =
```
### Cabinet 6
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_06"
cabinet_token="h-lP-SfQMXT_dLtc"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_06
url =
token =
```
### Cabinet 7
Godot `arcade_network.cfg`:
```ini
[network]
url="http://WINDOWS_PC_IP:8766"
cabinet_id="cabinet_07"
cabinet_token="pUT4GdBYtW3slhOq"
```
Merge into this Pi's launcher INI, preserving the other settings:
```ini
[cabinet]
id = cabinet_07
url =
token =
```
## Start and verify each Pi
1. Verify its cabinet profile is registered in `data/project_database.tres`, with its own hardware controller ID and actions. Configure the bridge to match that profile. The current catalog contains Cabinets 1–3; Cabinets 4–7 are reserved token assignments until their profiles are created, registered, exported, and deployed.
2. Run Control Setup on that Pi if not already configured.
3. If using the CFG examples above, clear stale overrides in that Pi's launching terminal:
```sh
unset ARCADE_ROOM_URL ARCADE_CABINET_TOKEN
```
4. Start the cabinet from the deployment folder:
```sh
bash start-arcade.sh cabinet --check
bash start-arcade.sh cabinet
```
5. Confirm that the correct cabinet ID becomes ONLINE in the browser. Run only one process per ID.
Alternatively, put the URL and this cabinet's token directly in `[cabinet]` in `launcher.ini` (unquoted). That overrides the Godot CFG. Existing environment variables have the highest priority. For real GPIO, use the hardware profile's controller ID and BCM pin allowlist in `[gpio]`; the bridge remains local on 8765 while the game connects to the room controller on 8766.
## Generate additional tokens
Windows PowerShell:
```powershell
py -3 -c "import secrets; print(secrets.token_urlsafe(12))"
```
Pi / macOS / Linux:
```sh
python3 -c "import secrets; print(secrets.token_urlsafe(12))"
```
Each generates exactly 16 characters. Use a different value for each identity. To replace a cabinet token, change the controller's JSON entry and that cabinet's configured token together, update this registry, and restart both affected processes. Operator token changes also require a controller restart and a fresh browser login.
See the [project README](../README.md), [launcher guide](../tools/launch/README.md), and [controller guide](../tools/room_controller/README.md) for the full deployment workflow.
+94
View File
@@ -0,0 +1,94 @@
# Master Pi installation and cabinet provisioning
Status: target architecture. A first terminal-wizard implementation now exists; see [the box-test guide](../tools/deploy/README.md). SSH staging/activation and configuration generation are implemented. Browser provisioning, automatic network isolation and arbitrary runtime cabinet profiles remain future work.
## Intended installer experience
1. Install the Arcade master package on a Raspberry Pi running 64-bit Raspberry Pi OS. The master does not need the Godot editor or a display to serve the operator website.
2. Run the local first-time setup wizard. Select the venue-facing interface and the private cabinet interface; review the network plan before applying it.
3. Add each cabinet: display name, stable cabinet ID, private IP or DHCP reservation, SSH username/port, SSH authentication, hardware profile, and GPIO settings. Prefer reserving addresses by Ethernet MAC rather than relying on hostname discovery.
4. Test access and verify the SSH host fingerprint. Use the existing login once to authorize a dedicated master deployment key; do not retain login passwords. Reuse an existing deployment key on reruns.
5. Select an ARM64 release bundle and review the proposed target/configuration changes. Deploy to selected cabinets, with per-cabinet progress and errors.
6. Verify the installed release, desktop session, local GPIO bridge availability if enabled, authenticated controller heartbeat, and expected protocol/features. Then open the normal operator page. Do not automatically start a puzzle or trigger hardware outputs during installation.
7. Later, use the same deployment inventory to update, replace, or add cabinets without repeating the room configuration.
The initial setup should run locally on the master, with privilege elevation confined to OS installation/network tasks. A browser setup interface should be loopback-only initially and must not expose an unauthenticated remote deployment endpoint. The normal operator page remains available on the venue LAN using its operator credential.
## Recommended network
```text
Venue LAN / staff Wi-Fi
| computers and iPads
| HTTP(S) operator page
Master Pi venue interface (Wi-Fi, or a second Ethernet adapter)
Master Pi private Ethernet interface
|
Private Ethernet switch -- cabinet_01
-- cabinet_02
-- cabinet_03
```
A separate Ethernet switch is the simplest cabinet network. Do not connect that switch to the venue router. Disable cabinet Wi-Fi connections to the venue network after commissioning. The master needs two interfaces: built-in Ethernet plus venue Wi-Fi, or two Ethernet interfaces when wired access is preferred.
The private network uses a subnet that does not overlap the venue LAN, VPNs, or existing routes. Example only: master 192.168.77.1/24, cabinets 192.168.77.11 onward. The wizard must check overlap instead of hard-coding this subnet for every site.
Provide DHCP reservations on the private interface, scoped only to that interface. Do not advertise a default router to cabinets. The master terminates connections on both networks; it does not bridge or forward traffic between them. Disable forwarding and enforce an IPv4/IPv6 forward-drop firewall policy for this boundary. Do not use NetworkManager shared mode as the isolation mechanism: shared mode enables forwarding. Cabinets receive bundles from the master over SSH, not through an Internet route.
This isolates cabinets from the venue LAN; cabinets on the same switch can still communicate with each other. Port-isolated switching or VLANs would be needed if cabinet-to-cabinet isolation is also required.
Allow venue staff to reach only the master's operator web port (and explicitly enabled management access). Keep the cabinet GPIO bridge bound to 127.0.0.1:8765. Cabinet polling reaches the master's private address on controller port 8766. Use a stable venue address/DHCP reservation or site DNS name for the operator bookmark. No public Internet port forwarding is needed. Venue guest-network isolation can still prevent staff devices from reaching the master and must be addressed by the venue network administrator.
Changing venue Wi-Fi/address should affect only the master connection and the staff bookmark; cabinet addresses, identities, tokens, and controller URL remain stable on the private side. If the new venue subnet overlaps, a coordinated private-network migration is required.
## Cabinet prerequisites
- Raspberry Pi OS 64-bit with a desktop, SSH enabled, and the intended login already created.
- An active desktop session with desktop autologin for unattended game startup. The SSH account and game desktop account should be the same.
- Python 3 and OpenSSH, normally available on the prepared OS image.
- GPIO Zero/lgpio packages and GPIO group access for cabinets that use real outputs.
No additional persistent management client is necessary. SSH can upload an installer and the release, register the existing user service and desktop helper, and start the application. Current pi_service.py cannot be called unchanged from a plain SSH session: it requires DISPLAY or WAYLAND_DISPLAY. Add a remote-install mode that registers the helper without pretending it has captured a desktop environment. Report waiting-for-desktop-login until the helper records the actual session. Never hard-code DISPLAY=:0 or guess Wayland socket names. A first login/reboot may be required after initial registration; do not reboot or log out a live cabinet without an explicit maintenance action.
For the least repeated setup, prepare one documented cabinet OS baseline with SSH, desktop autologin, and GPIO packages before moving onto the closed network. An offline deployment bundle must include matching OS/architecture package dependencies if these are not preinstalled; a disconnected cabinet cannot simply apt install missing packages. The game can be installed remotely without a Godot editor.
## Current code that can be reused
- tools/room_controller/server.py: Python standard-library controller, SQLite state, browser operator, per-cabinet authentication and protocol checks.
- tools/launch/launch.py: configuration generation target, process supervision, GPIO dependency checks and managed-game startup.
- tools/launch/remote_cabinets.py: strict-host-key SSH starts and separate ONLINE confirmation. An already-running service is not restarted.
- tools/launch/pi_service.py: non-root cabinet user service, display helper and systemd load-state verification.
- tools/pi_gpio/pi_gpio_bridge.py: local-only hardware bridge and durable request ledger.
Current limitation: core/network/managed_cabinet.gd looks up the cabinet ID in the exported ProjectDatabase; the server independently looks it up in catalog.json. Registering a new ID only in INI files is insufficient. The initial installer can safely bind machines to IDs/profiles already included in a release. To support arbitrary new IDs and hardware selections without rebuilding, add a validated external deployment profile consumed by both the Godot runtime and controller. It must include compatible input capabilities and the intended hardware action/controller mappings. The wizard must not silently advertise capabilities that the game export lacks.
## Configuration ownership
Maintain one private master inventory, separate from the authored game/scenario catalog. It records site network settings, cabinet addresses, SSH users/key references and verified fingerprints, cabinet profile selections, installed versions, and deployment status. Stable cabinet IDs survive IP changes and hardware replacement.
Generate the controller INI, per-cabinet INIs, and any required Godot CFG files from that inventory. Generate distinct operator/cabinet tokens for a new site; reuse them on rerun unless explicitly rotating them. Cabinets receive only their own token. Never package the existing development site's live INIs, tokens, database, SSH keys, or Odin folder into a distributable release.
Launcher INI values override the network environment supplied to the game. Keep any generated CFG consistent for direct-launch fallback, and account for existing user:// configuration precedence. Do not overwrite control mappings, player/operator state, or GPIO request ledgers as part of an application update.
Install the master controller as its own service so it survives browser closure and master reboot. Staff browsers connect to the server; they do not run the game export. The existing service binds one address, so exposure to both networks must be planned through binding/firewall rules or a reverse proxy. HTTPS can be added at the venue-facing proxy using a certificate trusted by staff devices; do not claim the current HTTP server already provides TLS.
## Release and deployment requirements
- Build one explicit ARM64 release bundle containing the executable/PCK, required Python scripts, operator assets, catalog, release metadata and SHA-256 checksums. Checksums verify transfer integrity; authenticated SSH establishes the source/channel.
- Keep credentials and persistent state outside versioned release folders.
- Validate IDs, IPs, duplicate targets, catalog compatibility, free space, CPU/OS, dependencies and desktop readiness before activation.
- Copy into staging, verify checksums, and reject unsafe archive paths/symlinks before extracting. Use a versioned release directory and an atomic current-release switch.
- Do not stop a running game to upload a bundle. Activation is a separate maintenance action. Preserve the prior release/config for rollback and avoid database downgrades that are not supported by its schema.
- Resume/report per cabinet after network failure; never mark SSH upload success as successful game startup.
- Confirm process/service health and the authenticated cabinet heartbeat independently. Preserve all GPIO deduplication state; neither retries nor rollback should replay a reward.
- Back up the master inventory, controller database and deployment key securely so a replacement master can recover the site. Losing these should not require reflashing every cabinet.
## Implementation order and acceptance checks
1. Release packaging, inventory schema, validation and deterministic INI/CFG generation; unit tests for identity/token separation and exclusion of live site data.
2. Remote preflight, SSH enrollment, staged upload, remote-safe display-helper/service installation and health reporting; test on one spare Pi before fleet rollout.
3. Local first-run wizard and master controller service; test repeat setup, interrupted upload, unavailable cabinet and reboot recovery.
4. Private-network configuration with backup/recovery instructions; verify from a venue device that the operator works but direct cabinet access does not, and from a cabinet that the venue LAN/Internet is not routed.
5. Runtime cabinet-profile support for arbitrary IDs, then integration tests for game compatibility, physical controller mappings and GPIO allowlists.
6. Move the master to a different non-overlapping venue network and verify cabinets keep the same private configuration and still complete the three-cabinet scenario.
References: [Raspberry Pi SSH setup](https://www.raspberrypi.com/documentation/computers/remote-access.html), [NetworkManager connection settings and shared-mode forwarding](https://networkmanager.pages.freedesktop.org/NetworkManager/NetworkManager/nm-settings-nmcli.html).
+177
View File
@@ -0,0 +1,177 @@
# Escape Gallery: Arcade Cabinet Integration
## Purpose and status
The target architecture combines the existing Godot arcade cabinet framework with the Rust room-control framework developed for Odin. Rust owns room sequencing, NATS carries control and state messages, and Flutter provides the game-master interface. Starfish is the proposed tool for managing Raspberry Pi SSH access.
**This document describes the intended integration, not a completed deployment.** The current arcade installation uses a Python room controller and browser operator interface. A physical Pac-Man test has successfully demonstrated operator power-on, the boot sequence, RFID credit, and player Start on a Raspberry Pi. The NATS bridge, Flutter cabinet controls, and shared autopilot integration remain to be implemented.
Glitch: the Dark Machine and Odin's Dilemma remain separate room implementations. Shared infrastructure, drivers, and messaging can be reused without mixing their puzzle rules, assets, device identities, or deployment configuration.
## Target system
```text
Staff laptops / tablets
|
v
Flutter GM interface
|
v
Rust room controller / autopilot
|
v
NATS messaging
|
v
Cabinet Rust integration (Godot GDExtension)
|
v
Godot cabinet application
| Game, graphics, sound, controls, credits and objectives
| Cabinet hardware interface
v
RFID / I2C inputs / LEDs / configured outputs
```
This diagram shows the logical control flow. The Flutter-to-controller transport and browser connection path must be confirmed against the owner's current framework. Hardware may run in a local service rather than inside the Godot process; each device must have one owner.
Starfish sits alongside this runtime architecture for SSH access management. It is not the game-message transport. Release packaging, service installation, update activation, and rollback need an agreed deployment workflow; SSH-key management alone does not provide those functions.
## Responsibilities
| Component | Intended responsibility |
| --- | --- |
| Godot cabinet application | Game selection, rendering, game audio, joystick/trackball/button mappings, boot presentation, credits, objectives, and game lifecycle. |
| Cabinet hardware layer | RFID and I2C drivers, input interpretation, configured output actions, and hardware status. |
| Rust Godot extension | NATS connectivity, generated message serialization, command validation and translation, state publication, and connection reporting. |
| Rust room controller / autopilot | Room sessions, puzzle sequencing, timed actions, dependencies between cabinets, room-wide effects, and GM overrides. |
| Flutter GM interface | Room layout, cabinet/device views, progress, diagnostics, and operator commands. |
| Shared message definitions and generator | Compatible command and state types for Rust, Dart, and any additional required languages. |
| Starfish | Proposed management of Pi SSH identities and access, subject to confirming its workflow. |
Room sequencing has one authoritative owner: the Rust controller. A cabinet owns execution of its assigned game and local interactions. For example, an accepted RFID scan can grant a local credit under the current assignment, while a completed game is reported to autopilot to determine which room puzzle activates next.
## Fit with the existing cabinet framework
The existing games remain in GDScript. The Rust extension provides methods and signals that GDScript can call or observe; it does not require rewriting gameplay in Rust or C#.
The current framework already supplies:
- Arcade games with configurable objectives and mapped player controls.
- Cabinet profiles, compatible-game assignments, and a visual PCB/device inventory.
- Pac-Man software power, boot, credit, Start, and completion behavior.
- A local Python GPIO bridge and Rust RFID helper.
- A managed-cabinet command/state integration point.
- An operator interface, room-layout authoring, and deployment tools useful during migration.
The existing HTTP integration can remain available for development and rollback while a NATS backend is introduced. Each deployed cabinet session must select one controlling backend; the Python controller and Rust autopilot must not independently issue competing assignments or advance the same room sequence.
The Godot editor remains useful for designing cabinets, defining devices, and authoring game objectives. An agreed export format should carry those definitions into the Rust/Flutter system so staff do not maintain conflicting copies manually. Existing room and cabinet visuals can inform the Flutter implementation, but the current HTML interface is not automatically reusable as Flutter widgets.
## Rust, NATS, and shared messages
A [godot-rust](https://godot-rust.github.io/book/) GDExtension is the proposed integration mechanism. It can use the existing Rust message types and NATS libraries while exposing a small cabinet API to Godot. If message serialization stays inside Rust, C# or GDScript message generation is not required for the initial integration.
The local Odin repository already contains:
- `msgs/*.geno` message definitions.
- Generator commands producing Rust types and Dart JSON models.
- Rust prop agents and puzzle controllers.
- An autopilot that observes puzzle state and schedules actions.
- Flutter services and controls for room operation.
**The inspected version uses MessagePack on prop-facing connections and JSON on game/UI-facing connections.** The current upstream schemas and wire formats must be confirmed before implementation. Sharing the name MessagePack is not enough: field order, optional values, enum encoding, and schema compatibility must match the generated implementation.
Networking and hardware operations must not block Godot's rendering/input loop. The extension should queue incoming work and deliver game-facing actions on the appropriate Godot thread. Build and export checks must cover the project's actual Godot version, Windows development builds, and Linux ARM64 cabinet builds.
### Proposed cabinet contract
The following are capabilities to agree on, not existing Odin message names:
| Direction | Capability examples |
| --- | --- |
| Controller to cabinet | Assign a compatible game, configure its objective/credit policy, power on, reset, request full state, invoke a configured hardware action. |
| Cabinet to controller | Identity/version, assignment, power/boot/play state, credit count, objective progress, win/loss, input changes, RFID observations, device faults. |
| Command result | Accepted, rejected, completed, or failed, with a command identifier and explanation. |
Commands and events should identify the room, cabinet, and active session. Define command IDs, expiry, duplicate handling, acknowledgement meaning, reconnect behavior, and state resynchronization before enabling sequencing or physical outputs.
Core NATS does not retain messages for offline subscribers. A one-time win event alone must not be the only record of completion. Agree on authoritative state snapshots, acknowledgement/retry behavior, and whether JetStream persistence is needed. Retries must not repeat credit grants or physical pulses.
## Hardware integration
The cabinet can present one hardware interface to gameplay even when the underlying drivers are split across services. Initially, the working Python bridge and Rust RFID helper can remain behind that interface. Individual drivers can then move to shared Rust components without changing each game's logic.
For the tester:
- J1 and J2 are the RFID reader connections; J1 currently grants Pac-Man credit.
- BCM23 / J20 is connected to an LED, **not the power button**.
- The physical buttons connect through a separate board on I2C1.
- Operator power-on is the current tested method until that input board is integrated.
The I2C board needs its model, address, channel mapping, polarity, and matching driver identified. Existing I2C support elsewhere in Odin does not establish compatibility with this particular board. The previously authored J20 button mapping is obsolete and must be corrected during configuration migration; BCM23 must not be configured as a button input.
Only one process may own each GPIO line, shared reader selector/reset arrangement, or hardware interface. Output commands should refer to configured device actions rather than allow arbitrary network pin writes. Define startup, disconnect, shutdown, and process-failure behavior for connected outputs.
## GM access and network layout
The required staff experience is a GM interface accessible from laptops and tablets on the building's local network. Flutter Web is the proposed browser target, subject to checking the current UI and its dependencies. A working Flutter desktop application does not by itself establish browser compatibility.
The master can host the Rust controller, NATS, and web-serving components while connecting to both the venue network and a private cabinet network. Staff devices access the GM interface; cabinets use the private network. The exact browser transport, authentication, NATS permissions, and required listening ports must be defined without opening general routing between those networks.
Keep these credentials distinct:
- SSH identities for installation and maintenance.
- NATS credentials and permissions for runtime messaging.
- GM authentication for staff operations.
## First integration milestone
Reproduce the already tested Pac-Man experience through the shared system:
1. Flutter assigns Pac-Man to Cabinet 1 and displays Waiting for power.
2. A GM Power on command reaches the cabinet through Rust/NATS.
3. Godot runs its four-second boot sequence and reports Ready.
4. A fresh accepted RFID scan grants one credit and plays the credit sound.
5. The player presses Start; the cabinet consumes the credit and reports Playing.
6. The player completes the configured objective; the cabinet reports completion.
7. Glitch autopilot activates the next configured puzzle or cabinet.
Acceptance includes reconnecting the network, restarting a cabinet, repeating commands, resetting a session, and verifying that no old scan, win, or output action is applied to a new session. Add the physical I2C button after its driver and mapping are confirmed.
## What is needed next
| Needed from the framework owner | Purpose |
| --- | --- |
| Current Odin/shared-framework repository, revision, and build instructions | Establish the actual integration baseline and private dependencies. |
| Message-generator source or installation instructions, plus current schemas | Generate compatible types and agree on cabinet command/state definitions. |
| NATS subject conventions, connection configuration, credentials/permissions, and persistence policy | Integrate cabinets with the existing messaging architecture. |
| Proposed Rust/Godot bridge API and implementation ownership | Decide who builds the extension, what GDScript calls, and how releases are maintained. |
| Hardware schematics, I2C board identity/address/channel mapping, and driver source | Integrate the tester's buttons and LED correctly. |
| Current Flutter build targets and browser-access expectations | Confirm laptop/iPad access and the required web transport. |
| Starfish enrollment/key-management workflow | Align Pi maintenance access with the owner's tooling. |
| Deployment conventions and ownership of room definitions | Agree on service names, release packaging, configuration generation, updates, and rollback. |
## Migration approach
1. Agree on the cabinet contract and hardware ownership with the framework owner.
2. Implement and test the Rust/Godot bridge using one Pac-Man cabinet.
3. Add matching Glitch controller logic and Flutter cabinet controls.
4. Transfer sequencing authority to autopilot for the test scenario.
5. Integrate the confirmed I2C driver and remaining cabinet devices.
6. Expand to additional games/cabinets, then port room-map and diagnostic views.
7. Adopt the agreed Starfish and release workflow while retaining a tested rollback path.
The existing working installation remains the reference until the new path passes the same physical tests. No deployment or runtime migration is performed by this document.
## References
- [godot-rust / GDExtension](https://godot-rust.github.io/book/)
- [NATS](https://nats.io/) and [Core NATS delivery behavior](https://docs.nats.io/learn/core-nats/)
- [MessagePack](https://msgpack.org/index.html)
- [Starfish repository supplied by the owner](https://github.com/jlyonsmith/starfish) — implementation not reviewed here.
- [Current arcade master setup walkthrough](../pacman_rfid_first_setup.md) — updated for operator power-on, RFID-only live inventory, and the corrected BCM23 LED / I2C button arrangement.
## Isolated integration experiment
See the [Glitch integration lab](../../labs/glitch_odin/README.md) for the first Rust/NATS/Flutter test sources, prerequisites, startup commands, and acceptance checks. The lab uses an experimental named-field MessagePack contract and a browser JSON gateway. It is not yet the generated Odin protocol or a production transport. The opt-in real-Godot sidecar now passes local transport and lifecycle tests. Physical Pi/RFID validation remains pending. See the lab's Odin message audit for reusable message candidates.
+587
View File
@@ -0,0 +1,587 @@
# 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 in **Waiting for power**. Click the operator **Power on** button to run a four-second cosmetic ROM-check sequence followed by the Character / Nickname screen 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, `rfid_credit_device = cab1_rfid1`, and `power_on_device = cab1_btn_ylw`. The power setting and corresponding saved PCB entry are legacy metadata from the earlier button assumption; retain them temporarily for compatibility with the current 2.4 scenario validation. They do not describe the actual wiring. Do not include that legacy button in the bridge's `devices.json`. See the software-power notes at the end of this guide.
- 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.4** and both `rfid_credit_v1` and `pacman_power_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.
### Optional exact copy commands from Windows
While both fresh Pis are still on venue Wi-Fi, record their Wi-Fi addresses with `hostname -I` on each Pi. Use those actual addresses here, not the later private Ethernet addresses. Keep the PowerShell release variables from section 3.2. Substitute your Pi usernames and current Wi-Fi IPs:
```powershell
$masterLogin = 'YOUR_MASTER_USER'
$masterHost = 'MASTER_WIFI_IP'
$cabinetLogin = 'YOUR_CABINET_USER'
$cabinetHost = 'CABINET_WIFI_IP'
tar -czf "$releaseDir\master.tar.gz" -C "$releaseDir\master" .
if ($LASTEXITCODE -ne 0) { throw 'Master archive failed.' }
scp "$releaseDir\master.tar.gz" "${masterLogin}@${masterHost}:master.tar.gz"
if ($LASTEXITCODE -ne 0) { throw 'Master copy failed.' }
scp "$releaseDir\$releaseName.tar.gz" "${cabinetLogin}@${cabinetHost}:$releaseName.tar.gz"
if ($LASTEXITCODE -ne 0) { throw 'Cabinet copy failed.' }
```
These SSH copies can use the Pi login passwords set in Imager; SSH key enrollment is not required for the first manual test. On first connection, verify the host fingerprint against the intended Pi before accepting it. Do not copy the operator token into an SSH password prompt.
On the **fresh master**, extract the master archive:
```bash
mkdir -p ~/arcade
tar -xzf ~/master.tar.gz -C ~/arcade
ls ~/arcade/tools/room_controller/server.py
```
On the **fresh cabinet**, use section 3.5's cabinet extraction commands. Compile and test the RFID helper in section 5 **before** section 4 disables cabinet Wi-Fi. After that, laptop access is to the master only; use the master to SSH to `192.168.77.11` if needed.
### 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, Waiting for power -> operator Power on -> four-second boot -> 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. **BCM23 / J20 is connected to an LED, not a button.** Do not wire a button there or configure it as an input. This test does not drive that LED or any other output; leave the output pin allowlist empty.
The tester's physical buttons connect through a separate input board on **I2C1**. Its model, I2C address, channel mapping, and matching driver still need to be confirmed before those buttons can control the game. For now, use operator **Power on**. The mapped arcade Start control is separate from this unimplemented I2C input board.
If following an older version of this guide, stop the cabinet stack, remove only the `cab1_btn_ylw` / J20 input entry from `devices.json`, and restart the stack. Keep the two RFID entries and preserve any unrelated site-specific devices. Do not regenerate the live inventory blindly from the saved PCB catalog: its legacy J20 button entry has not yet been migrated to the corrected hardware model.
## 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.4`, 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 **Waiting for power** and fresh device readings. In **Cabinets**, select Cabinet 1 and click **Power on**. The operator should change to **Booting**, then **Ready** after four seconds; the cabinet shows the Character / Nickname introduction and **SCAN CARD / CREDIT 0**. Keep the card away until Ready; historical scans and scans during boot are discarded.
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. Operator Power on should be disabled during boot and play. Reset/re-prepare to return to Waiting for power and test again. Scanning J2 must never grant credit. Disconnecting the reader/bridge must not create credits; feedback becomes stale/error.
Use **Prepare assigned cabinets** for the named scenario, not the separate manual **Prepare game** action: ordinary Pac-Man boots automatically and does not wait for operator power-on.
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.4 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, first run `mkdir -p ~/.config/autostart`, then 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. |
| Operator Power on is greyed out | Prepare the named RFID scenario using Prepare assigned cabinets; Cabinet 1 must be online, Waiting for power, and have no pending command. Check version 2.4 and preparation errors. |
| I2C buttons do nothing | The separate I2C input board is not integrated yet; use operator Power on and the mapped arcade Start control. |
| Scenario rejected | Updated master catalog, cabinet 2.4 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. The operator power-on, RFID credit, and player Start flow has been physically confirmed by the user on the tester. Repeat the acceptance test for each fresh installation; the I2C input board and BCM23 LED control remain unverified and outside this test. 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).
### Pac-Man software power and updates
This is a software state, not a mains relay. Operator **Power on** starts the cosmetic ROM-check sequence; it does not energize the BCM23 LED. Other games retain their existing startup behavior. The boot pattern is a visual recreation, not a checksum or hardware diagnostic.
**Temporary 2.4 compatibility:** keep `power_on_device = cab1_btn_ylw` in the scenario and its corresponding legacy input entry in the saved cabinet/catalog inventory for now. The current controller validates that reference, and the game uses its presence to remain in Waiting for power. Removing the setting makes Pac-Man boot immediately; removing only its catalog entry makes scenario validation fail. This is legacy configuration metadata, not permission to configure BCM23 as an input. The bridge's live `devices.json` must contain only the reader entries shown above for this test. A future change should introduce an explicit operator-only power mode and correct the saved PCB inventory together.
For a fresh release, export the Pi game, re-export the catalog, and package the controller and bridge from the same source version. A Windows game export is optional for PC testing. On an existing working 2.4 installation, removing the incorrect J20 entry from `devices.json` requires restarting the cabinet stack, not rebuilding the game or RFID helper. Preserve unrelated site-specific devices and settings. See section 3.8 for full software updates and rollback.
+118
View File
@@ -0,0 +1,118 @@
# Start the Pi cabinets from the controller PC
After one-time setup, your usual command starts the Windows controller, opens the browser, and asks each configured Pi to start its cabinet service:
```powershell
.\Start-Arcade.ps1 -Mode controller -Browser
```
The Pi service runs the existing cabinet launcher, which starts its GPIO bridge when enabled and then the managed game. SSH disconnects after the start request; the service stays running on the Pi. Already-running services are not restarted. Starting the apps does not automatically prepare or start a scenario; continue doing that in the operator.
The Pis must be powered on, reachable, and logged into their graphical desktop. This is remote application startup, not remote power-on. Desktop autologin is useful for cabinets; configure it on each Pi if desired.
## 1. Install the cabinet service on each Pi
Copy the updated `tools/launch/` scripts to the Pi's deployment folder. Stop the manually running cabinet launcher with Ctrl+C first so it releases the GPIO bridge port.
From a terminal **on the Pi desktop**, as the normal account that runs the game (without sudo):
```sh
python3 tools/launch/pi_service.py install --config tools/launch/launcher.ini
```
Use your actual INI filename, such as `launcher-pi.ini`, if different. It must contain that Pi's executable path, cabinet ID, controller URL/token (or blank URL/token to use its existing Godot CFG), and GPIO settings. The installer records the absolute config path; rerun it if the deployment/config moves.
This creates:
- `~/.config/systemd/user/arcade-cabinet.service`: runs your existing managed launcher as the desktop user.
- `~/.config/autostart/arcade-cabinet-display.desktop`: refreshes display settings at each desktop login, without launching the game.
- A temporary display environment file under the user's runtime directory, refreshed immediately during installation.
The installer does not start the game, enable boot startup, configure SSH, or change OS autologin. It captures only display-related variables, not application tokens. The service clears inherited `ARCADE_*` credential/URL overrides, so keep its intended settings in the configured INI or Godot CFG rather than relying on a terminal's environment.
Test locally:
```sh
systemctl --user start arcade-cabinet.service
systemctl --user status arcade-cabinet.service
```
Confirm the game appears on the cabinet monitor and becomes ONLINE. To stop it:
```sh
systemctl --user stop arcade-cabinet.service
```
If display settings change, run `python3 tools/launch/pi_service.py refresh-session` from the current Pi desktop terminal before starting it again.
## 2. Set up SSH access from Windows
SSH must be enabled on each Pi. Use the same SSH username as the desktop account where you installed the service.
These are **SSH keys**, separate from the 16-character application tokens in the cabinet setup guide. The tokens cannot authenticate SSH.
Generate a dedicated key on the PC (do not overwrite an existing one):
```powershell
ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\arcade_cabinets" -C "arcade-controller"
```
If you use a passphrase, load the key into your SSH agent before launching the room. Automated startup uses batch mode and cannot ask for passwords or passphrases.
For each Pi, first connect interactively and verify its host fingerprint before trusting it:
```powershell
ssh PI_USER@PI_HOST
```
Log out with `exit`. To authorize the public key on that Pi, run from PowerShell (substitute its real user and host):
```powershell
Get-Content "$env:USERPROFILE\.ssh\arcade_cabinets.pub" | ssh PI_USER@PI_HOST 'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys'
```
Only copy the `.pub` key; the private key stays on the PC. Test unattended access:
```powershell
ssh -i "$env:USERPROFILE\.ssh\arcade_cabinets" -o BatchMode=yes -o StrictHostKeyChecking=yes PI_USER@PI_HOST "systemctl --user status arcade-cabinet.service"
```
An inactive service may return a nonzero status; confirm SSH succeeded and systemd found the installed unit. The launcher keeps host-key checking enabled and never automatically accepts a new or changed host key.
## 3. Add the Pi list to the PC launcher INI
Merge this into the controller INI you actually launch with. Replace each host and username:
```ini
[remote]
enabled = true
cabinets = [{"id":"cabinet_01","host":"PI_1_HOST","user":"PI_USER","identity_file":"~/.ssh/arcade_cabinets"},{"id":"cabinet_02","host":"PI_2_HOST","user":"PI_USER","identity_file":"~/.ssh/arcade_cabinets"},{"id":"cabinet_03","host":"PI_3_HOST","user":"PI_USER","identity_file":"~/.ssh/arcade_cabinets"}]
```
`identity_file` is optional if your default SSH identity or agent is already configured. `port` is optional and defaults to 22. Each ID must exist in the controller catalog and its cabinet-token map. The Pi's own launcher must use the same cabinet ID; this list does not remotely rewrite its identity or credentials.
Validate, then launch:
```powershell
.\Start-Arcade.ps1 -Mode controller -Check
.\Start-Arcade.ps1 -Mode controller -Browser
```
Use `-Config tools/launch/launcher-pc.ini` if that is your actual config filename. `-Check` validates local settings only; it does not contact or start a Pi.
The controller starts first. The launcher sends the SSH requests concurrently, then checks controller heartbeats. It prints separate **SSH OK/FAILED** and **ONLINE/OFFLINE** results, so a successful service command is not mistaken for a working game connection. A failed/offline Pi leaves the controller running for troubleshooting.
## Operating behavior
- Starting the controller again leaves active Pi services running; it does not interrupt their games with `restart`.
- Closing the browser or stopping the PC controller leaves the Pi services running. They reconnect when the controller returns.
- Closing the Pi game with Escape stops that Pi's launcher/bridge. It will not restart automatically; the next controller launch can start it again.
- A crash is not automatically restarted, avoiding unexpected game resets during a room session.
- Use `ssh PI_USER@PI_HOST "systemctl --user stop arcade-cabinet.service"` for an intentional remote stop.
- Application logs remain in the Pi launcher's configured log folder. Service startup errors are available through `journalctl --user -u arcade-cabinet.service -n 60 --no-pager`.
- If the PC launches before the Pi desktop is ready, startup can fail. Wait for desktop login and launch the controller again, or issue the same `systemctl --user start` command over SSH. There is no automatic power-on or infinite retry loop.
- After a desktop restart/logout, confirm the display helper has run before starting games. If your desktop does not honor XDG autostart entries, run `pi_service.py refresh-session` locally and check the desktop's startup configuration.
The service can be removed by stopping it, removing the two managed files listed above, and running `systemctl --user daemon-reload`. Game resources, saved control mappings, tokens, and GPIO ledger are unaffected.
Implementation references: [OpenSSH client configuration](https://man.openbsd.org/ssh_config), [systemd execution environment](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html), and [systemd desktop integration](https://github.com/systemd/systemd/blob/main/docs/DESKTOP_ENVIRONMENTS.md).
+92
View File
@@ -0,0 +1,92 @@
# Three-cabinet objective test
Choose **3-Cabinet Test: 5 seconds / 500 points / 1 frog** in the browser operator.
| Cabinet | Game | Starts when | Win objective |
| --- | --- | --- | --- |
| `cabinet_01` | Pac-Man | Operator or physical Start after preparation | Automatic win five seconds after Start |
| `cabinet_02` | Galaga | Cabinet 1 reports a win | Score reaches **500 or more** |
| `cabinet_03` | Frogger | Cabinet 2 reports a win | **One frog** reaches a valid home |
Cabinets 2 and 3 remain unloaded while waiting. They load and automatically start after their predecessor wins. The five-second clock begins with Cabinet 1's game-start event, including its intro; it does not count time spent waiting in READY. Galaga keeps its normal brief victory transition before reporting completion. Frogger reports completion after the home-arrival pause.
## What was configured
The resource is [three_cabinet_objective_test.tres](../data/scenarios/three_cabinet_objective_test.tres), registered in [project_database.tres](../data/project_database.tres).
Each `ScenarioAssignment` now has a `game_config` Dictionary, in addition to its cabinet, game, variant, dependency, and event mappings. These values are exported to the controller catalog, included in the prepare command, and passed to the game's `configure()` method. The editor's working copy preserves these dictionaries independently.
The assignments are:
```text
Cabinet 1 / Pac-Man
activate_after_win: empty
game_config:
test_auto_win_seconds: 5.0
boards_to_win: 0
score_to_win: 0
Cabinet 2 / Galaga
activate_after_win: cabinet_01
game_config:
score_to_win: 500
stages_to_win: 0
Cabinet 3 / Frogger
activate_after_win: cabinet_02
game_config:
homes_to_win: 1
boards_to_win: 0
```
Setting board/stage targets to zero disables those alternate win conditions for this scenario. Galaga already supported score targets. Frogger now supports the individual-home target; its default remains a full board. The managed agent's test timer is enabled only by `test_auto_win_seconds`; ordinary scenarios and manual assignments do not inherit it. Reset/unload cancels the timer.
Cabinet 3's profile now includes `joystick_4way`, alongside its existing controls, so Frogger passes compatibility checks. Map its actual joystick using Control Setup. This test has no hardware event mappings, so no GPIO reward or additional wiring is required.
## How to change it in Godot
1. Open `data/scenarios/three_cabinet_objective_test.tres` in the Inspector.
2. Expand **Assignments**, then the assignment to edit.
3. Choose its Cabinet and Game references. Keep Variant as `standard`.
4. Set **Activate After Win** to the preceding cabinet's ID (blank for Cabinet 1).
5. Expand **Game Config** and set the objective keys and numeric values shown above.
6. Save the resource. The sample is already registered in the project database; register any newly created scenario there too.
7. Regenerate the controller catalog from the project root:
```sh
godot --headless --path . --script tools/room_controller/export_catalog.gd
```
Supported network objective keys currently include `test_auto_win_seconds`; Pac-Man board/score targets; Galaga stage/score targets; and Frogger board/home targets. Invalid values and unsupported keys are rejected before scenario dispatch. Chain execution belongs to the managed controller; the editor's local simulation does not run this network handoff.
## Build and deployment checklist
**This update needs fresh game exports on all three Pis.** Agents now advertise `arcade-cabinet-2.1` and the `assignment_config_v1` feature. The controller rejects this objective scenario on older agents so a cabinet cannot silently ignore the five-second or one-frog objective.
1. In Godot, build **Windows Desktop** for PC testing and **Linux** (ARM64) for the Pis. Use the configured engine version and matching export templates. The current presets write one directory above the project; check the output paths before exporting.
2. Copy the new ARM64 executable and any accompanying export files to all three Pis. The same build serves every cabinet; identity and tokens remain machine-specific.
3. Update the Windows controller's `server.py`, `catalog.json`, and `operator.html` together from `tools/room_controller/`.
4. Preserve each machine's local `launcher.ini`, Godot CFG, input mappings, controller database, and GPIO ledger. Confirm its unique ID/token using the [cabinet setup guide](cabinet_setup.md).
5. Restart Windows in **controller-only** mode and restart each Pi cabinet. Do not run an additional PC game with an ID already used by a Pi.
No builds or live controller restarts are performed by editing these resources. The exported binaries must be rebuilt and copied before the hardware test.
## Run the test
1. Confirm Cabinets 1, 2, and 3 are ONLINE with agent version `arcade-cabinet-2.1`.
2. Reset any active sessions. Select **3-Cabinet Test: 5 seconds / 500 points / 1 frog** and prepare it.
3. Confirm Cabinet 1 is READY and Cabinets 2/3 are WAITING.
4. Start Cabinet 1. After roughly five seconds plus the next network poll, Cabinet 2 loads and starts Galaga.
5. Play Galaga until the score reaches at least 500. Cabinet 3 then loads and starts Frogger.
6. Get one frog into an open home. Cabinet 3 becomes WON, completing the test.
7. To repeat, reset all three and prepare the scenario again. Resetting or restarting an upstream cabinet invalidates its dependent sessions. A lost game does not unlock the next cabinet.
## Verification
The automated integration test runs three actual headless Godot clients against a temporary controller, with no real GPIO. Cabinet 1 uses the real five-second timer. Test-only code adds Galaga points at 499 and 500, then places Frogger at a valid home; it exercises normal score/home completion paths rather than directly forcing their wins.
```sh
python3 -m unittest discover -s tests/room_network -v
python3 tests/room_network/check_objective_integration.py /path/to/godot
godot --headless --path . --script tests/room_network/check_objective_defaults.gd
```
+209
View File
@@ -0,0 +1,209 @@
@tool
extends EditorProperty
var open_button: Button
var dialog: ConfirmationDialog
var draft: CabinetPCBConfig
var chosen := "J1"
var buttons: Dictionary = {}
var type_select: OptionButton
var input_pull: CheckBox
var device_name: LineEdit
var device_id: LineEdit
var binding: LineEdit
var notes: TextEdit
var pin_label: Label
var warning_label: Label
var summary: Label
var loading := false
func _init() -> void:
open_button = Button.new()
open_button.text = "Open PCB connections..."
open_button.pressed.connect(_open)
add_child(open_button)
add_focusable(open_button)
func _update_property() -> void:
var config: CabinetPCBConfig = get_edited_object().get(get_edited_property())
open_button.text = "PCB connections (%d)..." % config.connections.size() if config else "Set up PCB connections..."
func _open() -> void:
var source: CabinetPCBConfig = get_edited_object().get(get_edited_property())
draft = source.duplicate(true) if source else CabinetPCBConfig.new()
# Rebuild static labels after editor script reloads as well as dynamic fields.
if is_instance_valid(dialog):
dialog.hide()
dialog.queue_free()
buttons.clear()
_build_dialog()
dialog.title = "%s - Custom GPIO routing PCB" % get_edited_object().display_name
_select(chosen)
dialog.popup_centered_clamped(Vector2i(1120, 760), 0.9)
func _label(text: String, parent: Node) -> Label:
var label := Label.new()
label.text = text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
parent.add_child(label)
return label
func _build_dialog() -> void:
dialog = ConfirmationDialog.new()
dialog.ok_button_text = "Apply connections"
dialog.min_size = Vector2i(700, 550)
dialog.confirmed.connect(_apply)
add_child(dialog)
var scroll := ScrollContainer.new()
dialog.add_child(scroll)
var content := VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(content)
_label("Select a socket, then describe the device. Apply supports Undo/Redo; save the cabinet resource to keep changes.", content)
var panel := PanelContainer.new()
var style := StyleBoxFlat.new()
style.bg_color = Color("164d3a")
style.content_margin_left = 16
style.content_margin_right = 16
style.content_margin_top = 16
style.content_margin_bottom = 16
panel.add_theme_stylebox_override("panel", style)
content.add_child(panel)
var board := VBoxContainer.new()
board.add_theme_constant_override("separation", 18)
panel.add_child(board)
var header := Button.new()
header.text = "J9 | Raspberry Pi GPIO ribbon | verify pin 1 orientation"
header.disabled = true
board.add_child(header)
var middle := HBoxContainer.new()
board.add_child(middle)
var group_index := 0
for group in [["J15", "J14", "J13", "J12", "J11", "J10"], ["J22", "J21", "J20", "J19", "J18", "J17"], ["J16"]]:
var column := VBoxContainer.new()
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
middle.add_child(column)
_label(["SIGNAL + 3V3", "SIGNAL / GND", "I2C"][group_index], column)
group_index += 1
var grid := GridContainer.new()
grid.columns = group.size()
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
column.add_child(grid)
for id in group:
_port_button(id, grid)
_label("READER CONNECTORS | selector BCM22 / BCM27 / BCM17 | shared reset BCM20", board)
var readers := GridContainer.new()
readers.columns = 8
board.add_child(readers)
for index in range(1, 9):
_port_button("J%d" % index, readers)
summary = _label("", content)
var details := HBoxContainer.new()
details.add_theme_constant_override("separation", 24)
content.add_child(details)
var left := VBoxContainer.new()
left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
details.add_child(left)
pin_label = _label("", left)
_label("GPIO is a control signal. Motors, relays and lighting need an appropriate driver and supply. This editor does not write GPIO or install reader drivers.", left)
var form := VBoxContainer.new()
form.size_flags_horizontal = Control.SIZE_EXPAND_FILL
details.add_child(form)
_label("Connected device", form)
type_select = OptionButton.new()
type_select.item_selected.connect(_type_changed)
form.add_child(type_select)
input_pull = CheckBox.new()
input_pull.text = "Input pull-up (active when connected to GND)"
input_pull.toggled.connect(func(_value: bool): _store())
form.add_child(input_pull)
device_name = _field("Device name", form)
device_id = _field("Unique device ID (within this cabinet)", form)
binding = _field("Hardware action ID (outputs) / event ID (inputs)", form)
_label("Wiring notes / driver / supply / I2C address", form)
notes = TextEdit.new()
notes.custom_minimum_size.y = 80
notes.text_changed.connect(_store)
form.add_child(notes)
warning_label = _label("", content)
warning_label.modulate = Color("ffd07a")
func _field(title: String, parent: Node) -> LineEdit:
_label(title, parent)
var field := LineEdit.new()
field.text_changed.connect(func(_text: String): _store())
parent.add_child(field)
return field
func _port_button(id: String, parent: Node) -> void:
var button := Button.new()
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(64, 82)
button.toggle_mode = true
button.pressed.connect(_select.bind(id))
parent.add_child(button)
buttons[id] = button
func _select(id: String) -> void:
chosen = id
loading = true
var definition := CabinetPCBConfig.port(id)
var item := draft.connection(id)
pin_label.text = "%s | %s\n\n%s" % [id, definition.label, definition.pins]
type_select.clear()
type_select.add_item("Empty / unplugged")
type_select.set_item_metadata(0, "")
for kind in definition.types:
type_select.add_item(CabinetPCBConfig.TYPES[kind])
type_select.set_item_metadata(type_select.item_count - 1, kind)
type_select.select(0)
if item:
for index in range(type_select.item_count):
if type_select.get_item_metadata(index) == item.device_type:
type_select.select(index)
device_name.text = item.display_name if item else ""
device_id.text = item.device_id if item else ""
binding.text = item.binding_id if item else ""
notes.text = item.notes if item else ""
input_pull.set_pressed_no_signal(item.input_pull_up if item else true)
loading = false
_refresh_board()
func _type_changed(_index: int) -> void:
_store()
func _store() -> void:
if loading:
return
var item := PCBConnection.new()
item.port_id = chosen
item.device_type = str(type_select.get_selected_metadata())
item.display_name = device_name.text.strip_edges()
item.device_id = device_id.text.strip_edges()
item.binding_id = binding.text.strip_edges()
item.notes = notes.text
item.input_pull_up = input_pull.button_pressed
draft.set_connection(item)
_refresh_board()
func _refresh_board() -> void:
for id in buttons:
var definition := CabinetPCBConfig.port(id)
var item := draft.connection(id)
var button: Button = buttons[id]
button.text = "%s\n%s\n%s" % [id, definition.label, item.device_type.to_upper() if item else "Empty"]
button.set_pressed_no_signal(id == chosen)
button.tooltip_text = "%s\n%s" % [definition.pins, item.display_name if item else "Unassigned"]
var disabled := str(type_select.get_selected_metadata()).is_empty()
device_name.editable = not disabled
device_id.editable = not disabled
binding.editable = not disabled
notes.editable = not disabled
input_pull.visible = str(type_select.get_selected_metadata()) in ["button", "sensor"]
summary.text = "%d connections configured | %s selected" % [draft.connections.size(), chosen]
var issues := draft.issues(get_edited_object().hardware_config)
warning_label.text = "Review wiring:\n- " + "\n- ".join(issues) if not issues.is_empty() else "Inventory is consistent. Runtime wiring and drivers are configured separately."
func _apply() -> void:
emit_changed(get_edited_property(), draft.duplicate(true))
_update_property()
@@ -0,0 +1 @@
uid://pqhm21y6t7c6
+80 -1
View File
@@ -231,7 +231,12 @@ var simulation_driver := EditorSimulationDriver.new()
var editor_selection := EscapeRoomEditorSelection.new()
var 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)
+3 -3
View File
@@ -11,8 +11,8 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../ArcadeEscape.arm64"
exclude_filter="setup/*,labs/*,tests/odin_lab/*"
export_path="setup/pi/ArcadeEscape.arm64"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -57,7 +57,7 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
exclude_filter="labs/*,tests/odin_lab/*"
export_path="../ArcadeEscape.exe"
patches=PackedStringArray()
patch_delta_encoding=false
+7 -2
View File
@@ -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
+94 -1
View File
@@ -13,6 +13,48 @@ var model = Model.new()
var view
var audio
var accumulator := 0.0
signal power_state_changed(state: String)
const BOOT_SECONDS := 4.0
var power_state := "off"
var boot_elapsed := 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 power_state == "off" and entry.get("device") == run_config.get("power_on_device", "") and entry.get("kind") == "input" and entry.get("value") == true and str(entry.get("error", "")).is_empty():
for sample in feedback.get("devices", []):
if sample.get("id") == entry.get("device") and int(entry.get("age_ms", 999999)) < 2000 and int(sample.get("age_ms", 999999)) < 2000 and str(sample.get("error", "")).is_empty():
power_on()
if power_state != "ready" or 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()
@@ -23,19 +65,55 @@ func _ready() -> void:
model.sound_requested.connect(audio.play_effect)
model.finished.connect(_on_finished)
_apply_config()
if str(run_config.get("power_on_device", "")).is_empty(): power_on()
func configure(config: Dictionary = {}) -> void:
power_off()
super.configure(config)
credits = 0
rfid_seen.clear()
rfid_initialized = false
_apply_config()
if str(run_config.get("power_on_device", "")).is_empty(): power_on()
func power_on() -> bool:
if power_state != "off": return false
boot_elapsed = 0.0
power_state = "booting"
view.power_state = power_state
view.startup_time = 0.0
audio.stop_all()
power_state_changed.emit(power_state)
return true
func power_off() -> void:
is_running = false
credits = 0
boot_elapsed = 0.0
power_state = "off"
model.reset()
accumulator = 0.0
if audio != null: audio.stop_all()
if view != null:
view.power_state = power_state
view.startup_time = 0.0
view.credits = 0
power_state_changed.emit(power_state)
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)
model.extra_life_score = maxi(0, int(run_config.get("extra_life_score", extra_life_score)))
func start_game() -> bool:
if is_running: return false
if is_running or power_state != "ready": 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 +121,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
@@ -72,6 +152,19 @@ func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed(pair[0]): model.requested = pair[1]
func _physics_process(delta: float) -> void:
if power_state == "off": return
if power_state == "booting":
boot_elapsed = minf(BOOT_SECONDS, boot_elapsed + delta)
view.startup_time = boot_elapsed
if boot_elapsed >= BOOT_SECONDS:
power_state = "ready"
view.power_state = power_state
view.startup_time = 0.0
# Ignore scans collected while the ROM check was running.
rfid_initialized = false
power_state_changed.emit(power_state)
return
if not is_running: view.startup_time += delta
if not is_running:
model.clock += delta
if is_running:
+69 -2
View File
@@ -10,6 +10,10 @@ const DEATH_ANIMATION_SECONDS := 1.55
const DEATH_POP_SECONDS := 0.24
var model
var debug_grid := false
var power_state := "off"
var startup_time := 0.0
var credit_required := false
var credits := 0
func _ready() -> void:
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
@@ -26,6 +30,17 @@ func _process(_dt: float) -> void:
func _draw() -> void:
draw_rect(_viewport_bounds(), Color.BLACK)
if model == null: return
if power_state == "off":
_text("WAITING FOR POWER", Vector2(44, 136))
return
if power_state == "booting":
_boot_screen()
_draw_edge_mask(GAME_RECT)
return
if model.state == "idle":
_character_intro()
_draw_edge_mask(GAME_RECT)
return
if model.state == "intermission":
_intermission()
_draw_edge_mask(GAME_RECT)
@@ -50,12 +65,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)
@@ -142,6 +158,57 @@ func _text(message: String, at: Vector2, color := Color.WHITE) -> void:
if code >= 65 and code <= 79: region = Rect2((code - 65) * 8, 0, 8, 8)
elif code >= 80 and code <= 90: region = Rect2((code - 80) * 8, 8, 8, 8)
elif code >= 48 and code <= 57: region = Rect2((code - 48) * 8, 16, 8, 8)
elif c == "/":
for dot in range(6): draw_rect(Rect2(at + Vector2(i * 8 + 6 - dot, dot + 1), Vector2.ONE), color)
continue
elif c == "!": region = Rect2(88, 8, 8, 8)
else: continue
draw_texture_rect_region(FONT, Rect2(at + Vector2(i * 8, 0), Vector2(8, 8)), region, color)
# Cosmetic recreation of the original video-RAM test, not an actual ROM test.
# All graphics remain on the native 224 x 288 pixel grid; no shader or noise texture.
func _boot_screen() -> void:
if startup_time < 0.12 or startup_time >= 3.75: return
var colors := [Color.CYAN, Color.WHITE, Color.YELLOW, Color("ffb8ff"), Color("ffb852"), Color.RED]
var blue := startup_time >= 1.8
var sweep := int(startup_time * 16.0)
for y in range(36):
for x in range(28):
var tile := (y * 7 + (x % 8) + sweep) % 32
var color: Color = Color("2121de") if blue else colors[(x + y) % colors.size()]
var at := Vector2(x * 8, y * 8)
match y % 9:
0, 1: pass
2: _text("P" if tile % 3 else "0", at, color)
3: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2(456 + (tile % 3) * 8, 0, 8, 8), color)
4: draw_rect(Rect2(at + Vector2(2, 3), Vector2(2, 2)), Color.WHITE)
5: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2((tile % 4) * 8, 0, 8, 8), color)
6: _text("0", at, color)
7: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2(456 + (tile % 4) * 8, 64, 8, 8), color)
8: if x % 4 == 0: _text(str(tile % 10), at, color)
func _character_intro() -> void:
_text("1UP", Vector2(24, 0))
_text("HIGH SCORE", Vector2(80, 0))
_text("00", Vector2(32, 8))
_text(str(model.high_score).pad_zeros(2), Vector2(88, 8))
_text("CHARACTER / NICKNAME", Vector2(40, 40))
var names := ["SHADOW", "SPEEDY", "BASHFUL", "POKEY"]
var nicknames := ["BLINKY", "PINKY", "INKY", "CLYDE"]
var colors := [Color("ff0000"), Color("ffb8ff"), Color("00ffff"), Color("ffb852")]
var elapsed := fmod(startup_time, 22.0)
for i in range(4):
var age := elapsed - float(i) * 1.6
if age < 0.2: continue
var y := 64 + i * 24
_sprite(Rect2(456 + (int(startup_time * 8) % 2) * 16, 64 + i * 16, 16, 16), Vector2(24, y + 4))
_text(names[i].left(clampi(int((age - 0.4) * 20), 0, names[i].length())), Vector2(48, y), colors[i])
_text(nicknames[i].left(clampi(int((age - 0.9) * 20), 0, nicknames[i].length())), Vector2(136, y), colors[i])
if elapsed > 7.0:
draw_rect(Rect2(64, 196, 2, 2), Color("ffb8ae"))
_text("10 PTS", Vector2(88, 192))
if int(startup_time * 4) % 2 == 0: draw_circle(Vector2(65, 216), 3.5, Color("ffb8ae"))
_text("50 PTS", Vector2(88, 212))
_text("1980 NAMCO", Vector2(72, 240), Color("ffb8ff"))
_text("SCAN CARD" if credit_required and credits == 0 else "PRESS START", Vector2(64, 264), Color.YELLOW)
_text("CREDIT %d" % credits, Vector2(16, 280))
View File
Whitespace-only changes.
+15
View File
@@ -0,0 +1,15 @@
target/
.dart_tool/
build/
.flutter-plugins-dependencies
*.log
__pycache__/
evidence/
qa.stop
.speech/
.lab-data/
.training/
+1756
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "glitch-odin-lab"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
anyhow = "1"
async-nats = "0.47"
axum = "0.8"
futures-util = "0.3"
rmp-serde = "1.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors"] }
sha2 = "0.10"
+54
View File
@@ -0,0 +1,54 @@
# Odin messages: shared candidates and Glitch gaps
Reviewed sibling Odin commit `eecb42c2364d915bc79ea99b16a7dad8f71baf1b`. No Odin files were changed.
## Sources
Paths below are relative to the Odin repository:
- `msgs/common.geno`: shared audio enums, currently populated with Odin content.
- `msgs/prop.geno`: hardware-agent command/state schemas.
- `msgs/game.geno`: GM/controller command/state schemas.
- `odin_prop_msg/src/msgs.rs` and `odin_game_msg/src/msgs.rs`: generated Rust types.
- `odin_ui/lib/models/msgs.dart`: generated Dart models.
- `odin_prop_msg/src/lib.rs`, `odin_game_msg/src/lib.rs`: subject constants.
- `odin_msg_util/src/lib.rs`: serialization and shared full-state helpers.
- `Justfile`, task `gen-msg`: invokes external `geno` for Rust serde, Rust state merging, Rust command-to-state, and Dart JSON generation.
Generator source was not found in this repository; the executable and version need to come from the owner.
## Candidates to share without room-specific fields
| Types | Existing content | Fit |
| --- | --- | --- |
| PropSystemState / PropSystemCmd | Optional name, nextPing, version; optional fullState request | Agent identity, liveness and state refresh |
| PropInfo / SystemState / SystemCmd | alive/version per named prop; fullState | GM device-health overview |
| StopwatchAction / StopwatchState / StopwatchCmd | none/start/stop/reset/stopAndReset; running/elapsedSecs; fullState | Room timer |
| GameCondition | off, ready, welcome, playing, debriefing, resetting | Overall room lifecycle, separate from cabinet lifecycle |
| Av2PropState / Av2PropCmd | Optional text and playing; fullState | Generic text/playback-shaped contract, though the existing agent is Odin's AV2 |
These are reusable schema candidates, not already activated services in the Glitch lab. Keep field order, types, optionality and enum numeric values if wire compatibility is required. Confirm nextPing's timing semantics from the system implementation before consuming it.
## Patterns to extract, not copy verbatim
- ClueState/ClueCmd combine text and playing with the Odin-specific memirEye output. Split the shared clue transport from room-specific effects.
- AudioState/AudioCmd use BackgroundMusic and SoundEffect enums containing feast/throne music and Odin-named effects. Share the mechanism with an agreed asset identifier strategy.
- AutoPilotState/AutoPilotCmd combine GameCondition with named Odin puzzles. Preserve the lifecycle enum but define Glitch's own puzzle progress.
- Prop commands commonly use optional fields for partial updates and fullState to request a snapshot.
- Props commonly report solved, but their enclosing messages are strongly tied to the named puzzle hardware.
## Transport compatibility
The checked-in Rust utilities use `rmp_serde::to_vec` for prop messages (struct fields serialize as ordered arrays), and JSON for the GM/controller messages. Subject names include `prop.system.cmd` and `prop.system.state`; other subjects are tied to Odin props.
The Glitch lab deliberately retains its separate `escape_gallery.glitch.lab.v1...` subject and named-field MessagePack contract. It is not wire-compatible with these generated Odin messages. No shared subject is subscribed or published by this lab, so it cannot command Odin devices.
For a multi-room hub, agree room scoping and device identities before sharing a broker. Sharing message types does not require sharing unscoped subjects.
## New Glitch definitions needed
Cabinet assignment/game identity; power/boot/ready/playing/won/lost; credits and score/objective progress; RFID admission and reader health; command correlation, acknowledgement, expiry and replay semantics; cabinet/session identity.
The existing lab Command/Reply/CabinetState types are an experimental boundary covering only part of this list. The Godot adapter implements that boundary behind a Rust sidecar, so the generated schema can replace it without rewriting Pac-Man.
Next agreement with the owner: extract the generic system/timer/lifecycle types into a shared package, obtain the generator, add Glitch cabinet definitions, and choose the supported browser transport. Do not reinterpret an Odin puzzle's solved/latch fields as arcade commands.
+183
View File
@@ -0,0 +1,183 @@
# Glitch / Odin integration lab
A separate experiment for **Glitch: the Dark Machine**, hosted in the Glitch repository. Odin stays untouched. This is a first vertical slice, not a migrated room or a deployment release.
## Whole-room simulation
The supplied Glitch GDD now has an isolated multi-cabinet room simulation with puzzles, boss, cells, calibration, launch and Teddy captions. Run `py -3 lab.py start-room` and open http://127.0.0.1:18777/?room=1 after building. See [the full walkthrough and test coverage](ROOM_SIMULATION.md). It uses separate ports and never sends GPIO outputs. Chatterbox Teddy speech and a phone-accessible LAN demo are available: run `py -3 lab.py start-room --speech --lan` on the configured GPU host. See [voice prerequisites and the optional Piper fallback](ROOM_SIMULATION.md#hear-teddy-with-chatterbox). Enable audio in the Teddy tab on each listening device; see the walkthrough for setup and timing.
## Status
The Rust controller, simulated cabinet, and real Godot sidecar build successfully. Rust tests, Flutter analysis/widget test, Python launcher tests, a headless HTTP/NATS/Godot lifecycle test, and the real Pac-Man score-completion adapter test have passed locally.
Physical RFID/Pi testing is still pending. The existing simulator remains the default. The new explicit Godot mode is a source-only lab, excluded from production exports. The Godot score test reports existing Pac-Man resource-retention warnings at shutdown; its assertions pass.
## Relationship to Odin
Reference: sibling Odin repository, commit `eecb42c2364d915bc79ea99b16a7dad8f71baf1b`.
The reference uses Rust/async-nats for control, MessagePack for prop messages, and JSON for game/Flutter messages. This lab follows that separation. It is newly written, with no dependency on or modifications to Odin, no copied production credentials, and no Odin puzzle rules.
The Flutter screen is a small Glitch-specific starting point, **not a fork of the full Odin UI yet**. We should establish the transport and message contract before migrating its room layout, services, routing, and puzzle controls. Escape Gallery is the app identity; Glitch is the current room.
```text
Flutter web GM (127.0.0.1:18767)
| HTTP JSON / polling
Rust controller/gateway (127.0.0.1:18766)
| NATS request/reply, named-field MessagePack
Local NATS (127.0.0.1:14222)
|
Rust simulated cabinet_01
```
The HTTP browser gateway is experimental. It avoids assuming the existing Odin Dart NATS client works in browsers. The owner may prefer a supported NATS WebSocket client or a different gateway.
## Prerequisites
Install these on the development computer, then open a new terminal:
1. [Rust stable](https://www.rust-lang.org/tools/install), including the native compiler/linker required by its installer. This project uses edition 2024.
2. [Flutter stable](https://docs.flutter.dev/install), with Dart 3.9.2 or newer within Dart 3.x. Run `flutter doctor`.
3. [NATS Server](https://docs.nats.io/running-a-nats-service/introduction/installation), with `nats-server` on PATH.
4. Python 3.10 or newer.
No Docker, Starfish, Pi login, room tokens, or existing production server are required for desktop tests. Dependency fetching needs internet access on the build computer. Commit Cargo.lock and gm/pubspec.lock after the first successful dependency resolution to pin the tested versions.
## Build and start on Windows
From the Glitch repository root:
```powershell
cd labs/glitch_odin
py -3 lab.py preflight
py -3 lab.py build
py -3 lab.py start
```
Run each command separately and stop if it reports an error. Build runs Rust tests, compiles the two-role Rust executable, resolves Flutter dependencies, runs analysis and widget tests, and builds the browser app. It does not export or change Godot.
Keep the startup terminal open. When it prints READY, visit **http://127.0.0.1:18767** on this same computer. Ctrl+C stops only the child processes created by this launcher. An occupied port causes startup to fail instead of terminating another service.
On Linux/macOS, use `python3` in place of `py -3`.
## First acceptance test
1. Confirm the amber simulated-cabinet banner and **POWERED OFF / Waiting for operator**.
2. Click **Power on**. The phase becomes booting for four seconds.
3. Wait for ready. Credits must still be zero.
4. Click **Simulate RFID scan**. Credits become one.
5. Click **Simulate player Start**. Credits return to zero and phase becomes playing.
6. Click **Simulate win**. Phase becomes won.
7. Click **Reset test** to return to powered off.
In another terminal, run the automated test while the lab is running:
```powershell
cd labs/glitch_odin
py -3 lab.py smoke
```
This test resets the simulated cabinet, exercises the actual HTTP/NATS/MessagePack path, verifies boot gating, credit gating, duplicate scan handling, stale-session rejection, and expiry, then leaves the cabinet powered off. Do not interact with the GM controls during this test.
To check unavailable-state behavior, run the three backend components manually in separate terminals instead of using the supervisor:
```powershell
nats-server -c nats.conf
.\target\debug\glitch-odin-lab.exe cabinet
.\target\debug\glitch-odin-lab.exe controller
py -3 -m http.server 18767 --bind 127.0.0.1 --directory gm/build/web
```
Stop the cabinet process. The UI should show disconnected and disable controls after the next poll/timeout. Restart it: a new session begins powered off. No previous command should be replayed automatically.
## Contract and limits
- Subject: `escape_gallery.glitch.lab.v1.cabinet_01.request`.
- Commands: snapshot, power_on, scan, start, win, reset.
- Mutations require an ID, matching cabinet-process session, and expiry within 30 seconds.
- Replies contain acceptance, duplicate status, a message, and the current cabinet snapshot.
- MessagePack uses named fields. **This is not wire-compatible with Odin's generated tuple messages.**
- The simulator remembers the last 256 commands in memory. It is not a persistent command journal.
- Reset clears simulated game state but keeps the process session and replay cache. Restart creates a new session.
- Ready state advances when queried after the boot deadline; the simulator has no rendering loop.
- HTTP 200 can contain a rejected command: clients must inspect `accepted`.
- No responder/timeouts produce HTTP 503. An unconfirmed delivery is never treated as success or automatically retried.
- There is no room autopilot, next-cabinet sequencing, authentication, durable recovery, or generated Odin schema yet. The opt-in Godot sidecar is experimental.
- All listeners are loopback only. The unauthenticated test gateway is deliberately unsuitable for LAN deployment.
- BCM23 remains an LED. I2C button integration is still pending hardware identification.
## What we need next
1. Owner confirmation of the current Odin branch and permission/strategy for sharing its framework crates.
2. Generator source, canonical schema, subjects, command acknowledgements, and state/session semantics.
3. Choose the browser transport and verify the target Flutter build on laptops and iPads.
4. Replace the experimental sidecar boundary with the agreed generated contract and, if desired, a Rust GDExtension. Only one controller may own a cabinet.
5. Run the same acceptance flow on a disconnected test cabinet, then connect the known RFID driver. Keep the I2C board disabled until identified.
6. Add room autopilot and multi-cabinet sequencing, then migrate the useful Odin GM room views.
7. Integrate Starfish deployment/SSH management after runtime communication works.
The current Python controller, Godot exports, Pi services, and deployment guide remain the working deployment path until these gates pass.
## Development checks
```powershell
py -3 -m unittest discover -s . -p test_runner.py -v
cargo test
cd gm
flutter analyze
flutter test
```
Build products are ignored locally. The lab has a Godot import ignore marker and is excluded from game exports.
## Real Godot cabinet test (desktop)
Stop the running simulator with Ctrl+C first. From this lab folder:
```powershell
py -3 lab.py build
py -3 lab.py start-godot
```
The launcher starts the local NATS broker, JSON controller, Rust Godot sidecar, a separate real Pac-Man scene, and the Flutter web server. Open http://127.0.0.1:18767. It should explicitly say REAL GODOT CABINET.
1. Power on from the GM screen.
2. Watch the actual four-second boot and character intro in Godot.
3. Click Simulate RFID scan. This desktop fixture is explicitly enabled by the launcher; it enters the game's normal RFID admission path.
4. Press your mapped player Start in Godot, or Start game in the GM screen.
5. Play to 500 points. The GM phase should become won. Simulate win is disabled for real Godot.
6. Reset test returns the cabinet to powered off.
Set GLITCH_LAB_GODOT to the absolute executable path if the repository's Windows Godot executable is unavailable. The lab scene is tests/odin_lab/cabinet.tscn; it refuses to run without --glitch-lab. It does not load the managed production cabinet connection or invoke room reward mappings.
The sidecar talks only to 127.0.0.1:18768 using one newline-delimited JSON command/reply per TCP connection. The current Rust layer owns NATS and MessagePack. This is **not a GDExtension yet**.
### Automated checks
Stop the interactive lab before running these:
```powershell
py -3 godot_smoke.py
```
This starts and stops its own headless Godot, broker, sidecar and controller. It tests the real transport, injected scan admission, Start, reset, offline 503, and a new process session after restart. No physical devices are used. The original lab.py smoke intentionally refuses real Godot.
From the repository root, the score-completion test exercises Pac-Man's actual score objective:
```powershell
.\Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://tests/odin_lab/check_adapter.gd -- --glitch-lab --lab-inject-rfid
```
### Physical RFID: next bench test, not a deployment change
The lab can read the existing local GPIO bridge when Godot is started with --gpio=real instead of --lab-inject-rfid. These options cannot be combined. Leave BCM23 as an LED; I2C buttons remain unimplemented. No output command is exposed by the lab.
For that bench test, manually run NATS, the controller, the sidecar (binary argument godot, not cabinet), and Godot on the **same test host**. Start the known RFID-only bridge configuration separately. Godot arguments are:
```text
--path <project-root> res://tests/odin_lab/cabinet.tscn -- --glitch-lab --gpio=real --lab-rfid-device=cab1_rfid1
```
Use the device ID actually configured on that reader. Without --lab-inject-rfid, the GM scan button is disabled: a fresh physical card scan is required after boot. This initial test accepts any nonempty card UID, matching the existing test policy. A Pi needs its own Rust build, source project/assets and a compatible Godot executable; the Windows binary is not portable to it. This path has not been physically verified yet. Do not run another cabinet/Godot owner or another reader driver on the same hardware.
See [Odin message audit](ODIN_MESSAGES.md) for reusable system/timer/lifecycle definitions and the new Glitch-specific messages still needed.
+259
View File
@@ -0,0 +1,259 @@
# Glitch whole-room simulation
This lab turns the supplied **Glitch: the Dark Machine** design into an interactive, deterministic room prototype. Rust owns room state; the Flutter screen sends revision-checked simulation intents through the local JSON gateway and NATS. No real cabinet, RFID reader, GPIO bridge, Teddy motor, or release output is contacted in this mode.
The supplied files are retained as `design/glitch_implementation_contract.json` and `design/GDD_reference.txt`. They are design references, not existing Odin wire definitions. The original files and Odin repository are unchanged.
Current design additions are recorded in [Design updates](design/DESIGN_UPDATES.md), including the junction-box power opening, delayed alien reveal, and proposed cross-cabinet communication mechanic. Power restoration is now implemented; cross-cabinet control remains proposed.
## Start
From the repository root:
```powershell
cd labs/glitch_odin
py -3 lab.py build
py -3 lab.py start-room
```
Open **http://127.0.0.1:18777/?room=1**.
The original single-cabinet lab can continue using 18767. Whole-room mode has a separate broker on **14232**, room API on **18776**, and browser UI on **18777**; all bind to loopback by default. Ctrl+C stops the processes this launcher owns.
The default is local-only. Optional --lan mode exposes the simulation UI/gateway for a shared phone/tablet demonstration; see below. This is not a production room operator service.
## What is simulated
- Seven cabinets: six named token-gated arcade assignments plus the Dark Machine boss.
- P01 claw repair, P02A/P02B claw rewards, P03 records, P04 service-panel symbols, P05 tickets and P06 poster discoveries.
- Correct token identity and freshness, persistent retry authorization, attempt IDs and score/home targets.
- Virtual cell charge, bay-open confirmation, identity/location observations, debounce and stable ship power.
- Two tutorial practices, party-size-dependent calibration waves and profile-specific pass/hold/deadline requirements.
- Sequential station arming, operator-group acknowledgements, navigator commit and a five-second launch countdown.
- Teddy's authored transcripts, queued cues, simulated playback duration, hint requests, mute, failure/fallback and GM takeover.
- Pause, technical holds, time extension, assisted rescue and confirmed reset.
- Command deduplication, stale-run/revision/expiry checks and an exportable/replayable trace.
The server advances the clock continuously. The countdown starts after briefing; pause and technical holds freeze gameplay. Diagnostics provides manual fast-forward controls. Multiple browser tabs do not accelerate time. Clock events are included in the replay trace.
The room overview is a conceptual dependency layout, not a measured CAD plan.
## Walk through a full run
### 1. Prepare and brief
Select the profile and party size, then Apply profile. Guided uses one calibration pass; standard two; challenge three. Party size sets the number of concurrent operator stations independently of difficulty. One player is explicitly assisted.
Enable audio in Teddy, then return to Room Flow and click Start run. Hear the haunted-arcade introduction with cabinets OFF. After narration, click Briefing complete: the countdown begins and the room waits for the junction-box key and switch. Click Flip power switch up to simulate unlocking and raising the physical switch. All six token-gated stations boot for four seconds, then show attract mode awaiting their matching token. Scan a token and press Start in Cabinets. The boss retains its six-win gate. These are simulated startup states, not rendered Godot ROM-check animations or physical outputs.
### 2. Discover tokens
In Room flow:
1. Simulate solving P01.
2. Solve P02A, then Observe delivery for T01.
3. Solve P02B and confirm T02 delivery.
4. Solve P03–P06 and confirm their corresponding token deliveries.
Solving and delivery are separate. ACK only / jam test acknowledges the output request without confirming a token arrived. P02B remains blocked until T01 is delivered. GM hand-delivery requires a staff name/reason and marks the run assisted.
The first demo deliberately accepts a correct fresh token even if delivery was not observed, as the GDD requests to avoid a sensor-created soft lock. The trace shows the scan; a dedicated delivery-discrepancy warning remains future work.
### 3. Play six cabinets
Open Cabinets. Each card shows its game, required token, objective, attempt, losses, charge, health and calibration progress.
For each cabinet: scan its correct logical token, Start attempt, then Simulate target reached. These are fake observations, not six Godot windows. Wrong token tests are available.
Charge starts on the first Start and needs 30 seconds of active virtual time. Advance +30 seconds while the run is active. A loss requires a three-second retry delay; authorization and charge remain. The game cannot count a second first-state win.
Only all six distinct wins make the boss available.
### 4. Defeat the boss and open the passage
In Room flow, Start boss and Simulate boss victory. A passage-release request appears in Diagnostics.
A simulated ACK does not open the passage. Click Observe passage open, then advance at least 300 ms (the +1 second button is sufficient). If five active seconds pass without an open observation, the room enters RECOVERY_HOLD. Observe opening, advance for debounce and Resume; the game clock does not run during the hold.
Boss gameplay/stages are a deliberate placeholder in this first whole-room test.
### 5. Move the six cells
In Ship & cells, for each cell:
1. Wait for 100 percent charge.
2. Observe bay open.
3. Remove from bay.
4. Insert matching socket.
Advance +3 seconds after insertion. This allows 500 ms observation debounce plus two seconds of stable ship power.
Inserting a cell without removing it from its bay intentionally creates a duplicate-location fault. Fix the observations; the ship cannot power up just because six sockets appear occupied.
### 6. Practice and calibrate
Open Calibration & launch. The ship displays targets; choose the operator's corresponding selection and click Simulate held Ready for every active station, then Navigator Execute.
Complete two untimed tutorial practices first. Scored waves then use the selected profile's deadlines and pass count. The fixture Ready action supplies the configured stable-hold duration; actual physical selection stability is not being measured.
A wrong/incomplete Execute fails the active wave, preserves earlier passes, issues a fresh challenge and adds a three-second retry delay. A deadline at exact equality is closed. Advance time to test expiry.
With two players, one roaming operator handles stations sequentially. With six players, five stations can be active in a wave. With eight, six are active and extra helpers are not required by the guard.
### 7. Launch
Once all passes are complete:
1. Arm each of the six stations, in any order.
2. Acknowledge each operator group.
3. Click Navigator Commit.
4. Advance five seconds.
The controller rechecks ship power/health before awarding exactly one launch. The normal outcome is WON. An explicit Finish assisted action produces ASSISTED_RESCUE instead and does not manufacture missing wins.
Removing a cell during calibration or countdown creates a hold, cancels current readiness/countdown and retains completed passes. Restore the cell, advance for stability, then Resume. A canceled countdown never restarts by itself.
### 8. Review and reset
Diagnostics shows output intent versus acknowledged/observed status. Refresh trace, then Copy trace JSON to retain a run. Reset simulation requires confirmation and produces a new run identity.
This reset only resets fake room state. It does not perform or certify a physical reset checklist.
## Teddy
Teddy acts like Odin's narrator/clue device but is not an authority for room progress. The simulated cues include welcome, station naming, readback, holding, readiness, boss, reveal, training, launch and ending. Requested/automatic hints are generic draft text, not final physical-puzzle answers.
Only one simulated cue plays at once. Queued cues expire or are discarded when their phase changes. Noncritical playback has an eight-second gap. The prototype uses a fixed six-second playback duration rather than real audio acknowledgements.
Use the Teddy tab to preview the transcript, request hints, stop, replay, mute, simulate failure, or take over/release narration. Offline/muted Teddy retains captions; gameplay can still finish.
Future work: per-puzzle inactivity/hint ladders, exact calibration-target hints, operator live voice, configurable cue lengths/priorities, playback acknowledgement/timeouts, authored audio and bounded motor control. Optional Teddy speech is available below; doll drivers remain unimplemented.
## Automated checks and evidence
Stop whole-room mode before running its network test. The single-cabinet lab uses separate ports and can remain running.
```powershell
py -3 room_smoke.py
.\target\debug\glitch-odin-lab.exe replay-room evidence\room-golden-trace.json
cargo test
cd gm
flutter analyze
flutter test
```
The smoke test owns its temporary broker/controller and stops them afterward. It executes the full standard six-player run through HTTP and NATS, verifies six cells and two passes per station, commits one launch, exports the trace, and replays it offline.
Local generated evidence (git-ignored):
- evidence/room-golden-trace.json — requests, receive timestamps, results and a state hash after each input.
- evidence/room-result.json — transport, final outcome, pass counts and final SHA-256.
- evidence/room-runtime.log — test runtime output.
Replay requires the exact imported contract hash. It reconstructs state and verifies every recorded hash without connecting to NATS or physical devices. It is deterministic regression evidence, **not durable crash recovery**.
## Acceptance coverage and deliberate gaps
| GDD cases | Current evidence |
| --- | --- |
| SIM01–04 | Full pure-reducer runs for standard six-player, guided two-player, assisted solo and challenge eight-player; exact trace replay. Standard path also passes over real NATS. |
| SIM05–07 | Wrong/repeated/aged token rejection, retries without another token, and six-distinct-win guard. Production source/boot/assignment validation is not implemented by this fixture API. |
| SIM08–09 | ACK versus delivery/open observation, recorded hand-delivery and passage hold. |
| SIM10–13 | Cell conflicts, calibration/countdown cancellation, retained passes, challenge expiry and readiness-window retry. Real sensors, agent handshakes and clock synchronization remain untested. |
| SIM14–16 | **Deferred:** durable transactional outbox/restart, device dedupe across restarts, message drop/reorder/snapshot reconciliation. In-memory command dedupe is not a substitute. |
| SIM17 | Offline Teddy does not gate the full run; stale phase cues are discarded. Real playback-status failure detection and puzzle-specific stale hints are deferred. |
| SIM18–20 | Paused active clock, wall-time expiry, calibration time expiry/extension, and rejection of old-run win/token/launch commands. |
| UI | All panels checked at 390, 900 and 1400 px; disconnected controls disabled; mutations send authoritative run/revision. |
These are implementations of selected semantic assertions, not certification that the complete production contract is satisfied.
## Architecture boundary
The whole-room simulator has one serialized Rust reducer. It uses a separate experimental NATS subject, `escape_gallery.glitch.lab.v1.room.simulation.request`, and named-field MessagePack. Its fixture command API is **not** the supplied normalized event schema, generated Odin contract, or a production agent ingress.
The controller currently runs fake observations in one process; it does not launch seven Godot agents. The separately tested real Godot/Pac-Man mode remains available with start-godot. Bridging room assignments into those real agents is a subsequent step.
The following must be added before live room use: generated shared contracts; authenticated/authorized sources and room-scoped subjects; agent boot/sequence/assignment fencing and readiness handshakes; durable journal/outbox and device ledgers; production recovery tests SIM14–16; real cell/passage feedback; physical preflight/reset; and hardware-specific Teddy/I2C integration.
The existing Pi deployment, GPIO configuration, single-cabinet lab and Odin repository remain separate.
## Hear Teddy with Chatterbox
Chatterbox Turbo is now the default lab voice, using the actor's approved reference recording. On this development PC, the model runs in Ubuntu/WSL on the RTX 5070 and stays loaded between hints. WAV audio is cached and delivered through the existing browser audio controls.
From `labs/glitch_odin`:
```powershell
py -3 lab.py start-room --speech --lan
```
Wait for READY, then open Teddy and tap **Enable audio and test speaker** on each listening phone/tablet/PC. Use **Say now** on a saved hint, or start the room briefing. Audio requires the page to remain active; mobile browsers can suspend background or locked-screen playback.
The configured installation is `~/teddy-chatterbox` inside WSL Ubuntu. Runtime reference: `.speech/voices/teddy-chatterbox-reference.wav`. These local dependencies and voice assets are not included in Git. See [Teddy voice setup](TEDDY_TRAINING.md#chatterbox-live-lab-backend) before moving to a fresh machine. This GPU backend has not been deployed to a Raspberry Pi; a Pi can receive prepared audio from a GPU host in a future deployment integration.
The follower stops publishing audio on mute, stop, takeover, offline, reset, replacement, or connection loss. In-flight GPU computation may finish to fill the cache, but cancelled cues cannot begin playback. An unavailable voice engine leaves captions and room controls available; inspect `.speech/chatterbox.log` and restart the lab to retry. No automatic change to a different voice occurs.
For the PC's speakers instead of browsers:
```powershell
py -3 lab.py start-room --speech --speech-output local
```
Piper is retained as an explicitly selected fallback:
```powershell
py -3 lab.py setup-speech
py -3 lab.py start-room --speech --speech-backend piper --lan
```
`setup-speech` installs the original stock Piper voice only. Chatterbox does not use Piper ONNX models. Audio completion still does not advance puzzle state, and different browsers are not sample-synchronized.
## Demonstrate from a phone on the same LAN
```powershell
py -3 lab.py start-room --speech --lan
```
On the phone, join the same normal Wi-Fi network as the PC and open:
`http://<PC-Wi-Fi-IPv4>:18777/?room=1`
For the development PC's current primary Wi-Fi address, that is **http://10.0.0.57:18777/?room=1**. Check `ipconfig` if the address changes. Use the Wi-Fi address, not a VPN adapter address. The PC also currently has another Wi-Fi adapter at 10.0.0.227.
The page and its room/audio requests use the same host/port, so a phone never tries to contact its own localhost. Only the UI/gateway binds to the LAN; NATS, the room API, and speech remain on loopback. This is a shared, unauthenticated **simulation demo**: anyone who can reach that port can change the simulated run. It does not connect to real cabinets or GPIO. Do not port-forward it onto the Internet.
If Windows Firewall blocks the page, allow the Python launcher on the trusted Private network. Alternatively, in administrator PowerShell create a narrowly scoped demo rule:
```powershell
New-NetFirewallRule -DisplayName "Glitch lab demo 18777" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 18777 -RemoteAddress LocalSubnet -Profile Private
```
Remove that optional rule after demonstrations with `Remove-NetFirewallRule -DisplayName "Glitch lab demo 18777"`. A guest Wi-Fi network, client isolation, or VPN LAN blocking may prevent a phone from reaching the PC.
Use Ctrl+C in the launching terminal to stop the demo. Start without `--lan` to return to localhost-only access.
## Speech checks
`py -3 -m unittest test_runner test_teddy_speech` checks startup handling and stale-cue cancellation, duplicate polls, mute/takeover/reset, synthesis failures and single playback. `py -3 speech_smoke.py` uses temporary loopback ports and real Piper to test non-silent WAV generation and local playback with room-state fixtures; it briefly plays a test line. It does not change any running room session.
## Saved hints and manual speech (October 18 update)
The Teddy tab now supports:
- **New saved hint / Edit**: title, spoken text, multiple comma-separated categories, phase, puzzle/cabinet, target completion condition, and strength (1 subtle, 2 direct, 3 solution).
- **Relevant now**: filters by the live room phase and target state. All eligible parallel branches are included. P02A requires P01 solved; P02B requires the first claw token delivery. Solved puzzles and won cabinets leave the incomplete-target suggestions. General room hints remain available.
- **All hints**: turn off Relevant now; category and text search still apply. This is the escape hatch for non-linear play or situations the simulation does not model.
- **Say now**: sends the selected text through the room controller to the speech backend immediately. It replaces the current cue and bypasses the virtual narration gap. It does not complete puzzles or advance the clock.
- **Write a one-off hint**: say custom text without saving, or save it into the library.
- **GM takeover**: pauses automatic narration only. Manual hints and Replay work during takeover. Muted/offline Teddy still suppresses speech.
The library is stored on the hosting computer at `labs/glitch_odin/.lab-data/hints.json`, with the previous save in `hints.bak`. Back up these files when moving to another PC. They are deliberately excluded from Git and persist independently of simulation resets. Reload library to retrieve edits from another device. Conflicting saves preserve your open draft and ask you to review it before saving again. Initial hints are editable draft suggestions, not final puzzle solutions.
### Audio troubleshooting
Refresh the updated page, open Teddy, and click **Enable audio and test speaker**. You should hear a one-second tone. The tone is generated in the browser and does not depend on the speech backend. If silent, check that device's media volume, output/Bluetooth routing and browser/tab mute.
Then click **Say now** on a hint. Speech should progress from generating to ready, and browser status should show playback. If automatic playback is blocked, tap **Play current hint here**; that directly starts the prepared WAV in a user gesture. The button is disabled until a valid current cue has finished generating. Stop/mute/takeover changes and phase/time jumps can cancel cues.
A server status of **ready** means the WAV is available, not that anybody heard it. Browser status tracks its own playback. The lab no longer reports a fake browser completion based on server elapsed time.
The previous silence reproduced with GM takeover enabled: manual hints and replay were queued behind the same gate as automatic narration. They now explicitly bypass that gate. Rebuilds also bypass stale bootstrap/JavaScript caches so refreshed browsers get the current controls.
+144
View File
@@ -0,0 +1,144 @@
# Teddy voice experiment
This experiment is separate from the live `.speech` Piper installation. The first GPU fine-tuning experiment has now been launched in Ubuntu. The Piper experiment is retained for comparison; the live lab now defaults to Chatterbox Turbo (see below).
## Source recording
The original files remain at `D:/Projects/escape-gallery/TeddyFull.wav` and `teddy-transcript.txt`. Working copies and checksums are in `.training/teddy-v1/`. The source is 387.898 seconds, mono, 44.1 kHz, 16-bit PCM, with 1,107 whitespace-separated transcript words. `.training/` is ignored by Git; back it up separately if needed.
## Prepare the dataset
From this lab directory in PowerShell:
```powershell
.training/venv/Scripts/python.exe -X utf8 prepare_teddy_dataset.py
```
The preparation environment contains faster-whisper 1.2.1 and scipy. It downloads the small.en speech recognition model and processes the audio locally on CPU. It does not upload recordings. It caches word timestamps, compares normalized recognized words with the supplied transcript, and exports only fully matched sentences lasting 0.7 to 15 seconds as mono 22.05 kHz PCM.
Outputs in `.training/teddy-v1/`:
- `asr-words.json`: recognition timestamps.
- `audio/`: candidate sentence clips.
- `metadata.draft.csv`: candidate filename/text pairs for Piper.
- `alignment-report.json`: accepted boundaries, excluded sentences, and source clipping statistics.
These are draft ASR-derived boundaries, not verified forced alignment. Listen to the clips, especially beginnings and endings, and reconcile exclusions against the actual recording. Recognition mismatch does not prove that the actor made a mistake. Do not replace the supplied transcript with recognition guesses without listening. Once reviewed, produce a separate approved metadata file and keep several sentences out of training for evaluation.
## Training environment prerequisite
The attempted WSL installer returned “Windows Subsystem for Linux is not installed” and exited without installing. Training has not started. In an administrator PowerShell window, use Microsoft's installation procedure:
```powershell
wsl --install -d Ubuntu
```
Restart Windows if prompted (save work and stop the demo first), launch Ubuntu, and complete its initial Linux username/password setup. Then check:
```powershell
wsl --list --verbose
wsl -d Ubuntu -- nvidia-smi
```
Do not replace the Windows NVIDIA driver with a Linux display driver inside WSL. Next, build an isolated Linux Python environment following Piper's official training guide. Verify CUDA support for the RTX 5070 with a real tensor operation before training; detecting the GPU name alone is insufficient.
## First experiment plan
1. Review clips and transcript matches; reserve validation sentences.
2. Select and document a compatible medium-quality training checkpoint and its provenance. The stock ONNX inference file is not a training checkpoint.
3. Install and pin Piper training code/dependencies in a separate Linux environment; retain code revision and package versions.
4. Fine-tune with a small GPU batch and a bounded run, retaining checkpoints. Do not assume more epochs improve this small dataset.
5. Export ONNX plus the JSON configuration created during training.
6. Synthesize unseen hints and compare intelligibility, voice consistency, and pronunciation with the source and stock voice.
7. Add explicit custom-model selection to the lab only after validation. Keep the stock model as a fallback.
No training checkpoint, validated CUDA environment, approved dataset, or new model is claimed by this setup document.
## References
- Piper training: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/TRAINING.md
- Windows WSL installation: https://learn.microsoft.com/en-us/windows/wsl/install
- Local timestamp generation: https://github.com/SYSTRAN/faster-whisper
## First preparation result
89 draft clips, 294.96 seconds retained. Ten sentences require reconciliation; normalized word match was 90.79 percent. This measures ASR/transcript agreement, not verified accuracy. No full-scale clipped samples were found. All exported clips were checked as mono, 22.05 kHz, 16-bit PCM. Open `.training/teddy-v1/review.html` to listen alongside the proposed labels. This was the initial preparation status; Ubuntu setup is now complete and an experimental run is active as described below.
## October 18: Ubuntu training experiment launched
The earlier WSL blocker is resolved. Ubuntu is running as `jeff` under WSL 2. The isolated workspace is `/home/jeff/teddy-training`; the live Windows inference environment remains separate.
Verified environment: Python 3.12.15, PyTorch 2.14.1+cu130, NVIDIA RTX 5070 (12 GB). A real CUDA matrix multiplication completed successfully. Piper source revision: `619f513580c04dff5ef9a680f6f3f6979ed94d2e`. The source was cloned from the official OHF-Voice repository. Build extensions compiled successfully. Exact packages are recorded in `~/teddy-training/requirements-lock.txt`.
Starting weights: `rhasspy/piper-checkpoints/en/en_US/ljspeech/medium/lj-med_1000.ckpt`. The accompanying model card identifies the LJ Speech dataset as public domain. Its card, configuration and checksum are saved under `~/teddy-training/checkpoints`. This is the starting voice, not a claim about the final trained voice.
Dataset: 89 ASR-matched draft clips. Eight are explicitly withheld in `data/holdout.csv`; 81 enter Piper's seeded train/validation/test split (71 training, 8 validation, 2 test examples). The ten transcript-mismatch sentences remain excluded. This experiment uses automatically checked clips, not a manually auditioned and approved dataset. Listening review is still required before deployment.
Launch from PowerShell (only when no existing run is active):
```powershell
wsl -d Ubuntu -- bash /mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/train_teddy_wsl.sh
```
The first run writes `~/teddy-training/train.log`. It warm-starts matching generator/discriminator weights with fresh optimizers. The log confirmed 784 parameters copied and zero skipped. Batch size is 4, with at most 60 epochs or 1,000 Lightning optimizer steps. This is an initial bounded experiment, not an asserted optimal duration. Checkpoints are under `~/teddy-training/runs/teddy-v1/lightning_logs/`.
Check progress from PowerShell:
```powershell
wsl -d Ubuntu -- tail -n 25 /home/jeff/teddy-training/train.log
wsl -d Ubuntu -- nvidia-smi
```
Do not restart Windows or shut down WSL during training. Checkpoints can support a later explicit resume; rerunning the launch script starts another fresh experiment. Export and listening evaluation have not yet been completed. Do not switch the live hint voice until those checks pass.
### Resume after optional MOS callback issue
Epoch zero saved successfully, then upstream's optional `val_mos` checkpoint callback raised because MOS scoring was disabled. `train_teddy_entry.py` removes only that callback, retaining mel-loss and last-checkpoint saving. The active run resumed from `version_0/checkpoints/last.ckpt` into `version_1`; all training states were restored. Epochs one and two subsequently saved successfully. Version one's checkpoints are the ongoing experiment. The downloaded upstream source remains unchanged; our wrapper is in the project.
To resume the latest version-one checkpoint after a deliberate stop:
```powershell
wsl -d Ubuntu -- bash /mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/train_teddy_wsl.sh --ckpt_path /home/jeff/teddy-training/runs/teddy-v1/lightning_logs/version_1/checkpoints/last.ckpt
```
Do not launch that command while training is already running.
## Export and first listening comparison
The bounded run completed with `max_steps=1000`. Exported `version_1/checkpoints/last.ckpt` and `epoch=14-val_mel=0.4643.ckpt` to `.training/teddy-v1/exports/`, each with the training-generated `.onnx.json` configuration. `export_teddy.py` selects the legacy dynamic-axis exporter required by Piper's opset-15 export code on this PyTorch version. The upstream source remains unchanged.
Both exports loaded and synthesized successfully in the lab's Windows Piper inference environment. Comparison audio is in `.training/teddy-v1/samples/`: `teddy-final.wav`, `teddy-best-mel.wav`, and `stock-reference.wav`. All read the same unseen passage from `comparison-transcript.txt`. File/model hashes and durations are in `report.json`.
A local small.en ASR sanity check recovered most words, with word-sequence similarity approximately 0.919 for the final model, 0.968 for best-mel, and 1.0 for stock. These are rough recognition comparisons, not subjective voice-quality scores or formal WER measurements. Number formatting and recognition mistakes affect them. Listen before deciding which checkpoint to develop further. Neither trained voice has been selected in the live hint system.
## Chatterbox Turbo comparison
An isolated zero-shot comparison now lives in `/home/jeff/teddy-chatterbox` (separate venv, source, model cache and logs). The comparison did not change Piper; Chatterbox was subsequently selected for the live lab. Official Chatterbox source revision: `5de7a54aa4e5e2baadb0182dde554908b48b85c2`. GPU dependency overrides were necessary because upstream pins PyTorch 2.6 for Python 3.12; resolved versions are recorded in `requirements-lock.txt` (torch 2.14.1, torchaudio 2.11.0). The successful inference test validates this specific path, not every Torchaudio feature.
`compare_teddy_chatterbox.py` uses the first 13.12 seconds of the actor recording as a reference; no new training is performed and audio stays local. The model is downloaded from `ResembleAI/chatterbox-turbo`. Output is `.training/teddy-v1/samples/teddy-chatterbox-turbo.wav`, with three individual hint WAVs and `chatterbox-report.json`. The model's default audio watermark remains enabled.
Measured loaded-model generation times on RTX 5070: 2.20 seconds for 7.48 seconds of speech; 1.16 seconds for 6.20 seconds; 0.95 seconds for 5.20 seconds. These are whole-utterance synthesis times, not streaming time-to-first-audio or end-to-end browser latency. Model download/load and reference preparation are separate fields in the report. Human listening comparison remains necessary.
To reproduce with downloaded weights:
```powershell
wsl -d Ubuntu -- bash -lc 'export HF_HOME=~/teddy-chatterbox/models; export HF_HUB_DISABLE_XET=1; ~/teddy-chatterbox/venv/bin/python /mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/compare_teddy_chatterbox.py'
```
## Chatterbox live lab backend
The lab now defaults to Chatterbox Turbo when launched with `--speech`. The existing Ubuntu installation at `~/teddy-chatterbox` supplies its Python environment, official source and model cache. `chatterbox_client.py` starts one resident worker, and `chatterbox_worker.py` loads the GPU model and actor reference once. The Windows follower continues to provide the existing audio/health routes, cancellation and browser playback. Odin and the production cabinet bridge are unchanged.
Required local assets for another development host:
- Ubuntu/WSL and an NVIDIA GPU with a working CUDA-enabled PyTorch installation.
- The Chatterbox source revision and dependency overrides recorded in the comparison section above. Preserve `~/teddy-chatterbox/requirements-lock.txt` when reproducing this tested environment.
- Cached model files under `~/teddy-chatterbox/models`, or network access for their initial download.
- The approved 13.12-second actor reference copied to `.speech/voices/teddy-chatterbox-reference.wav`. The original comparison copy is `.training/teddy-v1/samples/chatterbox-reference.wav`.
`TEDDY_CHATTERBOX_HOME` can override the Linux installation directory. Windows uses the Ubuntu WSL distribution; native Linux uses the same worker directly through bash. This is not a CPU/Pi inference installer.
Launch: `py -3 lab.py start-room --speech --lan`. Startup waits for model readiness. Logs: `.speech/chatterbox.log`. The reference hash and backend version are included in cache keys, so old Piper WAVs are not reused. Hints are split into sentences (and bounded word groups for long sentences) to avoid long-utterance truncation. No actor recording is uploaded; synthesis and caching stay local.
Fallback: `py -3 lab.py start-room --speech --speech-backend piper --lan`. The fallback uses the original stock voice and requires `setup-speech` first. Chatterbox errors do not silently substitute Piper.
+100
View File
@@ -0,0 +1,100 @@
"""Nonblocking, cancellable requests to a resident Chatterbox worker in WSL/Linux."""
import hashlib
import json
import os
from pathlib import Path
import queue
import shlex
import subprocess
import threading
ROOT = Path(__file__).resolve().parent
REFERENCE = ROOT / ".speech/voices/teddy-chatterbox-reference.wav"
def linux_path(path):
path = Path(path).resolve()
if os.name != "nt":
return str(path)
return "/mnt/" + path.drive[0].lower() + path.as_posix()[2:]
def voice_id():
return "teddy-chatterbox-turbo-v1-" + hashlib.sha256(REFERENCE.read_bytes()).hexdigest()[:16]
class Job:
def __init__(self, text, output):
self.text, self.output = text, output
self.cancelled = threading.Event()
self.returncode = None
def poll(self):
return self.returncode
def terminate(self):
# In-flight GPU work may finish and populate cache, but must never play.
self.cancelled.set()
def wait(self, timeout=None):
return self.returncode
class Engine:
def __init__(self):
self.jobs = queue.Queue()
self.ready = threading.Event()
self.error = None
self.process = None
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def submit(self, text, output):
job = Job(text, output)
self.jobs.put(job)
return job
def close(self):
self.jobs.put(None)
def _run(self):
current = None
try:
home = os.environ.get("TEDDY_CHATTERBOX_HOME", "~/teddy-chatterbox")
# Expand the conventional home prefix in the Linux shell only.
home_arg = '"$HOME"' + shlex.quote(home[1:]) if home.startswith("~/") else shlex.quote(home)
script = linux_path(ROOT / "chatterbox_worker.py")
command = (f"export HF_HOME={home_arg}/models; export HF_HUB_DISABLE_XET=1; "
f"exec {home_arg}/venv/bin/python -u {shlex.quote(script)} {shlex.quote(linux_path(REFERENCE))}")
args = (["wsl", "-d", "Ubuntu", "--", "bash", "-lc", command] if os.name == "nt"
else ["bash", "-lc", command])
log = ROOT / ".speech/chatterbox.log"
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("a", encoding="utf-8") as logs:
self.process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=logs, text=True, encoding="utf-8",
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
if not json.loads(self.process.stdout.readline()).get("ready"):
raise RuntimeError("Chatterbox did not become ready")
self.ready.set()
while True:
current = self.jobs.get()
if current is None:
break
if current.cancelled.is_set():
current.returncode = 1
continue
self.process.stdin.write(json.dumps({"text": current.text,
"output": linux_path(current.output)}) + "\n")
self.process.stdin.flush()
result = json.loads(self.process.stdout.readline())
current.returncode = 0 if result.get("ok") else 1
if not result.get("ok"):
print("Chatterbox synthesis: " + result.get("error", "unknown error"), flush=True)
current = None
except Exception as error:
self.error = str(error)
if current is not None:
current.returncode = 1
print("Chatterbox worker failed: " + self.error, flush=True)
finally:
if self.process:
self.process.stdin.close()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.terminate()
+51
View File
@@ -0,0 +1,51 @@
"""Persistent local GPU voice worker. JSON lines on stdin/stdout; logs on stderr."""
import contextlib
import json
from pathlib import Path
import sys
def main():
protocol = sys.stdout
with contextlib.redirect_stdout(sys.stderr):
import numpy as np
import soundfile as sf
import torch
from chatterbox.tts_turbo import ChatterboxTurboTTS
torch.set_num_threads(4)
if not torch.cuda.is_available():
raise RuntimeError("Chatterbox lab requires the configured NVIDIA GPU")
model = ChatterboxTurboTTS.from_pretrained(device="cuda")
model.prepare_conditionals(sys.argv[1])
print(json.dumps({"ready": True}), file=protocol, flush=True)
for line in sys.stdin:
try:
request = json.loads(line)
with contextlib.redirect_stdout(sys.stderr):
# Sentence-sized chunks avoid Turbo's long-utterance truncation.
import re
chunks = re.split(r"(?<=[.!?])\s+", request["text"].strip())
parts = []
for chunk in chunks:
if not chunk:
continue
# Bound even punctuation-free custom hints.
words = chunk.split()
for offset in range(0, len(words), 45):
samples = model.generate(" ".join(words[offset:offset+45])).squeeze().detach().cpu().numpy()
if parts:
parts.append(np.zeros(int(model.sr * .18), dtype=np.float32))
parts.append(samples)
if not parts:
raise ValueError("Hint text is empty")
output = Path(request["output"])
temp = output.with_suffix(".partial")
sf.write(temp, np.concatenate(parts), model.sr, format="WAV", subtype="PCM_16")
temp.replace(output)
print(json.dumps({"ok": True}), file=protocol, flush=True)
except Exception as error:
print(json.dumps({"ok": False, "error": str(error)}), file=protocol, flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,42 @@
"""Isolated Chatterbox GPU comparison; no change to the live hint system."""
from pathlib import Path
import json,time,random
import numpy as np
import soundfile as sf
import torch
from chatterbox.tts_turbo import ChatterboxTurboTTS
root=Path("/mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/.training/teddy-v1/samples")
random.seed(42);np.random.seed(42);torch.manual_seed(42)
torch.set_num_threads(4)
assert torch.cuda.is_available()
x=torch.randn(32,32,device="cuda");x@x;torch.cuda.synchronize()
start=time.perf_counter()
model=ChatterboxTurboTTS.from_pretrained(device="cuda")
load_seconds=time.perf_counter()-start
start=time.perf_counter()
model.prepare_conditionals(str(root/"chatterbox-reference.wav"))
torch.cuda.synchronize();reference_seconds=time.perf_counter()-start
texts=[
"Something has been moving in the arcade after closing time. Find the locked junction box, and see whether your friends have discovered its key.",
"The switch is up. Give the machines four seconds to wake, then bring the matching token to your cabinet.",
"Tell your partner which symbol is flashing. They can move the controls, but only you can see the target."]
parts=[];timings=[]
for i,text in enumerate(texts):
start=time.perf_counter()
wav=model.generate(text)
torch.cuda.synchronize()
elapsed=time.perf_counter()-start
samples=wav.squeeze().detach().cpu().numpy()
sf.write(root/f"chatterbox-hint-{i+1}.wav",samples,model.sr)
duration=len(samples)/model.sr
timings.append(dict(text=text,generation_seconds=elapsed,audio_seconds=duration,real_time_factor=elapsed/duration))
parts.append(samples)
print(json.dumps(timings[-1]),flush=True)
joined=[]
for i,part in enumerate(parts):
if i: joined.append(np.zeros(int(model.sr*.45),dtype=np.float32))
joined.append(part)
sf.write(root/"teddy-chatterbox-turbo.wav",np.concatenate(joined),model.sr)
report=dict(engine="Chatterbox Turbo",reference="chatterbox-reference.wav",model_load_seconds=load_seconds,reference_seconds=reference_seconds,sample_rate=model.sr,device=torch.cuda.get_device_name(0),torch_version=torch.__version__,chunks=timings)
(root/"chatterbox-report.json").write_text(json.dumps(report,indent=2),encoding="utf-8")
print("Comparison complete",flush=True)
+56
View File
@@ -0,0 +1,56 @@
# Glitch: the Dark Machine - design updates
## October 18, 2026: opening, reveal, and cooperative cabinet play
These updates supplement the imported GDD and implementation contract. They record current design direction; they do not claim that the running simulation implements these changes. Preserve the imported references as the baseline.
### Confirmed opening: restore apparent room power
Players enter to investigate strange happenings in an arcade believed to be haunted. Teddy is the only apparently powered interactive character at entry. He delivers the opening story, establishes the mood, and can guide players toward their first task. Cabinets and other show-controlled effects initially appear inactive. Real electrical supply remains on; this is a room-sequencing state, not a request to interrupt building power.
The opening objective is:
1. Locate the junction box.
2. Find the physical key.
3. Use the key to remove the lock preventing operation of the large switch.
4. Physically move the switch upward.
5. The controller accepts the switch observation and begins the powered arcade portion of the experience.
Unlocking alone does not restore apparent power. The switch action is the completion trigger. Do not assume that key discovery or lock removal has an electronic sensor; the mechanical lock may enforce those prerequisites. Confirm the switch input hardware before assigning a GPIO or I2C channel. BCM23 remains an LED output, not this input.
Suggested controller states: prepared, opening investigation, power restoration accepted, arcade active. Opening narration and hint delivery must work before power restoration. Starting the GM session and starting powered arcade play are distinct actions. Whether the room timer starts on entry or on switch activation remains a design decision.
A proposed GM panel shows the reported switch position, observation freshness, and room-power story state separately. Provide an explicit, logged override for a failed prop. Repeated switch observations must not replay the opening or reset progress. A switch already up when a new run starts should produce a setup warning; do not silently skip the puzzle. The effect of lowering the switch during an active run remains to be decided; it must not implicitly erase progress.
Restoring room power does not automatically award RFID credits or bypass individual cabinet prerequisites. Decide which cabinet boot sequences and lighting cues accompany restoration when authoring the opening sequence.
### Confirmed narrative direction: haunted appearance, alien explanation
Early activity supports a plausible haunted-arcade interpretation: unusual machine behavior, unexplained sounds, and unsettling coincidences. Teddy sets the tone without explicitly explaining an alien cause. The alien manipulation is revealed when the secret room opens.
Review player-facing Teddy dialogue, puzzle text, cabinet art, and hint categories for premature alien, spacecraft, or launch references. GM-only information can describe the complete plot. Pre-reveal hints should remain available without exposing post-reveal material. After the reveal, prior events can be reinterpreted in light of what players have discovered.
### Proposal for playtesting: one cabinet controls another
During the later return to the arcade cabinets, a player's controls could operate a task displayed at another cabinet. The player with the useful view must communicate with the player controlling that view. This is a proposed mechanic, not yet a fixed routing plan or acceptance requirement.
Start with a two-cabinet test: player A operates controls while player B sees the target and gives directions; then exchange roles. Give both players meaningful feedback without showing the operator enough information to solve alone. Keep routing stable during an attempt and clearly announce changes between rounds. A larger circular arrangement can be tested after the pair works reliably.
Retain the secret-room navigator's role by giving that player the objective or target information while the arcade players coordinate its execution. Test whether this creates useful collaboration rather than excessive layers of instruction. Account for sightlines, room noise, latency, available controls, and smaller parties. Avoid requiring all six cabinets simultaneously for a small group.
Implementation candidate: the room controller authorizes source-to-target assignments for each round; cabinet clients relay bounded gameplay input through the agreed transport. Discard stale-round input and release held controls on disconnect, pause, or reassignment. This routing is for gameplay, not arbitrary remote physical outputs. GM diagnostics should show the control route and both cabinet connection states.
### Confirmed content direction: original arcade identities
Replace the current recognizable game names and sprites with original names and artwork for the room's arcade games. Track final names, character designs, marquees, screen art, sound, music, and text as authored assets rather than assuming a sprite swap completes the work. Existing game identifiers can remain internal during migration so saves, scenarios, and hardware assignments do not break unnecessarily.
This records the intended creative direction, not a legal clearance determination. Do not describe renaming and reskinning alone as a guarantee of clearance.
### Next simulation work
- Add the opening power gate, switch simulation, and logged GM override.
- Keep Teddy available while the arcade appears unpowered.
- Add stage-aware, spoiler-aware hint eligibility before the secret-room reveal.
- Test repeated switch events, startup with switch already up, recovery, and no automatic RFID credit.
- Prototype paired cabinet control with a clear GM routing display and small-party fallback.
- Inventory replacement game presentation assets without changing stable scenario IDs prematurely.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,782 @@
{
"design_version": "1.0",
"status": "proposed_semantic_contract_not_existing_wire_schema",
"target_architecture": {
"authority": "Rust Glitch room controller",
"transport": "NATS",
"operator_ui": "Flutter",
"game_runtime": "Godot",
"persistence_proposal": "durable transactional journal and outbox with JetStream observation delivery"
},
"profiles": {
"guided": {
"time_limit_ms": 3600000,
"passes_per_station": 1,
"response_window_ms": 60000,
"stable_target_hold_ms": 1000,
"final_ack_window_ms": 30000,
"hint_1_inactivity_ms": 90000,
"next_hint_interval_ms": 60000,
"score_target_multiplier": 0.6,
"frogger_homes": 1
},
"standard": {
"time_limit_ms": 3600000,
"passes_per_station": 2,
"response_window_ms": 40000,
"stable_target_hold_ms": 2000,
"final_ack_window_ms": 20000,
"hint_1_inactivity_ms": 120000,
"next_hint_interval_ms": 90000,
"score_target_multiplier": 1.0,
"frogger_homes": 1
},
"challenge": {
"time_limit_ms": 3600000,
"passes_per_station": 3,
"response_window_ms": 25000,
"stable_target_hold_ms": 3000,
"final_ack_window_ms": 12000,
"hint_1_inactivity_ms": 180000,
"next_hint_interval_ms": 120000,
"score_target_multiplier": 1.4,
"frogger_homes": 1
}
},
"entities": [
{
"cabinet_id": "cabinet_01",
"first_state_id": "A01",
"game": "pacman",
"token_id": "T01",
"cell_id": "CELL01",
"socket_id": "SOCKET01",
"status": "existing_assignment"
},
{
"cabinet_id": "cabinet_02",
"first_state_id": "A02",
"game": "galaga",
"token_id": "T02",
"cell_id": "CELL02",
"socket_id": "SOCKET02",
"status": "existing_assignment"
},
{
"cabinet_id": "cabinet_03",
"first_state_id": "A03",
"game": "tempest",
"token_id": "T03",
"cell_id": "CELL03",
"socket_id": "SOCKET03",
"status": "existing_assignment"
},
{
"cabinet_id": "cabinet_04",
"first_state_id": "A04",
"game": "missile_command",
"token_id": "T04",
"cell_id": "CELL04",
"socket_id": "SOCKET04",
"status": "existing_assignment"
},
{
"cabinet_id": "cabinet_05",
"first_state_id": "A05",
"game": "frogger",
"token_id": "T05",
"cell_id": "CELL05",
"socket_id": "SOCKET05",
"status": "proposed_assignment"
},
{
"cabinet_id": "cabinet_06",
"first_state_id": "A06",
"game": "space_invaders",
"token_id": "T06",
"cell_id": "CELL06",
"socket_id": "SOCKET06",
"status": "proposed_assignment"
}
],
"boss": {
"cabinet_id": "cabinet_07",
"id": "B07",
"game_proposal": "robotron_or_bespoke_variant",
"unlock": "all_six_distinct_first_state_wins"
},
"durations_ms": {
"charge": 30000,
"passage_debounce": 300,
"cell_debounce": 500,
"ship_power_stable": 2000,
"retry_delay": 3000,
"countdown": 5000,
"agent_stale": 3000,
"agent_offline": 8000,
"command_accept": 2000,
"command_expiry": 5000,
"boot_completion": 10000
},
"staffing": {
"operator_count_rule": "max(1, min(party_size - 1, 6))",
"calibration_concurrency": "operator_count",
"solo": "assisted sequential ship/arcade interaction",
"finale": "sequential station arm, group readiness, navigator commit",
"solo_final_ack_window_ms": 90000
},
"event_envelope_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Proposed Glitch normalized event envelope",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"event_id",
"type",
"room_id",
"run_id",
"entity_id",
"source_id",
"boot_id",
"source_seq",
"assignment_id",
"occurred_at_ms",
"received_at_ms",
"payload"
],
"properties": {
"schema_version": {
"type": "integer",
"minimum": 0,
"const": 1
},
"event_id": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"minLength": 1
},
"room_id": {
"type": "string",
"minLength": 1
},
"run_id": {
"type": "string",
"minLength": 1
},
"entity_id": {
"type": "string",
"minLength": 1
},
"source_id": {
"type": "string",
"minLength": 1
},
"boot_id": {
"type": "string",
"minLength": 1
},
"source_seq": {
"type": "integer",
"minimum": 0
},
"assignment_id": {
"type": [
"string",
"null"
]
},
"occurred_at_ms": {
"type": "integer",
"minimum": 0
},
"received_at_ms": {
"type": "integer",
"minimum": 0
},
"payload": {
"type": "object"
},
"correlation_id": {
"type": "string",
"minLength": 1
},
"causation_id": {
"type": "string",
"minLength": 1
},
"actor_id": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
}
},
"room_state_example": {
"schema_version": 1,
"room_id": "glitch",
"run_id": "example-run-001",
"revision": 0,
"config_hash": "EXAMPLE_REPLACE_WITH_CONTENT_HASH",
"lifecycle": "READY",
"phase": "BRIEFING",
"outcome": null,
"active_elapsed_ms": 0,
"time_limit_ms": 3600000,
"difficulty": "standard",
"party_size": 2,
"controller_epoch": 1,
"seed": 42,
"cabinet_first_wins": [],
"cells_installed": [],
"calibration_passes": {},
"launch": {
"launch_epoch": 0,
"armed_stations": [],
"acknowledged_groups": [],
"committed": false
}
},
"simulation_cases": [
{
"id": "SIM01",
"scenario": "Golden path with 6 players, standard",
"assertion": "All acts, six cells and two calibration passes; one launch commit",
"execution_status": "specified_not_executed"
},
{
"id": "SIM02",
"scenario": "2 players, guided; one roaming operator",
"assertion": "All six stations completed; no simultaneous multi-station human input required",
"execution_status": "specified_not_executed"
},
{
"id": "SIM03",
"scenario": "1 assisted player; sequential ship and arcade interactions",
"assertion": "No deadlock; clearly assisted outcome/metadata",
"execution_status": "specified_not_executed"
},
{
"id": "SIM04",
"scenario": "8 players, challenge; six stations active",
"assertion": "Unused helper never required for a guard; three passes per station",
"execution_status": "specified_not_executed"
},
{
"id": "SIM05",
"scenario": "Wrong token, repeated scan, stale historical scan",
"assertion": "No credit for wrong/stale; only one authorization for correct scan",
"execution_status": "specified_not_executed"
},
{
"id": "SIM06",
"scenario": "Lose A03 twice then retry",
"assertion": "No extra token needed; other wins and charge retained",
"execution_status": "specified_not_executed"
},
{
"id": "SIM07",
"scenario": "Duplicate A01 win and missing A06 win",
"assertion": "Boss remains dark; unique set count stays 5",
"execution_status": "specified_not_executed"
},
{
"id": "SIM08",
"scenario": "Claw reward ACK but token delivery jam",
"assertion": "Reward pending; GM hand-delivery logged; no duplicate dispense",
"execution_status": "specified_not_executed"
},
{
"id": "SIM09",
"scenario": "Boss won; passage ACK without open sensor",
"assertion": "Reveal hold after 5 s; no assumed opening",
"execution_status": "specified_not_executed"
},
{
"id": "SIM10",
"scenario": "Wrong socket, duplicate cell identity, missing cell",
"assertion": "Ship power guard false; explicit diagnostic per conflict",
"execution_status": "specified_not_executed"
},
{
"id": "SIM11",
"scenario": "Cell removed during calibration and countdown",
"assertion": "Current wave/countdown canceled; completed passes retained; fresh IDs",
"execution_status": "specified_not_executed"
},
{
"id": "SIM12",
"scenario": "Late Ready for old challenge; Execute exactly at deadline",
"assertion": "Both rejected; current wave remains consistent",
"execution_status": "specified_not_executed"
},
{
"id": "SIM13",
"scenario": "One final group late, then retry",
"assertion": "Acknowledgements clear; calibration retained; launch once on valid retry",
"execution_status": "specified_not_executed"
},
{
"id": "SIM14",
"scenario": "Restart controller after durable boss win before output publish",
"assertion": "Recovered outbox sends same command ID; no repeated physical release",
"execution_status": "specified_not_executed"
},
{
"id": "SIM15",
"scenario": "Agent restarts after uncertain bay release",
"assertion": "No blind pulse replay; reconcile or GM inspect",
"execution_status": "specified_not_executed"
},
{
"id": "SIM16",
"scenario": "Network drop, duplicate and reordered events",
"assertion": "No regression/double reward; snapshot reconciliation reaches same state",
"execution_status": "specified_not_executed"
},
{
"id": "SIM17",
"scenario": "Teddy unavailable or stale queued hint",
"assertion": "Fallback text; stale hint discarded; gameplay can finish",
"execution_status": "specified_not_executed"
},
{
"id": "SIM18",
"scenario": "Pause 5 min; expire action while paused",
"assertion": "Active timer frozen; expired wall-time command rejected",
"execution_status": "specified_not_executed"
},
{
"id": "SIM19",
"scenario": "Time expires in calibration; extend 5 min",
"assertion": "TIME_EXPIRED then preserved phase; no automatic normal win",
"execution_status": "specified_not_executed"
},
{
"id": "SIM20",
"scenario": "Reset and inject old-run win/scan/launch",
"assertion": "All rejected; fresh run remains pristine",
"execution_status": "specified_not_executed"
}
],
"calibration_selections": {
"cabinet_01": {
"min": 0,
"max": 3
},
"cabinet_02": {
"min": 1,
"max": 5
},
"cabinet_03": {
"min": 0,
"max": 7
},
"cabinet_04": {
"min": 1,
"max": 3
},
"cabinet_05": {
"min": 1,
"max": 4
},
"cabinet_06": {
"min": 1,
"max": 4
}
},
"payload_schemas": {
"puzzle.solved": {
"type": "object",
"additionalProperties": false,
"properties": {
"puzzle_id": {
"type": "string",
"minLength": 1
},
"evidence": {
"type": "string",
"minLength": 1
}
},
"required": [
"puzzle_id",
"evidence"
]
},
"reward.delivered": {
"type": "object",
"additionalProperties": false,
"properties": {
"reward_id": {
"type": "string",
"minLength": 1
},
"evidence": {
"type": "string",
"minLength": 1
}
},
"required": [
"reward_id",
"evidence"
]
},
"token.observed": {
"type": "object",
"additionalProperties": false,
"properties": {
"reader_id": {
"type": "string",
"minLength": 1
},
"token_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"reader_id",
"token_id"
]
},
"game.started": {
"type": "object",
"additionalProperties": false,
"properties": {
"attempt_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"attempt_id"
]
},
"game.progress": {
"type": "object",
"additionalProperties": false,
"properties": {
"attempt_id": {
"type": "string",
"minLength": 1
},
"value": {
"type": "integer",
"minimum": 0
}
},
"required": [
"attempt_id",
"value"
]
},
"game.won": {
"type": "object",
"additionalProperties": false,
"properties": {
"attempt_id": {
"type": "string",
"minLength": 1
},
"objective_id": {
"type": "string",
"minLength": 1
},
"value": {
"type": "integer",
"minimum": 0
}
},
"required": [
"attempt_id",
"objective_id",
"value"
]
},
"game.lost": {
"type": "object",
"additionalProperties": false,
"properties": {
"attempt_id": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
},
"required": [
"attempt_id",
"reason"
]
},
"cell.observed": {
"type": "object",
"additionalProperties": false,
"properties": {
"cell_id": {
"type": "string",
"minLength": 1
},
"location": {
"type": "string",
"minLength": 1
},
"present": {
"type": "boolean"
}
},
"required": [
"cell_id",
"location",
"present"
]
},
"passage.observed": {
"type": "object",
"additionalProperties": false,
"properties": {
"open": {
"type": "boolean"
},
"sensor_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"open",
"sensor_id"
]
},
"calibration.ready": {
"type": "object",
"additionalProperties": false,
"properties": {
"challenge_id": {
"type": "string",
"minLength": 1
},
"selection": {
"type": "integer",
"minimum": 0
},
"stable_for_ms": {
"type": "integer",
"minimum": 0
}
},
"required": [
"challenge_id",
"selection",
"stable_for_ms"
]
},
"calibration.ready_revoked": {
"type": "object",
"additionalProperties": false,
"properties": {
"challenge_id": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
},
"required": [
"challenge_id",
"reason"
]
},
"calibration.execute": {
"type": "object",
"additionalProperties": false,
"properties": {
"challenge_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"challenge_id"
]
},
"launch.group_ready": {
"type": "object",
"additionalProperties": false,
"properties": {
"launch_epoch": {
"type": "integer",
"minimum": 0
},
"group_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"launch_epoch",
"group_id"
]
},
"launch.commit_requested": {
"type": "object",
"additionalProperties": false,
"properties": {
"launch_epoch": {
"type": "integer",
"minimum": 0
}
},
"required": [
"launch_epoch"
]
},
"teddy.playback_status": {
"type": "object",
"additionalProperties": false,
"properties": {
"cue_id": {
"type": "string",
"minLength": 1
},
"status": {
"enum": [
"accepted",
"started",
"completed",
"failed"
]
}
},
"required": [
"cue_id",
"status"
]
}
},
"payload_validation": "Validate envelope, then select payload schema by event.type. Reject unknown event types unless separately defined as controller-internal events. Apply semantic guards after schema validation.",
"command_envelope_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"schema_version": {
"const": 1
},
"command_id": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"minLength": 1
},
"room_id": {
"type": "string",
"minLength": 1
},
"run_id": {
"type": "string",
"minLength": 1
},
"target_entity": {
"type": "string",
"minLength": 1
},
"target_boot_id": {
"type": "string",
"minLength": 1
},
"controller_epoch": {
"type": "integer",
"minimum": 0
},
"expected_revision": {
"type": "integer",
"minimum": 0
},
"expires_at_ms": {
"type": "integer",
"minimum": 0
},
"payload": {
"type": "object"
}
},
"required": [
"schema_version",
"command_id",
"type",
"room_id",
"run_id",
"target_entity",
"target_boot_id",
"controller_epoch",
"expected_revision",
"expires_at_ms",
"payload"
]
},
"command_result_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"command_id": {
"type": "string",
"minLength": 1
},
"status": {
"enum": [
"ACCEPTED",
"REJECTED",
"COMPLETED",
"FAILED",
"DELIVERY_UNKNOWN"
]
},
"reason_code": {
"type": "string",
"minLength": 1
},
"snapshot_revision": {
"type": "integer",
"minimum": 0
}
},
"required": [
"command_id",
"status",
"reason_code",
"snapshot_revision"
]
},
"event_example": {
"schema_version": 1,
"event_id": "evt-example-001",
"type": "game.won",
"room_id": "glitch",
"run_id": "example-run-001",
"entity_id": "cabinet_01",
"source_id": "agent-cabinet-01",
"boot_id": "boot-example-001",
"source_seq": 14,
"assignment_id": "assignment-a01-001",
"occurred_at_ms": 1792260000000,
"received_at_ms": 1792260000010,
"payload": {
"attempt_id": "attempt-a01-001",
"objective_id": "score_500",
"value": 500
}
}
}
+8
View File
@@ -0,0 +1,8 @@
"""Export Piper with its legacy dynamic-axis ONNX exporter on newer PyTorch."""
import functools
import torch
from piper.train.export_onnx import main
# Piper's exporter specifies dynamic_axes/opset 15 and predates the dynamo default.
torch.onnx.export = functools.partial(torch.onnx.export, dynamo=False)
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// Null means outside the current context; all parallel unfinished branches qualify.
String? hintReason(Map<String,dynamic> hint, Map<String,dynamic> room) {
final phase = room['phase'];
if (hint['phase'] != 'ALL' && hint['phase'] != phase) return null;
final target = hint['target'];
if (target == 'room') return hint['phase'] == 'ALL' ? 'General room hint' : 'Current phase: $phase';
final isPuzzle = (target as String).startsWith('P');
final items = (room[isPuzzle ? 'puzzles' : 'stations'] as List?) ?? [];
final matches = items.where((p) => p['id'] == target);
if (matches.isEmpty) return null;
final item = matches.first;
final complete = item[isPuzzle ? 'solved' : 'won'] == true;
if (hint['condition'] == 'complete' && !complete) return null;
if (hint['condition'] == 'incomplete') {
if (complete || phase != 'ARCADE') return null;
bool flag(String id, String field) => ((room['puzzles'] as List?) ?? []).any((p) => p['id'] == id && p[field] == true);
if (target == 'P02A' && !flag('P01','solved')) return null;
if (target == 'P02B' && !flag('P02A','delivered')) return null;
}
return '$target: ${complete ? 'completed' : 'unfinished'}';
}
class HintPanel extends StatefulWidget {
const HintPanel({super.key, required this.room, required this.client, required this.base, required this.onSpeak, required this.canSpeak});
final Map<String,dynamic> room;
final http.Client client;
final String base;
final Future<void> Function(String text) onSpeak;
final bool canSpeak;
@override
State<HintPanel> createState() => _HintPanelState();
}
class _HintPanelState extends State<HintPanel> {
List<Map<String,dynamic>> hints = [];
int revision = 0;
bool loading = false, suggested = true;
String category = 'All categories', query = '', message = '';
final draft = TextEditingController();
@override
void initState() { super.initState(); load(); }
@override
void dispose() { draft.dispose(); super.dispose(); }
Future<void> load() async {
if (loading) return;
setState(() => loading = true);
try {
final response = await widget.client.get(Uri.parse('${widget.base}/hints')).timeout(const Duration(seconds:5));
final body = jsonDecode(response.body);
if (response.statusCode != 200 || body['hints'] is! List) throw Exception('Hint library unavailable. Restart the updated lab gateway.');
if (mounted) setState(() {
hints = (body['hints'] as List).map((h)=>Map<String,dynamic>.from(h)).toList();
revision = body['revision']; message = '';
});
} catch (e) { if (mounted) setState(()=>message=e.toString()); }
finally { if (mounted) setState(()=>loading=false); }
}
Future<void> edit([Map<String,dynamic>? original, String? initialText]) async {
final title = TextEditingController(text:original?['title'] ?? '');
final text = TextEditingController(text:original?['text'] ?? initialText ?? '');
final tags = TextEditingController(text:((original?['categories'] as List?) ?? ['General']).join(', '));
var phase = original?['phase'] ?? 'ALL';
var target = original?['target'] ?? 'room';
var condition = original?['condition'] ?? 'any';
var level = original?['level'] ?? 1;
var error = '';
var saving = false;
var editRevision = revision;
await showDialog<void>(context:context,builder:(context)=>StatefulBuilder(builder:(context,update) {
Widget pick(String label, dynamic value, List<dynamic> values, void Function(dynamic) change) =>
DropdownButtonFormField<dynamic>(initialValue:value,isExpanded:true,
decoration:InputDecoration(labelText:label),
items:values.map((v)=>DropdownMenuItem<dynamic>(value:v,child:Text('$v'))).toList(),
onChanged:saving ? null : (v)=>update(()=>change(v)));
return AlertDialog(
title:Text(original == null ? 'Create saved hint' : 'Edit saved hint'),
content:SizedBox(width:580,child:SingleChildScrollView(child:Column(mainAxisSize:MainAxisSize.min,children:[
TextField(controller:title,maxLength:100,decoration:const InputDecoration(labelText:'Hint title')),
TextField(controller:text,maxLength:1500,minLines:3,maxLines:6,decoration:const InputDecoration(labelText:'What Teddy should say')),
TextField(controller:tags,decoration:const InputDecoration(labelText:'Categories, separated by commas')),
pick('Room phase',phase,['ALL','BRIEFING','ARCADE','BOSS','REVEAL','CELLS','CALIBRATION_TUTORIAL','CALIBRATION','LAUNCH_ARM','COUNTDOWN','DEBRIEF'],(v)=>phase=v),
pick('Puzzle or cabinet',target,['room','P01','P02A','P02B','P03','P04','P05','P06',...List.generate(6,(i)=>'cabinet_0${i+1}')],(v)=>target=v),
pick('Show when target is',condition,['any','incomplete','complete'],(v)=>condition=v),
pick('Hint strength: 1 subtle, 2 direct, 3 solution',level,[1,2,3],(v)=>level=v),
if (error.isNotEmpty) Text(error,style:TextStyle(color:Theme.of(context).colorScheme.error)),
]))),
actions:[
TextButton(onPressed:saving?null:()=>Navigator.pop(context),child:const Text('Cancel')),
FilledButton(onPressed:saving?null:() async {
update(()=>saving=true);
try {
final result=await widget.client.post(Uri.parse('${widget.base}/hints'),
headers:{'Content-Type':'application/json'},
body:jsonEncode({'revision':editRevision,'hint':{
'id':original?['id'] ?? '', 'title':title.text,'text':text.text,
'categories':tags.text.split(',').map((s)=>s.trim()).where((s)=>s.isNotEmpty).toList(),
'phase':phase,'target':target,'condition':condition,'level':level,
}})).timeout(const Duration(seconds:6));
final body=jsonDecode(result.body);
if (result.statusCode != 200) {
if (result.statusCode == 409) {
await load(); editRevision=revision;
throw Exception('Library changed on another device. Your draft is kept. Review it, then Save again to apply your version.');
}
throw Exception(body['error'] ?? 'Save failed');
}
if (mounted) setState(() {
hints=(body['hints'] as List).map((h)=>Map<String,dynamic>.from(h)).toList();
revision=body['revision']; message='Hint saved on the host PC.';
});
if (context.mounted) Navigator.pop(context);
} catch(e) { if(context.mounted) update(()=>error=e.toString()); }
finally { if(context.mounted) update(()=>saving=false); }
},child:Text(saving?'Saving...':'Save hint')),
],
);
}));
// Controllers are released after the dialog route's closing animation.
await Future<void>.delayed(const Duration(milliseconds:250));
title.dispose(); text.dispose(); tags.dispose();
}
@override
Widget build(BuildContext context) {
final categories = {'All categories',...hints.expand((h)=>(h['categories'] as List).cast<String>())}.toList()..sort();
final shown = hints.where((h) {
if(suggested && hintReason(h,widget.room)==null) return false;
if(category!='All categories' && !(h['categories'] as List).contains(category)) return false;
return '${h['title']} ${h['text']} ${h['categories']}'.toLowerCase().contains(query.toLowerCase());
}).toList()..sort((a,b)=>(a['level'] as int).compareTo(b['level'] as int));
return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
const SizedBox(height:24),
Text('Hint library',style:Theme.of(context).textTheme.titleLarge),
Text('Room phase: ${widget.room['phase']}. Suggestions include all eligible parallel puzzles. These are suggestions; the GM chooses what to say.'),
Wrap(spacing:10,runSpacing:8,children:[
FilterChip(label:const Text('Relevant now'),selected:suggested,onSelected:(v)=>setState(()=>suggested=v)),
OutlinedButton(onPressed:()=>edit(),child:const Text('New saved hint')),
TextButton(onPressed:loading?null:load,child:const Text('Reload library')),
]),
DropdownButton<String>(isExpanded:true,value:categories.contains(category)?category:'All categories',
items:categories.map((c)=>DropdownMenuItem(value:c,child:Text(c))).toList(),
onChanged:(v)=>setState(()=>category=v!)),
TextField(decoration:const InputDecoration(labelText:'Search hints'),onChanged:(v)=>setState(()=>query=v)),
if(message.isNotEmpty) Text(message),
if(loading) const LinearProgressIndicator(),
if(!loading && shown.isEmpty) const Text('No matching hints. Turn off Relevant now to browse the whole library.'),
...shown.map((h)=>Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text(h['title'],style:Theme.of(context).textTheme.titleMedium),
Text('${(h['categories'] as List).join(' / ')} · Level ${h['level']} · ${hintReason(h,widget.room) ?? 'Outside current context'}'),
Text(h['text']),
Wrap(spacing:8,children:[
FilledButton(onPressed:widget.canSpeak?()=>widget.onSpeak(h['text']):null,child:const Text('Say now')),
TextButton(onPressed:()=>edit(h),child:const Text('Edit')),
]),
])))),
const SizedBox(height:16),
TextField(controller:draft,minLines:2,maxLines:5,maxLength:1500,
decoration:const InputDecoration(labelText:'Write a one-off hint',hintText:'Type exactly what Teddy should say')),
Wrap(spacing:8,children:[
FilledButton(onPressed:widget.canSpeak?() {
if(draft.text.trim().isNotEmpty) widget.onSpeak(draft.text.trim());
}:null,child:const Text('Say typed hint now')),
OutlinedButton(onPressed:()=>edit(null,draft.text),child:const Text('Save typed hint...')),
]),
]);
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'room_page.dart';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() => runApp(const GlitchLab());
class GlitchLab extends StatelessWidget {
const GlitchLab({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Glitch GM Lab',
theme: ThemeData.dark(useMaterial3: true).copyWith(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.cyan, brightness: Brightness.dark)),
home: Uri.base.queryParameters['room'] == '1' ? const RoomPage() : const CabinetPage(),
);
}
class CabinetPage extends StatefulWidget {
const CabinetPage({super.key, this.client});
final http.Client? client;
@override
State<CabinetPage> createState() => _CabinetPageState();
}
class _CabinetPageState extends State<CabinetPage> {
late final http.Client client;
Timer? timer;
Map<String, dynamic>? cabinet;
String? error;
String notice = 'Connecting to the local simulation...';
bool pending = false;
bool polling = false;
int sequence = 0;
int revision = 0;
static const base = 'http://127.0.0.1:18766';
@override
void initState() {
super.initState();
client = widget.client ?? http.Client();
refresh();
timer = Timer.periodic(const Duration(seconds: 1), (_) => refresh());
}
Future<void> refresh() async {
if (polling || pending) return;
polling = true;
final requestRevision = revision;
try {
final response = await client.get(Uri.parse('$base/state')).timeout(const Duration(seconds: 3));
if (response.statusCode != 200) throw Exception('Cabinet or controller unavailable');
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (mounted && !pending && requestRevision == revision) setState(() { cabinet = data['state']; error = null; });
} catch (_) {
if (mounted && requestRevision == revision) setState(() => error = 'Disconnected - controls disabled. Check the lab terminals.');
} finally { polling = false; }
}
Future<void> command(String action) async {
if (pending || error != null || cabinet == null) return;
revision++;
setState(() { pending = true; notice = 'Waiting for cabinet acknowledgement...'; });
try {
final response = await client.post(Uri.parse('$base/command'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${sequence++}',
'session': cabinet!['session'],
'expires_ms': DateTime.now().millisecondsSinceEpoch + 10000,
'action': action,
})).timeout(const Duration(seconds: 4));
if (response.statusCode != 200) throw Exception('No acknowledgement');
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (mounted) setState(() {
cabinet = data['state'];
notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}';
error = null;
});
} catch (_) {
if (mounted) setState(() {
error = 'Delivery unconfirmed. Refresh state before trying again.';
notice = 'No success acknowledgement received.';
});
} finally { if (mounted) setState(() => pending = false); }
}
@override
void dispose() { timer?.cancel(); client.close(); super.dispose(); }
@override
Widget build(BuildContext context) {
final phase = cabinet?['phase'] ?? 'unknown';
final available = cabinet != null && error == null && !pending;
Widget control(String label, String action, bool enabled) => FilledButton(
onPressed: available && enabled ? () => command(action) : null, child: Text(label));
return Scaffold(
appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')),
body: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 900),
child: ListView(padding: const EdgeInsets.all(24), children: [
Text(cabinet?['simulated'] == false ? 'INTEGRATION LAB - REAL GODOT CABINET' : 'INTEGRATION LAB - SIMULATED CABINET', style: const TextStyle(color: Colors.amber)),
const SizedBox(height: 16),
const Text('Cabinet 1 / Pac-Man', style: TextStyle(fontSize: 28)),
Text(cabinet?['simulated'] == false ? 'Real Pac-Man. Target: 500 points. No reward outputs. RFID injection: ${cabinet?['rfid_injection'] == true ? 'enabled' : 'disabled'}.' : 'No physical RFID, LEDs, GPIO, or arcade game is connected.'),
const SizedBox(height: 24),
Card(child: Padding(padding: const EdgeInsets.all(24), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(phase.replaceAll('_', ' ').toUpperCase(), style: const TextStyle(fontSize: 32)),
Text('Credits: ${cabinet?['credits'] ?? '-'} Accepted scans: ${cabinet?['scans'] ?? '-'}'),
const SizedBox(height: 12),
Text(phase == 'powered_off' ? 'Waiting for operator' :
phase == 'booting' ? 'Four-second boot sequence...' :
phase == 'ready' ? 'Scan a card, then press Start' :
phase == 'playing' ? 'Game in progress' :
phase == 'won' ? 'Objective completed' : phase == 'lost' ? 'Game lost - reset the test to retry' : 'Awaiting state'),
]))),
const SizedBox(height: 16),
Wrap(spacing: 12, runSpacing: 12, children: [
control('Power on', 'power_on', phase == 'powered_off'),
control('Simulate RFID scan', 'scan', phase == 'ready' && (cabinet?['simulated'] == true || cabinet?['rfid_injection'] == true)),
control('Start game', 'start', phase == 'ready' && cabinet?['credits'] == 1),
control('Simulate win', 'win', phase == 'playing' && cabinet?['simulated'] == true),
control('Reset test', 'reset', true),
]),
const SizedBox(height: 24),
Text(error ?? notice, style: TextStyle(color: error == null ? Colors.white70 : Colors.orange)),
const SizedBox(height: 16),
const Text('Flutter → local JSON gateway → NATS / MessagePack → simulated cabinet'),
]))),
);
}
}
+437
View File
@@ -0,0 +1,437 @@
import 'dart:async';
import 'dart:convert';
import 'teddy_audio.dart';
import 'hint_panel.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
class RoomPage extends StatefulWidget {
const RoomPage({super.key, this.client});
final http.Client? client;
@override
State<RoomPage> createState() => _RoomPageState();
}
class _RoomPageState extends State<RoomPage> {
static final base = (Uri.base.scheme == 'http' || Uri.base.scheme == 'https') ? Uri.base.origin : 'http://127.0.0.1:18777';
late final http.Client client;
Timer? timer;
Map<String, dynamic>? room;
bool pending = false, polling = false;
String? error;
String notice = 'Connecting to the isolated room controller...';
String profile = 'standard';
int party = 6, tab = 0, serial = 0, generation = 0;
final Map<String, int> selections = {};
int? selectionWave;
Map<String, dynamic>? trace;
String speechStatus = 'Teddy audio not enabled (captions available)';
bool speechPolling = false;
final audio = TeddyAudio();
String? readyAudioUrl;
Future<void> refreshSpeech() async {
if (speechPolling) return;
speechPolling = true;
var status = 'Teddy audio not enabled. Start lab.py start-room --speech.';
try {
final response = await client.get(Uri.parse('$base/speech/health')).timeout(const Duration(seconds: 2));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final teddy = room?['teddy'];
final valid = error == null && data['output'] == 'browser' &&
data['run_id'] == room?['run_id'] && data['cue_id'] != null &&
data['cue_id'] == teddy?['current']?['id'] &&
teddy?['muted'] != true && teddy?['online'] == true &&
(teddy?['takeover'] != true || (teddy?['current']?['key'] as String? ?? '').startsWith('gm-'));
readyAudioUrl = valid && data['audio_url'] is String ? '$base/speech${data['audio_url']}' : null;
audio.sync(readyAudioUrl);
status = 'Teddy: ${data['status']} · ${data['voice']} — ${data['message']}';
} else { readyAudioUrl=null; audio.sync(null); }
} catch (_) { readyAudioUrl=null; audio.sync(null); /* Speech is optional; room controls stay available. */ }
if (mounted) setState(() => speechStatus = status);
speechPolling = false;
}
@override
void initState() {
super.initState();
client = widget.client ?? http.Client();
refresh();
refreshSpeech();
timer = Timer.periodic(const Duration(seconds: 1), (_) { refresh(); refreshSpeech(); });
}
@override
void dispose() { timer?.cancel(); audio.dispose(); client.close(); super.dispose(); }
Future<void> refresh() async {
if (pending || polling) return;
polling = true;
final gen = generation;
try {
final response = await client.get(Uri.parse('$base/room/state')).timeout(const Duration(seconds: 6));
if (response.statusCode != 200) throw Exception();
final data = jsonDecode(response.body);
if (data['state'] is! Map) throw Exception();
if (mounted && gen == generation) setState(() { if (room == null || error != null) notice = 'Connected. The room timer runs after briefing; Pause freezes gameplay.'; room = Map<String, dynamic>.from(data['state']); error = null; });
} catch (_) {
if (mounted && gen == generation) setState(() => error = 'Room simulator disconnected. Start lab.py start-room; controls are disabled.');
} finally { polling = false; }
}
Future<void> act(String action, [String entity = '', dynamic value]) async {
if (pending || room == null || error != null) return;
generation++;
setState(() => pending = true);
try {
final response = await client.post(Uri.parse('$base/room/command'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command_id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${serial++}',
'run_id': room!['run_id'], 'expected_revision': room!['revision'],
'expires_at_ms': DateTime.now().millisecondsSinceEpoch + 10000,
'input': {'action': action, 'entity': entity, 'value': value},
})).timeout(const Duration(seconds: 7));
if (response.statusCode != 200) throw Exception();
final data = jsonDecode(response.body);
if (data['state'] is! Map) throw Exception();
if (mounted) setState(() {
room = Map<String, dynamic>.from(data['state']);
notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}';
if (room?['teddy']?['current'] == null) audio.sync(null);
});
} catch (_) {
if (mounted) setState(() => error = 'Delivery unconfirmed. Refresh the room state before retrying.');
} finally { if (mounted) setState(() => pending = false); }
}
Future<void> confirmReset() async {
final confirmed = await showDialog<bool>(context: context, builder: (context) => AlertDialog(
title: const Text('Reset this simulated run?'),
content: const Text('Clears tokens, wins, charge, calibration and launch readiness. No physical room is affected. The current process trace retains the reset.'),
actions: [TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Reset simulation'))]));
if (confirmed == true) await act('reset', '', {'confirmed': true});
}
Future<void> withReason(String action, String entity) async {
final controller = TextEditingController();
final reason = await showDialog<String>(context: context, builder: (context) => AlertDialog(
title: Text(action == 'deliver' ? 'Confirm GM hand-delivery' : 'Finish as assisted rescue'),
content: TextField(controller: controller, autofocus: true,
decoration: const InputDecoration(labelText: 'Staff name and reason')),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(onPressed: () {
if (controller.text.trim().isNotEmpty) Navigator.pop(context, controller.text.trim());
}, child: const Text('Record intervention'))]));
controller.dispose();
if (reason != null) await act(action, entity, action == 'deliver' ? {'hand': true, 'reason': reason} : reason);
}
Future<void> loadTrace() async {
try {
final response = await client.get(Uri.parse('$base/room/trace')).timeout(const Duration(seconds: 6));
if (response.statusCode != 200) throw Exception();
final data = jsonDecode(response.body);
if (mounted) setState(() => trace = Map<String, dynamic>.from(data));
} catch (_) { if (mounted) setState(() => notice = 'Could not fetch trace.'); }
}
bool get enabled => room != null && error == null && !pending;
List<dynamic> get stations => room?['stations'] ?? [];
String get phase => room?['phase'] ?? 'UNKNOWN';
String get lifecycle => room?['lifecycle'] ?? 'UNKNOWN';
Widget button(String label, String action, {String entity = '', dynamic value, bool allow = true}) =>
OutlinedButton(onPressed: enabled && allow ? () => act(action, entity, value) : null, child: Text(label));
Widget panel(String title, List<Widget> children, {Color? color}) => SizedBox(width: double.infinity, child: Card(
color: color, child: Padding(padding: const EdgeInsets.all(18),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
const SizedBox(height: 12), ...children]))));
Widget wrap(List<Widget> children) => Wrap(spacing: 8, runSpacing: 8, children: children);
Widget fact(String text) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(text));
Widget badge(String text, [Color color = Colors.cyan]) => Chip(
label: Text(text), side: BorderSide(color: color.withValues(alpha: .5)));
String blocker() {
if ((room?['hold'] ?? '') != '') return room!['hold'];
switch (phase) {
case 'BRIEFING': return 'Start the run, then finish briefing to enable the discovery puzzles.';
case 'POWER_RESTORE': return 'Find the junction box and key, remove the lock, then raise the large switch. Teddy is awake; cabinets are off.';
case 'BOOTING': return 'Power restored. All arcade cabinets are running their four-second startup check.';
case 'ARCADE': return 'Find six tokens, authorize their cabinets, and achieve six distinct first-state wins.';
case 'BOSS': return 'All six wins are latched. Start and defeat The Dark Machine.';
case 'REVEAL': return 'Passage release is requested. ACK is not an open sensor. Confirm opening and advance 300 ms.';
case 'CELLS': return 'Confirm charged bays open, remove each cell, and install it in its matching socket. Advance 2.5 seconds for stability.';
case 'CALIBRATION_TUTORIAL': return 'Complete two readback/Ready/Execute practices before scored waves.';
case 'CALIBRATION': return 'Match ship targets, simulate stable Ready at each active station, then Execute.';
case 'LAUNCH_ARM': return 'Arm all six stations sequentially, acknowledge each operator group, then navigator Commit.';
case 'COUNTDOWN': return 'Five-second countdown. Removing a cell or losing a required station cancels launch.';
case 'DEBRIEF': return 'Run finished. Review the trace or reset the simulation.';
default: return 'Waiting for a room snapshot.';
}
}
Widget navigation() => ListView(children: [
const Padding(padding: EdgeInsets.all(20), child: Text('ROOM OPERATIONS', style: TextStyle(fontWeight: FontWeight.bold))),
for (final entry in ['Room Flow','Cabinets','Ship & Cells','Calibration & Launch','Teddy','Diagnostics'].asMap().entries)
ListTile(selected: tab == entry.key, leading: Icon([Icons.account_tree,Icons.sports_esports,Icons.battery_charging_full,Icons.rocket_launch,Icons.record_voice_over,Icons.monitor_heart][entry.key]),
title: Text(entry.value), onTap: () { setState(() => tab=entry.key); if (MediaQuery.sizeOf(context).width < 900) Navigator.pop(context); }),
]);
Widget overview() => Column(children: [
panel('What happens next', [
fact(blocker()),
wrap([
button('Start run', 'start_run', allow: lifecycle == 'READY'),
button('Briefing complete', 'briefing_done', allow: lifecycle == 'RUNNING' && phase == 'BRIEFING'),
button('Flip power switch up', 'power_switch', value: true, allow: lifecycle == 'RUNNING' && phase == 'POWER_RESTORE'),
button('Start boss', 'boss_start', allow: lifecycle == 'RUNNING' && room?['boss'] == 'READY'),
button('Simulate boss victory', 'boss_win', allow: lifecycle == 'RUNNING' && room?['boss'] == 'PLAYING'),
button('Observe passage open', 'passage', value: true, allow: phase == 'REVEAL'),
]),
]),
panel('Room flow · conceptual layout', [
const Text('Discovery arcade → Dark Machine → hidden ship → calibration → launch. This is a flow diagram, not a measured floor plan.'),
const SizedBox(height: 12),
wrap([
badge('Puzzles ${(room?['puzzles'] as List? ?? []).where((p) => p['solved'] == true).length}/7'),
badge('First wins ${stations.where((s) => s['won'] == true).length}/6'),
badge('Boss ${room?['boss']}'),
badge('Passage ${room?['passage_open'] == true ? 'OPEN' : 'UNCONFIRMED'}'),
badge('Ship power ${room?['ship_power'] == true ? 'STABLE' : 'WAITING'}'),
badge('Launches ${room?['launches']}'),
]),
]),
panel('Discovery puzzles and rewards', [
const Text('These are the supplied placeholder puzzle contracts. Solve and physical delivery are separate observations.'),
const SizedBox(height: 12),
...((room?['puzzles'] as List?) ?? []).map((p) {
const labels = {'P01':'Restore claw controls','P02A':'Claw stage A','P02B':'Claw stage B',
'P03':'Record and sleeve','P04':'Service-panel symbols','P05':'Ticket values','P06':'Poster overlay'};
final effects = (room?['effects'] as List? ?? []).where((e) => e['target'] == p['token']).toList();
return Padding(padding: const EdgeInsets.only(bottom: 14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${p['id']} · ${labels[p['id']]} ${p['token']}',
style: const TextStyle(fontWeight: FontWeight.bold)),
Text(p['delivered'] == true ? 'Delivered' : p['solved'] == true ?
(p['token'] == '' ? 'Solved' : 'Solved · reward pending') : 'Unsolved'),
wrap([
button('Simulate solve', 'solve', entity: p['id'], allow: phase == 'ARCADE' && p['solved'] != true),
if (p['token'] != '') button('Observe delivery', 'deliver', entity: p['token'], allow: p['solved'] == true && p['delivered'] != true),
if (effects.isNotEmpty) button('ACK only / jam test', 'ack', entity: effects.first['id'], allow: effects.first['status'] == 'REQUESTED'),
if (p['solved'] == true && p['token'] != '' && p['delivered'] != true)
TextButton(onPressed: enabled ? () => withReason('deliver', p['token']) : null, child: const Text('GM hand-delivery...')),
]),
]));
}),
]),
]);
Widget cabinets() => LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth > 1000 ? (constraints.maxWidth - 24) / 3 :
constraints.maxWidth > 650 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth;
return wrap(stations.map<Widget>((s) => SizedBox(width: width, child: panel(
'${s['id']} · ${s['game'].toString().replaceAll('_', ' ')}', [
wrap([badge(s['status'], s['won'] == true ? Colors.green : Colors.cyan),
badge(s['online'] == true ? 'ONLINE' : 'OFFLINE', s['online'] == true ? Colors.green : Colors.red)]),
if (s['status'] == 'BOOTING') ...[const LinearProgressIndicator(), const Text('ROM check / startup - four seconds')],
if (s['status'] == 'TOKEN_WAIT') const Text('ATTRACT MODE - insert token'),
fact('Token ${s['token']} · ${s['authorized'] == true ? 'authorized for retries' : 'not authorized'}'),
fact('Objective ${s['progress']} / ${s['target']} · attempt ${s['attempt']} · losses ${s['losses']}'),
LinearProgressIndicator(value: (s['charge_ms'] as num) / 30000),
fact('${s['cell']} charge ${((s['charge_ms'] as num) / 300).round()}% · calibration passes ${s['passes']}'),
wrap([
button('Scan ${s['token']}', 'token', entity: s['id'], value: {'token':s['token'],'age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'),
button('Wrong token', 'token', entity: s['id'], value: {'token':'WRONG','age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'),
button('Start attempt', 'start', entity: s['id'], allow: s['status'] == 'READY'),
button('Simulate target reached', 'win', entity: s['id'], value: {'attempt':s['attempt'],'progress':s['target']}, allow: s['status'] == 'PLAYING'),
button('Simulate loss', 'lose', entity: s['id'], value: {'attempt':s['attempt']}, allow: s['status'] == 'PLAYING'),
button(s['online'] == true ? 'Disconnect' : 'Reconnect', 'online', entity:s['id'], value:s['online'] != true),
]),
]))).toList());
});
Widget ship() {
final locations = Map<String, dynamic>.from(room?['locations'] ?? {});
final faults = room?['cell_faults'] as List? ?? [];
return Column(children: [
panel('Passage and ship power', [
fact('Passage: ${room?['passage_open'] == true ? 'observed open' : 'unconfirmed'} · ship power: ${room?['ship_power']}'),
if (faults.isNotEmpty) Text(faults.join('\n'), style: const TextStyle(color: Colors.amber)),
]),
...stations.map<Widget>((s) {
final bay = 'BAY${s['cell'].toString().substring(4)}';
final here = locations.entries.where((e) => e.value == s['cell']).map((e) => e.key).join(', ');
return panel('${s['cell']} → ${s['socket']}', [
fact('Location: ${here.isEmpty ? 'carried / unobserved' : here} · bay open: ${s['bay_open']}'),
wrap([
button('Observe bay open', 'bay', entity:s['id'], allow: room?['passage_open'] == true && s['charge_ms'] == 30000 && s['bay_open'] != true),
button('Remove from bay', 'cell', entity:bay, value:{'cell':s['cell'],'present':false}, allow: s['bay_open'] == true && locations[bay] == s['cell']),
button('Insert matching socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':true}, allow: room?['passage_open'] == true && locations[s['socket']] != s['cell']),
button('Remove from socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':false}, allow: locations[s['socket']] == s['cell']),
]),
]);
}),
]);
}
Widget calibration() {
final wave = room?['wave'] as Map?;
if (wave?['id'] != selectionWave) {
selectionWave = wave?['id'];
selections.clear();
}
final targets = Map<String, dynamic>.from(wave?['targets'] ?? {});
const ranges = {'cabinet_01':[0,3],'cabinet_02':[1,5],'cabinet_03':[0,7],
'cabinet_04':[1,3],'cabinet_05':[1,4],'cabinet_06':[1,4]};
final hold = {'guided':1000,'standard':2000,'challenge':3000}[room?['profile']] ?? 2000;
return Column(children: [
panel('Navigator and operator practice', [
fact('Party ${room?['party']} · ${room?['operators']} operator groups · practices ${room?['tutorial']}/2'),
fact(blocker()),
if (wave != null) ...[
fact('Challenge ${wave['id']} · active-time deadline ${wave['deadline'] ?? 'untimed practice'} ms'),
...targets.entries.map((entry) {
final range = ranges[entry.key]!;
final selected = selections[entry.key] ?? range.first;
return Padding(padding: const EdgeInsets.only(bottom: 14), child: wrap([
SizedBox(width: 230, child: Text('${entry.key}\nShip target: ${entry.value}\nReadback: ${wave['ready'][entry.key] ?? 'waiting'}')),
DropdownButton<int>(value:selected, items:[for (int i = range.first; i <= range.last; i++)
DropdownMenuItem(value:i, child:Text('Selection $i'))],
onChanged:enabled ? (v) => setState(() => selections[entry.key] = v!) : null),
button('Simulate held Ready', 'ready', entity:entry.key,
value:{'challenge':wave['id'],'selection':selected,'stable_ms':hold}),
button('Revoke Ready', 'revoke', entity:entry.key, value:wave['id']),
]));
}),
button('Navigator Execute', 'execute', value:wave['id']),
],
const SizedBox(height: 12),
wrap(stations.map<Widget>((s) => badge('${s['id']}: ${s['passes']} passes')).toList()),
]),
panel('Launch barrier', [
fact('Epoch ${room?['launch_epoch']} · Armed ${(room?['armed'] as List? ?? []).length}/6'),
wrap(stations.map<Widget>((s) => button('Arm ${s['id']}', 'arm', entity:s['id'], value:room?['launch_epoch'],
allow:phase == 'LAUNCH_ARM' && !(room?['armed'] as List? ?? []).contains(s['id']))).toList()),
const SizedBox(height: 12),
wrap([for (int i=0; i < (room?['operators'] ?? 1); i++)
button('Group ${i+1} ready', 'group_ready', value:{'epoch':room?['launch_epoch'],'group':i},
allow:phase == 'LAUNCH_ARM' && !(room?['groups'] as List? ?? []).contains(i))]),
fact('One operator can arm several stations in sequence. No six-person simultaneous button press is required.'),
button('Navigator Commit', 'commit', value:room?['launch_epoch'], allow:phase == 'LAUNCH_ARM'),
if (phase == 'COUNTDOWN') button('Advance five-second countdown', 'advance', value:5000),
]),
]);
}
Widget teddy() {
final t = room?['teddy'] as Map? ?? {};
return panel('Teddy Ruxpin · hints and speech', [
wrap([badge(t['mode'] ?? 'DORMANT'), badge(t['online'] == true ? 'ONLINE' : 'FAULT'),
badge(t['muted'] == true ? 'MUTED' : 'CAPTIONS + OPTIONAL PIPER'), if (t['takeover'] == true) badge('GM TAKEOVER')]),
Text(speechStatus),
Text(audio.status),
OutlinedButton(onPressed: () async {
if (audio.enabled) { setState(() => audio.disable()); }
else { await audio.enable(); if (mounted) { setState(() {}); await refreshSpeech(); } }
}, child: Text(audio.enabled ? 'Disable audio on this device' : 'Enable audio and test speaker')),
Wrap(spacing:8,runSpacing:8,children:[
OutlinedButton(onPressed:() async { await audio.enable(); if(mounted) setState(() {}); },child:const Text('Test speaker tone')),
FilledButton(onPressed:readyAudioUrl == null ? null : () { audio.playNow(readyAudioUrl!); setState(() {}); },child:const Text('Play current hint here')),
]),
if (t['takeover'] == true) const Text('GM takeover: automatic narration is paused. Say now and Replay still work.'),
if (t['muted'] == true || t['online'] != true) const Text('Teddy is muted or offline. Unmute/reconnect before sending hints.'),
const Text('With browser speech enabled, sound plays on each device that opts in. Keep the page open. Listen before advancing virtual time: advancing may stop or replace the cue. Doll movement remains simulated.'),
const SizedBox(height: 18),
SelectableText(t['transcript'] == '' ? 'Waiting for a cue...' : t['transcript'] ?? '',
style: const TextStyle(fontSize: 22)),
const SizedBox(height: 18),
fact('Current cue: ${t['current']?['key'] ?? 'none'} · Hint level: ${t['hint_level'] ?? 0}'),
wrap([
button('Quick phase hint now', 'hint', allow:['ARCADE','CALIBRATION','CALIBRATION_TUTORIAL'].contains(phase) && t['online'] == true && t['muted'] != true),
button(t['muted'] == true ? 'Unmute' : 'Mute', 'teddy', value:'mute'),
button('Stop cue', 'teddy', value:'stop'),
button('Replay transcript cue', 'teddy', value:'replay'),
button(t['online'] == true ? 'Simulate Teddy failure' : 'Reconnect Teddy', 'teddy', value:'offline'),
button(t['takeover'] == true ? 'Release to autopilot' : 'GM takeover', 'teddy',
value:t['takeover'] == true ? 'release' : 'takeover'),
]),
const SizedBox(height: 18),
const Text('Queued cues'),
...((t['queue'] as List?) ?? []).map((c) => ListTile(title:Text(c['key']), subtitle:Text(c['transcript']))),
HintPanel(key:const ValueKey('hint-library'),room:room!,client:client,base:base,canSpeak:enabled && t['online'] == true && t['muted'] != true,
onSpeak:(text)=>act('speak','',{'text':text})),
]);
}
Widget diagnostics() => panel('Diagnostics and replay trace', [
const Text('Fast-forward the simulation clock for testing:'),
wrap([
button('+ 1 second','advance',value:1000),
button('+ 3 seconds','advance',value:3000),
button('+ 30 seconds','advance',value:30000),
button('+ 2 minutes','advance',value:120000),
]),
fact('Run ${room?['run_id']} · revision ${room?['revision']}'),
const Text('In-memory simulation trace. Save it before closing the controller. Durable recovery and physical output replay are not implemented.'),
wrap([
OutlinedButton(onPressed: enabled ? loadTrace : null, child: const Text('Refresh trace')),
OutlinedButton(onPressed: trace == null ? null : () async {
await Clipboard.setData(ClipboardData(text:const JsonEncoder.withIndent(' ').convert(trace)));
if (mounted) setState(() => notice = 'Trace copied. Save it as a JSON file for replay.');
}, child: const Text('Copy trace JSON')),
]),
if (trace != null) fact('Trace events: ${(trace!['trace'] as List).length} · final SHA-256: ${trace!['final_hash']}'),
...((room?['effects'] as List?) ?? []).map((e) => ListTile(
title:Text('${e['kind']} · ${e['target']}'), subtitle:Text('${e['status']} — ${e['id']}'),
trailing:button('Simulate ACK', 'ack', entity:e['id'], allow:e['status']=='REQUESTED'))),
if (trace != null) ...((trace!['trace'] as List).reversed.take(20)).map((e) => ListTile(
leading:Icon(e['accepted'] == true ? Icons.check_circle_outline : Icons.block),
title:Text('${e['request']['input']['action']} ${e['request']['input']['entity']}'),
subtitle:Text(e['message']))),
]);
@override
Widget build(BuildContext context) {
final remaining = ((room?['limit_ms'] ?? 3600000) - (room?['active_ms'] ?? 0)) ~/ 1000;
return Scaffold(
appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')),
drawer: Drawer(child: SafeArea(child: navigation())),
body: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (MediaQuery.sizeOf(context).width >= 900) SizedBox(width: 230, child: navigation()),
Expanded(child: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 1450),
child: ListView(padding: const EdgeInsets.all(20), children: [
const Text('LAB SIMULATION • LIVE TIMER AFTER BRIEFING • NO GPIO',
style: TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
wrap([badge(lifecycle),badge(phase),badge('${room?['profile'] ?? 'standard'} / ${room?['party'] ?? 6} players'),badge('Remaining ${remaining ~/ 60}:${(remaining % 60).toString().padLeft(2,'0')}'),
badge(room?['assisted'] == true ? 'ASSISTED' : 'UNASSISTED'),
if (room?['outcome'] != null) badge(room!['outcome'],Colors.green)]),
if (lifecycle == 'READY') wrap([
DropdownButton<String>(value:profile,items:['guided','standard','challenge'].map((p) =>
DropdownMenuItem(value:p,child:Text(p))).toList(),onChanged:enabled ? (v)=>setState(()=>profile=v!):null),
DropdownButton<int>(value:party,items:[for(int i=1;i<=8;i++) DropdownMenuItem(value:i,child:Text('$i players'))],
onChanged:enabled ? (v)=>setState(()=>party=v!):null),
button('Apply profile', 'prepare', value:{'profile':profile,'party':party,'seed':42}),
]),
wrap([
button('Pause','pause',allow:lifecycle=='RUNNING'),
button('Resume','resume',allow:['PAUSED','RECOVERY_HOLD'].contains(lifecycle)),
button('Extend 5 minutes','extend',value:5),
TextButton(onPressed:enabled ? ()=>withReason('finish_assisted',''):null,child:const Text('Finish assisted...')),
TextButton(onPressed:enabled ? confirmReset:null,child:const Text('Reset simulation...')),
]),
const SizedBox(height: 10),
Text(error ?? notice,style:TextStyle(color:error==null ? Colors.white70:Colors.orange)),
const Divider(height: 30),
const SizedBox(height: 16),
if (room == null) const LinearProgressIndicator()
else [overview,cabinets,ship,calibration,teddy,diagnostics][tab](),
])))),
]),
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'teddy_audio_stub.dart' if (dart.library.html) 'teddy_audio_web.dart';
@@ -0,0 +1,9 @@
class TeddyAudio {
bool enabled = false;
String status = 'Audio disabled on this device';
Future<void> enable() async { enabled = true; }
void sync(String? url) {}
void playNow(String url) { enabled = true; }
void disable() { enabled = false; }
void dispose() {}
}
@@ -0,0 +1,62 @@
// ignore_for_file: deprecated_member_use
import 'dart:html' as html;
import 'dart:convert';
import 'dart:typed_data';
import 'dart:math' as math;
class TeddyAudio {
final html.AudioElement _audio = html.AudioElement();
bool enabled = false;
String status = 'Audio disabled on this device';
String? _url;
int _generation = 0;
TeddyAudio() {
_audio.onEnded.listen((_) { status = 'Cue finished'; });
_audio.onError.listen((_) { status = 'Browser audio error ${_audio.error?.code}. Use Play current hint here to retry.'; });
}
Future<void> enable() async {
// Unlock this exact media element in the user's tap handler (required on phones).
final data = ByteData(16044);
void text(int offset, String value) { for (var i=0;i<value.length;i++) { data.setUint8(offset+i,value.codeUnitAt(i)); } }
text(0,'RIFF'); data.setUint32(4,16036,Endian.little); text(8,'WAVEfmt ');
data.setUint32(16,16,Endian.little); data.setUint16(20,1,Endian.little);
data.setUint16(22,1,Endian.little); data.setUint32(24,8000,Endian.little);
data.setUint32(28,16000,Endian.little); data.setUint16(32,2,Endian.little);
data.setUint16(34,16,Endian.little); text(36,'data'); data.setUint32(40,16000,Endian.little);
for (var i=0;i<8000;i++) { data.setInt16(44+i*2,(4000*math.sin(2*math.pi*440*i/8000)).round(),Endian.little); }
_audio.src = 'data:audio/wav;base64,${base64Encode(data.buffer.asUint8List())}';
try {
await _audio.play();
enabled = true;
_url = null;
status = 'Speaker test playing (one-second tone)';
} catch (_) {
enabled = false;
status = 'Browser blocked audio. Tap Enable audio again.';
}
}
void playNow(String url) { enabled = true; sync(url, force:true); }
void sync(String? url, {bool force=false}) {
if (!enabled || (!force && url == _url)) return;
_generation++;
final generation = _generation;
_audio.pause();
_url = url;
if (url == null) { status = 'Waiting for Teddy'; return; }
_audio.src = url;
status = 'Loading Teddy audio';
_audio.play().then((_) {
if (generation == _generation) status = 'Playing on this device';
}).catchError((Object error) {
if (generation == _generation) status = 'Playback blocked or unavailable. Tap Play current hint here.';
});
}
void disable() {
_generation++;
enabled = false;
_url = null;
_audio.pause();
status = 'Audio disabled on this device';
}
void dispose() { disable(); _audio.src = ''; }
}
+221
View File
@@ -0,0 +1,221 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
url: "https://pub.dev"
source: hosted
version: "15.4.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.11.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
+15
View File
@@ -0,0 +1,15 @@
name: glitch_gm_lab
description: Isolated Glitch game master integration experiment.
publish_to: none
version: 0.1.0+1
environment:
sdk: '>=3.9.2 <4.0.0'
dependencies:
flutter:
sdk: flutter
http: ^1.5.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
+1
View File
@@ -0,0 +1 @@
{"state":{"active_ms":10000,"armed":[],"assisted":false,"boss":"DARK","cell_faults":["SOCKET01 needs CELL01","CELL01 is not charged","SOCKET02 needs CELL02","CELL02 is not charged","SOCKET03 needs CELL03","CELL03 is not charged","SOCKET04 needs CELL04","CELL04 is not charged","SOCKET05 needs CELL05","CELL05 is not charged","SOCKET06 needs CELL06","CELL06 is not charged"],"config_hash":"38b54e5bdcdc9df3b9852cf204bcb5b1973018d94bc4ad04565b7bb959f68d20","countdown":null,"effects":[{"id":"sim-1792303569839-next:token.dispense:T01","kind":"token.dispense","status":"OBSERVED","target":"T01"}],"groups":[],"hold":"","last_progress":0,"launch_epoch":0,"launches":0,"lifecycle":"RUNNING","limit_ms":3600000,"location_since":{},"locations":{"BAY01":"CELL01","BAY02":"CELL02","BAY03":"CELL03","BAY04":"CELL04","BAY05":"CELL05","BAY06":"CELL06"},"operators":5,"outcome":null,"party":6,"passage_open":false,"passage_since":null,"phase":"ARCADE","power_since":null,"profile":"standard","puzzles":[{"delivered":false,"id":"P01","solved":true,"token":""},{"delivered":true,"id":"P02A","solved":true,"token":"T01"},{"delivered":false,"id":"P02B","solved":false,"token":"T02"},{"delivered":false,"id":"P03","solved":false,"token":"T03"},{"delivered":false,"id":"P04","solved":false,"token":"T04"},{"delivered":false,"id":"P05","solved":false,"token":"T05"},{"delivered":false,"id":"P06","solved":false,"token":"T06"}],"ready_deadline":null,"reveal_at":0,"revision":100,"run_id":"sim-1792303569839-next","seed":42,"ship_power":false,"simulation":true,"stations":[{"attempt":1,"authorized":true,"bay_open":false,"cell":"CELL01","charge_ms":10000,"charging":true,"game":"pacman","id":"cabinet_01","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET01","status":"PLAYING","target":500,"token":"T01","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL02","charge_ms":0,"charging":false,"game":"galaga","id":"cabinet_02","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET02","status":"TOKEN_WAIT","target":500,"token":"T02","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL03","charge_ms":0,"charging":false,"game":"tempest","id":"cabinet_03","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET03","status":"TOKEN_WAIT","target":500,"token":"T03","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL04","charge_ms":0,"charging":false,"game":"missile_command","id":"cabinet_04","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET04","status":"TOKEN_WAIT","target":500,"token":"T04","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL05","charge_ms":0,"charging":false,"game":"frogger","id":"cabinet_05","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET05","status":"TOKEN_WAIT","target":1,"token":"T05","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL06","charge_ms":0,"charging":false,"game":"space_invaders","id":"cabinet_06","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET06","status":"TOKEN_WAIT","target":500,"token":"T06","won":false}],"teddy":{"current":null,"hint_level":0,"mode":"GUIDE","muted":false,"next_voice_at":0,"online":true,"played":["welcome"],"queue":[],"takeover":false,"transcript":"I remember this place full of people. The machines remember too. Find their tokens. Each one belongs to only one machine."},"tutorial":0,"wall_ms":10000,"wave":null,"wave_serial":0}}
@@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:glitch_gm_lab/hint_panel.dart';
void main() {
test('suggestions follow parallel branches and claw prerequisites', () {
final room=<String,dynamic>{'phase':'ARCADE','puzzles':[
{'id':'P01','solved':false,'delivered':false},
{'id':'P02A','solved':false,'delivered':false},
{'id':'P02B','solved':false,'delivered':false},
{'id':'P03','solved':false,'delivered':false},
{'id':'P04','solved':false,'delivered':false},
]};
Map<String,dynamic> hint(String target) => {'phase':'ARCADE','target':target,'condition':'incomplete'};
expect(hintReason(hint('P01'),room),isNotNull);
expect(hintReason(hint('P03'),room),isNotNull);
expect(hintReason(hint('P04'),room),isNotNull);
expect(hintReason(hint('P02A'),room),isNull);
(room['puzzles'] as List)[0]['solved']=true;
expect(hintReason(hint('P01'),room),isNull);
expect(hintReason(hint('P02A'),room),isNotNull);
expect(hintReason(hint('P02B'),room),isNull);
(room['puzzles'] as List)[1]['delivered']=true;
expect(hintReason(hint('P02B'),room),isNotNull);
room['phase']='CELLS';
expect(hintReason(hint('P03'),room),isNull);
expect(hintReason({'phase':'ALL','target':'room','condition':'any'},room),isNotNull);
});
test('cabinet first wins remove incomplete hints', () {
final room=<String,dynamic>{'phase':'ARCADE','stations':[{'id':'cabinet_01','won':false}]};
final hint=<String,dynamic>{'phase':'ALL','target':'cabinet_01','condition':'incomplete'};
expect(hintReason(hint,room),isNotNull);
(room['stations'] as List)[0]['won']=true;
expect(hintReason(hint,room),isNull);
});
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:glitch_gm_lab/room_page.dart';
void main() {
for (final width in [390.0, 900.0, 1400.0]) {
testWidgets('room panels fit at width $width and use authoritative revision', (tester) async {
WidgetController.hitTestWarningShouldBeFatal = true;
addTearDown(() => WidgetController.hitTestWarningShouldBeFatal = false);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = Size(width, 1000);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final data = jsonDecode(File('test/fixtures/room.json').readAsStringSync());
final state = data['state'] as Map<String, dynamic>;
Map<String, dynamic>? sent;
final client = MockClient((request) async {
if (request.method == 'POST') {
sent = jsonDecode(request.body);
return http.Response(jsonEncode({'accepted':true,'message':'test accepted','state':state}), 200);
}
if (request.url.path.endsWith('trace')) {
return http.Response(jsonEncode({'trace':[],'final_hash':'fixture'}),200);
}
return http.Response(jsonEncode({'state':state}),200);
});
await tester.pumpWidget(MaterialApp(home:RoomPage(client:client)));
await tester.pump();
expect(find.textContaining('Connected. The room timer'), findsOneWidget);
expect(tester.takeException(), isNull);
Future<void> select(String name) async {
if (width < 900) { await tester.tap(find.byTooltip('Open navigation menu')); await tester.pumpAndSettle(); }
await tester.ensureVisible(find.text(name)); await tester.tap(find.text(name)); await tester.pumpAndSettle();
}
await select('Diagnostics');
await tester.ensureVisible(find.text('+ 1 second'));
await tester.tap(find.text('+ 1 second'));
await tester.pump();
expect(sent?['expected_revision'], state['revision']);
expect(sent?['run_id'], state['run_id']);
for (final tab in ['Cabinets','Ship & Cells','Calibration & Launch','Teddy','Diagnostics','Room Flow']) {
await select(tab);
expect(tester.takeException(), isNull, reason:tab);
}
state['phase']='CALIBRATION';
state['wave']={'id':99,'targets':{'cabinet_01':2},'ready':{},'deadline':40000,'retry_at':0};
await tester.pump(const Duration(seconds:1));
await tester.pump();
await select('Calibration & Launch');
expect(find.text('Simulate held Ready'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
testWidgets('disconnected room disables mutating controls', (tester) async {
await tester.pumpWidget(MaterialApp(home:RoomPage(client:MockClient((_) async => http.Response('{}',503)))));
await tester.pump();
expect(find.textContaining('disconnected'),findsOneWidget);
for (final button in tester.widgetList<OutlinedButton>(find.byType(OutlinedButton))) {
expect(button.onPressed,isNull);
}
await tester.pumpWidget(const SizedBox());
});
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glitch_gm_lab/main.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
testWidgets('commands remain disabled without cabinet state', (tester) async {
final client = MockClient((_) async => http.Response('{}', 503));
await tester.pumpWidget(MaterialApp(home: CabinetPage(client: client)));
await tester.pump();
expect(find.text('INTEGRATION LAB - SIMULATED CABINET'), findsOneWidget);
expect(find.text('Power on'), findsOneWidget);
for (final button in tester.widgetList<FilledButton>(find.byType(FilledButton))) {
expect(button.onPressed, isNull);
}
expect(find.textContaining('Disconnected'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
}
@@ -0,0 +1,7 @@
{{flutter_js}}
{{flutter_build_config}}
// The live lab must not retain an older operator UI across local rebuilds.
for (const build of _flutter.buildConfig.builds) {
if (build.mainJsPath) build.mainJsPath += '?v=' + Date.now();
}
_flutter.loader.load();
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Glitch GM Lab | Escape Gallery</title>
</head>
<body><script>
const bootstrap = document.createElement('script');
bootstrap.src = 'flutter_bootstrap.js?v=' + Date.now();
bootstrap.async = true;
document.body.appendChild(bootstrap);
</script></body>
</html>
+104
View File
@@ -0,0 +1,104 @@
"""Real Godot / Rust / NATS smoke test; no physical hardware. Stop other labs first."""
import os
from pathlib import Path
import shutil
import socket
import subprocess
import tempfile
import time
import urllib.error
import lab
ROOT = Path(__file__).resolve().parent
PROJECT = ROOT.parents[1]
def main():
for port in (*lab.PORTS, 18768):
with socket.socket() as sock:
sock.bind(("127.0.0.1", port))
nats = shutil.which("nats-server")
godot = os.environ.get("GLITCH_LAB_GODOT") or str(PROJECT / "Godot_v4.7.2-stable_win64_console.exe")
binary = ROOT / "target/debug" / ("glitch-odin-lab.exe" if os.name == "nt" else "glitch-odin-lab")
if not nats or not Path(godot).is_file() or not binary.is_file():
raise RuntimeError("Need nats-server, built Rust lab, and Godot (or GLITCH_LAB_GODOT)")
children = []
with tempfile.TemporaryFile(mode="w+b") as log:
def spawn(args):
p = subprocess.Popen(args, cwd=ROOT, stdout=log, stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
children.append(p)
return p
def launch_game(inject):
return spawn([godot, "--headless", "--path", str(PROJECT),
"res://tests/odin_lab/cabinet.tscn", "--", "--glitch-lab",
*(["--lab-inject-rfid"] if inject else [])])
def state():
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
try:
return lab.api("/state")["state"]
except (OSError, ValueError):
time.sleep(.1)
raise RuntimeError("No cabinet state after startup")
try:
spawn([nats, "-c", str(ROOT / "nats.conf")])
deadline = time.monotonic() + 10
while True:
try:
with socket.create_connection(("127.0.0.1", 14222), timeout=.2):
break
except OSError:
if time.monotonic() > deadline: raise
time.sleep(.1)
game = launch_game(True)
spawn([str(binary), "godot"])
spawn([str(binary), "controller"])
initial = state()
assert initial["simulated"] is False and initial["phase"] == "powered_off"
session = initial["session"]
def send(action):
return lab.api("/command", lab.command(action, session))
assert not send("start")["accepted"]
assert send("power_on")["state"]["phase"] == "booting"
assert not send("scan")["accepted"]
deadline = time.monotonic() + 8
while state()["phase"] != "ready":
assert time.monotonic() < deadline, "Boot did not finish"
time.sleep(.1)
assert not send("start")["accepted"]
scan = lab.command("scan", session)
assert lab.api("/command", scan)["state"]["credits"] == 1
replay = lab.api("/command", scan)
assert replay["duplicate"] and replay["state"]["scans"] == 1
assert send("start")["state"]["phase"] == "playing"
assert not send("win")["accepted"], "Remote synthetic win must be disabled"
assert send("reset")["state"]["phase"] == "powered_off"
game.terminate()
game.wait(timeout=5)
try:
lab.api("/state")
raise AssertionError("Offline cabinet falsely returned state")
except urllib.error.HTTPError as error:
assert error.code == 503
launch_game(False)
restarted = state()
assert restarted["session"] != session and not restarted["rfid_injection"]
assert not send("power_on")["accepted"], "Old session accepted"
session = restarted["session"]
assert not send("scan")["accepted"]
print("PASS: HTTP -> Rust -> NATS -> sidecar -> real Godot, boot/credit/start/reset, offline and restart")
finally:
for child in reversed(children):
if child.poll() is None:
child.terminate()
for child in children:
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
log.seek(0)
print(log.read().decode("utf-8", errors="replace")[-4000:])
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""Persistent, revision-checked hint library for the lab. Text is data, never executable."""
import json
import os
from pathlib import Path
import threading
import uuid
ROOT = Path(__file__).resolve().parent
PATH = ROOT / ".lab-data" / "hints.json"
LOCK = threading.Lock()
PHASES = ["ALL","BRIEFING","ARCADE","BOSS","REVEAL","CELLS","CALIBRATION_TUTORIAL","CALIBRATION","LAUNCH_ARM","COUNTDOWN","DEBRIEF"]
TARGETS = ["room"] + ["P01","P02A","P02B","P03","P04","P05","P06"] + [f"cabinet_{i:02}" for i in range(1,7)]
def defaults():
hints=[]
for id,title,text in [
("P01","Claw controls","Start by investigating the claw machine controls."),
("P02A","First claw reward","Now that the controls work, try retrieving the first token."),
("P02B","Second claw reward","The first token has arrived. The claw still has another reward."),
("P03","Records and sleeves","Look at the records together with their sleeves."),
("P04","Service panel","Compare the symbols on the service panel."),
("P05","Ticket values","Look closely at the values on the tickets."),
("P06","Poster clues","There may be more to the poster when you look at its layers.")]:
hints.append({"id":"default-"+id,"title":title,"text":text,"categories":["Discovery"],"phase":"ARCADE","target":id,"condition":"incomplete","level":1})
for id,title,text,phase,category in [
("tokens","Matching tokens","Each token belongs to one machine. Look for the cabinet name.","ARCADE","Arcade"),
("cells","Power cells","Charge each cell, remove it from its bay, then place it in its matching ship socket.","CELLS","Ship"),
("practice","Practice together","Name the station, repeat the target, hold it steady, press Ready, then Execute.","CALIBRATION_TUTORIAL","Calibration"),
("calibration","Team coordination","Read back the target before pressing Ready. The navigator presses Execute when the team is ready.","CALIBRATION","Calibration"),
("launch","Final launch","Arm the stations in order. Confirm each group is ready before the navigator commits the launch.","LAUNCH_ARM","Finale")]:
hints.append({"id":"default-"+id,"title":title,"text":text,"categories":[category],"phase":phase,"target":"room","condition":"any","level":1})
return {"revision":0,"hints":hints}
def read():
with LOCK:
return _read()
def _read():
if not PATH.exists(): return defaults()
data=json.loads(PATH.read_text(encoding="utf-8"))
if not isinstance(data,dict) or not isinstance(data.get("revision"),int) or not isinstance(data.get("hints"),list):
raise ValueError("Hint file is invalid; it was not overwritten")
return data
class Conflict(ValueError): pass
def validate(hint):
if not isinstance(hint,dict): raise ValueError("Missing hint")
clean={}
for key,limit in (("title",100),("text",1500)):
value=hint.get(key)
if not isinstance(value,str) or not 1<=len(value.strip())<=limit:
raise ValueError(f"{key} must contain 1-{limit} characters")
clean[key]=value.strip()
categories=hint.get("categories",[])
if not isinstance(categories,list) or not 1<=len(categories)<=8 or any(not isinstance(x,str) or not 1<=len(x.strip())<=40 for x in categories):
raise ValueError("Use 1-8 category names, each up to 40 characters")
clean["categories"]=list(dict.fromkeys(x.strip() for x in categories))
for key,allowed in (("phase",PHASES),("target",TARGETS),("condition",["any","incomplete","complete"]),("level",[1,2,3])):
if hint.get(key) not in allowed: raise ValueError("Invalid "+key)
clean[key]=hint[key]
return clean
def save(body):
if not isinstance(body,dict): raise ValueError("Expected a hint object")
hint=validate(body.get("hint"))
with LOCK:
data=_read()
if body.get("revision")!=data["revision"]: raise Conflict("Hints changed on another device. Reload the library, then save your draft again.")
id=body["hint"].get("id","")
if id:
index=next((i for i,h in enumerate(data["hints"]) if h["id"]==id),None)
if index is None: raise ValueError("Hint no longer exists")
data["hints"][index]={"id":id,**hint}
else:
if len(data["hints"])>=1000: raise ValueError("Library is limited to 1000 hints")
data["hints"].append({"id":uuid.uuid4().hex,**hint})
data["revision"]+=1
PATH.parent.mkdir(parents=True,exist_ok=True)
temp=PATH.with_suffix(".tmp")
with temp.open("w",encoding="utf-8") as stream:
json.dump(data,stream,ensure_ascii=False,indent=2)
stream.flush()
os.fsync(stream.fileno())
if PATH.exists():
PATH.with_suffix(".bak").write_bytes(PATH.read_bytes())
temp.replace(PATH)
return data
+235
View File
@@ -0,0 +1,235 @@
"""Local-only Glitch lab orchestration; never contacts a Pi or Odin."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parent
BASE = "http://127.0.0.1:18766"
TOOLS = ("cargo", "flutter", "nats-server")
PORTS = (14222, 18766, 18767)
def preflight():
missing = []
for name in TOOLS:
path = shutil.which(name)
print(f"{name}: {path or 'MISSING - install and add to PATH'}")
if not path:
missing.append(name)
if missing:
print("See README.md prerequisites. Nothing was started.")
return False
return True
def run_checked(args, cwd=ROOT):
# Flutter ships as a batch file on Windows; cmd is used solely for its fixed CLI.
if os.name == "nt" and str(args[0]).lower().endswith((".bat", ".cmd")):
args = ["cmd.exe", "/d", "/c", *args]
subprocess.run(args, cwd=cwd, check=True)
def api(path, body=None, base=BASE):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(base + path, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=5) as response:
return json.load(response)
def command(action, session, command_id=None):
return {"id": command_id or str(uuid.uuid4()), "session": session,
"expires_ms": int(time.time() * 1000) + 10000, "action": action}
def smoke():
state = api("/state")["state"]
assert state["simulated"] is True, "Refusing to test a real cabinet"
session = state["session"]
def send(action):
return api("/command", command(action, session))
assert send("reset")["accepted"]
assert not send("start")["accepted"], "Start must require power and credit"
power = command("power_on", session)
assert api("/command", power)["state"]["phase"] == "booting"
assert api("/command", power)["duplicate"], "Duplicate command must be acknowledged"
assert not send("scan")["accepted"], "Boot-period scans must not grant credit"
deadline = time.monotonic() + 6
while time.monotonic() < deadline:
state = api("/state")["state"]
if state["phase"] == "ready":
break
time.sleep(0.1)
assert state["phase"] == "ready" and state["credits"] == 0
assert not send("start")["accepted"]
scan = command("scan", session)
assert api("/command", scan)["state"]["credits"] == 1
replay = api("/command", scan)
assert replay["duplicate"] and replay["state"]["scans"] == 1
assert send("start")["state"]["phase"] == "playing"
assert send("win")["state"]["phase"] == "won"
assert not api("/command", command("reset", "stale-session"))["accepted"]
expired = command("reset", session)
expired["expires_ms"] = 1
assert not api("/command", expired)["accepted"]
assert send("reset")["state"]["phase"] == "powered_off"
print("PASS: real HTTP -> Rust -> NATS -> MessagePack -> simulated cabinet flow")
def build():
run_checked([shutil.which("cargo"), "test"])
run_checked([shutil.which("cargo"), "build"])
flutter = shutil.which("flutter")
run_checked([flutter, "pub", "get"], ROOT / "gm")
run_checked([flutter, "analyze"], ROOT / "gm")
run_checked([flutter, "test"], ROOT / "gm")
run_checked([flutter, "build", "web", "--release"], ROOT / "gm")
def start(godot=False, room=False, speech=False, lan=False, speech_output="browser", speech_backend="chatterbox"):
if speech:
import teddy_speech
if not room:
raise RuntimeError("--speech requires start-room")
if speech_backend == "chatterbox":
from chatterbox_client import REFERENCE
if not REFERENCE.exists():
raise RuntimeError("Missing Teddy Chatterbox reference; see TEDDY_TRAINING.md")
if speech_backend == "piper" and (not teddy_speech.python_path().exists() or not teddy_speech.MODEL.exists()):
raise RuntimeError("Install the lab voice first: py -3 lab.py setup-speech")
# Fail before launching anything if another process owns a lab port.
ports = (14232, 18776, 18777) if room else PORTS
check_ports = (*ports, 18768) if godot else ports
if speech:
check_ports = (*check_ports, 18778)
for port in check_ports:
with socket.socket() as sock:
sock.bind(("127.0.0.1", port))
binary = ROOT / "target" / "debug" / ("glitch-odin-lab.exe" if os.name == "nt" else "glitch-odin-lab")
if not binary.exists() or not (ROOT / "gm/build/web/index.html").exists():
raise RuntimeError("Build the lab first: python lab.py build")
processes = []
def spawn(args):
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
process = subprocess.Popen(args, cwd=ROOT, creationflags=flags)
processes.append(process)
return process
try:
broker = spawn([shutil.which("nats-server"), "-c", str(ROOT / ("room-nats.conf" if room else "nats.conf"))])
deadline = time.monotonic() + 5
while True:
if broker.poll() is not None:
raise RuntimeError("NATS exited; see its output above")
try:
with socket.create_connection(("127.0.0.1", ports[0]), timeout=0.2):
break
except OSError:
if time.monotonic() >= deadline:
raise RuntimeError("NATS did not become ready")
time.sleep(0.1)
if room:
spawn([str(binary), "room"])
elif godot:
executable = ROOT.parents[1] / "Godot_v4.7.2-stable_win64_console.exe"
godot_path = os.environ.get("GLITCH_LAB_GODOT") or str(executable)
if not Path(godot_path).is_file():
raise RuntimeError("Set GLITCH_LAB_GODOT to the Godot executable path")
spawn([godot_path, "--path", str(ROOT.parents[1]), "--windowed",
"res://tests/odin_lab/cabinet.tscn", "--", "--glitch-lab", "--lab-inject-rfid"])
spawn([str(binary), "godot"])
else:
spawn([str(binary), "cabinet"])
if not room:
spawn([str(binary), "controller"])
if room:
spawn([sys.executable, str(ROOT / "room_web.py"), "--bind", "0.0.0.0" if lan else "127.0.0.1"])
else:
spawn([sys.executable, "-m", "http.server", str(ports[2]), "--bind", "127.0.0.1",
"--directory", str(ROOT / "gm/build/web")])
deadline = time.monotonic() + 10
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("A lab process exited during startup")
try:
api("/room/state" if room else "/state", base=f"http://127.0.0.1:{ports[1]}")
break
except (OSError, ValueError):
if time.monotonic() >= deadline:
raise RuntimeError("Controller/cabinet did not become ready")
time.sleep(0.2)
if speech:
spawn([sys.executable, str(ROOT / "teddy_speech.py"), "follow", "--backend", speech_backend, "--output-device", speech_output])
deadline = time.monotonic() + 180
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("Teddy speech failed to start")
try:
health = api("/health", base="http://127.0.0.1:18778")
if health.get("status") == "error":
raise RuntimeError(health.get("message", "Teddy failed to load"))
if health.get("status") != "starting":
break
if time.monotonic() >= deadline:
raise RuntimeError("Teddy model loading timed out; see .speech/chatterbox.log")
time.sleep(.2)
except (OSError, ValueError):
if time.monotonic() >= deadline:
raise RuntimeError("Teddy speech did not become ready")
time.sleep(0.2)
url = f"http://127.0.0.1:{ports[2]}/" + ("?room=1" if room else "")
print(f"READY: {url} - press Ctrl+C to stop all lab processes", flush=True)
if lan:
print("LAN DEMO: open http://<this-PC-Wi-Fi-IPv4>:18777/?room=1 on your phone. Shared simulation controls; enable browser audio on each listening device.", flush=True)
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("A lab process exited; stopping this lab")
time.sleep(0.5)
finally:
for process in reversed(processes):
if process.poll() is None:
process.terminate()
for process in processes:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("preflight", "build", "start", "start-godot", "start-room", "setup-speech", "smoke"))
parser.add_argument("--speech", action="store_true", help="Play Teddy cues using Chatterbox (default) or Piper")
parser.add_argument("--lan", action="store_true", help="Expose only the room demo webpage/gateway to the LAN")
parser.add_argument("--speech-backend", choices=("chatterbox", "piper"), default="chatterbox")
parser.add_argument("--speech-output", choices=("browser","local"), default="browser")
args = parser.parse_args()
mode = args.mode
if args.lan and mode != "start-room":
parser.error("--lan is only supported with start-room")
if args.speech and mode != "start-room":
parser.error("--speech is only supported with start-room")
if mode == "setup-speech":
import teddy_speech
teddy_speech.setup()
return 0
if mode == "smoke":
smoke()
return 0
if not preflight():
return 1
if mode == "build":
build()
elif mode in ("start", "start-godot", "start-room"):
start(godot=mode == "start-godot", room=mode == "start-room", speech=args.speech, lan=args.lan, speech_output=args.speech_output, speech_backend=args.speech_backend)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nLab stopped.")
except Exception as error:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)
+3
View File
@@ -0,0 +1,3 @@
host: "127.0.0.1"
port: 14222
server_name: "glitch-local-lab"
+86
View File
@@ -0,0 +1,86 @@
"""Create conservative draft Piper clips using local ASR word timestamps.
Run with .training/venv/Scripts/python.exe. No audio is uploaded.
Only sentences matching the supplied transcript exactly after normalization
are exported. Review timing and excluded sentences before training.
"""
import difflib
import json
import re
from pathlib import Path
import numpy as np
from scipy.io import wavfile
from scipy.signal import resample_poly
from faster_whisper import WhisperModel
ROOT = Path(__file__).resolve().parent / ".training" / "teddy-v1"
def tokens(text):
return re.findall(r"[a-z0-9]+(?:'[a-z]+)?", text.lower().replace("’", "'"))
def main():
text = (ROOT / "teddy-transcript.txt").read_text(encoding="utf-8-sig")
cache = ROOT / "asr-words.json"
if cache.exists():
words = json.loads(cache.read_text(encoding="utf-8"))
else:
model = WhisperModel("small.en", device="cpu", compute_type="int8", cpu_threads=8,
download_root=str(ROOT.parent / "models"))
rate, source = wavfile.read(ROOT / "TeddyFull.wav")
assert source.ndim == 1 and source.dtype == np.int16
recognition_audio = resample_poly(source.astype(np.float32) / 32768, 16000, rate)
segments, _ = model.transcribe(recognition_audio, language="en",
word_timestamps=True, vad_filter=True, beam_size=5)
words = []
for segment in segments:
for word in segment.words:
words.append({"text":word.word, "start":word.start, "end":word.end})
print(f"Aligned through {segment.end:.1f} seconds", flush=True)
cache.write_text(json.dumps(words, indent=2), encoding="utf-8")
recognized = []
for word in words:
for token in tokens(word["text"]):
recognized.append(dict(word, token=token))
reference = tokens(text)
matcher = difflib.SequenceMatcher(None, reference, [w["token"] for w in recognized], autojunk=False)
mapping = {}
for block in matcher.get_matching_blocks():
for offset in range(block.size):
mapping[block.a+offset] = block.b+offset
rate, pcm = wavfile.read(ROOT / "TeddyFull.wav")
assert pcm.ndim == 1 and pcm.dtype == np.int16
audio = pcm.astype(np.float64) / 32768
clips = ROOT / "audio"
clips.mkdir(exist_ok=True)
accepted, excluded, rows = [], [], []
cursor = 0
for index, sentence in enumerate(re.split(r"(?<=[.!?])\s+", text.strip()), 1):
count = len(tokens(sentence))
positions = [mapping.get(i) for i in range(cursor,cursor+count)]
cursor += count
if not count: continue
if any(i is None for i in positions) or positions != list(range(positions[0],positions[0]+count)):
excluded.append({"sentence":index,"text":sentence,"reason":"Transcript / recognition mismatch; review required"})
continue
first,last = positions[0],positions[-1]
start = max(0,recognized[first]["start"]-.10)
end = min(len(audio)/rate,recognized[last]["end"]+.10)
# Do not include neighboring spoken words in the padding.
if first: start=max(start,(recognized[first-1]["end"]+recognized[first]["start"])/2)
if last+1<len(recognized): end=min(end,(recognized[last]["end"]+recognized[last+1]["start"])/2)
if not .7 <= end-start <= 15:
excluded.append({"sentence":index,"text":sentence,"reason":"Duration outside 0.7-15 seconds"})
continue
clip = resample_poly(audio[int(start*rate):int(end*rate)],22050,rate)
filename=f"teddy_{index:04}.wav"
wavfile.write(clips/filename,22050,np.clip(clip*32768,-32768,32767).astype(np.int16))
rows.append(filename+"|"+sentence.replace("\n"," "))
accepted.append({"file":filename,"start":start,"end":end,"text":sentence})
(ROOT/"metadata.draft.csv").write_text("\n".join(rows)+"\n",encoding="utf-8")
report={"status":"DRAFT - inspect timing and transcript before training", "matched_word_ratio":len(mapping)/len(reference),
"source_seconds":len(audio)/rate,"source_peak":float(np.max(np.abs(audio))),
"source_clipped_samples":int(np.sum(np.abs(audio)>=32767/32768)),
"clip_count":len(accepted),"clip_seconds":sum(c["end"]-c["start"] for c in accepted),
"clips":accepted,"excluded":excluded}
(ROOT/"alignment-report.json").write_text(json.dumps(report,indent=2),encoding="utf-8")
print(json.dumps({k:v for k,v in report.items() if k not in ("clips","excluded")},indent=2))
print(f"Excluded {len(excluded)} sentences for review")
if __name__ == "__main__": main()
+3
View File
@@ -0,0 +1,3 @@
host: "127.0.0.1"
port: 14232
server_name: "glitch-room-simulation"
+154
View File
@@ -0,0 +1,154 @@
"""Owns an isolated room broker/controller for a full simulated run and trace replay."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parent
BASE = "http://127.0.0.1:18776"
PORTS = (14232, 18776, 18777)
def api(path, body=None):
req = urllib.request.Request(BASE + path,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as response:
return json.load(response)
def action(name, entity="", value=None):
state = api("/room/state")["state"]
payload = {"command_id":str(uuid.uuid4()),"run_id":state["run_id"],
"expected_revision":state["revision"],"expires_at_ms":int(time.time()*1000)+10000,
"input":{"action":name,"entity":entity,"value":value}}
result = api("/room/command", payload)
assert result["accepted"], (name, result["message"])
return result["state"]
def golden():
action("prepare", value={"profile":"standard","party":6,"seed":42})
action("start_run")
action("briefing_done")
for puzzle, token in [("P01",None),("P02A","T01"),("P02B","T02"),("P03","T03"),("P04","T04"),("P05","T05"),("P06","T06")]:
action("solve", puzzle)
if token: action("deliver", token)
for i in range(1,7):
cabinet=f"cabinet_{i:02}"
action("token", cabinet, {"token":f"T{i:02}","age_ms":0})
action("start", cabinet)
state=action("advance", value=30000)
for station in state["stations"]:
action("win", station["id"], {"attempt":station["attempt"],"progress":station["target"]})
action("boss_start")
state=action("boss_win")
effect=next(e for e in state["effects"] if e["kind"]=="passage.release")
assert not action("ack", effect["id"])["passage_open"]
action("passage", value=True)
state=action("advance", value=300)
for station in state["stations"]:
action("bay", station["id"])
action("cell","BAY"+station["cell"][4:],{"cell":station["cell"],"present":False})
action("cell",station["socket"],{"cell":station["cell"],"present":True})
state=action("advance", value=2500)
while state["phase"] in ("CALIBRATION_TUTORIAL","CALIBRATION"):
wave=state["wave"]
for station,target in wave["targets"].items():
action("ready",station,{"challenge":wave["id"],"selection":target,"stable_ms":2000})
state=action("execute", value=wave["id"])
assert state["phase"]=="LAUNCH_ARM"
epoch=state["launch_epoch"]
for station in state["stations"]:
action("arm",station["id"],epoch)
for group in range(state["operators"]):
action("group_ready",value={"epoch":epoch,"group":group})
action("commit",value=epoch)
state=action("advance",value=5000)
assert state["lifecycle"]=="WON" and state["launches"]==1
assert all(s["passes"]==2 for s in state["stations"])
return state
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("--preview",action="store_true",help="After tests, serve GM UI for up to 10 minutes; create qa.stop to stop early")
args=parser.parse_args()
for port in PORTS:
with socket.socket() as sock: sock.bind(("127.0.0.1",port))
nats=shutil.which("nats-server")
binary=ROOT/"target/debug"/("glitch-odin-lab.exe" if os.name=="nt" else "glitch-odin-lab")
if not nats or not binary.is_file(): raise RuntimeError("Install NATS and build the Rust lab first")
children=[]
output=ROOT/"evidence"
output.mkdir(exist_ok=True)
with tempfile.TemporaryFile(mode="w+b") as log:
def spawn(argv):
child=subprocess.Popen(argv,cwd=ROOT,stdout=log,stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW if os.name=="nt" else 0)
children.append(child)
return child
try:
spawn([nats,"-c",str(ROOT/"room-nats.conf")])
deadline=time.monotonic()+10
while True:
try:
with socket.create_connection(("127.0.0.1",14232),timeout=.2): break
except OSError:
if time.monotonic()>deadline: raise
time.sleep(.1)
spawn([str(binary),"room"])
deadline=time.monotonic()+20
while True:
try:
api("/room/state")
break
except OSError:
if time.monotonic()>deadline: raise
time.sleep(.1)
state=golden()
trace=api("/room/trace")
trace_path=output/"room-golden-trace.json"
trace_path.write_text(json.dumps(trace,indent=2),encoding="utf-8")
subprocess.run([str(binary),"replay-room",str(trace_path)],check=True)
(output/"room-result.json").write_text(json.dumps({
"status":"passed","transport":"HTTP -> Rust -> NATS -> Rust room reducer",
"lifecycle":state["lifecycle"],"launches":state["launches"],
"passes":[s["passes"] for s in state["stations"]],
"final_hash":trace["final_hash"],"trace_events":len(trace["trace"])
},indent=2),encoding="utf-8")
print("PASS: complete seven-cabinet room, puzzles, cells, practices, calibration, launch, and offline trace replay",flush=True)
if args.preview:
action("reset",value={"confirmed":True})
action("start_run")
action("briefing_done")
action("solve","P01")
action("solve","P02A")
action("deliver","T01")
action("token","cabinet_01",{"token":"T01","age_ms":0})
action("start","cabinet_01")
action("advance",value=10000)
spawn([sys.executable,str(ROOT/"room_web.py")])
stop=ROOT/"qa.stop"
if stop.exists(): stop.unlink()
print("PREVIEW: http://127.0.0.1:18777/?room=1",flush=True)
deadline=time.monotonic()+600
while time.monotonic()<deadline and not stop.exists():
time.sleep(.2)
if stop.exists(): stop.unlink()
finally:
for p in reversed(children):
if p.poll() is None: p.terminate()
for p in children:
try: p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill();p.wait()
log.seek(0)
text=log.read().decode("utf-8",errors="replace")
(output/"room-runtime.log").write_text(text,encoding="utf-8")
if __name__=="__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
"""Same-origin static UI/gateway for the isolated room demo; never proxies arbitrary URLs."""
import argparse
import json
import hint_library
import re
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
import urllib.request
import urllib.error
ROOT = Path(__file__).resolve().parent
ROUTES = {"/room/state": (18776, "/room/state"), "/room/trace": (18776, "/room/trace"),
"/room/command": (18776, "/room/command"), "/speech/health": (18778, "/health")}
class Handler(SimpleHTTPRequestHandler):
def list_directory(self, path):
self.send_error(403)
def relay(self):
path = urlsplit(self.path).path
route = ROUTES.get(path)
if re.fullmatch(r"/speech/audio/[a-f0-9]{64}\.wav", path):
route = (18778, path.removeprefix("/speech"))
if not route or (self.command == "POST" and path != "/room/command") or (self.command == "GET" and path == "/room/command"):
self.send_error(404)
return
if self.command == "POST":
origin = self.headers.get("Origin")
if origin and origin != "http://" + self.headers.get("Host", ""):
self.send_error(403, "Use the room demo page to send commands")
return
try:
size = int(self.headers.get("Content-Length", "0"))
if size < 0 or size > 65536:
self.send_error(413)
return
body = self.rfile.read(size) if self.command == "POST" else None
request = urllib.request.Request(f"http://127.0.0.1:{route[0]}{route[1]}",
data=body, method=self.command, headers={"Content-Type":"application/json", **({"Range":self.headers["Range"]} if "Range" in self.headers else {})})
try:
with urllib.request.urlopen(request, timeout=8) as response:
data, status = response.read(), response.status
content_type = response.headers.get("Content-Type","application/json")
content_range = response.headers.get("Content-Range")
except urllib.error.HTTPError as error:
data, status = error.read(), error.code
content_type, content_range = "text/plain", None
self.send_response(status)
self.send_header("Content-Type",content_type)
self.send_header("Accept-Ranges","bytes")
if content_range:
self.send_header("Content-Range",content_range)
self.send_header("Content-Length",str(len(data)))
self.send_header("Cache-Control","no-store")
self.end_headers()
self.wfile.write(data)
except (OSError, ValueError):
self.send_error(503, "Local lab service unavailable")
def hints(self):
try:
if self.command == "POST":
origin=self.headers.get("Origin")
if origin and origin != "http://" + self.headers.get("Host",""):
self.send_error(403)
return
size=int(self.headers.get("Content-Length","0"))
if not 0<size<=16384:
self.send_error(413)
return
result=hint_library.save(json.loads(self.rfile.read(size)))
else:
result=hint_library.read()
status=200
except hint_library.Conflict as error:
result,status={"error":str(error)},409
except (ValueError, OSError) as error:
result,status={"error":str(error)},400
data=json.dumps(result).encode()
self.send_response(status)
self.send_header("Content-Type","application/json")
self.send_header("Content-Length",str(len(data)))
self.send_header("Cache-Control","no-store")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if urlsplit(self.path).path == "/hints":
return self.hints()
if urlsplit(self.path).path.startswith(("/room/", "/speech/")):
self.relay()
else:
super().do_GET()
def do_POST(self):
if urlsplit(self.path).path == "/hints":
return self.hints()
self.relay()
def end_headers(self):
self.send_header("X-Content-Type-Options","nosniff")
self.send_header("Cache-Control","no-store")
super().end_headers()
def log_message(self,*_): pass
if __name__ == "__main__":
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bind",default="127.0.0.1")
args=parser.parse_args()
server=ThreadingHTTPServer((args.bind,18777),partial(Handler,directory=str(ROOT/"gm/build/web")))
print(f"Room demo UI/gateway: http://{args.bind}:18777/?room=1",flush=True)
try: server.serve_forever()
finally: server.server_close()
+71
View File
@@ -0,0 +1,71 @@
"""Real Piper playback test against isolated room-state fixtures. Uses free loopback ports."""
import array
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import subprocess
import threading
import time
import urllib.request
import wave
import teddy_speech as speech
ROOT = Path(__file__).resolve().parent
STATE = {"run_id":"speech-smoke", "teddy":{"online":True,"muted":False,"takeover":False,"current":None}}
class Fixture(BaseHTTPRequestHandler):
def do_GET(self):
data=json.dumps({"state":STATE}).encode()
self.send_response(200)
self.send_header("Content-Length",str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self,*_): pass
def wait(status,port,seconds=60):
until=time.monotonic()+seconds
while time.monotonic()<until:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health",timeout=1) as response:
if json.load(response)["status"]==status: return
except OSError: pass
time.sleep(.2)
raise AssertionError("Timed out waiting for audio: "+status)
def run():
import socket
fixture=ThreadingHTTPServer(("127.0.0.1",0),Fixture)
threading.Thread(target=fixture.serve_forever,daemon=True).start()
with socket.socket() as sock:
sock.bind(("127.0.0.1",0))
status_port=sock.getsockname()[1]
with (speech.DATA/"smoke.log").open("w",encoding="utf-8") as log:
process=subprocess.Popen([str(speech.python_path()),str(ROOT/"teddy_speech.py"),"follow",
"--backend","piper","--room-port",str(fixture.server_port),"--status-port",str(status_port)],cwd=ROOT,
stdout=log,stderr=log,creationflags=subprocess.CREATE_NO_WINDOW if os.name=="nt" else 0)
try:
wait("idle",status_port)
cue={"id":"welcome","started":0,"transcript":"Welcome to Glitch, the Dark Machine. I am Teddy. Let us bring these arcade machines back to life."}
STATE["teddy"]["current"]=cue
wait("playing",status_port)
print("Piper is playing the test welcome cue.",flush=True)
time.sleep(2)
STATE["teddy"]["muted"]=True
wait("idle",status_port)
STATE["teddy"]["muted"]=False
STATE["teddy"]["current"]={**cue,"id":"replay"}
wait("playing",status_port)
STATE["teddy"]["current"]=None
wait("idle",status_port)
wavs=list((speech.DATA/"cache").glob("*.wav"))
with wave.open(str(max(wavs,key=lambda p:p.stat().st_mtime))) as wav:
samples=array.array("h",wav.readframes(wav.getnframes()))
assert wav.getnframes()>22050 and max(abs(x) for x in samples)>100
print(f"Verified non-silent PCM WAV: {wav.getframerate()} Hz; {wav.getnframes()/wav.getframerate():.1f}s")
print("PASS: room-state fixture -> real Piper -> local playback; mute/replay/stop")
finally:
process.terminate()
process.wait(timeout=5)
fixture.shutdown()
fixture.server_close()
if __name__=="__main__":
run()
+37
View File
@@ -0,0 +1,37 @@
//! Local JSON sidecar boundary; NATS/MessagePack remains in Rust.
use anyhow::{Context, Result};
use futures_util::StreamExt;
use glitch_odin_lab::*;
use tokio::{io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, net::TcpStream};
use std::time::Duration;
async fn forward(command: Command) -> Result<Reply> {
let mut stream = TcpStream::connect("127.0.0.1:18768").await?;
let mut bytes = serde_json::to_vec(&command)?;
bytes.push(b'\n');
stream.write_all(&bytes).await?;
let mut line = String::new();
BufReader::new(stream.take(65536)).read_line(&mut line).await?;
let reply: Reply = serde_json::from_str(&line)?;
anyhow::ensure!(!reply.state.simulated, "Expected real Godot cabinet");
Ok(reply)
}
pub async fn run(client: async_nats::Client) -> Result<()> {
let mut subscription = client.subscribe(SUBJECT).await?;
client.flush().await?;
println!("Godot sidecar ready: 127.0.0.1:18768; no simulated responder");
while let Some(message) = subscription.next().await {
if let Some(subject) = message.reply {
let result = async {
let command: Command = rmp_serde::from_slice(&message.payload)?;
tokio::time::timeout(Duration::from_millis(1500), forward(command))
.await.context("Godot reply timeout")?
}.await;
match result {
Ok(reply) => client.publish(subject, rmp_serde::to_vec_named(&reply)?.into()).await?,
Err(error) => eprintln!("Godot request unconfirmed: {error:#}"),
}
}
}
Ok(())
}
+157
View File
@@ -0,0 +1,157 @@
//! Experimental lab contract. NOT Odin's generated production protocol.
//! Named MessagePack fields deliberately avoid depending on Odin tuple order.
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
pub const SUBJECT: &str = "escape_gallery.glitch.lab.v1.cabinet_01.request";
pub const NATS_URL: &str = "nats://127.0.0.1:14222";
pub const HTTP_ADDR: &str = "127.0.0.1:18766";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Action { Snapshot, PowerOn, Scan, Start, Win, Reset }
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Command {
pub id: String,
pub session: String,
pub expires_ms: u64,
pub action: Action,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct CabinetState {
pub session: String,
pub cabinet_id: String,
pub phase: String,
pub credits: u32,
pub scans: u32,
pub simulated: bool,
#[serde(default)]
pub rfid_injection: bool,
#[serde(default)]
pub score: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Reply {
pub accepted: bool,
pub duplicate: bool,
pub message: String,
pub state: CabinetState,
}
pub struct Cabinet {
pub state: CabinetState,
boot_until: Option<u64>,
seen: VecDeque<(String, Command, bool, String)>,
}
impl Cabinet {
pub fn new(session: String) -> Self {
Self {
state: CabinetState { session, cabinet_id: "cabinet_01".into(),
phase: "powered_off".into(), credits: 0, scans: 0, simulated: true, rfid_injection: false, score: 0 },
boot_until: None, seen: VecDeque::new(),
}
}
pub fn tick(&mut self, now: u64) {
if self.boot_until.is_some_and(|end| now >= end) {
self.boot_until = None;
self.state.phase = "ready".into();
}
}
fn reply(&self, accepted: bool, duplicate: bool, message: &str) -> Reply {
Reply { accepted, duplicate, message: message.into(), state: self.state.clone() }
}
pub fn handle(&mut self, cmd: Command, now: u64) -> Reply {
self.tick(now);
if cmd.action == Action::Snapshot {
return self.reply(true, false, "Current simulated state");
}
if cmd.id.is_empty() || cmd.id.len() > 128 || cmd.session != self.state.session {
return self.reply(false, false, "Invalid command ID or stale session; refresh state");
}
if let Some((_, previous, accepted, message)) = self.seen.iter().find(|v| v.0 == cmd.id) {
if previous.action != cmd.action || previous.expires_ms != cmd.expires_ms {
return self.reply(false, false, "Command ID reused with different contents");
}
return self.reply(*accepted, true, message);
}
if cmd.expires_ms <= now || cmd.expires_ms.saturating_sub(now) > 30_000 {
return self.reply(false, false, "Expired command or expiry exceeds 30 seconds");
}
let accepted = match cmd.action {
Action::PowerOn if self.state.phase == "powered_off" => {
self.state.phase = "booting".into();
self.boot_until = Some(now + 4_000); true
},
Action::Scan if self.state.phase == "ready" => {
self.state.credits = 1; self.state.scans += 1; true
},
Action::Start if self.state.phase == "ready" && self.state.credits > 0 => {
self.state.credits -= 1; self.state.phase = "playing".into(); true
},
Action::Win if self.state.phase == "playing" => {
self.state.phase = "won".into(); true
},
Action::Reset => {
self.state.phase = "powered_off".into();
self.state.credits = 0; self.state.scans = 0; self.boot_until = None; true
},
_ => false,
};
let message = if accepted { "Executed by simulated cabinet" }
else { "Action is not valid in the current cabinet state" };
self.seen.push_back((cmd.id.clone(), cmd, accepted, message.into()));
// Bounded replay window: not persistent delivery or production idempotency.
if self.seen.len() > 256 { self.seen.pop_front(); }
self.reply(accepted, false, message)
}
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.expect("system clock before epoch").as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
fn command(id: &str, action: Action, now: u64) -> Command {
Command { id: id.into(), session: "test".into(), expires_ms: now + 10_000, action }
}
#[test]
fn boot_credit_start_win() {
let mut c = Cabinet::new("test".into());
assert!(!c.handle(command("early", Action::Start, 0), 0).accepted);
assert!(c.handle(command("power", Action::PowerOn, 0), 0).accepted);
assert!(!c.handle(command("boot_scan", Action::Scan, 100), 100).accepted);
c.tick(3999); assert_eq!(c.state.phase, "booting");
c.tick(4000); assert_eq!(c.state.phase, "ready");
assert_eq!(c.state.credits, 0);
assert!(!c.handle(command("no_credit", Action::Start, 4000), 4000).accepted);
assert!(c.handle(command("scan", Action::Scan, 4000), 4000).accepted);
assert!(c.handle(command("start", Action::Start, 4001), 4001).accepted);
assert_eq!(c.state.credits, 0);
assert!(c.handle(command("win", Action::Win, 4002), 4002).accepted);
assert_eq!(c.state.phase, "won");
}
#[test]
fn replay_stale_expired_and_reset() {
let mut c = Cabinet::new("test".into());
let cmd = command("power", Action::PowerOn, 0);
c.handle(cmd.clone(), 0);
assert!(c.handle(cmd, 1).duplicate);
let mut stale = command("stale", Action::Reset, 1); stale.session = "old".into();
assert!(!c.handle(stale, 1).accepted);
assert!(!c.handle(command("expired", Action::Reset, 0), 10_001).accepted);
c.handle(command("reset", Action::Reset, 2), 2);
c.tick(5000); assert_eq!(c.state.phase, "powered_off");
}
#[test]
fn messagepack_roundtrip() {
let original = command("roundtrip", Action::Scan, 100);
let bytes = rmp_serde::to_vec_named(&original).unwrap();
let decoded: Command = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(decoded.action, Action::Scan);
assert_eq!(decoded.session, original.session);
}
}
pub mod room;
+80
View File
@@ -0,0 +1,80 @@
mod room_server;
mod godot;
use anyhow::{Context, Result};
use axum::{extract::State, http::{Method, StatusCode, HeaderValue}, routing::{get, post}, Json, Router};
use futures_util::StreamExt;
use glitch_odin_lab::*;
use std::time::Duration;
use tower_http::cors::CorsLayer;
async fn request(client: &async_nats::Client, command: Command) -> Result<Reply> {
let payload = rmp_serde::to_vec_named(&command)?;
let message = tokio::time::timeout(Duration::from_secs(2),
client.request(SUBJECT, payload.into())).await
.context("Cabinet reply timed out")??;
Ok(rmp_serde::from_slice(&message.payload)?)
}
async fn send(State(client): State<async_nats::Client>, Json(command): Json<Command>)
-> Result<Json<Reply>, (StatusCode, Json<serde_json::Value>)> {
request(&client, command).await.map(Json).map_err(|error|
(StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({
"error": format!("Cabinet unavailable: {error}")
}))))
}
async fn snapshot(State(client): State<async_nats::Client>)
-> Result<Json<Reply>, (StatusCode, Json<serde_json::Value>)> {
send(State(client), Json(Command { id: String::new(), session: String::new(),
expires_ms: 0, action: Action::Snapshot })).await
}
#[tokio::main]
async fn main() -> Result<()> {
let mode = std::env::args().nth(1).unwrap_or_default();
if mode == "replay-room" {
let path=std::env::args().nth(2).context("replay-room requires a trace JSON path")?;
let value=serde_json::from_slice(&std::fs::read(path)?)?;
let hash=glitch_odin_lab::room::replay_export(value).map_err(anyhow::Error::msg)?;
println!("REPLAY PASS: {hash}");
return Ok(());
}
if mode != "controller" && mode != "cabinet" && mode != "godot" && mode != "room" {
anyhow::bail!("Usage: glitch-odin-lab controller|cabinet|godot|room (isolated local lab)");
}
let client = async_nats::connect(if mode == "room" {"nats://127.0.0.1:14232"} else {NATS_URL}).await.context("Start lab NATS on 127.0.0.1:14222 first")?;
if mode == "room" {
return room_server::run(client).await;
}
if mode == "controller" {
// Explicit local web origins; never open this unauthenticated lab on the room LAN.
let cors = CorsLayer::new()
.allow_origin([
HeaderValue::from_static("http://127.0.0.1:18767"),
HeaderValue::from_static("http://localhost:18767"),
])
.allow_methods([Method::GET, Method::POST])
.allow_headers([axum::http::header::CONTENT_TYPE]);
let app = Router::new().route("/state", get(snapshot))
.route("/command", post(send)).layer(cors).with_state(client);
let listener = tokio::net::TcpListener::bind(HTTP_ADDR).await?;
println!("Glitch LAB controller: http://{HTTP_ADDR} (isolated lab)");
axum::serve(listener, app).await?;
} else if mode == "godot" {
godot::run(client).await?;
} else {
let mut subscription = client.subscribe(SUBJECT).await?;
client.flush().await?;
let mut cabinet = Cabinet::new(format!("lab-{}-{}", now_ms(), std::process::id()));
println!("Simulated cabinet_01 ready; no Godot or GPIO connected");
while let Some(message) = subscription.next().await {
if let Some(reply_subject) = message.reply {
match rmp_serde::from_slice::<Command>(&message.payload) {
Ok(command) => {
let reply = cabinet.handle(command, now_ms());
client.publish(reply_subject, rmp_serde::to_vec_named(&reply)?.into()).await?;
}
Err(error) => eprintln!("Rejected malformed lab message: {error}"),
}
}
}
}
Ok(())
}
+580
View File
@@ -0,0 +1,580 @@
//! Whole-room lab reducer. Virtual observations only; no production output adapter.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
pub const CONTRACT: &str = include_str!("../design/glitch_implementation_contract.json");
pub fn contract() -> &'static Value {
static CONFIG: std::sync::OnceLock<Value> = std::sync::OnceLock::new();
CONFIG.get_or_init(||serde_json::from_str(CONTRACT).expect("checked design contract"))
}
pub fn fingerprint<T: Serialize>(value: &T) -> String {
format!("{:x}", Sha256::digest(serde_json::to_vec(value).unwrap()))
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Station {
pub id: String, pub game: String, pub token: String, pub cell: String, pub socket: String,
pub status: String, pub authorized: bool, pub won: bool, pub online: bool,
pub target: u64, pub progress: u64, pub attempt: u64, pub losses: u64,
pub retry_at: u64, pub charging: bool, pub charge_ms: u64,
pub bay_open: bool, pub passes: u64, pub last_target: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Puzzle { pub id: String, pub token: String, pub solved: bool, pub delivered: bool }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Wave {
pub id: u64, pub targets: BTreeMap<String, u64>, pub ready: BTreeMap<String, u64>,
pub deadline: Option<u64>, pub retry_at: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Cue {
pub id: String, pub key: String, pub transcript: String, pub phase: String,
pub expires: u64, pub started: Option<u64>, pub priority: u8,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Teddy {
pub mode: String, pub online: bool, pub muted: bool, pub takeover: bool,
pub current: Option<Cue>, pub queue: Vec<Cue>, pub played: BTreeSet<String>,
pub transcript: String, pub next_voice_at: u64, pub hint_level: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Effect { pub id: String, pub kind: String, pub target: String, pub status: String }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoomState {
pub run_id: String, pub revision: u64, pub config_hash: String, pub simulation: bool,
pub lifecycle: String, pub phase: String, pub hold: String, pub outcome: Option<String>,
pub profile: String, pub party: u64, pub operators: u64, pub assisted: bool, pub seed: u64,
pub boot_until: Option<u64>,
pub wall_ms: u64, pub active_ms: u64, pub limit_ms: u64,
pub stations: Vec<Station>, pub puzzles: Vec<Puzzle>,
pub boss: String, pub passage_open: bool, pub passage_since: Option<u64>, pub reveal_at: u64,
pub locations: BTreeMap<String, String>, pub location_since: BTreeMap<String, u64>,
pub cell_faults: Vec<String>, pub power_since: Option<u64>, pub ship_power: bool,
pub tutorial: u64, pub wave_serial: u64, pub wave: Option<Wave>,
pub launch_epoch: u64, pub armed: BTreeSet<String>, pub groups: BTreeSet<u64>,
pub ready_deadline: Option<u64>, pub countdown: Option<u64>, pub launches: u64,
pub effects: Vec<Effect>, pub teddy: Teddy, pub last_progress: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Input {
pub action: String,
#[serde(default)] pub entity: String,
#[serde(default)] pub value: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub command_id: String, pub run_id: String, pub expected_revision: u64,
pub expires_at_ms: u64, pub input: Input,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Response { pub accepted: bool, pub duplicate: bool, pub message: String, pub state: RoomState }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TraceEntry {
pub request: Request, pub received_at_ms: u64, pub accepted: bool,
pub message: String, pub final_hash: String,
}
pub struct Room {
pub state: RoomState, pub trace: Vec<TraceEntry>,
initial_run: String, seen: BTreeMap<String, (String, bool, String)>,
}
impl RoomState {
pub fn new(run_id: String, profile: &str, party: u64, seed: u64) -> Self {
let c=contract(); let p=&c["profiles"][profile];
let stations=c["entities"].as_array().unwrap().iter().map(|e| {
let game=e["game"].as_str().unwrap().to_string();
Station { id:e["cabinet_id"].as_str().unwrap().into(), token:e["token_id"].as_str().unwrap().into(),
cell:e["cell_id"].as_str().unwrap().into(), socket:e["socket_id"].as_str().unwrap().into(),
target:if game=="frogger" {p["frogger_homes"].as_u64().unwrap()} else {(500.0*p["score_target_multiplier"].as_f64().unwrap()) as u64},
game, status:"OFF".into(), authorized:false, won:false, online:true, progress:0,
attempt:0, losses:0, retry_at:0, charging:false, charge_ms:0, bay_open:false,
passes:0,last_target:None }
}).collect::<Vec<_>>();
let locations=stations.iter().map(|s| (format!("BAY{}", &s.cell[4..]),s.cell.clone())).collect();
Self { run_id,revision:0,config_hash:fingerprint(&c),simulation:true,
lifecycle:"READY".into(),phase:"BRIEFING".into(),hold:String::new(),outcome:None,
profile:profile.into(),party,operators:party.saturating_sub(1).clamp(1,6),assisted:party==1,seed,
boot_until:None,wall_ms:0,active_ms:0,limit_ms:p["time_limit_ms"].as_u64().unwrap(),stations,
puzzles:[("P01",""),("P02A","T01"),("P02B","T02"),("P03","T03"),("P04","T04"),("P05","T05"),("P06","T06")]
.iter().map(|(id,token)|Puzzle{id:(*id).into(),token:(*token).into(),solved:false,delivered:false}).collect(),
boss:"DARK".into(),passage_open:false,passage_since:None,reveal_at:0,
locations,location_since:BTreeMap::new(),cell_faults:vec![],power_since:None,ship_power:false,
tutorial:0,wave_serial:0,wave:None,launch_epoch:0,armed:BTreeSet::new(),groups:BTreeSet::new(),
ready_deadline:None,countdown:None,launches:0,effects:vec![],
teddy:Teddy{mode:"DORMANT".into(),online:true,muted:false,takeover:false,current:None,
queue:vec![],played:BTreeSet::new(),transcript:String::new(),next_voice_at:0,hint_level:0},
last_progress:0 }
}
pub fn tuning(&self, name:&str)->u64 { contract()["profiles"][&self.profile][name].as_u64().unwrap() }
fn require(&self, condition:bool, reason:&str)->Result<(),String> {
if condition {Ok(())} else {Err(reason.into())}
}
fn station(&self,id:&str)->Result<usize,String> {
self.stations.iter().position(|s|s.id==id).ok_or("Unknown station".into())
}
fn effect(&mut self,kind:&str,target:&str) {
let id=format!("{}:{kind}:{target}",self.run_id);
if !self.effects.iter().any(|e|e.id==id) {
self.effects.push(Effect{id,kind:kind.into(),target:target.into(),status:"REQUESTED".into()});
}
}
fn cue(&mut self,key:&str,text:&str,priority:u8) {
if self.teddy.played.contains(key) || self.teddy.queue.iter().any(|c|c.key==key)
|| self.teddy.current.as_ref().is_some_and(|c|c.key==key) {return}
let cue=Cue{id:format!("{}:{key}",self.run_id),key:key.into(),transcript:text.into(),
phase:self.phase.clone(),expires:self.wall_ms+60_000,started:None,priority};
if priority>=8 {
self.teddy.current=None;
self.teddy.next_voice_at=self.wall_ms;
}
self.teddy.queue.push(cue);
self.teddy.queue.sort_by_key(|c|std::cmp::Reverse(c.priority));
}
fn phase(&mut self, phase:&str) {
self.phase=phase.into(); self.last_progress=self.active_ms;
self.teddy.hint_level=0;
self.teddy.mode=match phase {
"ARCADE"=>"GUIDE","BOSS"=>"GLITCH","REVEAL"|"CELLS"=>"REVEAL",
"CALIBRATION_TUTORIAL"|"CALIBRATION"=>"COACH","LAUNCH_ARM"|"COUNTDOWN"|"DEBRIEF"=>"LAUNCH",_=>"GUIDE"
}.into();
}
fn cancel_wave(&mut self) {
self.wave=None; self.wave_serial+=1; self.armed.clear(); self.groups.clear();
self.countdown=None;self.ready_deadline=None;self.launch_epoch+=1;
if self.phase=="COUNTDOWN" {self.phase("LAUNCH_ARM");}
}
fn technical_hold(&mut self,reason:&str) {
if self.lifecycle=="WON" {return}
self.lifecycle="RECOVERY_HOLD".into();self.hold=reason.into();self.cancel_wave();
}
fn next_wave(&mut self) {
if self.wave.is_some() {return}
let tutorial=self.phase=="CALIBRATION_TUTORIAL";
if !tutorial && self.phase!="CALIBRATION" {return}
let passes=self.tuning("passes_per_station");
let mut candidates:Vec<_>=self.stations.iter().filter(|s|tutorial || s.passes<passes)
.map(|s|(s.passes,s.id.clone())).collect();
candidates.sort();
if candidates.is_empty() {
self.phase("LAUNCH_ARM"); self.launch_epoch+=1;return
}
self.wave_serial+=1;
let mut targets=BTreeMap::new();
for (_,id) in candidates.into_iter().take(if tutorial {1}else{self.operators as usize}) {
let range=&contract()["calibration_selections"][&id];
let min=range["min"].as_u64().unwrap();let max=range["max"].as_u64().unwrap();
let i=self.station(&id).unwrap();
let mut target=min+(self.seed+self.wave_serial+self.stations[i].passes)%(max-min+1);
if self.stations[i].last_target==Some(target) {target=min+(target-min+1)%(max-min+1);}
self.stations[i].last_target=Some(target);
targets.insert(id,target);
}
self.wave=Some(Wave{id:self.wave_serial,targets,ready:BTreeMap::new(),
deadline:if tutorial {None}else{Some(self.active_ms+self.tuning("response_window_ms"))},retry_at:self.active_ms});
}
fn power_check(&mut self) {
let mut faults=vec![];
for s in &self.stations {
let places:Vec<_>=self.locations.iter().filter(|(_,cell)|*cell==&s.cell).map(|(p,_)|p).collect();
if places.len()>1 {faults.push(format!("{} appears in multiple places",s.cell));}
if self.locations.get(&s.socket)!=Some(&s.cell) {faults.push(format!("{} needs {}",s.socket,s.cell));}
if s.charge_ms<30_000 {faults.push(format!("{} is not charged",s.cell));}
if !s.online {faults.push(format!("{} is offline",s.id));}
if self.locations.get(&s.socket)==Some(&s.cell) &&
self.wall_ms.saturating_sub(*self.location_since.get(&s.socket).unwrap_or(&self.wall_ms))<500 {
faults.push(format!("{} awaiting debounce",s.socket));
}
}
self.cell_faults=faults;
if !self.cell_faults.is_empty() {
let had_power=self.ship_power;
self.ship_power=false;self.power_since=None;
if had_power && ["CALIBRATION_TUTORIAL","CALIBRATION","LAUNCH_ARM","COUNTDOWN"].contains(&self.phase.as_str()) {
self.technical_hold("Cell identity/power changed; restore cells, then Resume");
}
} else {
let since=self.power_since.get_or_insert(self.wall_ms);
self.ship_power=self.wall_ms.saturating_sub(*since)>=2000;
}
}
fn voice_tick(&mut self) {
self.teddy.queue.retain(|c|c.phase==self.phase && c.expires>self.wall_ms);
if self.teddy.current.as_ref().is_some_and(|c|c.phase!=self.phase || c.expires<=self.wall_ms) {
self.teddy.current=None;
}
if self.teddy.current.as_ref().is_some_and(|c| c.started.is_some_and(|t| self.wall_ms>=t+5000+(c.transcript.split_whitespace().count() as u64)*600)) {
self.teddy.current=None; self.teddy.next_voice_at=self.wall_ms+8000;
}
if self.teddy.current.is_none() && self.wall_ms>=self.teddy.next_voice_at && (!self.teddy.takeover || self.teddy.queue.first().is_some_and(|c|c.key.starts_with("gm-"))) {
if !self.teddy.queue.is_empty() {
let mut cue=self.teddy.queue.remove(0);
self.teddy.transcript=cue.transcript.clone();
self.teddy.played.insert(cue.key.clone());
if self.teddy.online && !self.teddy.muted {
cue.started=Some(self.wall_ms);self.teddy.current=Some(cue);
} else {self.teddy.next_voice_at=self.wall_ms+8000;}
}
}
}
fn step(&mut self,ms:u64) {
self.wall_ms+=ms;
if self.lifecycle=="RUNNING" {
if self.phase!="BRIEFING" { self.active_ms+=ms; }
if self.phase=="BOOTING" && self.boot_until.is_some_and(|t|self.active_ms>=t) {
self.phase("ARCADE");
for s in &mut self.stations {s.status="TOKEN_WAIT".into();}
}
for s in &mut self.stations {
if s.charging && s.online {s.charge_ms=(s.charge_ms+ms).min(30_000);}
if s.status=="RETRY_READY" && self.active_ms>=s.retry_at {s.status="READY".into();}
}
if self.active_ms>=self.limit_ms {self.lifecycle="TIME_EXPIRED".into();self.hold="Time expired; extend or finish assisted".into();self.cancel_wave();}
else if self.phase=="REVEAL" && !self.passage_open && self.active_ms>=self.reveal_at+5000 {
self.technical_hold("Passage release unconfirmed; observe open, then Resume");
}
}
if self.phase=="REVEAL" && self.passage_since.is_some_and(|t|self.wall_ms>=t+300) {
self.passage_open=true;self.phase("CELLS");
self.cue("reveal","It was a call for help. Bring the six light cores to the ship. Match their symbols.",8);
}
if self.passage_open {
let charged:Vec<_>=self.stations.iter().filter(|s|s.charge_ms==30_000).map(|s|s.cell.clone()).collect();
for cell in charged {self.effect("bay.release",&cell);}
}
self.power_check();
if self.lifecycle=="RUNNING" {
if self.phase=="CELLS" && self.ship_power {
self.phase("CALIBRATION_TUTORIAL");
for s in &mut self.stations {s.status="CAL_READY".into();}
self.cue("training","Navigator, name a station and its target. Operator, repeat it and press Ready. Navigator, check it, then Execute.",7);
self.next_wave();
}
if self.wave.as_ref().is_some_and(|w|w.deadline.is_some_and(|t|self.active_ms>=t)) {
self.wave=None;self.next_wave();
if let Some(w)=&mut self.wave {w.retry_at=self.active_ms+3000;}
}
if self.ready_deadline.is_some_and(|t|self.active_ms>=t) && self.countdown.is_none() {
self.groups.clear();self.ready_deadline=None;
}
if self.countdown.is_some_and(|t|self.active_ms>=t) && self.ship_power && self.stations.iter().all(|s|s.online) {
self.launches+=1;self.countdown=None;self.lifecycle="WON".into();
self.outcome=Some("WON".into());self.phase("DEBRIEF");self.effect("finale","room");
self.cue("ending","A signal has become a journey. You gave our visitor a way home.",9);
}
let threshold=self.tuning("hint_1_inactivity_ms")+self.teddy.hint_level*self.tuning("next_hint_interval_ms");
if self.active_ms.saturating_sub(self.last_progress)>=threshold && self.teddy.hint_level<2 &&
["ARCADE","CALIBRATION"].contains(&self.phase.as_str()) {
self.hint();
}
}
self.voice_tick();
}
fn hint(&mut self) {
self.teddy.hint_level=(self.teddy.hint_level+1).min(3);
let text=if self.phase=="ARCADE" {
if self.teddy.hint_level==1 {"Each token belongs to one machine. Look for the cabinet name."}
else {"Check the claw controls, record sleeves, service panel, tickets and poster for the remaining tokens."}
} else {"The ship has the target. Name the station, repeat the target, hold it steady, press Ready, then Execute."};
let key=format!("gm-hint:{}",self.revision);
self.cue(&key,text,9);
}
pub fn apply(&mut self,input:&Input)->Result<String,String> {
let v=&input.value;
// Time, sensor observations and GM diagnostics remain available during holds.
let operational=["clock","advance","pause","resume","extend","reset","prepare","teddy","hint","speak","online","cell","passage","ack","deliver","finish_assisted"];
if !operational.contains(&input.action.as_str()) && input.action!="start_run" {
self.require(self.lifecycle=="RUNNING","Room is not running")?;
}
if ["WON","ASSISTED_RESCUE","ABORTED"].contains(&self.lifecycle.as_str()) {
self.require(["clock","advance","reset","teddy","speak","online","cell","ack"].contains(&input.action.as_str()),"Run is terminal; reset before new gameplay")?;
}
match input.action.as_str() {
"prepare" => {
self.require(self.lifecycle=="READY","Reset before changing the run profile")?;
let profile=v["profile"].as_str().ok_or("Missing profile")?;
self.require(contract()["profiles"].get(profile).is_some(),"Unknown profile")?;
let party=v["party"].as_u64().ok_or("Missing party size")?;
self.require((1..=8).contains(&party),"Party must be 1–8")?;
let revision=self.revision;
*self=Self::new(self.run_id.clone(),profile,party,v["seed"].as_u64().unwrap_or(42));
self.revision=revision;
}
"start_run" => {
self.require(self.lifecycle=="READY","Run is already started")?;
self.lifecycle="RUNNING".into();self.teddy.mode="GUIDE".into();
self.cue("welcome","Hello there. You came to investigate the strange happenings, did you? I have heard these machines whisper when nobody was playing. For now, I seem to be the only one awake. Find the junction box and the key to its lock. Then lift the large switch. Let us see what wakes up.",6);
}
"briefing_done" => {
self.require(self.phase=="BRIEFING","Briefing already complete")?;
self.phase("POWER_RESTORE");
self.cue("power-search","Find the junction box. A lock is keeping its switch down. Find the key, remove the lock, and lift the switch to restore power.",8);
}
"power_switch" => {
self.require(self.phase=="POWER_RESTORE","Power restoration is not awaiting a switch")?;
self.require(v.as_bool()==Some(true),"Raise the switch to restore power")?;
self.phase("BOOTING");self.boot_until=Some(self.active_ms+4000);
for s in &mut self.stations {s.status="BOOTING".into();}
}
"solve" => {
self.require(self.phase=="ARCADE","Discoveries are only active in the arcade")?;
let idx=self.puzzles.iter().position(|p|p.id==input.entity).ok_or("Unknown puzzle")?;
let p=&self.puzzles[idx];
self.require(!p.solved,"Puzzle already solved")?;
if p.id=="P02A" {self.require(self.puzzles[0].solved,"Restore claw controls P01 first")?;}
if p.id=="P02B" {self.require(self.puzzles[1].delivered,"Confirm T01 delivery before claw stage B")?;}
self.puzzles[idx].solved=true;
let token=self.puzzles[idx].token.clone();
if !token.is_empty() {self.effect("token.dispense",&token);}
self.last_progress=self.active_ms;
}
"deliver" => {
let idx=self.puzzles.iter().position(|p|p.token==input.entity).ok_or("Unknown token reward")?;
self.require(self.puzzles[idx].solved,"Solve the puzzle before confirming delivery")?;
self.require(!self.puzzles[idx].delivered,"Already delivered")?;
self.puzzles[idx].delivered=true;
if v["hand"].as_bool()==Some(true) {
self.require(v["reason"].as_str().is_some_and(|s|!s.trim().is_empty()),"Hand-delivery requires a reason")?;
self.assisted=true;
}
if let Some(e)=self.effects.iter_mut().find(|e|e.target==input.entity) {e.status="OBSERVED".into();}
self.last_progress=self.active_ms;
}
"token" => {
self.require(self.phase=="ARCADE","Token admission only in arcade phase")?;
let i=self.station(&input.entity)?;let s=&self.stations[i];
self.require(s.online && s.status=="TOKEN_WAIT","Station is not waiting for a token")?;
self.require(v["token"].as_str()==Some(s.token.as_str()),"Wrong token for this cabinet")?;
self.require(v["age_ms"].as_u64().is_some_and(|n|n<2000),"Stale token observation")?;
self.stations[i].authorized=true;self.stations[i].status="READY".into();
self.last_progress=self.active_ms;
}
"start" => {
let i=self.station(&input.entity)?;let s=&self.stations[i];
self.require(self.phase=="ARCADE" && s.online && s.authorized && s.status=="READY","Cabinet needs correct token / retry delay")?;
let s=&mut self.stations[i];s.status="PLAYING".into();s.attempt+=1;s.progress=0;s.charging=true;
self.last_progress=self.active_ms;
}
"win" | "lose" => {
let i=self.station(&input.entity)?;let s=&self.stations[i];
self.require(self.phase=="ARCADE" && s.online && s.status=="PLAYING","No active first-state attempt")?;
self.require(v["attempt"].as_u64()==Some(s.attempt),"Stale attempt")?;
if input.action=="lose" {
let s=&mut self.stations[i];s.losses+=1;s.status="RETRY_READY".into();s.retry_at=self.active_ms+3000;
} else {
self.require(v["progress"].as_u64().is_some_and(|p|p>=s.target),"Objective not reached")?;
let s=&mut self.stations[i];s.progress=s.target;s.won=true;s.status="WON".into();
self.last_progress=self.active_ms;
let count=self.stations.iter().filter(|s|s.won).count();
let lesson=match count {1=>Some(("name","One signal restored. Use the name on the cabinet when you call out.")),
2=>Some(("echo","Say the instruction back before you act.")),3=>Some(("hold","That pattern looks like stars. Keep a good setting steady.")),
5=>Some(("ready","Ready means waiting for the same moment. It does not mean go.")),_=>None};
if let Some((key,text))=lesson {self.cue(key,text,4);}
if count==6 {self.phase("BOSS");self.boss="READY".into();
self.cue("boss","The dark machine is listening now. Break through, and we may hear who is calling.",8);}
}
}
"boss_start" => {self.require(self.phase=="BOSS" && self.boss=="READY","Six unique wins required")?;self.boss="PLAYING".into();}
"boss_win" => {
self.require(self.phase=="BOSS" && self.boss=="PLAYING","Boss is not playing")?;
self.boss="WON".into();self.phase("REVEAL");self.reveal_at=self.active_ms;
self.effect("passage.release","passage");
}
"ack" => {
let e=self.effects.iter_mut().find(|e|e.id==input.entity).ok_or("Unknown effect")?;
if e.status=="REQUESTED" {e.status="ACKNOWLEDGED".into();}
}
"passage" => {
self.require(self.phase=="REVEAL","No reveal pending")?;
self.passage_since=if v.as_bool()==Some(true) {Some(self.wall_ms)}else{None};
}
"bay" => {
let i=self.station(&input.entity)?;
self.require(self.passage_open && self.stations[i].charge_ms==30_000,"Bay requires reveal and full charge")?;
self.stations[i].bay_open=true;
let cell=self.stations[i].cell.clone();
if let Some(e)=self.effects.iter_mut().find(|e|e.kind=="bay.release" && e.target==cell) {e.status="OBSERVED".into();}
}
"cell" => {
self.require(self.passage_open,"Cells are not accessible before reveal")?;
let cell=v["cell"].as_str().ok_or("Missing cell identity")?;
self.require(self.stations.iter().any(|s|s.cell==cell),"Unknown cell")?;
self.require(self.stations.iter().any(|s|s.socket==input.entity || format!("BAY{}",&s.cell[4..])==input.entity),"Unknown location")?;
if v["present"].as_bool()==Some(true) {
self.locations.insert(input.entity.clone(),cell.into());self.location_since.insert(input.entity.clone(),self.wall_ms);
} else {
if input.entity.starts_with("BAY") {
let s=self.stations.iter().find(|s|s.cell==cell).unwrap();
self.require(s.bay_open,"Bay open sensor not confirmed")?;
}
self.require(self.locations.get(&input.entity).map(String::as_str)==Some(cell),"Cell is not at this location")?;
self.locations.remove(&input.entity);self.location_since.remove(&input.entity);
}
self.power_check();
}
"ready" => {
self.require(self.ship_power,"Ship power not stable")?;
let w=self.wave.as_ref().ok_or("No current challenge")?;
self.require(v["challenge"].as_u64()==Some(w.id),"Old challenge")?;
self.require(self.active_ms>=w.retry_at && w.deadline.is_none_or(|d|self.active_ms<d),"Challenge window closed")?;
self.require(w.targets.contains_key(&input.entity),"Station not in this wave")?;
let selection=v["selection"].as_u64().ok_or("Missing selection")?;
let range=&contract()["calibration_selections"][&input.entity];
self.require(selection>=range["min"].as_u64().unwrap() && selection<=range["max"].as_u64().unwrap(),"Selection outside station range")?;
let hold=if self.phase=="CALIBRATION_TUTORIAL" && self.tutorial==0 {0}else{self.tuning("stable_target_hold_ms")};
self.require(v["stable_ms"].as_u64().unwrap_or(0)>=hold,"Selection has not been held long enough")?;
self.wave.as_mut().unwrap().ready.insert(input.entity.clone(),selection);
}
"revoke" => {let w=self.wave.as_mut().ok_or("No current challenge")?;
if v.as_u64()!=Some(w.id) {return Err("Old challenge".into())}
w.ready.remove(&input.entity);}
"execute" => {
let w=self.wave.as_ref().ok_or("No current challenge")?;
self.require(v.as_u64()==Some(w.id),"Old challenge")?;
self.require(self.active_ms>=w.retry_at && w.deadline.is_none_or(|d|self.active_ms<d),"Challenge deadline closed")?;
if w.targets!=w.ready {
self.wave=None;self.next_wave();
if let Some(w)=&mut self.wave {w.retry_at=self.active_ms+3000;}
return Ok("Wave failed: readback not ready/correct. New challenge issued; retry in 3 seconds".into());
}
let ids:Vec<_>=w.targets.keys().cloned().collect();self.wave=None;
if self.phase=="CALIBRATION_TUTORIAL" {self.tutorial+=1;
if self.tutorial==2 {self.phase("CALIBRATION");}
} else {for id in ids {let i=self.station(&id)?;self.stations[i].passes+=1;}}
self.last_progress=self.active_ms;self.next_wave();
}
"arm" => {self.require(self.phase=="LAUNCH_ARM" && self.ship_power,"Calibration and ship power required")?;
let i=self.station(&input.entity)?;self.require(self.stations[i].online,"Station offline")?;
self.require(v.as_u64()==Some(self.launch_epoch),"Old launch epoch")?;self.armed.insert(input.entity.clone());}
"group_ready" => {
self.require(self.phase=="LAUNCH_ARM" && self.ship_power,"Launch is not arming")?;
self.require(v["epoch"].as_u64()==Some(self.launch_epoch),"Old launch epoch")?;
let group=v["group"].as_u64().ok_or("Missing operator group")?;
self.require(group<self.operators,"Unassigned operator group")?;
let assigned=self.stations.iter().enumerate().filter(|(i,_)|*i as u64%self.operators==group);
self.require(assigned.clone().all(|(_,s)|self.armed.contains(&s.id)),"Group stations not all armed")?;
if self.ready_deadline.is_none() {self.ready_deadline=Some(self.active_ms+if self.party==1 {90_000}else{self.tuning("final_ack_window_ms")});}
self.require(self.active_ms<self.ready_deadline.unwrap(),"Readiness window closed")?;
self.groups.insert(group);
}
"commit" => {
self.require(self.phase=="LAUNCH_ARM" && self.ship_power,"Launch unavailable")?;
self.require(v.as_u64()==Some(self.launch_epoch),"Old launch epoch")?;
self.require(self.armed.len()==6 && self.groups.len()==self.operators as usize,"Missing station arms or crew acknowledgements")?;
self.require(self.ready_deadline.is_some_and(|d|self.active_ms<d),"Readiness deadline closed")?;
self.phase("COUNTDOWN");self.countdown=Some(self.active_ms+5000);
self.cue(&format!("launch:{}",self.launch_epoch),"Every station is holding. Crew ready. Five, four, three, two, one.",9);
}
"clock" | "advance" => {
let amount=v.as_u64().ok_or("Advance requires milliseconds")?;
self.require(amount<=3_600_000,"Advance at most one hour")?;
let mut remaining=amount;while remaining>0 {let step=remaining.min(100);self.step(step);remaining-=step;}
}
"pause" => {self.require(self.lifecycle=="RUNNING","Only a running room can pause")?;
self.lifecycle="PAUSED".into();self.hold="GM pause".into();
if self.phase=="COUNTDOWN" {self.cancel_wave();}}
"resume" => {
self.require(["PAUSED","RECOVERY_HOLD"].contains(&self.lifecycle.as_str()),"Room is not paused or held")?;
self.require(self.stations.iter().all(|s|s.online),"Restore offline stations first")?;
if self.phase=="REVEAL" {self.require(self.passage_open,"Passage still unconfirmed")?;}
if ["CALIBRATION_TUTORIAL","CALIBRATION","LAUNCH_ARM"].contains(&self.phase.as_str()) {self.require(self.ship_power,"Restore ship power first")?;}
self.lifecycle="RUNNING".into();self.hold.clear();self.next_wave();
}
"extend" => {
let minutes=v.as_u64().ok_or("Minutes required")?;
self.require((1..=60).contains(&minutes),"Extend by 1–60 minutes")?;
self.limit_ms+=minutes*60_000;self.assisted=true;
if self.lifecycle=="TIME_EXPIRED" {self.lifecycle="RECOVERY_HOLD".into();self.hold="Time extended; reconcile then Resume".into();}
}
"online" => {
let i=self.station(&input.entity)?;let online=v.as_bool().ok_or("Boolean required")?;
self.stations[i].online=online;
if !online && self.lifecycle=="RUNNING" {self.technical_hold("Station offline; restore connection and Resume");}
}
"teddy" => match v.as_str().ok_or("Teddy action required")? {
"mute"=>{self.teddy.muted=!self.teddy.muted;self.teddy.current=None;},
"offline"=>{self.teddy.online=!self.teddy.online;self.teddy.current=None;},
"takeover"=>{self.teddy.takeover=true;self.teddy.queue.clear();self.teddy.current=None;},
"release"=>{self.teddy.takeover=false;},
"stop"=>{self.teddy.current=None;self.teddy.next_voice_at=self.wall_ms+8000;},
"replay"=>{let text=self.teddy.transcript.clone();let key=format!("gm-replay:{}",self.revision);self.cue(&key,&text,9);},
_=>return Err("Unknown Teddy action".into()),
},
"speak" => {
let text=v["text"].as_str().unwrap_or("").trim();
self.require(!text.is_empty() && text.chars().count()<=1500,"Hint must contain 1-1500 characters")?;
self.require(self.teddy.online && !self.teddy.muted,"Unmute or reconnect Teddy before speaking")?;
self.teddy.queue.retain(|c|!c.key.starts_with("gm-"));
let key=format!("gm-custom:{}",self.revision);
self.cue(&key,text,9);
},
"hint" => {self.require(["ARCADE","CALIBRATION","CALIBRATION_TUTORIAL"].contains(&self.phase.as_str()),"Hints suppressed in this phase")?;self.hint();}
"finish_assisted" => {
self.require(v.as_str().is_some_and(|r|!r.trim().is_empty()),"Reason required")?;
self.lifecycle="ASSISTED_RESCUE".into();self.outcome=Some("ASSISTED_RESCUE".into());
self.assisted=true;self.cancel_wave();self.phase("DEBRIEF");
}
"reset" => {
self.require(v["confirmed"].as_bool()==Some(true),"Confirm reset of simulated run")?;
let next=format!("{}-next",self.run_id);let revision=self.revision;
*self=Self::new(next,&self.profile,self.party,self.seed);self.revision=revision;
}
_=>return Err("Unknown simulation action".into()),
}
self.voice_tick();
Ok("Accepted simulated observation/control".into())
}
}
impl Room {
pub fn new(run:String)->Self {
Self {state:RoomState::new(run.clone(),"standard",6,42),trace:vec![],initial_run:run,seen:BTreeMap::new()}
}
pub fn handle(&mut self,req:Request,now:u64)->Response {
let key=fingerprint(&req);
if let Some((old,ok,msg))=self.seen.get(&req.command_id).filter(|_|req.run_id==self.state.run_id).cloned() {
let response=Response{accepted:old==key && ok,duplicate:old==key,
message:if old==key {msg}else{"Command ID collision".into()},state:self.state.clone()};
self.trace.push(TraceEntry{request:req,received_at_ms:now,accepted:response.accepted,
message:response.message.clone(),final_hash:fingerprint(&self.state)});
return response;
}
let result=if req.command_id.is_empty() {Err("Missing command ID".into())}
else if req.run_id!=self.state.run_id {Err("Stale run".into())}
else if req.expected_revision!=self.state.revision {Err("Stale GM revision; refresh state".into())}
else if req.expires_at_ms<=now || req.expires_at_ms>now+30_000 {Err("Command expired or expiry too far ahead".into())}
else {
let mut candidate=self.state.clone();
match candidate.apply(&req.input) {
Ok(message)=>{if req.input.action!="clock" {candidate.revision+=1;}self.state=candidate;Ok(message)}
Err(e)=>Err(e),
}
};
let accepted=result.is_ok();let message=result.unwrap_or_else(|e|e);
self.seen.insert(req.command_id.clone(),(key,accepted,message.clone()));
self.trace.push(TraceEntry{request:req,received_at_ms:now,accepted,message:message.clone(),final_hash:fingerprint(&self.state)});
Response{accepted,duplicate:false,message,state:self.state.clone()}
}
pub fn export(&self)->Value {json!({"simulation":true,"contract_version":"1.0","initial_run":self.initial_run,
"config_hash":self.state.config_hash,"trace":self.trace,"final_hash":fingerprint(&self.state)})}
}
#[cfg(test)]
mod tests;
pub fn replay_export(value:Value)->Result<String,String> {
if value["config_hash"].as_str()!=Some(fingerprint(&contract()).as_str()) {
return Err("Contract hash differs from the recorded fixture".into())
}
let initial=value["initial_run"].as_str().ok_or("Missing initial run")?;
let entries:Vec<TraceEntry>=serde_json::from_value(value["trace"].clone()).map_err(|e|e.to_string())?;
let mut room=Room::new(initial.into());
for (i,entry) in entries.iter().enumerate() {
let response=room.handle(entry.request.clone(),entry.received_at_ms);
if response.accepted!=entry.accepted || fingerprint(&room.state)!=entry.final_hash {
return Err(format!("Replay divergence at entry {i}"))
}
}
let hash=fingerprint(&room.state);
if value["final_hash"].as_str()!=Some(hash.as_str()) {return Err("Final hash mismatch".into())}
Ok(hash)
}
+221
View File
@@ -0,0 +1,221 @@
use super::*;
fn input(action:&str,entity:&str,value:Value)->Input {Input{action:action.into(),entity:entity.into(),value}}
fn send(r:&mut Room,action:&str,entity:&str,value:Value)->Response {
let req=Request{command_id:format!("cmd-{}",r.trace.len()),run_id:r.state.run_id.clone(),
expected_revision:r.state.revision,expires_at_ms:20_000,input:input(action,entity,value)};
r.handle(req,10_000)
}
fn ok(r:&mut Room,action:&str,entity:&str,value:Value) {
let result=send(r,action,entity,value);assert!(result.accepted,"{action} {entity}: {}",result.message);
}
fn init(profile:&str,party:u64)->Room {
let mut r=Room::new("fixture".into());
ok(&mut r,"prepare","",json!({"profile":profile,"party":party,"seed":42}));
ok(&mut r,"start_run","",Value::Null);ok(&mut r,"briefing_done","",Value::Null);ok(&mut r,"power_switch","",json!(true));ok(&mut r,"advance","",json!(4000));r
}
fn arcade(r:&mut Room) {
for p in ["P01","P02A","P02B","P03","P04","P05","P06"] {
ok(r,"solve",p,Value::Null);
let token=r.state.puzzles.iter().find(|v|v.id==p).unwrap().token.clone();
if !token.is_empty() {ok(r,"deliver",&token,Value::Null);}
}
for s in r.state.stations.clone() {
ok(r,"token",&s.id,json!({"token":s.token,"age_ms":0}));
ok(r,"start",&s.id,Value::Null);
}
ok(r,"advance","",json!(30_000));
for s in r.state.stations.clone() {ok(r,"win",&s.id,json!({"attempt":1,"progress":s.target}));}
assert_eq!(r.state.phase,"BOSS");
}
fn cells(r:&mut Room) {
ok(r,"boss_start","",Value::Null);ok(r,"boss_win","",Value::Null);
let release=r.state.effects.iter().find(|e|e.kind=="passage.release").unwrap().id.clone();
ok(r,"ack",&release,Value::Null);assert!(!r.state.passage_open);
ok(r,"passage","",json!(true));ok(r,"advance","",json!(300));
for s in r.state.stations.clone() {
ok(r,"bay",&s.id,Value::Null);
ok(r,"cell",&format!("BAY{}",&s.cell[4..]),json!({"cell":s.cell,"present":false}));
ok(r,"cell",&s.socket,json!({"cell":s.cell,"present":true}));
}
ok(r,"advance","",json!(2500));assert_eq!(r.state.phase,"CALIBRATION_TUTORIAL");
}
fn wave(r:&mut Room) {
let w=r.state.wave.clone().unwrap();
let hold=r.state.tuning("stable_target_hold_ms");
for (id,target) in w.targets {
ok(r,"ready",&id,json!({"challenge":w.id,"selection":target,"stable_ms":hold}));
}
ok(r,"execute","",json!(w.id));
}
fn calibration(r:&mut Room) {
while ["CALIBRATION_TUTORIAL","CALIBRATION"].contains(&r.state.phase.as_str()) {wave(r);}
assert_eq!(r.state.phase,"LAUNCH_ARM");
assert!(r.state.stations.iter().all(|s|s.passes==r.state.tuning("passes_per_station")));
}
fn arm(r:&mut Room) {
let epoch=r.state.launch_epoch;
for s in r.state.stations.clone() {ok(r,"arm",&s.id,json!(epoch));}
for group in 0..r.state.operators {ok(r,"group_ready","",json!({"epoch":epoch,"group":group}));}
}
#[test]
fn sim01_04_golden_profiles_and_replay() {
for (profile,party) in [("standard",6),("guided",2),("guided",1),("challenge",8)] {
let mut r=init(profile,party);arcade(&mut r);cells(&mut r);calibration(&mut r);arm(&mut r);
let epoch=r.state.launch_epoch;
ok(&mut r,"commit","",json!(epoch));ok(&mut r,"advance","",json!(5000));
assert_eq!(r.state.lifecycle,"WON");assert_eq!(r.state.launches,1);
assert_eq!(r.state.assisted,party==1);
assert!(r.state.effects.iter().filter(|e|e.kind=="finale").count()==1);
let mut replay=Room::new("fixture".into());
for entry in &r.trace {
let response=replay.handle(entry.request.clone(),entry.received_at_ms);
assert_eq!(response.accepted,entry.accepted);
assert_eq!(fingerprint(&replay.state),entry.final_hash);
}
assert_eq!(r.state,replay.state);
assert_eq!(replay_export(r.export()).unwrap(),fingerprint(&r.state));
}
}
#[test]
fn sim05_07_tokens_retries_and_distinct_wins() {
let mut r=init("standard",6);
assert!(!send(&mut r,"token","cabinet_01",json!({"token":"T02","age_ms":0})).accepted);
assert!(!send(&mut r,"token","cabinet_01",json!({"token":"T01","age_ms":2000})).accepted);
for i in 1..=5 {
let id=format!("cabinet_{i:02}");
ok(&mut r,"token",&id,json!({"token":format!("T{i:02}"),"age_ms":0}));
assert!(!send(&mut r,"token",&id,json!({"token":format!("T{i:02}"),"age_ms":0})).accepted);
ok(&mut r,"start",&id,Value::Null);
if i==3 {
for attempt in 1..=2 {
ok(&mut r,"lose",&id,json!({"attempt":attempt}));
assert!(!send(&mut r,"start",&id,Value::Null).accepted);
ok(&mut r,"advance","",json!(3000));ok(&mut r,"start",&id,Value::Null);
}
}
let attempt=r.state.stations[i-1].attempt;let target=r.state.stations[i-1].target;
ok(&mut r,"win",&id,json!({"attempt":attempt,"progress":target}));
assert!(!send(&mut r,"win",&id,json!({"attempt":attempt,"progress":target})).accepted);
}
assert_eq!(r.state.stations.iter().filter(|s|s.won).count(),5);assert_eq!(r.state.boss,"DARK");
assert!(!send(&mut r,"boss_start","",Value::Null).accepted);
}
#[test]
fn sim08_09_ack_is_not_observation() {
let mut r=init("standard",6);ok(&mut r,"solve","P01",Value::Null);ok(&mut r,"solve","P02A",Value::Null);
let effect=r.state.effects[0].id.clone();ok(&mut r,"ack",&effect,Value::Null);
assert!(!r.state.puzzles[1].delivered);
assert!(!send(&mut r,"solve","P02B",Value::Null).accepted);
ok(&mut r,"deliver","T01",json!({"hand":true,"reason":"GM inspected claw jam and handed token"}));
assert!(r.state.assisted);assert_eq!(r.state.effects.len(),1);
let mut r=init("standard",6);arcade(&mut r);ok(&mut r,"boss_start","",Value::Null);ok(&mut r,"boss_win","",Value::Null);
let e=r.state.effects.iter().find(|e|e.kind=="passage.release").unwrap().id.clone();ok(&mut r,"ack",&e,Value::Null);
ok(&mut r,"advance","",json!(5000));
assert_eq!(r.state.lifecycle,"RECOVERY_HOLD");assert!(!r.state.passage_open);
let elapsed=r.state.active_ms;ok(&mut r,"advance","",json!(300_000));assert_eq!(r.state.active_ms,elapsed);
assert!(!send(&mut r,"resume","",Value::Null).accepted);
}
#[test]
fn sim10_11_cell_conflicts_and_interruption() {
let mut r=init("guided",2);arcade(&mut r);cells(&mut r);wave(&mut r);wave(&mut r);wave(&mut r);
let passes=r.state.stations[0].passes;let old=r.state.wave.as_ref().unwrap().id;
ok(&mut r,"cell","SOCKET02",json!({"cell":"CELL01","present":true}));
assert_eq!(r.state.lifecycle,"RECOVERY_HOLD");assert!(!r.state.ship_power);
assert!(r.state.cell_faults.iter().any(|s|s.contains("multiple")));
ok(&mut r,"cell","SOCKET02",json!({"cell":"CELL02","present":true}));
ok(&mut r,"advance","",json!(2500));ok(&mut r,"resume","",Value::Null);
assert!(r.state.wave.as_ref().unwrap().id>old);assert_eq!(r.state.stations[0].passes,passes);
calibration(&mut r);arm(&mut r);let epoch=r.state.launch_epoch;
ok(&mut r,"commit","",json!(epoch));
ok(&mut r,"cell","SOCKET01",json!({"cell":"CELL01","present":false}));
ok(&mut r,"advance","",json!(5000));assert_eq!(r.state.launches,0);assert!(r.state.countdown.is_none());
}
#[test]
fn sim12_13_deadlines_and_launch_retry() {
let mut r=init("guided",2);arcade(&mut r);cells(&mut r);wave(&mut r);wave(&mut r);
let w=r.state.wave.clone().unwrap();let duration=r.state.tuning("response_window_ms");
ok(&mut r,"advance","",json!(duration));
assert!(!send(&mut r,"execute","",json!(w.id)).accepted);
assert!(!send(&mut r,"ready","cabinet_01",json!({"challenge":w.id,"selection":0,"stable_ms":1000})).accepted);
ok(&mut r,"advance","",json!(3000));calibration(&mut r);arm(&mut r);
let epoch=r.state.launch_epoch;let window=r.state.tuning("final_ack_window_ms");
ok(&mut r,"advance","",json!(window));assert!(r.state.groups.is_empty());
assert!(!send(&mut r,"commit","",json!(epoch)).accepted);
arm(&mut r);ok(&mut r,"commit","",json!(epoch));ok(&mut r,"advance","",json!(5000));assert_eq!(r.state.launches,1);
}
#[test]
fn sim17_teddy_fallback_and_no_voice_gate() {
let mut r=init("guided",2);ok(&mut r,"teddy","",json!("offline"));
ok(&mut r,"hint","",Value::Null);assert!(!r.state.teddy.transcript.is_empty());
arcade(&mut r);cells(&mut r);calibration(&mut r);arm(&mut r);
let epoch=r.state.launch_epoch;ok(&mut r,"commit","",json!(epoch));ok(&mut r,"advance","",json!(5000));
assert_eq!(r.state.lifecycle,"WON");assert!(r.state.teddy.current.is_none());
assert!(r.state.teddy.queue.iter().all(|c|c.phase==r.state.phase));
}
#[test]
fn sim18_20_pause_expiry_extension_reset_and_conflicts() {
let mut r=init("standard",6);ok(&mut r,"pause","",Value::Null);
let elapsed=r.state.active_ms;ok(&mut r,"advance","",json!(300_000));assert_eq!(elapsed,r.state.active_ms);
let req=Request{command_id:"expired".into(),run_id:r.state.run_id.clone(),expected_revision:r.state.revision,
expires_at_ms:10000,input:input("resume","",Value::Null)};
assert!(!r.handle(req,10000).accepted);
ok(&mut r,"resume","",Value::Null);arcade(&mut r);cells(&mut r);wave(&mut r);wave(&mut r);
ok(&mut r,"advance","",json!(3_600_000));assert_eq!(r.state.lifecycle,"TIME_EXPIRED");
let phase=r.state.phase.clone();ok(&mut r,"extend","",json!(5));ok(&mut r,"resume","",Value::Null);
assert_eq!(r.state.phase,phase);assert_eq!(r.state.launches,0);
let old=Request{command_id:"old".into(),run_id:r.state.run_id.clone(),expected_revision:r.state.revision,
expires_at_ms:20000,input:input("win","cabinet_01",json!({"attempt":1,"progress":500}))};
ok(&mut r,"reset","",json!({"confirmed":true}));
for action in ["win","token","commit"] {
let mut stale=old.clone();stale.command_id=format!("old-{action}");stale.input.action=action.into();
assert!(!r.handle(stale,10000).accepted);
}
assert!(r.state.stations.iter().all(|s|!s.won && !s.authorized));
let req=Request{command_id:"dup".into(),run_id:r.state.run_id.clone(),expected_revision:r.state.revision,
expires_at_ms:20000,input:input("start_run","",Value::Null)};
assert!(r.handle(req.clone(),10000).accepted);assert!(r.handle(req.clone(),10000).duplicate);
let mut conflicting=req;conflicting.command_id="conflict".into();
assert!(!r.handle(conflicting,10000).accepted);
}
#[test]
fn manual_hints_bypass_takeover_and_virtual_gap() {
let mut r=init("standard",6);
ok(&mut r,"teddy","",json!("takeover"));
r.state.teddy.next_voice_at=r.state.wall_ms+8000;
ok(&mut r,"speak","",json!({"text":"Find the record sleeve."}));
assert!(r.state.teddy.takeover);
assert_eq!(r.state.teddy.current.as_ref().unwrap().transcript,"Find the record sleeve.");
let first=r.state.teddy.current.as_ref().unwrap().id.clone();
ok(&mut r,"teddy","",json!("stop"));
ok(&mut r,"teddy","",json!("replay"));
assert_ne!(r.state.teddy.current.as_ref().unwrap().id,first);
ok(&mut r,"hint","",Value::Null);
assert!(r.state.teddy.current.as_ref().unwrap().key.starts_with("gm-hint:"));
assert!(send(&mut r,"speak","",json!({"text":" "})).accepted==false);
ok(&mut r,"teddy","",json!("mute"));
assert!(!send(&mut r,"speak","",json!({"text":"Must not speak muted"})).accepted);
assert!(r.state.teddy.current.is_none());
}
#[test]
fn power_opening_and_live_clock() {
let mut r=Room::new("opening".into());
ok(&mut r,"start_run","",Value::Null);
ok(&mut r,"clock","",json!(2000));
assert_eq!(r.state.active_ms,0);
assert!(r.state.stations.iter().all(|s|s.status=="OFF"));
ok(&mut r,"briefing_done","",Value::Null);
assert_eq!(r.state.phase,"POWER_RESTORE");
assert!(!send(&mut r,"token","cabinet_01",json!({"token":"T01","age_ms":0})).accepted);
ok(&mut r,"power_switch","",json!(true));
assert!(!send(&mut r,"power_switch","",json!(true)).accepted);
ok(&mut r,"clock","",json!(3999));
assert!(r.state.stations.iter().all(|s|s.status=="BOOTING"));
ok(&mut r,"clock","",json!(1));
assert!(r.state.stations.iter().all(|s|s.status=="TOKEN_WAIT" && !s.authorized));
ok(&mut r,"pause","",Value::Null);
let time=r.state.active_ms;
ok(&mut r,"clock","",json!(1000));assert_eq!(r.state.active_ms,time);
assert!(replay_export(r.export()).is_ok());
}
+84
View File
@@ -0,0 +1,84 @@
use anyhow::Result;
use axum::{extract::State, http::{HeaderValue,Method,StatusCode}, routing::{get,post}, Json,Router};
use futures_util::StreamExt;
use glitch_odin_lab::{room::{Room,Request}};
use serde::{Serialize,Deserialize};
use serde_json::{json,Value};
use std::time::Duration;
use tower_http::cors::CorsLayer;
const HTTP_ADDR:&str="127.0.0.1:18776";
const SUBJECT:&str="escape_gallery.glitch.lab.v1.room.simulation.request";
#[derive(Serialize,Deserialize)]
struct Wire { op:String, request:Option<Request> }
async fn exchange(client:async_nats::Client,wire:Wire)->Result<Json<Value>,(StatusCode,String)> {
let result=async {
let response=tokio::time::timeout(Duration::from_secs(5),
client.request(SUBJECT,rmp_serde::to_vec_named(&wire)?.into())).await??;
Ok::<Value,anyhow::Error>(rmp_serde::from_slice(&response.payload)?)
}.await;
result.map(Json).map_err(|e|(StatusCode::SERVICE_UNAVAILABLE,e.to_string()))
}
async fn state(State(client):State<async_nats::Client>)->Result<Json<Value>,(StatusCode,String)> {
exchange(client,Wire{op:"state".into(),request:None}).await
}
async fn trace(State(client):State<async_nats::Client>)->Result<Json<Value>,(StatusCode,String)> {
exchange(client,Wire{op:"trace".into(),request:None}).await
}
async fn command(State(client):State<async_nats::Client>,Json(request):Json<Request>)->Result<Json<Value>,(StatusCode,String)> {
exchange(client,Wire{op:"apply".into(),request:Some(request)}).await
}
pub async fn run(client:async_nats::Client)->Result<()> {
let mut sub=client.subscribe(SUBJECT).await?;
client.flush().await?;
let sender=client.clone();
let worker=tokio::spawn(async move {
let mut room=Room::new(format!("sim-{}",glitch_odin_lab::now_ms()));
let mut ticker=tokio::time::interval(Duration::from_millis(250));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last=std::time::Instant::now();
let mut clock_id=0u64;
loop {
let message=tokio::select! {
_=ticker.tick()=>{
let elapsed=last.elapsed().as_millis() as u64;last=std::time::Instant::now();
if room.state.lifecycle=="RUNNING" {
clock_id+=1;
let now=glitch_odin_lab::now_ms();
room.handle(Request{command_id:format!("clock-{clock_id}"),run_id:room.state.run_id.clone(),
expected_revision:room.state.revision,expires_at_ms:now+10000,
input:glitch_odin_lab::room::Input{action:"clock".into(),entity:String::new(),value:json!(elapsed.min(3600000))}},now);
}
continue;
}
next=sub.next()=>match next {Some(message)=>message,None=>break},
};
if let Some(reply)=message.reply {
let result=match rmp_serde::from_slice::<Wire>(&message.payload) {
Ok(wire)=>match wire.op.as_str() {
"state"=>json!({"state":room.state}),
"trace"=>room.export(),
"apply"=>match wire.request {
Some(request)=>serde_json::to_value(room.handle(request,glitch_odin_lab::now_ms())).unwrap(),
None=>json!({"error":"Missing request"}),
},
_=>json!({"error":"Unknown simulation operation"}),
},
Err(e)=>json!({"error":e.to_string()}),
};
if let Err(error)=sender.publish(reply,rmp_serde::to_vec_named(&result).unwrap().into()).await {
eprintln!("Room reply failed: {error}");
}
}
}
});
let cors=CorsLayer::new()
.allow_origin([HeaderValue::from_static("http://127.0.0.1:18777"),HeaderValue::from_static("http://localhost:18777")])
.allow_methods([Method::GET,Method::POST]).allow_headers([axum::http::header::CONTENT_TYPE]);
let app=Router::new().route("/room/state",get(state)).route("/room/command",post(command))
.route("/room/trace",get(trace)).layer(cors).with_state(client);
println!("WHOLE-ROOM SIMULATION ONLY: http://{HTTP_ADDR}/room/state");
let result=axum::serve(tokio::net::TcpListener::bind(HTTP_ADDR).await?,app).await;
worker.abort();
result?;
Ok(())
}
+254
View File
@@ -0,0 +1,254 @@
"""Local Teddy audio follower (Chatterbox or Piper). Reads simulated state; never changes game state."""
import argparse
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import threading
import time
import urllib.request
import wave
ROOT = Path(__file__).resolve().parent
DATA = ROOT / ".speech"
VOICE = "en_US-ljspeech-medium"
MODEL = DATA / "voices" / (VOICE + ".onnx")
AUDIO = {}
OUTPUT = "local"
STATUS = {"status": "starting", "voice": VOICE, "message": "Loading speech follower"}
def python_path():
return DATA / "venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
def setup():
import venv
DATA.mkdir(exist_ok=True)
if not python_path().exists():
venv.EnvBuilder(with_pip=True).create(DATA / "venv")
subprocess.run([str(python_path()), "-m", "pip", "install", "piper-tts==1.8.0"], check=True)
MODEL.parent.mkdir(exist_ok=True)
base = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ljspeech/medium/"
for name in (MODEL.name, MODEL.name + ".json", "MODEL_CARD"):
target = MODEL.parent / name
if not target.exists():
temp = target.with_suffix(target.suffix + ".download")
print("Downloading " + name, flush=True)
urllib.request.urlretrieve(base + name, temp)
temp.replace(target)
print("Piper ready. Run: py -3 lab.py start-room --speech")
def synth(text_file, output):
from piper import PiperVoice
voice = PiperVoice.load(str(MODEL))
temp = Path(output).with_suffix(".partial")
with wave.open(str(temp), "wb") as stream:
voice.synthesize_wav(Path(text_file).read_text(encoding="utf-8"), stream)
temp.replace(output)
class BrowserPlayer:
def stop(self): pass
def play(self, path): pass
class Player:
def __init__(self):
self.process = None
if os.name != "nt":
self.command = shutil.which("afplay" if sys.platform == "darwin" else "aplay")
if not self.command:
raise RuntimeError("Install aplay (alsa-utils) for Linux audio playback")
def stop(self):
if os.name == "nt":
import winsound
winsound.PlaySound(None, 0)
elif self.process and self.process.poll() is None:
self.process.terminate()
self.process.wait(timeout=3)
self.process = None
def play(self, path):
self.stop()
if os.name == "nt":
import winsound
winsound.PlaySound(str(path), winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
else:
self.process = subprocess.Popen([self.command, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def cue_for(state):
teddy = state.get("teddy", {})
cue = teddy.get("current")
if not cue or teddy.get("muted") or (teddy.get("takeover") and not cue.get("key", "").startswith("gm-")) or not teddy.get("online"):
return None
return (state["run_id"], cue["id"], cue.get("started"), cue["transcript"])
class Follower:
def __init__(self, player, engine=None):
self.engine = engine
self.player = player
self.current = None
self.job = None
self.output = None
self.end = None
self.failed = False
(DATA / "cache").mkdir(parents=True, exist_ok=True)
def status(self, value, message):
global STATUS
STATUS = {"status": value, "voice": VOICE, "message": message}
def cancel(self):
global AUDIO
AUDIO = {}
if self.job and self.job.poll() is None:
self.job.terminate()
self.job.wait(timeout=5)
self.job = None
self.end = None
self.player.stop()
def update(self, state):
cue = cue_for(state) if state else None
if cue != self.current:
self.cancel()
self.current = cue
self.failed = False
if cue:
digest = hashlib.sha256((VOICE + "\n" + cue[3]).encode()).hexdigest()
self.output = DATA / "cache" / (digest + ".wav")
if self.output.exists():
self.play()
else:
text_file = self.output.with_suffix(".txt")
text_file.write_text(cue[3], encoding="utf-8")
self.job = self.engine.submit(cue[3], self.output) if self.engine else subprocess.Popen([str(python_path()), str(Path(__file__).resolve()),
"synth", "--text-file", str(text_file), "--output", str(self.output)],
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
self.status("generating", "Generating Teddy's current cue")
if self.job and self.job.poll() is not None:
result = self.job.returncode
self.job = None
if result == 0:
self.play()
else:
self.failed = True
self.status("error", "Speech synthesis failed; captions remain available. Replay to retry.")
if self.end is not None and time.monotonic() >= self.end:
self.end = None
self.status("finished", "Cue finished. Advance virtual time to release the next queued cue.")
if not cue:
self.status("idle" if state else "disconnected",
"Waiting for a cue" if state else "Room connection lost; audio stopped")
def play(self):
try:
with wave.open(str(self.output)) as audio:
duration = audio.getnframes() / audio.getframerate()
self.player.play(self.output)
global AUDIO
identity = hashlib.sha256(repr(self.current[:3]).encode()).hexdigest()
AUDIO = {"path":str(self.output), "audio_url":"/audio/" + identity + ".wav",
"cue_id":self.current[1], "run_id":self.current[0]}
self.end = None if OUTPUT == "browser" else time.monotonic() + duration
self.status("ready" if OUTPUT == "browser" else "playing",
"Audio ready for enabled browsers" if OUTPUT == "browser" else "Playing on the lab computer's default audio output")
except Exception as error:
self.failed = True
self.status("error", "Audio playback failed: " + str(error))
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
origin = self.headers.get("Origin", "")
allowed = ("http://127.0.0.1:18777", "http://localhost:18777")
if origin and origin not in allowed:
self.send_error(403)
return
audio = AUDIO.copy()
status = 200
content_range = None
if self.path == "/health":
data = json.dumps({**STATUS, "output":OUTPUT,
**{k:v for k,v in audio.items() if k != "path"}}).encode()
content_type = "application/json"
elif audio and self.path == audio.get("audio_url"):
data = Path(audio["path"]).read_bytes()
content_type = "audio/wav"
byte_range = self.headers.get("Range")
if byte_range:
import re
match = re.fullmatch(r"bytes=(\d+)-(\d*)", byte_range)
start = int(match[1]) if match else len(data)
end = min(int(match[2]) if match and match[2] else len(data)-1,len(data)-1)
if start > end:
self.send_error(416)
return
content_range = f"bytes {start}-{end}/{len(data)}"
data = data[start:end+1]
status = 206
else:
self.send_error(404)
return
self.send_response(status)
if origin:
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Content-Type", content_type)
self.send_header("Accept-Ranges", "bytes")
if content_range:
self.send_header("Content-Range", content_range)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *_):
pass
def follow(room_port=18776, status_port=18778, output="local", backend="chatterbox"):
global OUTPUT, VOICE
OUTPUT = output
engine = None
if backend == "chatterbox":
from chatterbox_client import Engine, voice_id
VOICE = voice_id()
engine = Engine()
follower = Follower(BrowserPlayer() if output == "browser" else Player(), engine)
server = ThreadingHTTPServer(("127.0.0.1", status_port), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
follower.status("starting" if engine else "idle", "Loading Teddy voice" if engine else "Waiting for a cue")
print(f"TEDDY AUDIO: {backend}, {output} output, status port {status_port}", flush=True)
try:
while True:
if engine and engine.error:
follower.cancel()
follower.status("error", "Chatterbox unavailable; see .speech/chatterbox.log. Restart speech to retry.")
time.sleep(1)
continue
if engine and not engine.ready.is_set():
time.sleep(.2)
continue
try:
with urllib.request.urlopen(f"http://127.0.0.1:{room_port}/room/state", timeout=1) as response:
state = json.load(response)["state"]
except Exception:
state = None
follower.update(state)
time.sleep(0.2)
finally:
follower.cancel()
if engine:
engine.close()
server.shutdown()
server.server_close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("setup", "follow", "synth"))
parser.add_argument("--backend", choices=("chatterbox", "piper"), default="chatterbox")
parser.add_argument("--text-file")
parser.add_argument("--output")
parser.add_argument("--room-port", type=int, default=18776)
parser.add_argument("--status-port", type=int, default=18778)
parser.add_argument("--output-device", choices=("local","browser"), default="local")
args = parser.parse_args()
if args.mode == "setup":
setup()
elif args.mode == "synth":
synth(args.text_file, args.output)
else:
follow(args.room_port, args.status_port, args.output_device, args.backend)
+39
View File
@@ -0,0 +1,39 @@
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import hint_library as lib
class HintLibraryTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory()
self.path=Path(self.temp.name)/"hints.json"
self.patch=patch.object(lib,"PATH",self.path); self.patch.start()
def tearDown(self):
self.patch.stop(); self.temp.cleanup()
def hint(self):
return {"title":"Find the records","text":"Look at the sleeve — then the record.","categories":["Records","Discovery"],
"phase":"ARCADE","target":"P03","condition":"incomplete","level":1}
def test_persists_and_updates_with_stable_id(self):
data=lib.save({"revision":0,"hint":self.hint()})
self.assertEqual(lib.read(),data)
added=data["hints"][-1]
added["text"]="A clearer hint"
updated=lib.save({"revision":1,"hint":added})
self.assertEqual(len(data["hints"]),len(updated["hints"]))
self.assertEqual(lib.read()["hints"][-1]["text"],"A clearer hint")
self.assertTrue(self.path.with_suffix(".bak").exists())
def test_conflict_preserves_other_device_edits(self):
lib.save({"revision":0,"hint":self.hint()})
before=self.path.read_bytes()
with self.assertRaises(lib.Conflict): lib.save({"revision":0,"hint":self.hint()})
self.assertEqual(self.path.read_bytes(),before)
def test_bad_input_and_corrupt_file_are_not_overwritten(self):
hint=self.hint(); hint["text"]="x"*1501
with self.assertRaises(ValueError): lib.save({"revision":0,"hint":hint})
self.path.write_text("broken",encoding="utf-8")
with self.assertRaises(ValueError): lib.save({"revision":0,"hint":self.hint()})
self.assertEqual(self.path.read_text(),"broken")
if __name__=="__main__": unittest.main()
+38
View File
@@ -0,0 +1,38 @@
"""Tests for lab startup failure handling. No SDK or network required."""
import contextlib
import io
import unittest
from unittest.mock import patch, Mock
import lab
class RunnerTests(unittest.TestCase):
def test_missing_sdk_fails_before_starting(self):
with patch.object(lab.shutil, "which", return_value=None), \
patch.object(lab.subprocess, "Popen") as spawn, \
patch.object(lab.sys, "argv", ["lab.py", "start"]), \
contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(lab.main(), 1)
spawn.assert_not_called()
def test_cleanup_when_cabinet_launch_fails(self):
broker = Mock()
broker.poll.return_value = None
with patch.object(lab.socket, "socket"), \
patch.object(lab.socket, "create_connection"), \
patch.object(lab.Path, "exists", return_value=True), \
patch.object(lab.shutil, "which", return_value="nats-server"), \
patch.object(lab.subprocess, "Popen", side_effect=[broker, OSError("launch failed")]):
with self.assertRaises(OSError):
lab.start()
broker.terminate.assert_called_once()
broker.wait.assert_called_once()
def test_command_identity_and_expiry(self):
first = lab.command("scan", "session")
second = lab.command("scan", "session")
self.assertNotEqual(first["id"], second["id"])
self.assertEqual(first["session"], "session")
self.assertGreater(first["expires_ms"], int(lab.time.time() * 1000))
if __name__ == "__main__":
unittest.main()
+92
View File
@@ -0,0 +1,92 @@
"""Audio cancellation/deduplication checks without speakers or Piper."""
import copy
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
import teddy_speech as speech
def state(key="welcome", run="run1"):
return {"run_id": run, "teddy": {"online": True, "muted": False, "takeover": False,
"current": {"id": key, "started": 0, "transcript": "Hello player"}}}
class SpeechTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.data = patch.object(speech, "DATA", Path(self.temp.name))
self.data.start()
self.player = Mock()
self.follower = speech.Follower(self.player)
self.job = Mock()
self.job.poll.return_value = None
self.spawn = patch.object(speech.subprocess, "Popen", return_value=self.job).start()
def tearDown(self):
patch.stopall()
self.temp.cleanup()
def test_poll_does_not_repeat_and_stale_generation_cannot_play(self):
self.follower.update(state())
self.follower.update(state())
self.assertEqual(self.spawn.call_count, 1)
self.follower.update(None)
self.job.terminate.assert_called_once()
self.player.play.assert_not_called()
self.assertEqual(speech.STATUS["status"], "disconnected")
def test_mute_failure_takeover_reset_and_replacement_cancel(self):
for gate in ("muted", "takeover", "online", "current", "reset", "replace"):
with self.subTest(gate=gate):
self.follower.current = None
self.follower.job = None
self.job.reset_mock()
self.job.poll.return_value = None
self.follower.update(state())
changed = copy.deepcopy(state())
if gate == "reset":
changed = {"run_id": "run2", "teddy": {"current": None}}
elif gate == "replace":
changed = state("new")
else:
changed["teddy"][gate] = False if gate == "online" else (None if gate == "current" else True)
self.follower.update(changed)
self.job.terminate.assert_called_once()
self.player.play.assert_not_called()
def test_completed_generation_plays_once(self):
self.follower.update(state())
with speech.wave.open(str(self.follower.output), "wb") as wav:
wav.setparams((1, 2, 22050, 0, "NONE", "not compressed"))
wav.writeframes(bytes(44100))
self.job.poll.return_value = 0
self.job.returncode = 0
self.follower.update(state())
self.follower.update(state())
self.player.play.assert_called_once()
self.assertEqual(speech.STATUS["status"], "playing")
def test_resident_worker_cancelled_result_cannot_play_after_replacement(self):
from chatterbox_client import Job
engine = Mock()
engine.submit.side_effect = lambda text, output: Job(text, output)
follower = speech.Follower(self.player, engine)
follower.update(state())
first = follower.job
follower.update(state("replacement"))
second = follower.job
self.assertTrue(first.cancelled.is_set())
first.returncode = 0 # Old GPU work finished after replacement.
follower.update(state("replacement"))
self.player.play.assert_not_called()
self.assertIs(follower.job, second)
engine.close.assert_not_called()
follower.update(None)
self.assertTrue(second.cancelled.is_set())
self.assertEqual(speech.AUDIO, {})
def test_synthesis_failure_keeps_caption_only(self):
self.follower.update(state())
self.job.poll.return_value = 1
self.job.returncode = 1
self.follower.update(state())
self.assertEqual(speech.STATUS["status"], "error")
self.player.play.assert_not_called()
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -0,0 +1,8 @@
"""Local Piper training entry point: mel checkpoints only; MOS is disabled."""
from piper.train import __main__ as training
# Upstream installs a val_mos checkpoint even with --model.mos_metric none.
# Lightning raises after epoch one when that optional metric is absent.
training._DEFAULT_CALLBACKS[:] = [callback for callback in training._DEFAULT_CALLBACKS
if callback.monitor != "val_mos"]
if __name__ == "__main__":
training.main()
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Run inside Ubuntu after the isolated Piper environment and dataset are prepared.
set -euo pipefail
cd "$HOME/teddy-training/piper"
source "$HOME/teddy-training/venv/bin/activate"
export OMP_NUM_THREADS=4
python /mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/train_teddy_entry.py fit \
--seed_everything 42 \
--data.voice_name teddy_experiment_v1 \
--data.csv_path "$HOME/teddy-training/data/metadata.csv" \
--data.audio_dir "$HOME/teddy-training/data/audio" \
--data.cache_dir "$HOME/teddy-training/cache" \
--data.config_path "$HOME/teddy-training/teddy-config.json" \
--data.espeak_voice en-us \
--data.batch_size 4 \
--data.num_workers 0 \
--data.validation_split 0.1 \
--data.num_test_examples 2 \
--model.sample_rate 22050 \
--model.warmstart_ckpt "$HOME/teddy-training/checkpoints/ljspeech-medium.ckpt" \
--model.learning_rate 0.0001 \
--model.learning_rate_d 0.00005 \
--model.mos_metric none \
--trainer.accelerator gpu \
--trainer.devices 1 \
--trainer.max_epochs 60 \
--trainer.max_steps 1000 \
--trainer.enable_progress_bar false \
--trainer.log_every_n_steps 5 \
--trainer.default_root_dir "$HOME/teddy-training/runs/teddy-v1" \
"$@"
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
launcher.ini
*.local.ini
logs/
state/
__pycache__/
+139
View File
@@ -0,0 +1,139 @@
# Start the Arcade stack
For the assigned internal operator/Cabinet 1–7 tokens and per-machine Godot CFG examples, see the [cabinet setup guide](../../docs/cabinet_setup.md).
One launcher starts the needed processes, waits for services to respond, saves logs, and stops its children when you press Ctrl+C. It runs on Windows and Linux/Pi (and supports controller-only use on macOS).
| Mode | Starts |
| --- | --- |
| `controller` | Room controller; optionally opens the browser operator |
| `cabinet` | Managed game and, when enabled, its local Pi GPIO bridge |
| `all` | Controller plus cabinet; useful for Windows simulation or a Pi hosting everything |
For the usual installation, run **controller on the PC/Mac** and **cabinet on each Pi**. Keep the launcher terminal open. These scripts do not install boot services or software packages.
## Files to deploy
Keep the following directory structure, either by copying the repository or creating this deployment folder:
```text
ArcadeEscape/
Start-Arcade.ps1
start-arcade.sh
ArcadeEscape.exe # Windows, with any accompanying export files
ArcadeEscape.arm64 # Pi, with any accompanying export files
tools/
launch/ # launch.py, examples, your launcher.ini
room_controller/ # server.py, operator.html, catalog.json
pi_gpio/ # pi_gpio_bridge.py
```
Use the executable appropriate to that machine. You can instead point the INI at an executable elsewhere. **All relative INI paths resolve from the INI's folder**, regardless of where you launch the script. Review your existing export location: the project's presets may place builds one directory above the repository.
## Windows controller
Install Python 3 first. From the project/deployment root:
```powershell
Copy-Item tools/launch/controller.example.ini tools/launch/launcher.ini
notepad tools/launch/launcher.ini
```
Fill in `operator_token` and `cabinet_tokens` with the same credentials already used by your cabinets. INI values here are **not surrounded by quotes**, except the JSON strings inside `cabinet_tokens`:
```ini
operator_token = YOUR_EXISTING_OPERATOR_TOKEN
cabinet_tokens = {"cabinet_01":"YOUR_EXISTING_CABINET_TOKEN"}
```
Each token must be distinct and at least 16 characters. Existing `ARCADE_OPERATOR_TOKEN`, `ARCADE_CABINET_TOKENS`, and legacy `ARCADE_CABINET_TOKEN` environment variables take precedence. Reuse credentials; the launcher does not generate or rotate them.
The launcher prints the configuration path and token source without printing the tokens. It warns when an environment operator/cabinet token overrides a different INI value, and rejects surrounding token quotes.
### Browser says Unauthorized
The controller was reached, but its operator token does not match what was entered. Use the **operator** token, not a cabinet token, and open the exact operator URL printed by the launcher. “Waiting for the room operator” in the game is normal until a scenario is prepared.
If you configured `operator_token` in `launcher.ini` but the launcher reports an environment override, stop your running stack with Ctrl+C and clear the old variable **in the same PowerShell terminal you use to launch**:
```powershell
Remove-Item Env:ARCADE_OPERATOR_TOKEN -ErrorAction SilentlyContinue
.\Start-Arcade.ps1 -Mode all -Browser
```
Use `-Mode controller` instead if this machine only hosts the controller. Enter the INI's operator token in the browser, without quotes. Clearing the variable affects only that PowerShell session; it does not delete an INI value or change a stored Windows environment variable. The controller must be restarted to pick up credential changes. Never paste real tokens into troubleshooting messages.
**Set `database` to your existing controller database** when upgrading. The example defaults to a new `tools/launch/state/arcade_room.sqlite3`; that would start a fresh controller history instead of using a database at the project root. To use a root-level database from the default INI location, set `database = ../../arcade_room.sqlite3`.
Then validate and start:
```powershell
.\Start-Arcade.ps1 -Mode controller -Check
.\Start-Arcade.ps1 -Mode controller -Browser
```
If PowerShell blocks script execution, the same launcher can be run directly without changing execution policy:
```powershell
py -3 tools/launch/launch.py controller --check
py -3 tools/launch/launch.py controller --browser
```
## Pi cabinet
Install the GPIO dependencies once, following the [GPIO guide](../pi_gpio/README.md). Configure the game's physical controls using the normal launcher before starting managed mode.
```sh
sudo apt update
sudo apt install python3-gpiozero python3-lgpio
cp tools/launch/pi.example.ini tools/launch/launcher.ini
nano tools/launch/launcher.ini
chmod +x ArcadeEscape.arm64
```
Set the game executable path, cabinet ID, bridge controller ID, and BCM pin allowlist. GPIO is enabled in this Pi example. Leave `url` and `token` blank to reuse the game's existing `user://arcade_network.cfg`, or supply them here:
```ini
[cabinet]
executable = ../../ArcadeEscape.arm64
id = cabinet_01
url = http://CONTROLLER_IP:8766
token = YOUR_EXISTING_CABINET_TOKEN
gpio = true
```
Do not copy operator credentials to a cabinet-only Pi. URL/token environment variables still override these INI values. Unlike the launcher INI, Godot's own CFG file requires double quotes around string values.
```sh
bash start-arcade.sh cabinet --check
bash start-arcade.sh cabinet
```
This starts the GPIO bridge first, waits for its local heartbeat endpoint, then launches the managed game with real GPIO enabled. The bridge uses **8765 locally**; the room URL uses **8766 on the controller**. `--check` validates files, settings, dependencies, and service port availability; it does not drive GPIO or prove wiring/permissions/network credentials work.
For Pi gameplay/network testing without physical outputs, set `gpio = false`. For a Pi that also hosts the controller, add the `[controller]` settings from the controller example and use `all` mode; blank cabinet URL/token then use that local controller's values.
## Windows all-in-one test
Copy `windows-all.example.ini` to `launcher.ini`, fill in the controller tokens, and set the Windows executable path. Keep `gpio = false`:
```powershell
.\Start-Arcade.ps1 -Mode all -Check
.\Start-Arcade.ps1 -Mode all -Browser
```
The game connects to the local controller using its cabinet ID's token. GPIO runs in simulation; Windows does not start the physical Pi bridge. To connect a Windows game to a remote controller instead, configure the cabinet section and use `-Mode cabinet`.
## Stopping, logs, and configuration
- **Ctrl+C** stops the stack started by this invocation. On the Pi, the bridge receives SIGTERM so its normal output cleanup runs.
- **Escape/game exit** also stops services started by that same invocation. A controller launched separately on another machine stays running.
- An unexpected child exit stops the other owned processes and returns an error; the launcher does not automatically restart a game or repeat hardware actions.
- An occupied service port causes startup to stop. Existing processes are never killed or silently adopted.
- Logs append to `tools/launch/logs/controller.log`, `gpio.log`, and `cabinet.log` by default. Check them for application errors after startup.
- Keep the database and GPIO request ledger across restarts. Do not delete them to replay a reward.
- Use `-Config C:\path\launcher.ini` on Windows or `--config /path/launcher.ini` on the Pi for another configuration file.
- `launcher.ini`, `*.local.ini`, the default logs, and the default state folder are Git-ignored within `tools/launch/`. Keep other private configuration paths out of Git yourself.
- Controller firewall rules, Godot exports, catalog generation, and GPIO wiring are still one-time deployment tasks; see the [room controller guide](../room_controller/README.md).
For macOS controller use: `bash start-arcade.sh controller --browser`, with Python 3 and a configured controller INI.
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Run the Arcade controller and/or cabinet with supervised child processes."""
import argparse
import configparser
import importlib.util
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import time
import urllib.request
import webbrowser
ROOT = Path(__file__).resolve().parents[2]
def credential(env, name, configured, label):
inherited = env.get(name, '')
selected = inherited or configured
source = 'environment variable '+name if inherited else 'launcher INI'
if selected:
print(f'{label} source: {source}', flush=True)
if inherited and configured and inherited != configured:
print(f'WARNING: {name} overrides a DIFFERENT value in the launcher INI. Clear that variable in the launching terminal to use the INI.', flush=True)
if selected and (selected[0] in "\"'" or selected[-1] in "\"'"):
raise ValueError(f'{label} has surrounding quote characters. In launcher.ini use the token without quotes; enter the same unquoted token in the browser.')
return selected
def plan(config_path, mode):
config_path = Path(config_path).resolve()
config = configparser.ConfigParser(interpolation=None)
if not config.read(config_path, encoding='utf-8-sig'):
raise ValueError(f'Configuration not found: {config_path}. Copy an example INI first.')
base = config_path.parent
def value(section, key, default=''):
return config.get(section, key, fallback=default).strip()
def path(section, key, default):
return (base / Path(os.path.expanduser(value(section, key, default)))).resolve()
def require_file(filename):
if not filename.is_file(): raise ValueError(f'File not found: {filename}')
env = dict(os.environ)
jobs = []
if mode in ('controller', 'all'):
script = ROOT / 'tools/room_controller/server.py'
catalog = path('controller', 'catalog', '../room_controller/catalog.json')
require_file(script)
require_file(script.with_name('operator.html'))
require_file(catalog)
operator = credential(env, 'ARCADE_OPERATOR_TOKEN', value('controller', 'operator_token'), 'Operator token')
tokens = json.loads(env.get('ARCADE_CABINET_TOKENS') or value('controller', 'cabinet_tokens', '{}'))
if not isinstance(tokens, dict): raise ValueError('cabinet_tokens must be a JSON object')
legacy = env.get('ARCADE_CABINET_TOKEN', '')
if legacy: tokens.setdefault('cabinet_01', legacy)
if len(operator) < 16 or not tokens 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):
raise ValueError('Configure distinct operator and cabinet tokens of at least 16 characters')
server_env = dict(env, ARCADE_OPERATOR_TOKEN=operator, ARCADE_CABINET_TOKENS=json.dumps(tokens))
server_env.pop('ARCADE_CABINET_TOKEN', None)
bind = value('controller', 'bind', '127.0.0.1')
port = config.getint('controller', 'port', fallback=8766)
if not 1 <= port <= 65535: raise ValueError('Controller port must be between 1 and 65535')
database = path('controller', 'database', 'state/arcade_room.sqlite3')
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 mode in ('cabinet', 'all'):
executable = path('cabinet', 'executable', '../../ArcadeEscape.exe' if os.name == 'nt' else '../../ArcadeEscape.arm64')
require_file(executable)
if os.name != 'nt' and not os.access(executable, os.X_OK):
raise ValueError(f'Game is not executable; run chmod +x "{executable}"')
cabinet_id = value('cabinet', 'id', 'cabinet_01')
if not cabinet_id or any(c.isspace() for c in cabinet_id): raise ValueError('Invalid cabinet ID')
cabinet_env = dict(env)
room_url = env.get('ARCADE_ROOM_URL') or value('cabinet', 'url')
token = credential(env, 'ARCADE_CABINET_TOKEN', value('cabinet', 'token'), 'Cabinet token')
if mode == 'all':
room_url = room_url or url
token = token or tokens.get(cabinet_id, '')
if token != tokens.get(cabinet_id): raise ValueError('Cabinet token does not match this controller\'s token for its ID')
# Omitted values preserve the cabinet's existing user://arcade_network.cfg.
if room_url: cabinet_env['ARCADE_ROOM_URL'] = room_url
if token: cabinet_env['ARCADE_CABINET_TOKEN'] = token
real_gpio = config.getboolean('cabinet', 'gpio', fallback=False)
if real_gpio:
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')
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))
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))
return jobs, path('launcher', 'logs', 'logs')
def check_ports(jobs):
for job in jobs:
if not job['port']: continue
with socket.socket() as probe:
try: probe.bind(job['port'])
except OSError as exc:
raise ValueError(f"{job['name']} port {job['port'][1]} is unavailable. Stop the existing service or use the appropriate launcher mode.") from exc
def wait_ready(job, process):
deadline = time.monotonic() + 12
while time.monotonic() < deadline:
if process.poll() is not None: raise RuntimeError(f"{job['name']} exited during startup; check its log")
try:
data = b'{}' if job['name'] == 'gpio' else None
with urllib.request.urlopen(urllib.request.Request(job['ready'], data=data), timeout=.5) as response:
if response.status == 200: return
except (OSError, ValueError): pass
time.sleep(.1)
raise RuntimeError(f"{job['name']} did not become ready; check its log")
def run(jobs, logs, open_browser=False):
check_ports(jobs)
logs.mkdir(parents=True, exist_ok=True)
children, files = [], []
try:
for job in jobs:
job['directory'].mkdir(parents=True, exist_ok=True)
output = open(logs / (job['name']+'.log'), 'ab', buffering=0)
files.append(output)
flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' and job['name'] != 'cabinet' else 0
process = subprocess.Popen(job['command'], env=job['env'], cwd=job['directory'], stdout=output, stderr=subprocess.STDOUT, creationflags=flags)
children.append((job, process))
if job['ready']: wait_ready(job, process)
print(f"Started {job['name']} (PID {process.pid}); log: {logs / (job['name']+'.log')}", flush=True)
if job['name'] == 'controller':
print('Operator: '+job['ready'], flush=True)
if open_browser: webbrowser.open(job['ready'])
print('Running. Ctrl+C stops this stack. Exiting the game also stops services started here.', flush=True)
while True:
for job, process in children:
code = process.poll()
if code is not None:
print(f"{job['name']} exited ({code}); stopping this stack.", flush=True)
return code if code else (0 if job['name'] == 'cabinet' else 1)
time.sleep(.25)
except KeyboardInterrupt:
return 0
finally:
# Only owned children; never terminate an unrelated process on a service port.
for job, process in reversed(children):
if process.poll() is None:
process.terminate()
try: process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
for output in files: output.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['controller', 'cabinet', 'all'])
parser.add_argument('--config', default=str(Path(__file__).with_name('launcher.ini')))
parser.add_argument('--check', action='store_true', help='Validate settings and ports without starting anything')
parser.add_argument('--browser', action='store_true', help='Open the local operator browser after controller startup')
args = parser.parse_args()
def interrupted(*_): raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
try:
print('Launcher configuration: '+str(Path(args.config).resolve()), flush=True)
jobs, logs = plan(args.config, args.mode)
if args.check:
check_ports(jobs)
print('Configuration and ports OK. Planned services: '+', '.join(j['name'] for j in jobs))
print('Logs: '+str(logs))
return 0
return run(jobs, logs, args.browser)
except (ValueError, OSError, RuntimeError, configparser.Error) as exc:
print('Startup failed: '+str(exc), file=sys.stderr)
return 1
except KeyboardInterrupt:
return 0
if __name__ == '__main__': sys.exit(main())
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more