From e58ceeb1926f8c15a8a24b263a67ba57e08c6646 Mon Sep 17 00:00:00 2001 From: Jeff Luke Date: Sun, 18 Oct 2026 10:54:38 -0700 Subject: [PATCH] Added chatterbox connections --- README.md | 14 +- docs/odin_integration/README.md | 8 +- export_presets.cfg | 4 +- labs/glitch_odin/.gdignore | 0 labs/glitch_odin/.gitignore | 15 + labs/glitch_odin/Cargo.lock | 1756 +++++++++++++++++ labs/glitch_odin/Cargo.toml | 18 + labs/glitch_odin/ODIN_MESSAGES.md | 54 + labs/glitch_odin/README.md | 183 ++ labs/glitch_odin/ROOM_SIMULATION.md | 259 +++ labs/glitch_odin/TEDDY_TRAINING.md | 144 ++ labs/glitch_odin/chatterbox_client.py | 100 + labs/glitch_odin/chatterbox_worker.py | 51 + labs/glitch_odin/compare_teddy_chatterbox.py | 42 + labs/glitch_odin/design/DESIGN_UPDATES.md | 56 + labs/glitch_odin/design/GDD_reference.txt | 1601 +++++++++++++++ .../glitch_implementation_contract.json | 782 ++++++++ labs/glitch_odin/export_teddy.py | 8 + labs/glitch_odin/gm/lib/hint_panel.dart | 169 ++ labs/glitch_odin/gm/lib/main.dart | 126 ++ labs/glitch_odin/gm/lib/room_page.dart | 437 ++++ labs/glitch_odin/gm/lib/teddy_audio.dart | 1 + labs/glitch_odin/gm/lib/teddy_audio_stub.dart | 9 + labs/glitch_odin/gm/lib/teddy_audio_web.dart | 62 + labs/glitch_odin/gm/pubspec.lock | 221 +++ labs/glitch_odin/gm/pubspec.yaml | 15 + labs/glitch_odin/gm/test/fixtures/room.json | 1 + labs/glitch_odin/gm/test/hint_panel_test.dart | 35 + labs/glitch_odin/gm/test/room_test.dart | 68 + labs/glitch_odin/gm/test/widget_test.dart | 20 + labs/glitch_odin/gm/web/flutter_bootstrap.js | 7 + labs/glitch_odin/gm/web/index.html | 15 + labs/glitch_odin/godot_smoke.py | 104 + labs/glitch_odin/hint_library.py | 87 + labs/glitch_odin/lab.py | 235 +++ labs/glitch_odin/nats.conf | 3 + labs/glitch_odin/prepare_teddy_dataset.py | 86 + labs/glitch_odin/room-nats.conf | 3 + labs/glitch_odin/room_smoke.py | 154 ++ labs/glitch_odin/room_web.py | 111 ++ labs/glitch_odin/speech_smoke.py | 71 + labs/glitch_odin/src/godot.rs | 37 + labs/glitch_odin/src/lib.rs | 157 ++ labs/glitch_odin/src/main.rs | 80 + labs/glitch_odin/src/room.rs | 580 ++++++ labs/glitch_odin/src/room/tests.rs | 221 +++ labs/glitch_odin/src/room_server.rs | 84 + labs/glitch_odin/teddy_speech.py | 254 +++ labs/glitch_odin/test_hint_library.py | 39 + labs/glitch_odin/test_runner.py | 38 + labs/glitch_odin/test_teddy_speech.py | 92 + labs/glitch_odin/train_teddy_entry.py | 8 + labs/glitch_odin/train_teddy_wsl.sh | 31 + tests/odin_lab/cabinet.gd | 143 ++ tests/odin_lab/cabinet.gd.uid | 1 + tests/odin_lab/cabinet.tscn | 6 + tests/odin_lab/check_adapter.gd | 32 + tests/odin_lab/check_adapter.gd.uid | 1 + 58 files changed, 8931 insertions(+), 8 deletions(-) create mode 100644 labs/glitch_odin/.gdignore create mode 100644 labs/glitch_odin/.gitignore create mode 100644 labs/glitch_odin/Cargo.lock create mode 100644 labs/glitch_odin/Cargo.toml create mode 100644 labs/glitch_odin/ODIN_MESSAGES.md create mode 100644 labs/glitch_odin/README.md create mode 100644 labs/glitch_odin/ROOM_SIMULATION.md create mode 100644 labs/glitch_odin/TEDDY_TRAINING.md create mode 100644 labs/glitch_odin/chatterbox_client.py create mode 100644 labs/glitch_odin/chatterbox_worker.py create mode 100644 labs/glitch_odin/compare_teddy_chatterbox.py create mode 100644 labs/glitch_odin/design/DESIGN_UPDATES.md create mode 100644 labs/glitch_odin/design/GDD_reference.txt create mode 100644 labs/glitch_odin/design/glitch_implementation_contract.json create mode 100644 labs/glitch_odin/export_teddy.py create mode 100644 labs/glitch_odin/gm/lib/hint_panel.dart create mode 100644 labs/glitch_odin/gm/lib/main.dart create mode 100644 labs/glitch_odin/gm/lib/room_page.dart create mode 100644 labs/glitch_odin/gm/lib/teddy_audio.dart create mode 100644 labs/glitch_odin/gm/lib/teddy_audio_stub.dart create mode 100644 labs/glitch_odin/gm/lib/teddy_audio_web.dart create mode 100644 labs/glitch_odin/gm/pubspec.lock create mode 100644 labs/glitch_odin/gm/pubspec.yaml create mode 100644 labs/glitch_odin/gm/test/fixtures/room.json create mode 100644 labs/glitch_odin/gm/test/hint_panel_test.dart create mode 100644 labs/glitch_odin/gm/test/room_test.dart create mode 100644 labs/glitch_odin/gm/test/widget_test.dart create mode 100644 labs/glitch_odin/gm/web/flutter_bootstrap.js create mode 100644 labs/glitch_odin/gm/web/index.html create mode 100644 labs/glitch_odin/godot_smoke.py create mode 100644 labs/glitch_odin/hint_library.py create mode 100644 labs/glitch_odin/lab.py create mode 100644 labs/glitch_odin/nats.conf create mode 100644 labs/glitch_odin/prepare_teddy_dataset.py create mode 100644 labs/glitch_odin/room-nats.conf create mode 100644 labs/glitch_odin/room_smoke.py create mode 100644 labs/glitch_odin/room_web.py create mode 100644 labs/glitch_odin/speech_smoke.py create mode 100644 labs/glitch_odin/src/godot.rs create mode 100644 labs/glitch_odin/src/lib.rs create mode 100644 labs/glitch_odin/src/main.rs create mode 100644 labs/glitch_odin/src/room.rs create mode 100644 labs/glitch_odin/src/room/tests.rs create mode 100644 labs/glitch_odin/src/room_server.rs create mode 100644 labs/glitch_odin/teddy_speech.py create mode 100644 labs/glitch_odin/test_hint_library.py create mode 100644 labs/glitch_odin/test_runner.py create mode 100644 labs/glitch_odin/test_teddy_speech.py create mode 100644 labs/glitch_odin/train_teddy_entry.py create mode 100644 labs/glitch_odin/train_teddy_wsl.sh create mode 100644 tests/odin_lab/cabinet.gd create mode 100644 tests/odin_lab/cabinet.gd.uid create mode 100644 tests/odin_lab/cabinet.tscn create mode 100644 tests/odin_lab/check_adapter.gd create mode 100644 tests/odin_lab/check_adapter.gd.uid diff --git a/README.md b/README.md index 71b4b61..f2be1ba 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Escape Gallery — Glitch: the Dark Machine +# Escape Gallery — Glitch: the Dark Machine **A Godot arcade escape-room framework with a browser-based operations hub.** @@ -22,7 +22,7 @@ The Arcade software is separate from Odin. Its RFID helper follows the tester PC | 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) | +| 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. @@ -103,7 +103,7 @@ Doorways are layout annotations. They do not change game collisions or act as li Open a cabinet profile's **PCB Connections** editor and select a connector to assign a device type, name, unique ID, binding, and wiring notes. Digital inputs expose their pull-up setting. The editor validates duplicate assignments and relevant pin/action conflicts, supports Undo/Redo, and saves the configuration with the cabinet resource. -The board includes direct GPIO connectors, J16 I2C, and multiplexed J1–J8 reader connectors. See the [pin mapping guide](docs/cabinet_pcb_setup.md). Saving this inventory does not energize outputs or install drivers. Supported entries can be exported into the optional runtime feedback configuration; I2C and Other devices remain inventory-only. +The board includes direct GPIO connectors, J16 I2C, and multiplexed J1–J8 reader connectors. See the [pin mapping guide](docs/cabinet_pcb_setup.md). Saving this inventory does not energize outputs or install drivers. Supported entries can be exported into the optional runtime feedback configuration; I2C and Other devices remain inventory-only. ## Operator hub and managed play @@ -226,4 +226,10 @@ tests/ Godot/Python checks and isolated fixtures 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). +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. diff --git a/docs/odin_integration/README.md b/docs/odin_integration/README.md index f93b8ff..a13cb5a 100644 --- a/docs/odin_integration/README.md +++ b/docs/odin_integration/README.md @@ -169,5 +169,9 @@ The existing working installation remains the reference until the new path passe - [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. +- [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. diff --git a/export_presets.cfg b/export_presets.cfg index 086734b..0619199 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -11,7 +11,7 @@ dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" -exclude_filter="setup/*" +exclude_filter="setup/*,labs/*,tests/odin_lab/*" export_path="setup/pi/ArcadeEscape.arm64" patches=PackedStringArray() patch_delta_encoding=false @@ -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 diff --git a/labs/glitch_odin/.gdignore b/labs/glitch_odin/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/labs/glitch_odin/.gitignore b/labs/glitch_odin/.gitignore new file mode 100644 index 0000000..bc49f2e --- /dev/null +++ b/labs/glitch_odin/.gitignore @@ -0,0 +1,15 @@ +target/ +.dart_tool/ +build/ +.flutter-plugins-dependencies +*.log +__pycache__/ + +evidence/ +qa.stop + +.speech/ + +.lab-data/ + +.training/ diff --git a/labs/glitch_odin/Cargo.lock b/labs/glitch_odin/Cargo.lock new file mode 100644 index 0000000..b001686 --- /dev/null +++ b/labs/glitch_odin/Cargo.lock @@ -0,0 +1,1756 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-nats" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d6f157065c3461096d51aacde0c326fa49f3f6e0199e204c566842cdaa5299" +dependencies = [ + "base64", + "bytes", + "futures-util", + "memchr", + "nkeys", + "nuid", + "pin-project", + "portable-atomic", + "rand", + "regex", + "ring", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "signature", + "subtle", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glitch-odin-lab" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-nats", + "axum", + "futures-util", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "tokio", + "tower-http", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom", + "log", + "rand", + "signatory", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/labs/glitch_odin/Cargo.toml b/labs/glitch_odin/Cargo.toml new file mode 100644 index 0000000..f8b6635 --- /dev/null +++ b/labs/glitch_odin/Cargo.toml @@ -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" diff --git a/labs/glitch_odin/ODIN_MESSAGES.md b/labs/glitch_odin/ODIN_MESSAGES.md new file mode 100644 index 0000000..5abacaf --- /dev/null +++ b/labs/glitch_odin/ODIN_MESSAGES.md @@ -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. diff --git a/labs/glitch_odin/README.md b/labs/glitch_odin/README.md new file mode 100644 index 0000000..82c892b --- /dev/null +++ b/labs/glitch_odin/README.md @@ -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 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. diff --git a/labs/glitch_odin/ROOM_SIMULATION.md b/labs/glitch_odin/ROOM_SIMULATION.md new file mode 100644 index 0000000..8d4d50a --- /dev/null +++ b/labs/glitch_odin/ROOM_SIMULATION.md @@ -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://: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. diff --git a/labs/glitch_odin/TEDDY_TRAINING.md b/labs/glitch_odin/TEDDY_TRAINING.md new file mode 100644 index 0000000..f6d6d51 --- /dev/null +++ b/labs/glitch_odin/TEDDY_TRAINING.md @@ -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. diff --git a/labs/glitch_odin/chatterbox_client.py b/labs/glitch_odin/chatterbox_client.py new file mode 100644 index 0000000..6b8f22d --- /dev/null +++ b/labs/glitch_odin/chatterbox_client.py @@ -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() diff --git a/labs/glitch_odin/chatterbox_worker.py b/labs/glitch_odin/chatterbox_worker.py new file mode 100644 index 0000000..ed242c4 --- /dev/null +++ b/labs/glitch_odin/chatterbox_worker.py @@ -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() diff --git a/labs/glitch_odin/compare_teddy_chatterbox.py b/labs/glitch_odin/compare_teddy_chatterbox.py new file mode 100644 index 0000000..d384eeb --- /dev/null +++ b/labs/glitch_odin/compare_teddy_chatterbox.py @@ -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) diff --git a/labs/glitch_odin/design/DESIGN_UPDATES.md b/labs/glitch_odin/design/DESIGN_UPDATES.md new file mode 100644 index 0000000..04bea24 --- /dev/null +++ b/labs/glitch_odin/design/DESIGN_UPDATES.md @@ -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. diff --git a/labs/glitch_odin/design/GDD_reference.txt b/labs/glitch_odin/design/GDD_reference.txt new file mode 100644 index 0000000..a46d46b --- /dev/null +++ b/labs/glitch_odin/design/GDD_reference.txt @@ -0,0 +1,1601 @@ +Glitch The Dark Machine + +Game Design Document and Room Control Specification + +Escape Gallery • Version 1.0 • October 17, 2026 + +Purpose + +Build one complete, deterministic escape-room run before committing to final physical puzzle mechanics. This specification connects the player experience to authoritative state, device signals, operator actions and acceptance tests. It targets Rust room orchestration, NATS messaging, Godot arcade clients and a Flutter game-master interface. + +Players investigate an abandoned arcade whose games appear alive. Six recovered tokens awaken six cabinets. Their victories wake a seventh machine, whose defeat exposes a crashed alien spacecraft. Players retrieve six charged power cells, restore the craft and repurpose the arcade controls to calibrate its flight. The final launch succeeds through communication, practiced earlier and adapted to the number of players. + +Design status + +Confirmed requirements: six uniquely token-gated cabinets, a seventh boss cabinet, hidden spacecraft, six removable power cells, second-state calibration games, a coordinated launch, computer-controlled Teddy Ruxpin, variable difficulty and small-party support. Rust/NATS and Flutter are the confirmed final approach. + +Proposed defaults: a 60-minute game; support for 2–8 players, plus an assisted solo test mode; the cabinet assignments, puzzle placeholders, character dialogue, tuning values and event contracts below. These are implementation candidates to validate through simulation and playtesting, not claims of completed room features. + +How to use this document + +Implement the rules and state contracts first. Use fake props, accelerated virtual time and scripted game outcomes to execute every act. Then replace each fake adapter with hardware or a Godot game without changing room rules. Sections 2–9 define play; 10–17 specify controls and operations; 18–21 provide implementation and test appendices. + +Companion file: glitch_implementation_contract.json contains proposed profile values, entity mappings, an event envelope schema, a room-state example and machine-readable test cases. It is a design interchange format requiring an importer, not a drop-in Escape Gallery catalog or existing Odin wire schema. + + + +1 Project alignment and implementation boundary + +The final system has one room sequencing authority: the Glitch Rust controller. Godot owns local game execution and input feedback; Flutter requests actions and displays authoritative state; NATS carries commands, observations and snapshots. Neither the UI nor an individual cabinet decides that the room has advanced. + +Area + +Observed project baseline + +Required Glitch extension + +Arcade runtime + +Seven Godot games and configurable objectives + +Six first-state assignments, boss treatment and six calibration variants + +Control path + +Python controller and browser hub; experimental Rust/NATS/Flutter lab + +Production Rust room reducer, durable recovery and Flutter run controls + +Tokens + +Pac-Man RFID credit admission and Start gating + +Per-token allowlists for all six cabinets; persistent authorization for retries + +Sequencing + +Single predecessor win dependencies + +All-six join, ship power join, communication rounds and launch barrier + +Props and hints + +Device feedback and configured output actions + +Generic puzzle facts, cell inventory, passage sensing and Teddy scheduler + + + +Migration constraints + +Retain the existing Godot games and their tested lifecycle where possible. Select exactly one controlling backend per cabinet session. Do not let Python scenario sequencing and Rust autopilot both drive the same run. Use the legacy controller only for comparison, rollback or isolated development. + +The Glitch lab is experimental: named-field MessagePack, a loopback JSON gateway and an in-memory replay cache. The Odin audit describes generated ordered-field prop messages. They are not wire-compatible. Generate agreed Glitch types for Rust and Dart; version the adapter explicitly and do not publish experimental payloads on Odin subjects. + +Hardware facts that affect this design + +The integration guide corrects older configuration: BCM23/J20 is an LED, not the power button; physical buttons use an unidentified I2C board. Keep that input mapping disabled until identified. The current catalog has only four cabinet profiles. Cabinets 5–7, cell bays, spacecraft sockets, Teddy and passage sensing need authored definitions. Cabinet 3 lacks Tempest’s second-button capability and Cabinet 4 lists roller where Missile Command requires trackball; reconcile capabilities before assignment. + +All proposed device names in this GDD are logical identities. Bind them to verified inventory later. Qualify reused local IDs with cabinet identity, for example cabinet_01/cab1_rfid1. + + + +2 Narrative and act structure + +Act + +Player experience + +Exit condition + +Target minutes + +Arrival + +Teddy wakes; investigators learn that tokens belong to particular machines. + +Briefing acknowledged and run started + +0–4 + +I The arcade + +Parallel physical discoveries unlock six playable cabinets. Games teach naming, responding and holding a setting. + +All six first-state wins committed + +4–27 + +II The dark machine + +Cabinet 7 boots and combines familiar controls into a short boss challenge. + +Boss won; passage physically confirmed open + +27–33 + +III The passenger + +Alien reveal; six charged cells are retrieved and installed. + +All six correct cells present and power stable + +33–40 + +IV Flight school + +Ship-side navigator gives instructions to arcade-side operators. + +All calibration checkpoints complete + +40–53 + +V Launch + +Crew arms stations, confirms readiness and performs a synchronized commit. + +Launch committed and finale completed + +53–60 + + + +The story behind the machines + +Proposed story: the alien’s emergency system has borrowed the arcade’s electrical network. Its damaged translation process makes the games appear mischievous and threatening. Teddy is an imperfect local voice for this system. The boss is a protective firewall, not the alien itself. Defeating it restores enough trust to reveal the passenger and its rescue request. + +Avoid an abrupt tonal reversal by planting three clues: a repeating distress rhythm after the first win, a star-like pattern after the third, and Teddy briefly saying “help” before correcting himself. The reveal reframes the strange energy as a stranded being trying to survive. The final launch is theatrical; no moving craft is required. + +Pacing rules + +Act budgets are targets, not gates or independent countdowns. Early teams progress immediately. At 35 elapsed minutes without the boss completed, suggest assistance; at 45 without ship power, surface a GM pacing alert; at 53 without calibration complete, offer guided calibration. No automatic solve occurs without the configured assistance policy or a logged GM decision. + +At zero remaining time, enter TIME_EXPIRED and suspend puzzle timers. The GM may extend time or choose a distinct assisted rescue/debrief ending. Only the normal launch guard produces a normal win. Keep achieved checkpoints for extensions; never restart the room merely because time expired. + + + +3 Dependency graph and physical placeholders + +Run start unlocks five independent discovery branches. Physical mechanics can change while their input/output contracts remain stable. All six tokens must remain obtainable without completing a game that requires one of those same tokens. + +Puzzle ID + +Placeholder interaction + +Prerequisite + +Reward + +P01 + +Restore claw controls using a simple arcade maintenance clue + +Run active + +Claw stage A enabled + +P02A + +Claw retrieval from a designated prize zone + +P01 solved + +T01 for Cabinet 1 + +P02B + +Second claw retrieval or alternate prize zone + +P02A reward delivered + +T02 for Cabinet 2 + +P03 + +Match a record excerpt to an album sleeve + +Run active + +T03 for Cabinet 3 + +P04 + +Reconstruct a service-panel symbol pattern + +Run active + +T04 for Cabinet 4 + +P05 + +Match ticket values to a cabinet maintenance label + +Run active + +T05 for Cabinet 5 + +P06 + +Read a poster overlay or aligned light pattern + +Run active + +T06 for Cabinet 6 + + + +P02A and P02B are separate reward facts even if they share one physical claw machine. Simulation uses explicit solve and delivery observations. A solution does not prove a token was physically delivered. Jam detection holds reward delivery pending and offers a GM hand-delivery action with a reason. + +Room dependencies + +P02A → T01 → A01; P02B → T02 → A02; P03 → T03 → A03; P04 → T04 → A04; P05 → T05 → A05; P06 → T06 → A06. + +ALL A01–A06 won → B07 available → B07 won → passage release → passage open observation → spacecraft reveal. + +Reveal → request all six cell bays open. Each cell also requires charge complete before its bay may release. + +ALL six correct cell installations stable → second-state mode ready on all six cabinets → communication tutorial → calibration waves → final arm → synchronized launch. + +Token policy + +Each token has a configured UID or supported identifier mapped to exactly one cabinet. Reject an incorrect token with a short in-world response; do not consume or confiscate it. Admit only a fresh scan in the current run and boot session. The successful scan grants persistent run authorization for that cabinet, not unlimited banked credits. Each failed attempt can restart without finding the token again. Re-scans cannot create additional rewards or wins. + +The first bench test accepted any card. Production admission must use explicit allowlists and test identifier collisions/reader limitations. A physically discovered token can be accepted even when its delivery sensor was missed; record the discrepancy for the GM rather than create a hidden soft lock. + + + +4 Six arcade assignments and first state + +Use the first four catalog assignments as the starting point. Cabinets 5–7 are proposals. The branded games below are project assets; public presentation and final naming remain a production decision. A win is a short configured objective, not an expectation that visitors master a full arcade game. + +ID and game + +First-state objective prototype + +Teaches + +Cell + +A01 Pac-Man + +Collect a configured score or clear a shortened board + +Identify a station and report completion + +CELL01 + +A02 Galaga + +Reach a configured hit/score target + +Hear a target, repeat it, then act + +CELL02 + +A03 Tempest + +Clear selected lanes or reach score threshold + +Use named sectors instead of vague directions + +CELL03 + +A04 Missile Command + +Protect a small target set for a short wave + +Wait for a cue; avoid premature firing + +CELL04 + +A05 Frogger + +Bring a configured number of frogs home + +Report ready and hold a successful state + +CELL05 + +A06 Space Invaders + +Clear a marked formation or reach score threshold + +Coordinate a countdown and confirm success + +CELL06 + + + +Objective tuning + +Proposed baseline: 60–120 seconds of successful play per cabinet, with a 180-second assistance threshold. Begin with the project’s supported score/board/home objectives; bespoke lane or marked-target objectives require game changes. The simulator models progress from 0 to objective_target and emits a validated win; it does not need arcade physics. + +Default standard fixtures: Pac-Man 500 points, Galaga 500 points, Tempest 500 points, Missile Command 500 points, Frogger 1 home, Space Invaders 500 points. These are simulation seeds, not balanced production goals. Guided uses 60 percent of score targets; challenge uses 140 percent. Integer count objectives use explicit profile values, never rounding to zero. Validate each game adapter’s supported fields at preflight. + +Teaching without blocking parallel play + +The first completed cabinet triggers Teddy’s station-name lesson. The next completion triggers an echo/readback example. The third and fifth completions introduce “hold” and “ready.” These beats depend on completion count, not a specific cabinet order. A game win never waits for a voice line to finish. Missed lessons are summarized in the ship tutorial. + +On loss, show the objective and allow Start again after a three-second reset. Preserve room wins, token authorization and cell charge. Offer reduced target or guidance after two failed attempts; apply a changed target only at the next attempt boundary. No automatic win timer is permitted in a production profile. + + + +5 The boss and spacecraft reveal + +Cabinet 7 The Dark Machine + +B07 remains dark and rejects player Start until all six distinct first-state wins are committed. Its boot animation follows a confirmed prepare/ready handshake. Proposed base: a Robotron-inspired protection challenge, subject to cabinet controls, with an alternative bespoke single-joystick variant. Do not require dual joysticks on an unverified cabinet. + +A three-part boss teaches the later protocol: identify a marked sector, protect the indicated signal, then hold a stable selection through a spoken countdown. Standard target duration is 90–150 seconds with retained stage checkpoints. Loss returns to the current stage after three seconds; repeated failures offer simpler targets. Boss victory is accepted only for its active assignment and attempt. + +Reveal sequence + +Step + +Command or observation + +Required behavior + +1 + +boss.won committed + +Latch victory once; queue reveal sound and passage release + +2 + +passage.release requested + +Only configured action; record command ID and delivery status + +3 + +passage.open observed + +Fresh sensor stable for 300 ms; begin reveal and cell retrieval + +4 + +ship.reveal complete + +Show passenger distress, six socket labels and cell instructions + +5 + +cell bay releases + +Release only charged bays; display pending charge for others + + + +Do not equate an acknowledged relay command with an open passage. If no open observation arrives within five seconds, enter a reveal hold, alert the GM and stop the active game clock. The operator can inspect, retry a safe configured action or attest physical opening. Record attestation as an override, not as a sensor reading. + +Audience and access + +Use light, sound and the alien’s breathing or movement to carry the reveal; provide readable text alongside speech. No strobe is needed. Keep the passage’s show function separate from emergency egress. A hardware safety layer and venue procedure govern people and moving parts; the narrative controller must never be the only way to leave. + +Teddy’s reveal line: “That was never an invasion. It was a call for help. The machines have been keeping our visitor alive. Bring the six light cores to the ship. Match every symbol, then come back to the controls.” + + + +6 Power cells and the second state + +Each cabinet contains one identifiable prop cell. “Charging” is a game abstraction unless engineering later specifies a real electrical function. Start its virtual charge on that cabinet’s first accepted game start. Charge takes 30 seconds of active room time and continues through local game losses. A powered-off or disconnected cabinet pauses only its own charge accumulation. A room hold pauses all charge timers. + +Cell state + +Entry or exit rule + +DOCKED_IDLE + +Preflight confirms cell identity and bay presence; charge 0 + +CHARGING + +First valid game start; accrue verified active time + +CHARGED_LOCKED + +Charge reaches 100 percent; cell remains inaccessible until reveal + +RELEASE_PENDING + +Reveal reached and charge full; issue bay release once + +AVAILABLE + +Bay open confirmed; removal now expected + +CARRIED + +Bay absence stable; no ship socket currently contains this identity + +INSTALLED + +Matching ship socket reports identity and presence stable for 500 ms + +UNKNOWN + +Conflicting or stale observations; hold dependent transition + + + +Each cell is mapped one-to-one to its socket, CELL01→SOCKET01 through CELL06→SOCKET06. Reject a wrong socket without penalizing progress. The controller retains both location and charge; a bay sensor going absent alone does not prove the cell reached the ship. Duplicate identity in two places is a fault, not two installed cells. + +Ship power barrier + +Require all six correct identities present, charged and healthy continuously for two seconds. Commit ship.power_ready, then prepare all six second-state assignments with new assignment IDs. Every required client must report calibration-ready before issuing tutorial challenges. Token scans are no longer required in this state. + +Removing any cell before launch commit cancels the current challenge and launch arm, suspends calibration and starts a technical hold. Retain completed calibration checkpoints. Reinsert the correct cell, verify two-second stability and reconcile device readiness; the GM resumes with a fresh challenge ID. Never resume an old countdown automatically. + +Practical recovery + +A missing cell may be replaced by a registered spare identity through a logged GM substitution. A failed bay sensor may be attested after inspection. Both actions mark the run assisted. Neither action can make a physically unsafe actuator move. After launch commit, cell removal changes diagnostics only; it does not reverse an already completed story ending. + + + +7 Communication and calibration games + +Use a consistent spoken grammar: “Station, target, action.” The arcade operator repeats “Station, target, ready.” The navigator answers “Execute.” The game then reports “Locked.” Voice recognition is not required: explicit player buttons and game-state checks record readiness and execution. Teddy models the words, while the actual target remains on the ship display. + +Station + +Second-state task + +Ship-only information + +Completion check + +01 Route + +Move to one of four route symbols + +Required symbol + +Selection equals target at Execute + +02 Vector + +Move a horizontal pointer to a numbered zone + +Zone number + +Pointer inside zone for hold duration + +03 Heading + +Rotate to one of eight named sectors + +Sector label + +Correct sector stable through commit + +04 Shield + +Select one of three shield banks + +Bank identifier + +Correct bank selected on cue + +05 Corridor + +Choose a safe lane or home + +Lane identifier + +Correct lane confirmed + +06 Thrust + +Set a discrete thrust level + +Level 1–4 + +Correct level held through cue + + + +Teaching sequence + +Round T1 is untimed: one navigator and one arcade operator practice a single station target. The navigator presses Dispatch on the ship console; the operator selects and presses Ready; the ship displays the readback target; the navigator presses Execute. This electronic readback allows the crew to correct mistakes before scoring. Require one successful practice, then one hold-and-countdown practice. Both are repeatable and narrated. + +Scored calibration contains one or two waves covering all six stations, scheduled at the concurrency supported by the party. Standard has two passes per station with different targets; guided has one; challenge has three. Each completed station-pass is a durable checkpoint. Idle cabinets display their names and wait, avoiding six competing audio tracks. + +Retry semantics + +A wrong target, early Execute or expired response window fails only the active wave. Show which station needs correction without erasing earlier passes. Generate a fresh challenge ID and permit replay after three seconds; after two failures, offer slower timing. Late Ready or Execute messages for an old challenge are rejected. Targets are generated from a stored seed, with different consecutive values per station. + +No calibration step requires an arcade expert. These tasks use familiar controls but assess precise communication. Provide large text, symbols and optional spoken targets; never encode information in color alone. + + + +8 Party size and difficulty profiles + +Party size determines simultaneous staffing; difficulty determines precision, repetition and hint timing. Store both at run preparation. Do not silently make a larger party harder or force a smaller party to skip story content. + +Players + +Navigator and operators + +Calibration concurrency + +Final human acknowledgements + +1 assisted + +Ship terminal repeats targets at active cabinet; walk between stations + +1; all results latch + +1 arcade acknowledgement then ship commit + +2 + +1 ship navigator and 1 roaming arcade operator + +1; six stations in sequence + +1 arcade acknowledgement then ship commit + +3 + +1 navigator and 2 operators + +2 stations per wave + +2 arcade acknowledgements then ship commit + +4 + +1 navigator and 3 operators + +3 stations per wave + +3 arcade acknowledgements then ship commit + +5–8 + +1 navigator; 4–6 operators; extra players scout/check + +min(players minus 1, 6) + +One per assigned operator group, up to 6 + + + +An operator group owns one or more stations. For two players, one operator sequentially arms all six stations; each station latches and displays armed. That operator acknowledges at the last assigned station, then the navigator commits from the ship. There is no simultaneous six-button requirement. Solo is an assisted simulation/access mode pending approval for sale. + +Parameter + +Guided + +Standard + +Challenge + +Passes per station + +1 + +2 + +3 + +Response window per wave + +60 s + +40 s + +25 s + +Stable target hold + +1 s + +2 s + +3 s + +Final acknowledgement window + +30 s + +20 s + +12 s + +Inactivity before hint 1 + +90 s + +120 s + +180 s + +Next hint eligibility + +60 s later + +90 s later + +120 s later + +Boss stage checkpoint + +Retained + +Retained + +Retained + + + +Assistance and changes during play + +Allow GM changes at an attempt or wave boundary; save the previous and new profile with reason. If staffing decreases mid-wave, cancel that wave, repartition unfinished work and preserve completed passes. Changing difficulty cannot retroactively invalidate a win. Accessibility options independently enable captions, longer holds, lower audio, alternative control mappings and a proxy narrator. + +The timer is 60 minutes for all launch profiles initially. A small-party run reduces concurrent work and repetition through the guided profile if selected; assess physical walking time in playtests. Any extension is visible to staff and logged. Production party limits and timing are pending venue approval. + +Assisted solo overrides the final acknowledgement window to 90 seconds to allow travel back to the ship. It still uses a five-second synchronized presentation countdown. Store assisted=true throughout; a valid normal launch may set outcome=WON while retaining that assistance marker, distinct from the GM-forced ASSISTED_RESCUE ending. + + + +9 Synchronized launch protocol + +The synchronization is a barrier of verified readiness followed by one scheduled commit. It must feel simultaneous without depending on every cabinet being physically occupied. Previously calibrated stations remain locked; automatic holding is presented as the ship’s stabilization system. + +Phase + +Guard and action + +Failure or retry + +ARM + +All passes complete and ship power stable. Sequentially arm all six stations for a new launch_epoch. + +Missing/offline station holds launch; GM may configure a proxy explicitly + +CREW_READY + +Every assigned operator group acknowledges once at its designated station. Open the profile’s readiness window on the first acknowledgement. + +Window expiry clears group acknowledgements only; station calibration remains + +COMMIT_REQUEST + +Navigator presses ship Commit within the readiness window; all six stations armed and group acknowledgements current. + +Reject with specific missing prerequisite; never partially commit + +COUNTDOWN + +Controller persists start and commit deadlines; five-second audiovisual countdown. Freeze new inputs except cancel. + +Power loss, offline required agent or GM cancel disarms launch and pauses + +COMMITTED + +At deadline, atomically recheck guards and append launch.committed once. Dispatch finale using stable effect IDs. + +After commit, presentation failures recover without undoing victory + +DEBRIEF + +Finale completion reported or GM confirms ending shown. + +If playback fails, offer alternate ending narration; record fault + + + +Timing contract + +The controller owns the countdown clock and accepts readiness using its receive time, not player-device timestamps. Final presentation commands carry a future presentation time and launch_epoch; clients acknowledge readiness before countdown. A proposed clock-offset tolerance of 100 ms and audiovisual skew target of 250 ms need installation testing. If timing quality is inadequate, centralize the countdown audio and let clients follow state. + +If a command arrives exactly at a deadline, accept only if received_at_ms < deadline_ms. Persist the ordering chosen by the reducer. A technical hold cancels an in-progress countdown; resumption always creates a new launch_epoch. Crew acknowledgements never survive a new epoch or staffing change. + +Win and assisted ending + +Normal outcome is WON when launch.committed is durable. Continue the ending even if a decorative cabinet animation fails. GM Finish assisted uses a separate outcome ASSISTED_RESCUE and reason; it does not forge six wins or cell readings. Abort and time-expired states lead to their own debrief paths. + + + +10 Room and component state machines + +Separate lifecycle from story phase. Lifecycle is IDLE, PREFLIGHT, READY, RUNNING, PAUSED, RECOVERY_HOLD, TIME_EXPIRED, WON, ASSISTED_RESCUE, ABORTED or RESETTING. Story phase is BRIEFING, ARCADE, BOSS, REVEAL, CELLS, CALIBRATION_TUTORIAL, CALIBRATION, LAUNCH_ARM, COUNTDOWN or DEBRIEF. A hold preserves the current phase and checkpoints. + +From + +Event and guard + +To and effects + +IDLE + +prepare; configuration valid + +PREFLIGHT; poll inventory and versions + +PREFLIGHT + +all required checks pass + +READY; no room timer running + +READY + +start_run authorized + +RUNNING/BRIEFING; start active clock + +BRIEFING + +briefing done or GM skip + +ARCADE; enable discovery branches + +ARCADE + +all A01–A06 won + +BOSS; prepare B07 + +BOSS + +valid B07 win + +REVEAL; request passage release + +REVEAL + +passage open confirmed + +CELLS; reveal and eligible bay releases + +CELLS + +all cells stable and clients ready + +CALIBRATION_TUTORIAL + +TUTORIAL + +two practices complete + +CALIBRATION; generate first wave + +CALIBRATION + +all station passes complete + +LAUNCH_ARM + +LAUNCH_ARM + +valid navigator commit + +COUNTDOWN; persist deadline + +COUNTDOWN + +deadline and all guards true + +WON/DEBRIEF; launch commit once + + + +PAUSED is GM-controlled; RECOVERY_HOLD is fault-controlled. Both freeze active elapsed time and gameplay deadlines, while health probes and command expiry continue on wall time. TIME_EXPIRED can return to the saved phase only through a logged time extension. Terminal outcomes accept diagnostics and debrief controls, not gameplay progression. + +Cabinet states + +OFF → BOOTING → TOKEN_WAIT → READY → PLAYING → WON or RETRY_READY. A valid token authorization enables READY; Start consumes one attempt credit. Loss enters RETRY_READY and regenerates one credit after three seconds. First-state WON latches permanently for this run. Second-state assignment replaces local play state with CAL_READY → CHALLENGE → LOCKED; it never erases first-state completion. Health ONLINE, STALE or OFFLINE is a separate dimension. + +Puzzle state is LOCKED, AVAILABLE, SOLVED or REWARD_PENDING/DELIVERED. SOLVED is durable; a delivery failure does not unsolve the puzzle. Device faults are overlays, not invented puzzle states. Any operator rewind requires an explicit dependency cascade preview; prefer local retry over rewinding a room checkpoint. + + + +11 Authoritative state and event contract + +Use a single serialized reducer per room. Validate an observation, deduplicate it, apply guards, append accepted domain events and update the snapshot in one transaction. Commit outgoing commands to an outbox in that same transaction. Publish after commit. The room state is reconstructed from the journal plus a verified snapshot; UI state is never the source of truth. + +State group + +Required fields + +Run + +room_id, run_id, schema_version, config_hash, lifecycle, phase, revision, outcome + +Timing + +active_elapsed_ms, time_limit_ms, hold_reason, remaining local durations, wall timestamps + +Configuration + +difficulty, party_size, operator_groups, seed, controller_epoch + +Cabinets + +entity_id, boot_id, assignment_id, attempt_id, mode, first_win, token_authorized, progress, health + +Props + +puzzle solved/delivery facts; cell identity/charge/location; socket observations; passage state + +Calibration + +challenge_id, wave_index, targets, station passes, ready set, deadlines + +Launch + +launch_epoch, armed stations, acknowledged groups, countdown deadline, committed flag + +Teddy + +mode, current cue, queue, played semantic keys, hint levels and cooldowns + +Audit + +event revision, actor, reason, overrides, command status, observation freshness + + + +Envelope shared by normalized events + +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 and payload. assignment_id is null for non-assignment events. Optional fields include correlation_id, causation_id, actor_id and reason. Controller events use controller identity and epoch. The gateway supplies received_at_ms; a client cannot choose it. + +event_id is globally unique; source_seq is monotonic within source_id plus boot_id. Reject a wrong room/run, unknown schema, untrusted source or stale assignment before mutation. A duplicate event returns its earlier result without rerunning effects. Gaps trigger a full-state request; do not guess missed progress. Out-of-order lower sequence reports cannot regress state. + +Important invariants + +Exactly six distinct first-state wins unlock B07. Seven repeated wins from one cabinet do not. + +One cell identity occupies at most one known location. Six occupied sockets without identity validation do not power the ship. + +Only the current challenge or launch epoch can accept readiness. A win occurs once per run. + +A command acknowledgement is not proof of motion, token delivery, sound playback or passage opening. + + + +12 Signals commands and guard behavior + +Normalized signal + +Required payload + +Acceptance guard + +puzzle.solved + +puzzle_id, evidence + +Puzzle available; trusted adapter or explicit GM override + +reward.delivered + +reward_id, evidence + +Reward pending; identity matches expected token + +token.observed + +reader_id, token_id + +Fresh current-boot scan; correct cabinet; token-wait phase + +game.started + +attempt_id + +Current assignment; credit admitted; cabinet ready + +game.progress + +attempt_id, value + +Current attempt; valid monotonic objective progress + +game.won / game.lost + +attempt_id, objective result + +Current playing attempt; win validated against objective + +cell.observed + +cell_id, location, present + +Known identity/location; debounce and no conflict + +passage.observed + +open, sensor_id + +Trusted current sensor; stable 300 ms + +calibration.ready + +challenge_id, selection + +Expected station; matching current challenge + +calibration.execute + +challenge_id + +Ship navigator; required ready set; deadline open + +launch.group_ready + +launch_epoch, group_id + +Assigned group station; all its stations armed + +launch.commit_requested + +launch_epoch + +Ship terminal; all groups ready; power and health valid + +teddy.playback_status + +cue_id, status + +Current cue; accepted/started/completed/failed distinct + + + +Server-generated events include charge.completed, phase.changed, calibration.passed, launch.committed, hint.eligible, timer.expired and recovery.required. Raw observations do not directly trigger outputs: adapters normalize them; the reducer decides the domain transition. + +Normalize selections to discrete integers: Route 0–3, Vector 1–5, Heading 0–7, Shield 1–3, Corridor 1–4 and Thrust 1–4. Godot reports selection plus stable_for_ms with Ready. The room checks target equality and the profile hold threshold; readback alone cannot award a pass. A station must retain its selection until Execute or revoke readiness if it moves. + +Commands and acknowledgements + +Commands include prepare_assignment, power_on, start_attempt, reset_attempt, set_mode, issue_challenge, arm_station, play_cue, cancel_cue, request_snapshot and invoke_configured_action. Each carries command_id, room/run, target boot/session, controller_epoch, expected state revision, expires_at_ms and typed payload. Return ACCEPTED, REJECTED, COMPLETED, FAILED or DELIVERY_UNKNOWN with a reason code and snapshot revision. + +Default acceptance deadline is two seconds and expiry five seconds; boot completion has a separate ten-second deadline. A pure state-setting command may be retried with the same command_id while unexpired. A pulse, release or other physical action with uncertain delivery is never blindly retried; reconcile feedback or ask the GM to inspect. Keep a durable device dedupe ledger across process restarts. + + + +13 Rust NATS and Flutter target architecture + +Implement a Rust Glitch domain crate containing a pure reducer, validation and profile resolution. A runtime crate supplies clocks, durable storage and a NATS adapter. Godot communicates through an agreed Rust integration boundary; the current sidecar can prototype it, while GDExtension remains a deployment choice. Each physical driver has one owner. + +Component + +Authority and responsibilities + +Rust room service + +Room journal/snapshots, dependency joins, timers, hints, recovery, operator audit and output outbox + +Cabinet agent + +Assignment validation, game lifecycle, RFID admission, local input, health, durable command dedupe + +Prop agents + +Claw and token delivery, cell/socket sensing, passage feedback, Teddy playback and configured outputs + +NATS + +Scoped delivery of observations, commands and state; authenticated clients and explicit permissions + +Flutter GM and gateway + +Authenticated actions, room map, diagnostic views, subscriptions and revision-aware snapshots + + + +Proposed subjects and persistence + +Use a versioned room namespace such as escape_gallery.glitch.v1. Publish observations to obs., commands to cmd., results to result. and authoritative snapshots to state.room. The gateway exposes room intent actions rather than arbitrary NATS publishing to browsers. Escape Gallery/Odin subjects and identities remain distinct. + +Recommended baseline: a transactional room journal and outbox in durable local storage, plus JetStream-backed observation delivery. Consumers acknowledge only after durable acceptance/rejection recording. Commands use request/reply plus the durable outbox; an expired command is discarded even if delivered later. State snapshots and reconnect reconciliation remain mandatory. Final storage engine and JetStream policy require engineering sign-off; Core NATS alone must not be treated as a replayable journal. + +Ordering and leadership + +Only one active controller owns the room; cabinets accept its fenced controller_epoch. During restart, enter RECOVERY_HOLD, load committed state, acquire authority and reconcile all agents before accepting play. Do not use two active services writing the same run. Any future failover design must define an exclusive lease and fencing before deployment. + +Generate versioned Rust/Dart contracts from the agreed message source; confirm field ordering for MessagePack and enum encodings. The JSON companion is semantic guidance, not the generated protocol. Flutter must show stale state if its subscription drops, fetch a full snapshot on reconnect and use expected_revision on mutations. Reject conflicting operator edits rather than silently overwrite them. + + + +14 Teddy modes scheduling and overrides + +Teddy is a character and a control-system output, never an authority that advances game state by speaking. Use authored cues with matching text transcripts. A deterministic cue scheduler selects lines from room facts; generative speech is unnecessary for the initial simulator. + +Mode + +Behavior and entry + +DORMANT + +Before run; silent, motors in resting pose + +GUIDE + +Arrival and discovery; warm, slightly unreliable arcade host + +GLITCH + +Brief authored interruptions at major wins; no continuous random chatter + +REVEAL + +Boss victory and ship discovery; voice becomes clear and empathetic + +COACH + +Calibration tutorial, readback reminders and retry encouragement + +LAUNCH + +Priority readiness and countdown coordination + +HINT + +Temporary targeted clue, then return to prior narrative mode + +OPERATOR + +GM-selected cue or live voice; autopilot narration suspended + +FAULT or MUTED + +No motor commands; text/room-speaker fallback available + + + +Scheduling rules + +Priority order: operational stop message, operator voice, launch cue, reveal beat, requested hint, tutorial, automatic hint, ambient line. Only one voice cue plays at a time. Duck cabinet ambience while speaking; never suppress necessary game feedback. Default minimum gap is eight seconds between noncritical lines; ambient frequency at most one per three minutes. + +Each cue has cue_id, semantic_key, required_phase, precondition, priority, max_duration_ms, interruptible, expires_after_ms and transcript. Before playback, recheck the precondition; discard a queued token hint if that cabinet has already started. Deduplicate story beats by semantic_key per run. Playback completion is recorded separately from scheduling. + +Override and failure + +Flutter offers Preview transcript, Play, Stop, Mute, Set mode, Replay last, Choose hint and Take over voice. Takeover cancels pending automatic speech and uses an explicit release back to autopilot. A stopped cue is not automatically replayed; the scheduler reevaluates whether its information is still needed. A GM skip of the briefing is logged and never skips puzzle prerequisites. + +If playback fails to start within two seconds or exceeds declared duration plus two seconds, stop motion, flag FAULT and show the transcript on the ship/arcade display or room speaker. Story progression does not wait for Teddy. If a mandatory tutorial cue fails, provide its text and require the same player practice, not a playback success flag. Motor limits, pose timing and audio interface require bench validation. + + + +15 Teddy script and escalating hints + +Cue + +Trigger and once-only key + +Proposed line + +T00 Welcome + +Run started; welcome + +“I remember this place full of people. The machines remember too. Find their tokens. Each one belongs to only one machine.” + +T10 Naming + +First first-state win; lesson.name + +“One signal restored. Use the name on the cabinet when you call out. Then we all know who you mean.” + +T20 Echo + +Second win; lesson.echo + +“Say the instruction back before you act. These wires do not always hear us correctly.” + +T30 Hold + +Third win; lesson.hold + +“That pattern looks like stars. Keep a good setting steady until someone says to change it.” + +T50 Ready + +Fifth win; lesson.ready + +“Ready means you are waiting for the same moment. It does not mean go.” + +T60 Boss + +All six won; boss.awake + +“The dark machine is listening now. Break through, and we may finally hear who is calling.” + +T70 Reveal + +Passage open; ship.reveal + +“It was a call for help. Bring the six light cores to the ship. Match their symbols.” + +T80 Training + +Calibration ready; coach.begin + +“Navigator, name a station and its target. Operator, repeat it and press Ready. Navigator, check it, then Execute.” + +T90 Launch + +Countdown entered; launch epoch + +“Every station is holding. Crew ready. Five, four, three, two, one.” + +T99 Ending + +Launch committed; ending + +“A signal has become a journey. You gave our visitor a way home.” + + + +Hint policy + +Maintain a hint level per active puzzle or challenge: 0 none, 1 orientation, 2 concrete direction, 3 explicit next action. Inactivity means no meaningful progress in that branch; button spam and repeated bad scans do not reset it. Automatic level 1 becomes eligible after the profile threshold. Subsequent levels require another threshold interval or a player/GM request. Default automatic maximum is level 2; level 3 is requested or GM-approved. A hint never marks a puzzle solved. + +Example P03: H1 “The music and the sleeves belong together.” H2 “Listen to the short excerpt and compare it with the marked album choices.” H3 “Choose the sleeve whose symbol matches the displayed excerpt cue, then confirm.” Replace H3 with the actual answer after the record puzzle is finalized; do not claim a nonexistent solution. + +Example calibration: H1 “Call the station name first.” H2 “The ship has the target; the arcade operator must repeat it.” H3 “Station 3, select NORTH, press Ready, then let the navigator press Execute.” Inject the current target, never a cached one. Suppress hints during reveal/countdown and expire them when the phase changes. + + + +16 Operator dashboard and run operations + +Flutter’s primary screen should answer: Where is the team, what can happen next, what is blocking it, and what action is safe for staff to take? Show both the narrative phase and technical lifecycle, active time remaining, party/profile, assistance status and the current next-transition guard. + +Panel + +Required contents and actions + +Run header + +Prepare, Start, Pause, Resume, Extend time, Abort, Finish assisted; show reason and audit outcome + +Dependency view + +Puzzles, tokens, six wins, boss, passage, cells and calibration; each blocked item explains its missing prerequisite + +Cabinet tiles + +Mode, game, objective, token authorization, attempt, progress, connectivity and latest fault + +Ship and cells + +Six identities, bay/socket location, charge percentage, sensor freshness and conflicts + +Communication + +Navigator and operator groups, current targets, ready set, wave deadline and completed passes + +Teddy + +Current mode/cue, transcript, upcoming cue, hint eligibility, mute and takeover + +Diagnostics + +Command lifecycle, delivery unknown, agent boot IDs, version/config mismatch and event history + + + +Intervention semantics + +Separate Retry current attempt, Restart cabinet process, Override puzzle solved, Confirm delivery, Substitute cell, Proxy failed station and Finish assisted. Each button explains its effects before application. Record staff identity, reason, affected entities and before/after revision. Physical actions show configured device and feedback status. Do not offer a generic “force next” that silently skips unknown dependencies. + +Routine play controls may be immediate. Reset room, abort and dependency rewinds require an explicit confirmation displaying what is lost. A hardware action requires an allowed action binding and valid target session. A stale browser may view history but cannot issue state-changing actions until refreshed. Two simultaneous GM actions use optimistic revision checks; the second receives a conflict with current state. + +Operational sequence + +Before guests: validate inventory and capabilities, confirm cells home, test passage and Teddy, reset all game clients, load a matching release/config hash, then Prepare. At briefing: select party size, difficulty and access options; Start begins the game clock. During play: investigate faults before substituting outcomes. After play: complete debrief, export run summary, then enter RESETTING and follow the physical checklist. + +Run summary records elapsed active time, final outcome, hint use, retries, assistance, technical hold duration, per-act duration and unresolved faults. Keep token identifiers out of guest-facing reports; staff diagnostics can use stable logical token names. + + + +17 Reset recovery and degraded operation + +Reset transaction + +Enter RESETTING and inhibit new gameplay. Cancel cues/countdowns; set outputs to their configured resting states. Archive the old run and create a new run_id only after reset checks pass. Clear token authorization, wins, charge, calibration, hints and launch acknowledgements for the new run. Persist device dedupe history and old-run audit records; clearing them can re-enable repeated physical actions. + +Return T01–T06 to their starting locations, reset claw prizes and placeholder puzzle mechanisms, and verify availability. + +Return each cell to its own cabinet bay; confirm socket absence and bay identity. Close and latch bays; verify passage show reset independently of egress. + +Confirm all seven clients in their initial states, boss dark, ship dormant, Teddy resting, and no pending command or delivery-unknown action. + +Run preflight and have the GM acknowledge the physical checklist. A reset button alone does not prove the room is ready. + +Fault policy + +Fault + +Response + +Recovery + +Cabinet disconnect + +Mark STALE after 3 s, OFFLINE after 8 s; hold if active or required by current barrier + +Request snapshot; reconcile assignment/attempt and preserve room checkpoints + +Agent restart + +New boot_id invalidates old targeted commands + +Hold; reassign from authoritative room state; never replay an old pulse + +Controller restart + +RECOVERY_HOLD, recover journal/outbox + +Reconcile every required agent and physical state; GM resumes + +NATS interruption + +Agents retain current visual state; stop accepting new timed work + +Reconnect, snapshot, dedupe replay; fresh challenge after resume + +Unknown delivery + +No automatic repeat of physical action + +Inspect sensor/device; confirm or deliberately reissue with reason + +Teddy unavailable + +Caption or speaker fallback; no room soft lock + +Repair independently; resume autopilot only on explicit release + +Cell removed + +Cancel current wave/countdown; preserve passes + +Correct identity reinstalled and stable; fresh challenge/epoch + + + +During any hold, health checks and wall-clock command expiry continue. Physical input observations are recorded but gameplay wins/ready inputs are not accepted. On resume, reissue current intent with fresh IDs where necessary. A completed cabinet can be visually reconstructed as won without granting another reward. A failed station proxy is a logged assisted mode; never hide it as healthy hardware. + + + +18 Simulation harness and reference run + +The simulator uses the same reducer and typed contracts as production. Replace NATS/clock/device adapters with a virtual clock and deterministic fake agents for seven cabinets, physical puzzles, claw delivery, six cell bays, six sockets, passage, ship console and Teddy. Fixtures must not open network connections to real props. Simulation mode is prominently shown and cannot be enabled by a production game profile. + +Harness controls + +Provide advance_time(ms), inject_observation, set_agent_online, restart_agent, delay/drop/duplicate/reorder_message, fail_output, confirm_sensor, snapshot and assert_invariant. Seed objective outcomes and calibration targets. Retain a trace of accepted/rejected inputs, state revisions and outgoing commands. Replay the trace to the same final hash without re-emitting physical effects. + +Virtual minute + +Stimulus + +Expected checkpoint + +0 + +Prepare, preflight success, Start, briefing complete + +RUNNING/ARCADE; five independent branches available + +2–12 + +Solve placeholders and confirm T01–T06 delivery + +Six rewards recorded; no game is automatically won + +4–25 + +Correct scans, Start and validated wins in arbitrary order + +Each first win latches once; charge completes after active time + +25 + +Sixth unique win + +BOSS; B07 boot/ready + +28 + +Boss win; release ACK then passage open sensor + +REVEAL then CELLS; bay releases requested once + +30–36 + +Confirm bays open, remove cells, insert matching sockets + +Ship power stable after 2 s; calibration assignments ready + +37–39 + +Complete two practice interactions + +CALIBRATION + +40–51 + +All required waves pass with current targets + +LAUNCH_ARM; all station passes retained + +52–55 + +Arm six, acknowledge groups, navigator commit, advance 5 s + +launch.committed once; WON and debrief + +After + +Reset plus physical observations + +New run ready; no old event can change it + + + +Minute values illustrate pacing, not a scheduled autopilot script. The golden-path fixture should also run accelerated without waiting 60 real minutes. No assertion depends on speech completing. Test both player-driven Start and permitted GM start controls, while retaining token admission. + +Acceptance evidence + +For every scenario record fixture version, config hash, seed, initial state, exact input trace, expected events, rejected events and final state hash. The tests below are specified acceptance cases; this GDD does not claim that the room implementation or those tests already exist or pass. + + + +19 End to end acceptance scenarios + +ID + +Scenario and stimulus + +Required assertion + +SIM01 + +Golden path with 6 players, standard + +All acts, six cells and two calibration passes; one launch commit + +SIM02 + +2 players, guided; one roaming operator + +All six stations completed; no simultaneous multi-station human input required + +SIM03 + +1 assisted player; sequential ship and arcade interactions + +No deadlock; clearly assisted outcome/metadata + +SIM04 + +8 players, challenge; six stations active + +Unused helper never required for a guard; three passes per station + +SIM05 + +Wrong token, repeated scan, stale historical scan + +No credit for wrong/stale; only one authorization for correct scan + +SIM06 + +Lose A03 twice then retry + +No extra token needed; other wins and charge retained + +SIM07 + +Duplicate A01 win and missing A06 win + +Boss remains dark; unique set count stays 5 + +SIM08 + +Claw reward ACK but token delivery jam + +Reward pending; GM hand-delivery logged; no duplicate dispense + +SIM09 + +Boss won; passage ACK without open sensor + +Reveal hold after 5 s; no assumed opening + +SIM10 + +Wrong socket, duplicate cell identity, missing cell + +Ship power guard false; explicit diagnostic per conflict + +SIM11 + +Cell removed during calibration and countdown + +Current wave/countdown canceled; completed passes retained; fresh IDs + +SIM12 + +Late Ready for old challenge; Execute exactly at deadline + +Both rejected; current wave remains consistent + +SIM13 + +One final group late, then retry + +Acknowledgements clear; calibration retained; launch once on valid retry + +SIM14 + +Restart controller after durable boss win before output publish + +Recovered outbox sends same command ID; no repeated physical release + +SIM15 + +Agent restarts after uncertain bay release + +No blind pulse replay; reconcile or GM inspect + +SIM16 + +Network drop, duplicate and reordered events + +No regression/double reward; snapshot reconciliation reaches same state + +SIM17 + +Teddy unavailable or stale queued hint + +Fallback text; stale hint discarded; gameplay can finish + +SIM18 + +Pause 5 min; expire action while paused + +Active timer frozen; expired wall-time command rejected + +SIM19 + +Time expires in calibration; extend 5 min + +TIME_EXPIRED then preserved phase; no automatic normal win + +SIM20 + +Reset and inject old-run win/scan/launch + +All rejected; fresh run remains pristine + + + + + +20 Implementation plan and decision register + +Build order and release gates + +Milestone 1: agreed generated contracts, Rust reducer, durable run state and a complete fake-prop golden path. Gate: SIM01–13 and SIM17–20 pass. + +Milestone 2: NATS adapter, durable command dedupe, recovery and Flutter operator view. Gate: SIM14–16 plus conflicting-GM-action and unauthorized-source tests pass. + +Milestone 3: one real Godot cabinet reproduces power, fresh token, Start, retry and win through the final transport. Gate: reconnect/restart produces no old credit or physical action. + +Milestone 4: all seven cabinets, calibration variants, cell/socket agents and Teddy. Gate: full run with fake outputs, then each real device accepted independently. + +Milestone 5: venue rehearsals for 2, 3, 4, 6 and 8 players. Gate: reset, degraded operation, timing, audibility and physical access verified before public operation. + +Decision + +Proposed default + +Owner and validation + +Party limits and duration + +2–8 players, 60 min; solo assisted + +Room designer; timed small-party walk-through + +Cabinets 5–7 and controls + +Frogger, Space Invaders, Robotron-style boss + +Game/hardware leads; capability validation + +Physical puzzle mechanics + +Contracts P01–P06 retained + +Puzzle designer; confirm token availability and H3 hints + +Cell charging and sensing + +Virtual 30 s; identity plus presence + +Hardware lead; choose sensors and spare procedure + +Finale timing + +Latched stations; crew barrier; 5 s countdown + +Designer/controls lead; test travel and latency + +Persistence and transport + +Durable journal/outbox plus JetStream observations + +Framework owner; retention, schema and recovery review + +Godot integration + +Rust boundary; sidecar or GDExtension + +Framework/game leads; platform lifecycle tests + +Teddy interfaces + +Authored audio, captions, bounded motion + +AV/hardware leads; playback and motor limits + +Physical release and reset + +Sensor-confirmed actions; inspection fallback + +Venue/hardware leads; hardware acceptance + +Difficulty balance + +Profile defaults in section 8 + +Playtest lead; completion and hint metrics + + + +Open production decisions do not block the first software simulation. Keep them as explicit configuration values with proposed defaults. Do not encode draft cabinet names, physical pin numbers or one-off test UIDs into the room reducer. + + + +21 Source notes and companion contract guide + +Design authority + +The user’s Glitch concept and follow-up requirements establish the room premise, six token gates, seventh cabinet, spacecraft rescue, six cells, second-state coordination and Teddy. The user’s October 17 direction makes Rust/NATS and Flutter the final approach. The timing, task names, thresholds, state contracts and scripts in this document are proposed design decisions. + +Project material consulted + +Project root: D:/Projects/escape-gallery/room_glitch. These local files informed compatibility and migration decisions; they were read, not modified: + +README.md — current arcade framework, games, authoring model and known missing room features. + +tools/room_controller/README.md and catalog.json — current cabinet assignments, capabilities, scenario structure, operator semantics and reward acknowledgement limitations. + +docs/odin_integration/README.md — intended Rust/NATS/Flutter responsibilities, single-controller ownership and corrected BCM23/I2C wiring facts. + +labs/glitch_odin/README.md — simulator and Godot-sidecar scope, experimental contract, replay limits and absent durable room orchestration. + +labs/glitch_odin/ODIN_MESSAGES.md — generated Odin schema candidates, ordered-field MessagePack distinction and required Glitch-specific messages. + +Where the older overview and integration notes differ, use the explicit wiring correction in the integration notes. A documentation statement that a bench test passed is historical project evidence, not a new physical verification performed for this GDD. + +Companion file usage + +glitch_implementation_contract.json includes design_version, target_architecture, profiles, entity maps, event and command schemas, typed observation payload schemas, an example event, a partial room-state example and simulation_cases. Validate the envelope and its type-selected payload, then apply the semantic guards in sections 11–12. Controller-internal events and complete persistent state types still require generated definitions. No generated Rust/Dart models or existing API compatibility are implied. + +All durations use integer milliseconds in the companion. Profile resolution happens once at prepare, then any change is logged at a safe attempt/wave boundary. In simulation, physical puzzle solutions and game outcomes are deliberate fixture inputs; in production, adapters must provide their real evidence. Simulation events must never be accepted from production agent credentials. + +Definition of ready for implementation + +The first deliverable from engineering should be a full simulated run with visible state transitions and a replayable event trace. Then implement the listed failure scenarios before energizing rewards. The design is successful when a two-person team can finish the full narrative, a restart cannot duplicate an effect, a GM can explain any blocked transition, and a reset reliably produces a clean next run. \ No newline at end of file diff --git a/labs/glitch_odin/design/glitch_implementation_contract.json b/labs/glitch_odin/design/glitch_implementation_contract.json new file mode 100644 index 0000000..7f8080c --- /dev/null +++ b/labs/glitch_odin/design/glitch_implementation_contract.json @@ -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 + } + } +} \ No newline at end of file diff --git a/labs/glitch_odin/export_teddy.py b/labs/glitch_odin/export_teddy.py new file mode 100644 index 0000000..6348629 --- /dev/null +++ b/labs/glitch_odin/export_teddy.py @@ -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() diff --git a/labs/glitch_odin/gm/lib/hint_panel.dart b/labs/glitch_odin/gm/lib/hint_panel.dart new file mode 100644 index 0000000..5c1fc48 --- /dev/null +++ b/labs/glitch_odin/gm/lib/hint_panel.dart @@ -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 hint, Map 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 room; + final http.Client client; + final String base; + final Future Function(String text) onSpeak; + final bool canSpeak; + @override + State createState() => _HintPanelState(); +} +class _HintPanelState extends State { + List> 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 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.from(h)).toList(); + revision = body['revision']; message = ''; + }); + } catch (e) { if (mounted) setState(()=>message=e.toString()); } + finally { if (mounted) setState(()=>loading=false); } + } + Future edit([Map? 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(context:context,builder:(context)=>StatefulBuilder(builder:(context,update) { + Widget pick(String label, dynamic value, List values, void Function(dynamic) change) => + DropdownButtonFormField(initialValue:value,isExpanded:true, + decoration:InputDecoration(labelText:label), + items:values.map((v)=>DropdownMenuItem(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.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.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())}.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(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...')), + ]), + ]); + } +} diff --git a/labs/glitch_odin/gm/lib/main.dart b/labs/glitch_odin/gm/lib/main.dart new file mode 100644 index 0000000..408d5a1 --- /dev/null +++ b/labs/glitch_odin/gm/lib/main.dart @@ -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 createState() => _CabinetPageState(); +} +class _CabinetPageState extends State { + late final http.Client client; + Timer? timer; + Map? 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 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; + 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 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; + 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'), + ]))), + ); + } +} diff --git a/labs/glitch_odin/gm/lib/room_page.dart b/labs/glitch_odin/gm/lib/room_page.dart new file mode 100644 index 0000000..49e1a61 --- /dev/null +++ b/labs/glitch_odin/gm/lib/room_page.dart @@ -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 createState() => _RoomPageState(); +} + +class _RoomPageState extends State { + 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? 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 selections = {}; + int? selectionWave; + Map? trace; + String speechStatus = 'Teddy audio not enabled (captions available)'; + bool speechPolling = false; + final audio = TeddyAudio(); + String? readyAudioUrl; + + Future 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 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.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 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.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 confirmReset() async { + final confirmed = await showDialog(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 withReason(String action, String entity) async { + final controller = TextEditingController(); + final reason = await showDialog(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 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.from(data)); + } catch (_) { if (mounted) setState(() => notice = 'Could not fetch trace.'); } + } + + bool get enabled => room != null && error == null && !pending; + List 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 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 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((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.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((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.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(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((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((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(value:profile,items:['guided','standard','challenge'].map((p) => + DropdownMenuItem(value:p,child:Text(p))).toList(),onChanged:enabled ? (v)=>setState(()=>profile=v!):null), + DropdownButton(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](), + ])))), + ]), + ); + } +} diff --git a/labs/glitch_odin/gm/lib/teddy_audio.dart b/labs/glitch_odin/gm/lib/teddy_audio.dart new file mode 100644 index 0000000..fa20f74 --- /dev/null +++ b/labs/glitch_odin/gm/lib/teddy_audio.dart @@ -0,0 +1 @@ +export 'teddy_audio_stub.dart' if (dart.library.html) 'teddy_audio_web.dart'; diff --git a/labs/glitch_odin/gm/lib/teddy_audio_stub.dart b/labs/glitch_odin/gm/lib/teddy_audio_stub.dart new file mode 100644 index 0000000..9ebf679 --- /dev/null +++ b/labs/glitch_odin/gm/lib/teddy_audio_stub.dart @@ -0,0 +1,9 @@ +class TeddyAudio { + bool enabled = false; + String status = 'Audio disabled on this device'; + Future enable() async { enabled = true; } + void sync(String? url) {} + void playNow(String url) { enabled = true; } + void disable() { enabled = false; } + void dispose() {} +} diff --git a/labs/glitch_odin/gm/lib/teddy_audio_web.dart b/labs/glitch_odin/gm/lib/teddy_audio_web.dart new file mode 100644 index 0000000..c9d7e6c --- /dev/null +++ b/labs/glitch_odin/gm/lib/teddy_audio_web.dart @@ -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 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=3.11.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/labs/glitch_odin/gm/pubspec.yaml b/labs/glitch_odin/gm/pubspec.yaml new file mode 100644 index 0000000..b2009c4 --- /dev/null +++ b/labs/glitch_odin/gm/pubspec.yaml @@ -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 diff --git a/labs/glitch_odin/gm/test/fixtures/room.json b/labs/glitch_odin/gm/test/fixtures/room.json new file mode 100644 index 0000000..a955d43 --- /dev/null +++ b/labs/glitch_odin/gm/test/fixtures/room.json @@ -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}} \ No newline at end of file diff --git a/labs/glitch_odin/gm/test/hint_panel_test.dart b/labs/glitch_odin/gm/test/hint_panel_test.dart new file mode 100644 index 0000000..8fe66f0 --- /dev/null +++ b/labs/glitch_odin/gm/test/hint_panel_test.dart @@ -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={'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 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={'phase':'ARCADE','stations':[{'id':'cabinet_01','won':false}]}; + final hint={'phase':'ALL','target':'cabinet_01','condition':'incomplete'}; + expect(hintReason(hint,room),isNotNull); + (room['stations'] as List)[0]['won']=true; + expect(hintReason(hint,room),isNull); + }); +} diff --git a/labs/glitch_odin/gm/test/room_test.dart b/labs/glitch_odin/gm/test/room_test.dart new file mode 100644 index 0000000..0d49a61 --- /dev/null +++ b/labs/glitch_odin/gm/test/room_test.dart @@ -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; + Map? 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 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(find.byType(OutlinedButton))) { + expect(button.onPressed,isNull); + } + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/labs/glitch_odin/gm/test/widget_test.dart b/labs/glitch_odin/gm/test/widget_test.dart new file mode 100644 index 0000000..ea24732 --- /dev/null +++ b/labs/glitch_odin/gm/test/widget_test.dart @@ -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(find.byType(FilledButton))) { + expect(button.onPressed, isNull); + } + expect(find.textContaining('Disconnected'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/labs/glitch_odin/gm/web/flutter_bootstrap.js b/labs/glitch_odin/gm/web/flutter_bootstrap.js new file mode 100644 index 0000000..dd79d85 --- /dev/null +++ b/labs/glitch_odin/gm/web/flutter_bootstrap.js @@ -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(); diff --git a/labs/glitch_odin/gm/web/index.html b/labs/glitch_odin/gm/web/index.html new file mode 100644 index 0000000..5f8262f --- /dev/null +++ b/labs/glitch_odin/gm/web/index.html @@ -0,0 +1,15 @@ + + + + + + + Glitch GM Lab | Escape Gallery + + + diff --git a/labs/glitch_odin/godot_smoke.py b/labs/glitch_odin/godot_smoke.py new file mode 100644 index 0000000..f9a9d45 --- /dev/null +++ b/labs/glitch_odin/godot_smoke.py @@ -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() diff --git a/labs/glitch_odin/hint_library.py b/labs/glitch_odin/hint_library.py new file mode 100644 index 0000000..01061b8 --- /dev/null +++ b/labs/glitch_odin/hint_library.py @@ -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 + diff --git a/labs/glitch_odin/lab.py b/labs/glitch_odin/lab.py new file mode 100644 index 0000000..bfb2174 --- /dev/null +++ b/labs/glitch_odin/lab.py @@ -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://: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) diff --git a/labs/glitch_odin/nats.conf b/labs/glitch_odin/nats.conf new file mode 100644 index 0000000..3b877e9 --- /dev/null +++ b/labs/glitch_odin/nats.conf @@ -0,0 +1,3 @@ +host: "127.0.0.1" +port: 14222 +server_name: "glitch-local-lab" diff --git a/labs/glitch_odin/prepare_teddy_dataset.py b/labs/glitch_odin/prepare_teddy_dataset.py new file mode 100644 index 0000000..16f137b --- /dev/null +++ b/labs/glitch_odin/prepare_teddy_dataset.py @@ -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=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() diff --git a/labs/glitch_odin/room-nats.conf b/labs/glitch_odin/room-nats.conf new file mode 100644 index 0000000..16ef94e --- /dev/null +++ b/labs/glitch_odin/room-nats.conf @@ -0,0 +1,3 @@ +host: "127.0.0.1" +port: 14232 +server_name: "glitch-room-simulation" diff --git a/labs/glitch_odin/room_smoke.py b/labs/glitch_odin/room_smoke.py new file mode 100644 index 0000000..5d7f87d --- /dev/null +++ b/labs/glitch_odin/room_smoke.py @@ -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() 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 022050 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() + diff --git a/labs/glitch_odin/src/godot.rs b/labs/glitch_odin/src/godot.rs new file mode 100644 index 0000000..113f10c --- /dev/null +++ b/labs/glitch_odin/src/godot.rs @@ -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 { + 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(()) +} diff --git a/labs/glitch_odin/src/lib.rs b/labs/glitch_odin/src/lib.rs new file mode 100644 index 0000000..2ed51e3 --- /dev/null +++ b/labs/glitch_odin/src/lib.rs @@ -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, + 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; diff --git a/labs/glitch_odin/src/main.rs b/labs/glitch_odin/src/main.rs new file mode 100644 index 0000000..94019d7 --- /dev/null +++ b/labs/glitch_odin/src/main.rs @@ -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 { + 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, Json(command): Json) + -> Result, (StatusCode, Json)> { + 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) + -> Result, (StatusCode, Json)> { + 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::(&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(()) +} diff --git a/labs/glitch_odin/src/room.rs b/labs/glitch_odin/src/room.rs new file mode 100644 index 0000000..95503c5 --- /dev/null +++ b/labs/glitch_odin/src/room.rs @@ -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 = std::sync::OnceLock::new(); + CONFIG.get_or_init(||serde_json::from_str(CONTRACT).expect("checked design contract")) +} +pub fn fingerprint(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, +} +#[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, pub ready: BTreeMap, + pub deadline: Option, 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, 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, pub queue: Vec, pub played: BTreeSet, + 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, + pub profile: String, pub party: u64, pub operators: u64, pub assisted: bool, pub seed: u64, + pub boot_until: Option, + pub wall_ms: u64, pub active_ms: u64, pub limit_ms: u64, + pub stations: Vec, pub puzzles: Vec, + pub boss: String, pub passage_open: bool, pub passage_since: Option, pub reveal_at: u64, + pub locations: BTreeMap, pub location_since: BTreeMap, + pub cell_faults: Vec, pub power_since: Option, pub ship_power: bool, + pub tutorial: u64, pub wave_serial: u64, pub wave: Option, + pub launch_epoch: u64, pub armed: BTreeSet, pub groups: BTreeSet, + pub ready_deadline: Option, pub countdown: Option, pub launches: u64, + pub effects: Vec, 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, + initial_run: String, seen: BTreeMap, +} +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::>(); + 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 { + 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=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 { + 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=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=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.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 { + 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 { + 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=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) +} diff --git a/labs/glitch_odin/src/room/tests.rs b/labs/glitch_odin/src/room/tests.rs new file mode 100644 index 0000000..ed16d8d --- /dev/null +++ b/labs/glitch_odin/src/room/tests.rs @@ -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()); +} diff --git a/labs/glitch_odin/src/room_server.rs b/labs/glitch_odin/src/room_server.rs new file mode 100644 index 0000000..9ac776b --- /dev/null +++ b/labs/glitch_odin/src/room_server.rs @@ -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 } +async fn exchange(client:async_nats::Client,wire:Wire)->Result,(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::(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)->Result,(StatusCode,String)> { + exchange(client,Wire{op:"state".into(),request:None}).await +} +async fn trace(State(client):State)->Result,(StatusCode,String)> { + exchange(client,Wire{op:"trace".into(),request:None}).await +} +async fn command(State(client):State,Json(request):Json)->Result,(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::(&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(()) +} diff --git a/labs/glitch_odin/teddy_speech.py b/labs/glitch_odin/teddy_speech.py new file mode 100644 index 0000000..59e4eeb --- /dev/null +++ b/labs/glitch_odin/teddy_speech.py @@ -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) diff --git a/labs/glitch_odin/test_hint_library.py b/labs/glitch_odin/test_hint_library.py new file mode 100644 index 0000000..5b0a2e8 --- /dev/null +++ b/labs/glitch_odin/test_hint_library.py @@ -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() diff --git a/labs/glitch_odin/test_runner.py b/labs/glitch_odin/test_runner.py new file mode 100644 index 0000000..3499de9 --- /dev/null +++ b/labs/glitch_odin/test_runner.py @@ -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() diff --git a/labs/glitch_odin/test_teddy_speech.py b/labs/glitch_odin/test_teddy_speech.py new file mode 100644 index 0000000..06ca803 --- /dev/null +++ b/labs/glitch_odin/test_teddy_speech.py @@ -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() + diff --git a/labs/glitch_odin/train_teddy_entry.py b/labs/glitch_odin/train_teddy_entry.py new file mode 100644 index 0000000..7c838b7 --- /dev/null +++ b/labs/glitch_odin/train_teddy_entry.py @@ -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() diff --git a/labs/glitch_odin/train_teddy_wsl.sh b/labs/glitch_odin/train_teddy_wsl.sh new file mode 100644 index 0000000..f92b054 --- /dev/null +++ b/labs/glitch_odin/train_teddy_wsl.sh @@ -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" \ + "$@" diff --git a/tests/odin_lab/cabinet.gd b/tests/odin_lab/cabinet.gd new file mode 100644 index 0000000..8dc54b0 --- /dev/null +++ b/tests/odin_lab/cabinet.gd @@ -0,0 +1,143 @@ +extends Node +# Explicit source-only experiment. Never connects to the production room controller. +var server := TCPServer.new() +var peers: Array[Dictionary] = [] +var game: Node +var session := "" +var phase := "powered_off" +var scans := 0 +var last_credits := 0 +var seen: Dictionary = {} +var rfid_device := "cab1_rfid1" +var inject_rfid := false + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + if not "--glitch-lab" in args: + push_error("This scene requires --glitch-lab") + get_tree().quit(2) + return + inject_rfid = "--lab-inject-rfid" in args + if inject_rfid and HardwareManager.real_gpio: + push_error("Choose physical RFID or lab injection, never both") + get_tree().quit(2) + return + for arg in args: + if arg.begins_with("--lab-rfid-device="): + rfid_device = arg.trim_prefix("--lab-rfid-device=") + session = Crypto.new().generate_random_bytes(16).hex_encode() + RoomRuntime.active_assignment = null + HardwareManager.active_cabinet = null + game = load("res://games/pacman/pacman.gd").new() + # Set before _ready to prevent automatic boot. + game.run_config = _config() + add_child(game) + game.game_started.connect(func(): phase = "playing") + game.game_won.connect(func(): phase = "won") + game.game_lost.connect(func(): phase = "lost") + game.power_state_changed.connect(_power) + reset() + if server.listen(18768, "127.0.0.1") != OK: + push_error("Godot lab port 18768 unavailable") + get_tree().quit(2) + return + print("GLITCH GODOT LAB READY (no room controller or reward outputs)") + +func _config() -> Dictionary: + return {"power_on_device": "__lab_operator_only__", "rfid_credit_device": rfid_device, + "score_to_win": 500, "boards_to_win": 0, "starting_lives": 3} + +func _power(value: String) -> void: + phase = {"off": "powered_off", "booting": "booting", "ready": "ready"}.get(value, value) + +func reset() -> void: + game.configure(_config()) + scans = 0 + last_credits = 0 + if inject_rfid: game.receive_device_feedback({"fresh": true, "events": [], "devices": []}) + +func feed_rfid(feedback: Dictionary) -> void: + # Only RFID input is admitted. No button (including BCM23) can power this lab on. + var filtered := feedback.duplicate(true) + filtered["events"] = feedback.get("events", []).filter(func(e): return e.get("kind") == "rfid") + game.receive_device_feedback(filtered) + _sync_credit() + +func _sync_credit() -> void: + if game.credits > last_credits: scans += 1 + last_credits = game.credits + +func snapshot() -> Dictionary: + _sync_credit() + return {"session": session, "cabinet_id": "cabinet_01", "phase": phase, + "credits": game.credits, "scans": scans, "simulated": false, + "rfid_injection": inject_rfid, "score": game.model.score} + +func reply(ok: bool, duplicate: bool, message: String) -> Dictionary: + return {"accepted": ok, "duplicate": duplicate, "message": message, "state": snapshot()} + +func execute(cmd: Dictionary) -> Dictionary: + var action := str(cmd.get("action", "")) + if action == "snapshot": return reply(true, false, "Real Godot cabinet state") + var id := str(cmd.get("id", "")) + if id.is_empty() or id.length() > 128 or cmd.get("session") != session: + return reply(false, false, "Invalid command ID or stale session") + if seen.has(id): + var previous: Dictionary = seen[id] + if previous.command != cmd: return reply(false, false, "Command ID reused with different contents") + return reply(previous.accepted, true, previous.message) + var now := int(Time.get_unix_time_from_system() * 1000) + var expires := int(cmd.get("expires_ms", 0)) + if expires <= now or expires - now > 30000: return reply(false, false, "Expired command") + var ok := false + match action: + "power_on": + if phase == "powered_off": ok = game.power_on() + "start": + if phase == "ready": ok = game.start_game() + "scan": + if inject_rfid and phase == "ready" and game.credits == 0: + # Synthetic fixture passes through the real admission logic, never sets credits. + game.receive_device_feedback({"fresh": true, "events": [], "devices": []}) + feed_rfid({"fresh": true, + "events": [{"id": id, "kind": "rfid", "device": rfid_device, "value": "LAB-CARD", "age_ms": 0}], + "devices": [{"id": rfid_device, "kind": "rfid", "value": "LAB-CARD", "age_ms": 0}]}) + ok = game.credits == 1 + "reset": + reset() + ok = true + var message := "Executed by Godot" if ok else "Action unavailable; scan a physical card or play to the score target" + seen[id] = {"command": cmd.duplicate(true), "accepted": ok, "message": message} + if seen.size() > 256: seen.erase(seen.keys()[0]) + return reply(ok, false, message) + +func _process(_delta: float) -> void: + if game == null: return + if HardwareManager.real_gpio and HardwareManager.client != null: + feed_rfid(HardwareManager.client.get_feedback()) + while server.is_connection_available(): + var peer := server.take_connection() + if peers.size() >= 8: + peer.disconnect_from_host() + else: + peers.append({"peer": peer, "data": "", "deadline": Time.get_ticks_msec() + 1800}) + for item in peers.duplicate(): + var peer: StreamPeerTCP = item.peer + peer.poll() + var available := peer.get_available_bytes() + if available > 0: + var packet := peer.get_data(mini(available, 8193)) + if packet[0] == OK: item.data += packet[1].get_string_from_utf8() + if item.data.length() > 8192 or Time.get_ticks_msec() > item.deadline or peer.get_status() != StreamPeerTCP.STATUS_CONNECTED: + peer.disconnect_from_host() + peers.erase(item) + elif "\n" in item.data: + var cmd = JSON.parse_string(item.data.get_slice("\n", 0)) + var result := execute(cmd) if cmd is Dictionary else reply(false, false, "Invalid JSON command") + peer.put_data((JSON.stringify(result) + "\n").to_utf8_buffer()) + peer.disconnect_from_host() + peers.erase(item) + +func _exit_tree() -> void: + server.stop() + for item in peers: item.peer.disconnect_from_host() diff --git a/tests/odin_lab/cabinet.gd.uid b/tests/odin_lab/cabinet.gd.uid new file mode 100644 index 0000000..1ea76e4 --- /dev/null +++ b/tests/odin_lab/cabinet.gd.uid @@ -0,0 +1 @@ +uid://bajlwadu5w4or diff --git a/tests/odin_lab/cabinet.tscn b/tests/odin_lab/cabinet.tscn new file mode 100644 index 0000000..eff74ff --- /dev/null +++ b/tests/odin_lab/cabinet.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/odin_lab/cabinet.gd" id="1"] + +[node name="GlitchLabCabinet" type="Node"] +script = ExtResource("1") diff --git a/tests/odin_lab/check_adapter.gd b/tests/odin_lab/check_adapter.gd new file mode 100644 index 0000000..9f45834 --- /dev/null +++ b/tests/odin_lab/check_adapter.gd @@ -0,0 +1,32 @@ +extends SceneTree +var failures := 0 +func check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var adapter = load("res://tests/odin_lab/cabinet.gd").new() + root.add_child(adapter) + adapter.set_process(false) + adapter.game.set_physics_process(false) + var now := int(Time.get_unix_time_from_system() * 1000) + var cmd := {"id": "power", "session": adapter.session, "expires_ms": now + 10000, "action": "power_on"} + check(adapter.execute(cmd).accepted, "Power on executes") + check(adapter.execute(cmd).duplicate, "Duplicate is acknowledged") + adapter.game._physics_process(4.0) + cmd = {"id": "scan", "session": adapter.session, "expires_ms": now + 10000, "action": "scan"} + check(adapter.execute(cmd).accepted and adapter.game.credits == 1, "Fixture goes through real RFID admission") + check(adapter.execute(cmd).duplicate and adapter.scans == 1, "Duplicate scan does not grant another credit") + cmd = {"id": "start", "session": adapter.session, "expires_ms": now + 10000, "action": "start"} + check(adapter.execute(cmd).accepted and adapter.phase == "playing", "Real game starts") + adapter.game.model._add_score(500) + check(adapter.phase == "won" and adapter.snapshot().score == 500, "Normal score objective propagates win") + adapter.reset() + adapter.inject_rfid = false + cmd = {"id": "no-fake", "session": adapter.session, "expires_ms": now + 10000, "action": "scan"} + check(not adapter.execute(cmd).accepted, "Physical mode rejects injected scans") + adapter.free() + await process_frame + print("GODOT LAB ADAPTER: ", "PASS" if failures == 0 else "FAIL") + quit(1 if failures else 0) diff --git a/tests/odin_lab/check_adapter.gd.uid b/tests/odin_lab/check_adapter.gd.uid new file mode 100644 index 0000000..147baa2 --- /dev/null +++ b/tests/odin_lab/check_adapter.gd.uid @@ -0,0 +1 @@ +uid://c452rq48fiyh8