Added chatterbox connections

This commit is contained in:
Jeff Luke committed 2026-10-18 10:54:38 -07:00
1 parent 41ef12f954
commit e58ceeb192
58 files changed
+8931 -8

No files matched your search

View File
Whitespace-only changes.
+15
View File
@@ -0,0 +1,15 @@
target/
.dart_tool/
build/
.flutter-plugins-dependencies
*.log
__pycache__/
evidence/
qa.stop
.speech/
.lab-data/
.training/
+1756
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "glitch-odin-lab"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
anyhow = "1"
async-nats = "0.47"
axum = "0.8"
futures-util = "0.3"
rmp-serde = "1.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors"] }
sha2 = "0.10"
+54
View File
@@ -0,0 +1,54 @@
# Odin messages: shared candidates and Glitch gaps
Reviewed sibling Odin commit `eecb42c2364d915bc79ea99b16a7dad8f71baf1b`. No Odin files were changed.
## Sources
Paths below are relative to the Odin repository:
- `msgs/common.geno`: shared audio enums, currently populated with Odin content.
- `msgs/prop.geno`: hardware-agent command/state schemas.
- `msgs/game.geno`: GM/controller command/state schemas.
- `odin_prop_msg/src/msgs.rs` and `odin_game_msg/src/msgs.rs`: generated Rust types.
- `odin_ui/lib/models/msgs.dart`: generated Dart models.
- `odin_prop_msg/src/lib.rs`, `odin_game_msg/src/lib.rs`: subject constants.
- `odin_msg_util/src/lib.rs`: serialization and shared full-state helpers.
- `Justfile`, task `gen-msg`: invokes external `geno` for Rust serde, Rust state merging, Rust command-to-state, and Dart JSON generation.
Generator source was not found in this repository; the executable and version need to come from the owner.
## Candidates to share without room-specific fields
| Types | Existing content | Fit |
| --- | --- | --- |
| PropSystemState / PropSystemCmd | Optional name, nextPing, version; optional fullState request | Agent identity, liveness and state refresh |
| PropInfo / SystemState / SystemCmd | alive/version per named prop; fullState | GM device-health overview |
| StopwatchAction / StopwatchState / StopwatchCmd | none/start/stop/reset/stopAndReset; running/elapsedSecs; fullState | Room timer |
| GameCondition | off, ready, welcome, playing, debriefing, resetting | Overall room lifecycle, separate from cabinet lifecycle |
| Av2PropState / Av2PropCmd | Optional text and playing; fullState | Generic text/playback-shaped contract, though the existing agent is Odin's AV2 |
These are reusable schema candidates, not already activated services in the Glitch lab. Keep field order, types, optionality and enum numeric values if wire compatibility is required. Confirm nextPing's timing semantics from the system implementation before consuming it.
## Patterns to extract, not copy verbatim
- ClueState/ClueCmd combine text and playing with the Odin-specific memirEye output. Split the shared clue transport from room-specific effects.
- AudioState/AudioCmd use BackgroundMusic and SoundEffect enums containing feast/throne music and Odin-named effects. Share the mechanism with an agreed asset identifier strategy.
- AutoPilotState/AutoPilotCmd combine GameCondition with named Odin puzzles. Preserve the lifecycle enum but define Glitch's own puzzle progress.
- Prop commands commonly use optional fields for partial updates and fullState to request a snapshot.
- Props commonly report solved, but their enclosing messages are strongly tied to the named puzzle hardware.
## Transport compatibility
The checked-in Rust utilities use `rmp_serde::to_vec` for prop messages (struct fields serialize as ordered arrays), and JSON for the GM/controller messages. Subject names include `prop.system.cmd` and `prop.system.state`; other subjects are tied to Odin props.
The Glitch lab deliberately retains its separate `escape_gallery.glitch.lab.v1...` subject and named-field MessagePack contract. It is not wire-compatible with these generated Odin messages. No shared subject is subscribed or published by this lab, so it cannot command Odin devices.
For a multi-room hub, agree room scoping and device identities before sharing a broker. Sharing message types does not require sharing unscoped subjects.
## New Glitch definitions needed
Cabinet assignment/game identity; power/boot/ready/playing/won/lost; credits and score/objective progress; RFID admission and reader health; command correlation, acknowledgement, expiry and replay semantics; cabinet/session identity.
The existing lab Command/Reply/CabinetState types are an experimental boundary covering only part of this list. The Godot adapter implements that boundary behind a Rust sidecar, so the generated schema can replace it without rewriting Pac-Man.
Next agreement with the owner: extract the generic system/timer/lifecycle types into a shared package, obtain the generator, add Glitch cabinet definitions, and choose the supported browser transport. Do not reinterpret an Odin puzzle's solved/latch fields as arcade commands.
+183
View File
@@ -0,0 +1,183 @@
# Glitch / Odin integration lab
A separate experiment for **Glitch: the Dark Machine**, hosted in the Glitch repository. Odin stays untouched. This is a first vertical slice, not a migrated room or a deployment release.
## Whole-room simulation
The supplied Glitch GDD now has an isolated multi-cabinet room simulation with puzzles, boss, cells, calibration, launch and Teddy captions. Run `py -3 lab.py start-room` and open http://127.0.0.1:18777/?room=1 after building. See [the full walkthrough and test coverage](ROOM_SIMULATION.md). It uses separate ports and never sends GPIO outputs. Chatterbox Teddy speech and a phone-accessible LAN demo are available: run `py -3 lab.py start-room --speech --lan` on the configured GPU host. See [voice prerequisites and the optional Piper fallback](ROOM_SIMULATION.md#hear-teddy-with-chatterbox). Enable audio in the Teddy tab on each listening device; see the walkthrough for setup and timing.
## Status
The Rust controller, simulated cabinet, and real Godot sidecar build successfully. Rust tests, Flutter analysis/widget test, Python launcher tests, a headless HTTP/NATS/Godot lifecycle test, and the real Pac-Man score-completion adapter test have passed locally.
Physical RFID/Pi testing is still pending. The existing simulator remains the default. The new explicit Godot mode is a source-only lab, excluded from production exports. The Godot score test reports existing Pac-Man resource-retention warnings at shutdown; its assertions pass.
## Relationship to Odin
Reference: sibling Odin repository, commit `eecb42c2364d915bc79ea99b16a7dad8f71baf1b`.
The reference uses Rust/async-nats for control, MessagePack for prop messages, and JSON for game/Flutter messages. This lab follows that separation. It is newly written, with no dependency on or modifications to Odin, no copied production credentials, and no Odin puzzle rules.
The Flutter screen is a small Glitch-specific starting point, **not a fork of the full Odin UI yet**. We should establish the transport and message contract before migrating its room layout, services, routing, and puzzle controls. Escape Gallery is the app identity; Glitch is the current room.
```text
Flutter web GM (127.0.0.1:18767)
| HTTP JSON / polling
Rust controller/gateway (127.0.0.1:18766)
| NATS request/reply, named-field MessagePack
Local NATS (127.0.0.1:14222)
|
Rust simulated cabinet_01
```
The HTTP browser gateway is experimental. It avoids assuming the existing Odin Dart NATS client works in browsers. The owner may prefer a supported NATS WebSocket client or a different gateway.
## Prerequisites
Install these on the development computer, then open a new terminal:
1. [Rust stable](https://www.rust-lang.org/tools/install), including the native compiler/linker required by its installer. This project uses edition 2024.
2. [Flutter stable](https://docs.flutter.dev/install), with Dart 3.9.2 or newer within Dart 3.x. Run `flutter doctor`.
3. [NATS Server](https://docs.nats.io/running-a-nats-service/introduction/installation), with `nats-server` on PATH.
4. Python 3.10 or newer.
No Docker, Starfish, Pi login, room tokens, or existing production server are required for desktop tests. Dependency fetching needs internet access on the build computer. Commit Cargo.lock and gm/pubspec.lock after the first successful dependency resolution to pin the tested versions.
## Build and start on Windows
From the Glitch repository root:
```powershell
cd labs/glitch_odin
py -3 lab.py preflight
py -3 lab.py build
py -3 lab.py start
```
Run each command separately and stop if it reports an error. Build runs Rust tests, compiles the two-role Rust executable, resolves Flutter dependencies, runs analysis and widget tests, and builds the browser app. It does not export or change Godot.
Keep the startup terminal open. When it prints READY, visit **http://127.0.0.1:18767** on this same computer. Ctrl+C stops only the child processes created by this launcher. An occupied port causes startup to fail instead of terminating another service.
On Linux/macOS, use `python3` in place of `py -3`.
## First acceptance test
1. Confirm the amber simulated-cabinet banner and **POWERED OFF / Waiting for operator**.
2. Click **Power on**. The phase becomes booting for four seconds.
3. Wait for ready. Credits must still be zero.
4. Click **Simulate RFID scan**. Credits become one.
5. Click **Simulate player Start**. Credits return to zero and phase becomes playing.
6. Click **Simulate win**. Phase becomes won.
7. Click **Reset test** to return to powered off.
In another terminal, run the automated test while the lab is running:
```powershell
cd labs/glitch_odin
py -3 lab.py smoke
```
This test resets the simulated cabinet, exercises the actual HTTP/NATS/MessagePack path, verifies boot gating, credit gating, duplicate scan handling, stale-session rejection, and expiry, then leaves the cabinet powered off. Do not interact with the GM controls during this test.
To check unavailable-state behavior, run the three backend components manually in separate terminals instead of using the supervisor:
```powershell
nats-server -c nats.conf
.\target\debug\glitch-odin-lab.exe cabinet
.\target\debug\glitch-odin-lab.exe controller
py -3 -m http.server 18767 --bind 127.0.0.1 --directory gm/build/web
```
Stop the cabinet process. The UI should show disconnected and disable controls after the next poll/timeout. Restart it: a new session begins powered off. No previous command should be replayed automatically.
## Contract and limits
- Subject: `escape_gallery.glitch.lab.v1.cabinet_01.request`.
- Commands: snapshot, power_on, scan, start, win, reset.
- Mutations require an ID, matching cabinet-process session, and expiry within 30 seconds.
- Replies contain acceptance, duplicate status, a message, and the current cabinet snapshot.
- MessagePack uses named fields. **This is not wire-compatible with Odin's generated tuple messages.**
- The simulator remembers the last 256 commands in memory. It is not a persistent command journal.
- Reset clears simulated game state but keeps the process session and replay cache. Restart creates a new session.
- Ready state advances when queried after the boot deadline; the simulator has no rendering loop.
- HTTP 200 can contain a rejected command: clients must inspect `accepted`.
- No responder/timeouts produce HTTP 503. An unconfirmed delivery is never treated as success or automatically retried.
- There is no room autopilot, next-cabinet sequencing, authentication, durable recovery, or generated Odin schema yet. The opt-in Godot sidecar is experimental.
- All listeners are loopback only. The unauthenticated test gateway is deliberately unsuitable for LAN deployment.
- BCM23 remains an LED. I2C button integration is still pending hardware identification.
## What we need next
1. Owner confirmation of the current Odin branch and permission/strategy for sharing its framework crates.
2. Generator source, canonical schema, subjects, command acknowledgements, and state/session semantics.
3. Choose the browser transport and verify the target Flutter build on laptops and iPads.
4. Replace the experimental sidecar boundary with the agreed generated contract and, if desired, a Rust GDExtension. Only one controller may own a cabinet.
5. Run the same acceptance flow on a disconnected test cabinet, then connect the known RFID driver. Keep the I2C board disabled until identified.
6. Add room autopilot and multi-cabinet sequencing, then migrate the useful Odin GM room views.
7. Integrate Starfish deployment/SSH management after runtime communication works.
The current Python controller, Godot exports, Pi services, and deployment guide remain the working deployment path until these gates pass.
## Development checks
```powershell
py -3 -m unittest discover -s . -p test_runner.py -v
cargo test
cd gm
flutter analyze
flutter test
```
Build products are ignored locally. The lab has a Godot import ignore marker and is excluded from game exports.
## Real Godot cabinet test (desktop)
Stop the running simulator with Ctrl+C first. From this lab folder:
```powershell
py -3 lab.py build
py -3 lab.py start-godot
```
The launcher starts the local NATS broker, JSON controller, Rust Godot sidecar, a separate real Pac-Man scene, and the Flutter web server. Open http://127.0.0.1:18767. It should explicitly say REAL GODOT CABINET.
1. Power on from the GM screen.
2. Watch the actual four-second boot and character intro in Godot.
3. Click Simulate RFID scan. This desktop fixture is explicitly enabled by the launcher; it enters the game's normal RFID admission path.
4. Press your mapped player Start in Godot, or Start game in the GM screen.
5. Play to 500 points. The GM phase should become won. Simulate win is disabled for real Godot.
6. Reset test returns the cabinet to powered off.
Set GLITCH_LAB_GODOT to the absolute executable path if the repository's Windows Godot executable is unavailable. The lab scene is tests/odin_lab/cabinet.tscn; it refuses to run without --glitch-lab. It does not load the managed production cabinet connection or invoke room reward mappings.
The sidecar talks only to 127.0.0.1:18768 using one newline-delimited JSON command/reply per TCP connection. The current Rust layer owns NATS and MessagePack. This is **not a GDExtension yet**.
### Automated checks
Stop the interactive lab before running these:
```powershell
py -3 godot_smoke.py
```
This starts and stops its own headless Godot, broker, sidecar and controller. It tests the real transport, injected scan admission, Start, reset, offline 503, and a new process session after restart. No physical devices are used. The original lab.py smoke intentionally refuses real Godot.
From the repository root, the score-completion test exercises Pac-Man's actual score objective:
```powershell
.\Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://tests/odin_lab/check_adapter.gd -- --glitch-lab --lab-inject-rfid
```
### Physical RFID: next bench test, not a deployment change
The lab can read the existing local GPIO bridge when Godot is started with --gpio=real instead of --lab-inject-rfid. These options cannot be combined. Leave BCM23 as an LED; I2C buttons remain unimplemented. No output command is exposed by the lab.
For that bench test, manually run NATS, the controller, the sidecar (binary argument godot, not cabinet), and Godot on the **same test host**. Start the known RFID-only bridge configuration separately. Godot arguments are:
```text
--path <project-root> res://tests/odin_lab/cabinet.tscn -- --glitch-lab --gpio=real --lab-rfid-device=cab1_rfid1
```
Use the device ID actually configured on that reader. Without --lab-inject-rfid, the GM scan button is disabled: a fresh physical card scan is required after boot. This initial test accepts any nonempty card UID, matching the existing test policy. A Pi needs its own Rust build, source project/assets and a compatible Godot executable; the Windows binary is not portable to it. This path has not been physically verified yet. Do not run another cabinet/Godot owner or another reader driver on the same hardware.
See [Odin message audit](ODIN_MESSAGES.md) for reusable system/timer/lifecycle definitions and the new Glitch-specific messages still needed.
+259
View File
@@ -0,0 +1,259 @@
# Glitch whole-room simulation
This lab turns the supplied **Glitch: the Dark Machine** design into an interactive, deterministic room prototype. Rust owns room state; the Flutter screen sends revision-checked simulation intents through the local JSON gateway and NATS. No real cabinet, RFID reader, GPIO bridge, Teddy motor, or release output is contacted in this mode.
The supplied files are retained as `design/glitch_implementation_contract.json` and `design/GDD_reference.txt`. They are design references, not existing Odin wire definitions. The original files and Odin repository are unchanged.
Current design additions are recorded in [Design updates](design/DESIGN_UPDATES.md), including the junction-box power opening, delayed alien reveal, and proposed cross-cabinet communication mechanic. Power restoration is now implemented; cross-cabinet control remains proposed.
## Start
From the repository root:
```powershell
cd labs/glitch_odin
py -3 lab.py build
py -3 lab.py start-room
```
Open **http://127.0.0.1:18777/?room=1**.
The original single-cabinet lab can continue using 18767. Whole-room mode has a separate broker on **14232**, room API on **18776**, and browser UI on **18777**; all bind to loopback by default. Ctrl+C stops the processes this launcher owns.
The default is local-only. Optional --lan mode exposes the simulation UI/gateway for a shared phone/tablet demonstration; see below. This is not a production room operator service.
## What is simulated
- Seven cabinets: six named token-gated arcade assignments plus the Dark Machine boss.
- P01 claw repair, P02A/P02B claw rewards, P03 records, P04 service-panel symbols, P05 tickets and P06 poster discoveries.
- Correct token identity and freshness, persistent retry authorization, attempt IDs and score/home targets.
- Virtual cell charge, bay-open confirmation, identity/location observations, debounce and stable ship power.
- Two tutorial practices, party-size-dependent calibration waves and profile-specific pass/hold/deadline requirements.
- Sequential station arming, operator-group acknowledgements, navigator commit and a five-second launch countdown.
- Teddy's authored transcripts, queued cues, simulated playback duration, hint requests, mute, failure/fallback and GM takeover.
- Pause, technical holds, time extension, assisted rescue and confirmed reset.
- Command deduplication, stale-run/revision/expiry checks and an exportable/replayable trace.
The server advances the clock continuously. The countdown starts after briefing; pause and technical holds freeze gameplay. Diagnostics provides manual fast-forward controls. Multiple browser tabs do not accelerate time. Clock events are included in the replay trace.
The room overview is a conceptual dependency layout, not a measured CAD plan.
## Walk through a full run
### 1. Prepare and brief
Select the profile and party size, then Apply profile. Guided uses one calibration pass; standard two; challenge three. Party size sets the number of concurrent operator stations independently of difficulty. One player is explicitly assisted.
Enable audio in Teddy, then return to Room Flow and click Start run. Hear the haunted-arcade introduction with cabinets OFF. After narration, click Briefing complete: the countdown begins and the room waits for the junction-box key and switch. Click Flip power switch up to simulate unlocking and raising the physical switch. All six token-gated stations boot for four seconds, then show attract mode awaiting their matching token. Scan a token and press Start in Cabinets. The boss retains its six-win gate. These are simulated startup states, not rendered Godot ROM-check animations or physical outputs.
### 2. Discover tokens
In Room flow:
1. Simulate solving P01.
2. Solve P02A, then Observe delivery for T01.
3. Solve P02B and confirm T02 delivery.
4. Solve P03–P06 and confirm their corresponding token deliveries.
Solving and delivery are separate. ACK only / jam test acknowledges the output request without confirming a token arrived. P02B remains blocked until T01 is delivered. GM hand-delivery requires a staff name/reason and marks the run assisted.
The first demo deliberately accepts a correct fresh token even if delivery was not observed, as the GDD requests to avoid a sensor-created soft lock. The trace shows the scan; a dedicated delivery-discrepancy warning remains future work.
### 3. Play six cabinets
Open Cabinets. Each card shows its game, required token, objective, attempt, losses, charge, health and calibration progress.
For each cabinet: scan its correct logical token, Start attempt, then Simulate target reached. These are fake observations, not six Godot windows. Wrong token tests are available.
Charge starts on the first Start and needs 30 seconds of active virtual time. Advance +30 seconds while the run is active. A loss requires a three-second retry delay; authorization and charge remain. The game cannot count a second first-state win.
Only all six distinct wins make the boss available.
### 4. Defeat the boss and open the passage
In Room flow, Start boss and Simulate boss victory. A passage-release request appears in Diagnostics.
A simulated ACK does not open the passage. Click Observe passage open, then advance at least 300 ms (the +1 second button is sufficient). If five active seconds pass without an open observation, the room enters RECOVERY_HOLD. Observe opening, advance for debounce and Resume; the game clock does not run during the hold.
Boss gameplay/stages are a deliberate placeholder in this first whole-room test.
### 5. Move the six cells
In Ship & cells, for each cell:
1. Wait for 100 percent charge.
2. Observe bay open.
3. Remove from bay.
4. Insert matching socket.
Advance +3 seconds after insertion. This allows 500 ms observation debounce plus two seconds of stable ship power.
Inserting a cell without removing it from its bay intentionally creates a duplicate-location fault. Fix the observations; the ship cannot power up just because six sockets appear occupied.
### 6. Practice and calibrate
Open Calibration & launch. The ship displays targets; choose the operator's corresponding selection and click Simulate held Ready for every active station, then Navigator Execute.
Complete two untimed tutorial practices first. Scored waves then use the selected profile's deadlines and pass count. The fixture Ready action supplies the configured stable-hold duration; actual physical selection stability is not being measured.
A wrong/incomplete Execute fails the active wave, preserves earlier passes, issues a fresh challenge and adds a three-second retry delay. A deadline at exact equality is closed. Advance time to test expiry.
With two players, one roaming operator handles stations sequentially. With six players, five stations can be active in a wave. With eight, six are active and extra helpers are not required by the guard.
### 7. Launch
Once all passes are complete:
1. Arm each of the six stations, in any order.
2. Acknowledge each operator group.
3. Click Navigator Commit.
4. Advance five seconds.
The controller rechecks ship power/health before awarding exactly one launch. The normal outcome is WON. An explicit Finish assisted action produces ASSISTED_RESCUE instead and does not manufacture missing wins.
Removing a cell during calibration or countdown creates a hold, cancels current readiness/countdown and retains completed passes. Restore the cell, advance for stability, then Resume. A canceled countdown never restarts by itself.
### 8. Review and reset
Diagnostics shows output intent versus acknowledged/observed status. Refresh trace, then Copy trace JSON to retain a run. Reset simulation requires confirmation and produces a new run identity.
This reset only resets fake room state. It does not perform or certify a physical reset checklist.
## Teddy
Teddy acts like Odin's narrator/clue device but is not an authority for room progress. The simulated cues include welcome, station naming, readback, holding, readiness, boss, reveal, training, launch and ending. Requested/automatic hints are generic draft text, not final physical-puzzle answers.
Only one simulated cue plays at once. Queued cues expire or are discarded when their phase changes. Noncritical playback has an eight-second gap. The prototype uses a fixed six-second playback duration rather than real audio acknowledgements.
Use the Teddy tab to preview the transcript, request hints, stop, replay, mute, simulate failure, or take over/release narration. Offline/muted Teddy retains captions; gameplay can still finish.
Future work: per-puzzle inactivity/hint ladders, exact calibration-target hints, operator live voice, configurable cue lengths/priorities, playback acknowledgement/timeouts, authored audio and bounded motor control. Optional Teddy speech is available below; doll drivers remain unimplemented.
## Automated checks and evidence
Stop whole-room mode before running its network test. The single-cabinet lab uses separate ports and can remain running.
```powershell
py -3 room_smoke.py
.\target\debug\glitch-odin-lab.exe replay-room evidence\room-golden-trace.json
cargo test
cd gm
flutter analyze
flutter test
```
The smoke test owns its temporary broker/controller and stops them afterward. It executes the full standard six-player run through HTTP and NATS, verifies six cells and two passes per station, commits one launch, exports the trace, and replays it offline.
Local generated evidence (git-ignored):
- evidence/room-golden-trace.json — requests, receive timestamps, results and a state hash after each input.
- evidence/room-result.json — transport, final outcome, pass counts and final SHA-256.
- evidence/room-runtime.log — test runtime output.
Replay requires the exact imported contract hash. It reconstructs state and verifies every recorded hash without connecting to NATS or physical devices. It is deterministic regression evidence, **not durable crash recovery**.
## Acceptance coverage and deliberate gaps
| GDD cases | Current evidence |
| --- | --- |
| SIM01–04 | Full pure-reducer runs for standard six-player, guided two-player, assisted solo and challenge eight-player; exact trace replay. Standard path also passes over real NATS. |
| SIM05–07 | Wrong/repeated/aged token rejection, retries without another token, and six-distinct-win guard. Production source/boot/assignment validation is not implemented by this fixture API. |
| SIM08–09 | ACK versus delivery/open observation, recorded hand-delivery and passage hold. |
| SIM10–13 | Cell conflicts, calibration/countdown cancellation, retained passes, challenge expiry and readiness-window retry. Real sensors, agent handshakes and clock synchronization remain untested. |
| SIM14–16 | **Deferred:** durable transactional outbox/restart, device dedupe across restarts, message drop/reorder/snapshot reconciliation. In-memory command dedupe is not a substitute. |
| SIM17 | Offline Teddy does not gate the full run; stale phase cues are discarded. Real playback-status failure detection and puzzle-specific stale hints are deferred. |
| SIM18–20 | Paused active clock, wall-time expiry, calibration time expiry/extension, and rejection of old-run win/token/launch commands. |
| UI | All panels checked at 390, 900 and 1400 px; disconnected controls disabled; mutations send authoritative run/revision. |
These are implementations of selected semantic assertions, not certification that the complete production contract is satisfied.
## Architecture boundary
The whole-room simulator has one serialized Rust reducer. It uses a separate experimental NATS subject, `escape_gallery.glitch.lab.v1.room.simulation.request`, and named-field MessagePack. Its fixture command API is **not** the supplied normalized event schema, generated Odin contract, or a production agent ingress.
The controller currently runs fake observations in one process; it does not launch seven Godot agents. The separately tested real Godot/Pac-Man mode remains available with start-godot. Bridging room assignments into those real agents is a subsequent step.
The following must be added before live room use: generated shared contracts; authenticated/authorized sources and room-scoped subjects; agent boot/sequence/assignment fencing and readiness handshakes; durable journal/outbox and device ledgers; production recovery tests SIM14–16; real cell/passage feedback; physical preflight/reset; and hardware-specific Teddy/I2C integration.
The existing Pi deployment, GPIO configuration, single-cabinet lab and Odin repository remain separate.
## Hear Teddy with Chatterbox
Chatterbox Turbo is now the default lab voice, using the actor's approved reference recording. On this development PC, the model runs in Ubuntu/WSL on the RTX 5070 and stays loaded between hints. WAV audio is cached and delivered through the existing browser audio controls.
From `labs/glitch_odin`:
```powershell
py -3 lab.py start-room --speech --lan
```
Wait for READY, then open Teddy and tap **Enable audio and test speaker** on each listening phone/tablet/PC. Use **Say now** on a saved hint, or start the room briefing. Audio requires the page to remain active; mobile browsers can suspend background or locked-screen playback.
The configured installation is `~/teddy-chatterbox` inside WSL Ubuntu. Runtime reference: `.speech/voices/teddy-chatterbox-reference.wav`. These local dependencies and voice assets are not included in Git. See [Teddy voice setup](TEDDY_TRAINING.md#chatterbox-live-lab-backend) before moving to a fresh machine. This GPU backend has not been deployed to a Raspberry Pi; a Pi can receive prepared audio from a GPU host in a future deployment integration.
The follower stops publishing audio on mute, stop, takeover, offline, reset, replacement, or connection loss. In-flight GPU computation may finish to fill the cache, but cancelled cues cannot begin playback. An unavailable voice engine leaves captions and room controls available; inspect `.speech/chatterbox.log` and restart the lab to retry. No automatic change to a different voice occurs.
For the PC's speakers instead of browsers:
```powershell
py -3 lab.py start-room --speech --speech-output local
```
Piper is retained as an explicitly selected fallback:
```powershell
py -3 lab.py setup-speech
py -3 lab.py start-room --speech --speech-backend piper --lan
```
`setup-speech` installs the original stock Piper voice only. Chatterbox does not use Piper ONNX models. Audio completion still does not advance puzzle state, and different browsers are not sample-synchronized.
## Demonstrate from a phone on the same LAN
```powershell
py -3 lab.py start-room --speech --lan
```
On the phone, join the same normal Wi-Fi network as the PC and open:
`http://<PC-Wi-Fi-IPv4>:18777/?room=1`
For the development PC's current primary Wi-Fi address, that is **http://10.0.0.57:18777/?room=1**. Check `ipconfig` if the address changes. Use the Wi-Fi address, not a VPN adapter address. The PC also currently has another Wi-Fi adapter at 10.0.0.227.
The page and its room/audio requests use the same host/port, so a phone never tries to contact its own localhost. Only the UI/gateway binds to the LAN; NATS, the room API, and speech remain on loopback. This is a shared, unauthenticated **simulation demo**: anyone who can reach that port can change the simulated run. It does not connect to real cabinets or GPIO. Do not port-forward it onto the Internet.
If Windows Firewall blocks the page, allow the Python launcher on the trusted Private network. Alternatively, in administrator PowerShell create a narrowly scoped demo rule:
```powershell
New-NetFirewallRule -DisplayName "Glitch lab demo 18777" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 18777 -RemoteAddress LocalSubnet -Profile Private
```
Remove that optional rule after demonstrations with `Remove-NetFirewallRule -DisplayName "Glitch lab demo 18777"`. A guest Wi-Fi network, client isolation, or VPN LAN blocking may prevent a phone from reaching the PC.
Use Ctrl+C in the launching terminal to stop the demo. Start without `--lan` to return to localhost-only access.
## Speech checks
`py -3 -m unittest test_runner test_teddy_speech` checks startup handling and stale-cue cancellation, duplicate polls, mute/takeover/reset, synthesis failures and single playback. `py -3 speech_smoke.py` uses temporary loopback ports and real Piper to test non-silent WAV generation and local playback with room-state fixtures; it briefly plays a test line. It does not change any running room session.
## Saved hints and manual speech (October 18 update)
The Teddy tab now supports:
- **New saved hint / Edit**: title, spoken text, multiple comma-separated categories, phase, puzzle/cabinet, target completion condition, and strength (1 subtle, 2 direct, 3 solution).
- **Relevant now**: filters by the live room phase and target state. All eligible parallel branches are included. P02A requires P01 solved; P02B requires the first claw token delivery. Solved puzzles and won cabinets leave the incomplete-target suggestions. General room hints remain available.
- **All hints**: turn off Relevant now; category and text search still apply. This is the escape hatch for non-linear play or situations the simulation does not model.
- **Say now**: sends the selected text through the room controller to the speech backend immediately. It replaces the current cue and bypasses the virtual narration gap. It does not complete puzzles or advance the clock.
- **Write a one-off hint**: say custom text without saving, or save it into the library.
- **GM takeover**: pauses automatic narration only. Manual hints and Replay work during takeover. Muted/offline Teddy still suppresses speech.
The library is stored on the hosting computer at `labs/glitch_odin/.lab-data/hints.json`, with the previous save in `hints.bak`. Back up these files when moving to another PC. They are deliberately excluded from Git and persist independently of simulation resets. Reload library to retrieve edits from another device. Conflicting saves preserve your open draft and ask you to review it before saving again. Initial hints are editable draft suggestions, not final puzzle solutions.
### Audio troubleshooting
Refresh the updated page, open Teddy, and click **Enable audio and test speaker**. You should hear a one-second tone. The tone is generated in the browser and does not depend on the speech backend. If silent, check that device's media volume, output/Bluetooth routing and browser/tab mute.
Then click **Say now** on a hint. Speech should progress from generating to ready, and browser status should show playback. If automatic playback is blocked, tap **Play current hint here**; that directly starts the prepared WAV in a user gesture. The button is disabled until a valid current cue has finished generating. Stop/mute/takeover changes and phase/time jumps can cancel cues.
A server status of **ready** means the WAV is available, not that anybody heard it. Browser status tracks its own playback. The lab no longer reports a fake browser completion based on server elapsed time.
The previous silence reproduced with GM takeover enabled: manual hints and replay were queued behind the same gate as automatic narration. They now explicitly bypass that gate. Rebuilds also bypass stale bootstrap/JavaScript caches so refreshed browsers get the current controls.
+144
View File
@@ -0,0 +1,144 @@
# Teddy voice experiment
This experiment is separate from the live `.speech` Piper installation. The first GPU fine-tuning experiment has now been launched in Ubuntu. The Piper experiment is retained for comparison; the live lab now defaults to Chatterbox Turbo (see below).
## Source recording
The original files remain at `D:/Projects/escape-gallery/TeddyFull.wav` and `teddy-transcript.txt`. Working copies and checksums are in `.training/teddy-v1/`. The source is 387.898 seconds, mono, 44.1 kHz, 16-bit PCM, with 1,107 whitespace-separated transcript words. `.training/` is ignored by Git; back it up separately if needed.
## Prepare the dataset
From this lab directory in PowerShell:
```powershell
.training/venv/Scripts/python.exe -X utf8 prepare_teddy_dataset.py
```
The preparation environment contains faster-whisper 1.2.1 and scipy. It downloads the small.en speech recognition model and processes the audio locally on CPU. It does not upload recordings. It caches word timestamps, compares normalized recognized words with the supplied transcript, and exports only fully matched sentences lasting 0.7 to 15 seconds as mono 22.05 kHz PCM.
Outputs in `.training/teddy-v1/`:
- `asr-words.json`: recognition timestamps.
- `audio/`: candidate sentence clips.
- `metadata.draft.csv`: candidate filename/text pairs for Piper.
- `alignment-report.json`: accepted boundaries, excluded sentences, and source clipping statistics.
These are draft ASR-derived boundaries, not verified forced alignment. Listen to the clips, especially beginnings and endings, and reconcile exclusions against the actual recording. Recognition mismatch does not prove that the actor made a mistake. Do not replace the supplied transcript with recognition guesses without listening. Once reviewed, produce a separate approved metadata file and keep several sentences out of training for evaluation.
## Training environment prerequisite
The attempted WSL installer returned “Windows Subsystem for Linux is not installed” and exited without installing. Training has not started. In an administrator PowerShell window, use Microsoft's installation procedure:
```powershell
wsl --install -d Ubuntu
```
Restart Windows if prompted (save work and stop the demo first), launch Ubuntu, and complete its initial Linux username/password setup. Then check:
```powershell
wsl --list --verbose
wsl -d Ubuntu -- nvidia-smi
```
Do not replace the Windows NVIDIA driver with a Linux display driver inside WSL. Next, build an isolated Linux Python environment following Piper's official training guide. Verify CUDA support for the RTX 5070 with a real tensor operation before training; detecting the GPU name alone is insufficient.
## First experiment plan
1. Review clips and transcript matches; reserve validation sentences.
2. Select and document a compatible medium-quality training checkpoint and its provenance. The stock ONNX inference file is not a training checkpoint.
3. Install and pin Piper training code/dependencies in a separate Linux environment; retain code revision and package versions.
4. Fine-tune with a small GPU batch and a bounded run, retaining checkpoints. Do not assume more epochs improve this small dataset.
5. Export ONNX plus the JSON configuration created during training.
6. Synthesize unseen hints and compare intelligibility, voice consistency, and pronunciation with the source and stock voice.
7. Add explicit custom-model selection to the lab only after validation. Keep the stock model as a fallback.
No training checkpoint, validated CUDA environment, approved dataset, or new model is claimed by this setup document.
## References
- Piper training: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/TRAINING.md
- Windows WSL installation: https://learn.microsoft.com/en-us/windows/wsl/install
- Local timestamp generation: https://github.com/SYSTRAN/faster-whisper
## First preparation result
89 draft clips, 294.96 seconds retained. Ten sentences require reconciliation; normalized word match was 90.79 percent. This measures ASR/transcript agreement, not verified accuracy. No full-scale clipped samples were found. All exported clips were checked as mono, 22.05 kHz, 16-bit PCM. Open `.training/teddy-v1/review.html` to listen alongside the proposed labels. This was the initial preparation status; Ubuntu setup is now complete and an experimental run is active as described below.
## October 18: Ubuntu training experiment launched
The earlier WSL blocker is resolved. Ubuntu is running as `jeff` under WSL 2. The isolated workspace is `/home/jeff/teddy-training`; the live Windows inference environment remains separate.
Verified environment: Python 3.12.15, PyTorch 2.14.1+cu130, NVIDIA RTX 5070 (12 GB). A real CUDA matrix multiplication completed successfully. Piper source revision: `619f513580c04dff5ef9a680f6f3f6979ed94d2e`. The source was cloned from the official OHF-Voice repository. Build extensions compiled successfully. Exact packages are recorded in `~/teddy-training/requirements-lock.txt`.
Starting weights: `rhasspy/piper-checkpoints/en/en_US/ljspeech/medium/lj-med_1000.ckpt`. The accompanying model card identifies the LJ Speech dataset as public domain. Its card, configuration and checksum are saved under `~/teddy-training/checkpoints`. This is the starting voice, not a claim about the final trained voice.
Dataset: 89 ASR-matched draft clips. Eight are explicitly withheld in `data/holdout.csv`; 81 enter Piper's seeded train/validation/test split (71 training, 8 validation, 2 test examples). The ten transcript-mismatch sentences remain excluded. This experiment uses automatically checked clips, not a manually auditioned and approved dataset. Listening review is still required before deployment.
Launch from PowerShell (only when no existing run is active):
```powershell
wsl -d Ubuntu -- bash /mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/train_teddy_wsl.sh
```
The first run writes `~/teddy-training/train.log`. It warm-starts matching generator/discriminator weights with fresh optimizers. The log confirmed 784 parameters copied and zero skipped. Batch size is 4, with at most 60 epochs or 1,000 Lightning optimizer steps. This is an initial bounded experiment, not an asserted optimal duration. Checkpoints are under `~/teddy-training/runs/teddy-v1/lightning_logs/`.
Check progress from PowerShell:
```powershell
wsl -d Ubuntu -- tail -n 25 /home/jeff/teddy-training/train.log
wsl -d Ubuntu -- nvidia-smi
```
Do not restart Windows or shut down WSL during training. Checkpoints can support a later explicit resume; rerunning the launch script starts another fresh experiment. Export and listening evaluation have not yet been completed. Do not switch the live hint voice until those checks pass.
### Resume after optional MOS callback issue
Epoch zero saved successfully, then upstream's optional `val_mos` checkpoint callback raised because MOS scoring was disabled. `train_teddy_entry.py` removes only that callback, retaining mel-loss and last-checkpoint saving. The active run resumed from `version_0/checkpoints/last.ckpt` into `version_1`; all training states were restored. Epochs one and two subsequently saved successfully. Version one's checkpoints are the ongoing experiment. The downloaded upstream source remains unchanged; our wrapper is in the project.
To resume the latest version-one checkpoint after a deliberate stop:
```powershell
wsl -d Ubuntu -- bash /mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/train_teddy_wsl.sh --ckpt_path /home/jeff/teddy-training/runs/teddy-v1/lightning_logs/version_1/checkpoints/last.ckpt
```
Do not launch that command while training is already running.
## Export and first listening comparison
The bounded run completed with `max_steps=1000`. Exported `version_1/checkpoints/last.ckpt` and `epoch=14-val_mel=0.4643.ckpt` to `.training/teddy-v1/exports/`, each with the training-generated `.onnx.json` configuration. `export_teddy.py` selects the legacy dynamic-axis exporter required by Piper's opset-15 export code on this PyTorch version. The upstream source remains unchanged.
Both exports loaded and synthesized successfully in the lab's Windows Piper inference environment. Comparison audio is in `.training/teddy-v1/samples/`: `teddy-final.wav`, `teddy-best-mel.wav`, and `stock-reference.wav`. All read the same unseen passage from `comparison-transcript.txt`. File/model hashes and durations are in `report.json`.
A local small.en ASR sanity check recovered most words, with word-sequence similarity approximately 0.919 for the final model, 0.968 for best-mel, and 1.0 for stock. These are rough recognition comparisons, not subjective voice-quality scores or formal WER measurements. Number formatting and recognition mistakes affect them. Listen before deciding which checkpoint to develop further. Neither trained voice has been selected in the live hint system.
## Chatterbox Turbo comparison
An isolated zero-shot comparison now lives in `/home/jeff/teddy-chatterbox` (separate venv, source, model cache and logs). The comparison did not change Piper; Chatterbox was subsequently selected for the live lab. Official Chatterbox source revision: `5de7a54aa4e5e2baadb0182dde554908b48b85c2`. GPU dependency overrides were necessary because upstream pins PyTorch 2.6 for Python 3.12; resolved versions are recorded in `requirements-lock.txt` (torch 2.14.1, torchaudio 2.11.0). The successful inference test validates this specific path, not every Torchaudio feature.
`compare_teddy_chatterbox.py` uses the first 13.12 seconds of the actor recording as a reference; no new training is performed and audio stays local. The model is downloaded from `ResembleAI/chatterbox-turbo`. Output is `.training/teddy-v1/samples/teddy-chatterbox-turbo.wav`, with three individual hint WAVs and `chatterbox-report.json`. The model's default audio watermark remains enabled.
Measured loaded-model generation times on RTX 5070: 2.20 seconds for 7.48 seconds of speech; 1.16 seconds for 6.20 seconds; 0.95 seconds for 5.20 seconds. These are whole-utterance synthesis times, not streaming time-to-first-audio or end-to-end browser latency. Model download/load and reference preparation are separate fields in the report. Human listening comparison remains necessary.
To reproduce with downloaded weights:
```powershell
wsl -d Ubuntu -- bash -lc 'export HF_HOME=~/teddy-chatterbox/models; export HF_HUB_DISABLE_XET=1; ~/teddy-chatterbox/venv/bin/python /mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/compare_teddy_chatterbox.py'
```
## Chatterbox live lab backend
The lab now defaults to Chatterbox Turbo when launched with `--speech`. The existing Ubuntu installation at `~/teddy-chatterbox` supplies its Python environment, official source and model cache. `chatterbox_client.py` starts one resident worker, and `chatterbox_worker.py` loads the GPU model and actor reference once. The Windows follower continues to provide the existing audio/health routes, cancellation and browser playback. Odin and the production cabinet bridge are unchanged.
Required local assets for another development host:
- Ubuntu/WSL and an NVIDIA GPU with a working CUDA-enabled PyTorch installation.
- The Chatterbox source revision and dependency overrides recorded in the comparison section above. Preserve `~/teddy-chatterbox/requirements-lock.txt` when reproducing this tested environment.
- Cached model files under `~/teddy-chatterbox/models`, or network access for their initial download.
- The approved 13.12-second actor reference copied to `.speech/voices/teddy-chatterbox-reference.wav`. The original comparison copy is `.training/teddy-v1/samples/chatterbox-reference.wav`.
`TEDDY_CHATTERBOX_HOME` can override the Linux installation directory. Windows uses the Ubuntu WSL distribution; native Linux uses the same worker directly through bash. This is not a CPU/Pi inference installer.
Launch: `py -3 lab.py start-room --speech --lan`. Startup waits for model readiness. Logs: `.speech/chatterbox.log`. The reference hash and backend version are included in cache keys, so old Piper WAVs are not reused. Hints are split into sentences (and bounded word groups for long sentences) to avoid long-utterance truncation. No actor recording is uploaded; synthesis and caching stay local.
Fallback: `py -3 lab.py start-room --speech --speech-backend piper --lan`. The fallback uses the original stock voice and requires `setup-speech` first. Chatterbox errors do not silently substitute Piper.
+100
View File
@@ -0,0 +1,100 @@
"""Nonblocking, cancellable requests to a resident Chatterbox worker in WSL/Linux."""
import hashlib
import json
import os
from pathlib import Path
import queue
import shlex
import subprocess
import threading
ROOT = Path(__file__).resolve().parent
REFERENCE = ROOT / ".speech/voices/teddy-chatterbox-reference.wav"
def linux_path(path):
path = Path(path).resolve()
if os.name != "nt":
return str(path)
return "/mnt/" + path.drive[0].lower() + path.as_posix()[2:]
def voice_id():
return "teddy-chatterbox-turbo-v1-" + hashlib.sha256(REFERENCE.read_bytes()).hexdigest()[:16]
class Job:
def __init__(self, text, output):
self.text, self.output = text, output
self.cancelled = threading.Event()
self.returncode = None
def poll(self):
return self.returncode
def terminate(self):
# In-flight GPU work may finish and populate cache, but must never play.
self.cancelled.set()
def wait(self, timeout=None):
return self.returncode
class Engine:
def __init__(self):
self.jobs = queue.Queue()
self.ready = threading.Event()
self.error = None
self.process = None
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def submit(self, text, output):
job = Job(text, output)
self.jobs.put(job)
return job
def close(self):
self.jobs.put(None)
def _run(self):
current = None
try:
home = os.environ.get("TEDDY_CHATTERBOX_HOME", "~/teddy-chatterbox")
# Expand the conventional home prefix in the Linux shell only.
home_arg = '"$HOME"' + shlex.quote(home[1:]) if home.startswith("~/") else shlex.quote(home)
script = linux_path(ROOT / "chatterbox_worker.py")
command = (f"export HF_HOME={home_arg}/models; export HF_HUB_DISABLE_XET=1; "
f"exec {home_arg}/venv/bin/python -u {shlex.quote(script)} {shlex.quote(linux_path(REFERENCE))}")
args = (["wsl", "-d", "Ubuntu", "--", "bash", "-lc", command] if os.name == "nt"
else ["bash", "-lc", command])
log = ROOT / ".speech/chatterbox.log"
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("a", encoding="utf-8") as logs:
self.process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=logs, text=True, encoding="utf-8",
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
if not json.loads(self.process.stdout.readline()).get("ready"):
raise RuntimeError("Chatterbox did not become ready")
self.ready.set()
while True:
current = self.jobs.get()
if current is None:
break
if current.cancelled.is_set():
current.returncode = 1
continue
self.process.stdin.write(json.dumps({"text": current.text,
"output": linux_path(current.output)}) + "\n")
self.process.stdin.flush()
result = json.loads(self.process.stdout.readline())
current.returncode = 0 if result.get("ok") else 1
if not result.get("ok"):
print("Chatterbox synthesis: " + result.get("error", "unknown error"), flush=True)
current = None
except Exception as error:
self.error = str(error)
if current is not None:
current.returncode = 1
print("Chatterbox worker failed: " + self.error, flush=True)
finally:
if self.process:
self.process.stdin.close()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.terminate()
+51
View File
@@ -0,0 +1,51 @@
"""Persistent local GPU voice worker. JSON lines on stdin/stdout; logs on stderr."""
import contextlib
import json
from pathlib import Path
import sys
def main():
protocol = sys.stdout
with contextlib.redirect_stdout(sys.stderr):
import numpy as np
import soundfile as sf
import torch
from chatterbox.tts_turbo import ChatterboxTurboTTS
torch.set_num_threads(4)
if not torch.cuda.is_available():
raise RuntimeError("Chatterbox lab requires the configured NVIDIA GPU")
model = ChatterboxTurboTTS.from_pretrained(device="cuda")
model.prepare_conditionals(sys.argv[1])
print(json.dumps({"ready": True}), file=protocol, flush=True)
for line in sys.stdin:
try:
request = json.loads(line)
with contextlib.redirect_stdout(sys.stderr):
# Sentence-sized chunks avoid Turbo's long-utterance truncation.
import re
chunks = re.split(r"(?<=[.!?])\s+", request["text"].strip())
parts = []
for chunk in chunks:
if not chunk:
continue
# Bound even punctuation-free custom hints.
words = chunk.split()
for offset in range(0, len(words), 45):
samples = model.generate(" ".join(words[offset:offset+45])).squeeze().detach().cpu().numpy()
if parts:
parts.append(np.zeros(int(model.sr * .18), dtype=np.float32))
parts.append(samples)
if not parts:
raise ValueError("Hint text is empty")
output = Path(request["output"])
temp = output.with_suffix(".partial")
sf.write(temp, np.concatenate(parts), model.sr, format="WAV", subtype="PCM_16")
temp.replace(output)
print(json.dumps({"ok": True}), file=protocol, flush=True)
except Exception as error:
print(json.dumps({"ok": False, "error": str(error)}), file=protocol, flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,42 @@
"""Isolated Chatterbox GPU comparison; no change to the live hint system."""
from pathlib import Path
import json,time,random
import numpy as np
import soundfile as sf
import torch
from chatterbox.tts_turbo import ChatterboxTurboTTS
root=Path("/mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/.training/teddy-v1/samples")
random.seed(42);np.random.seed(42);torch.manual_seed(42)
torch.set_num_threads(4)
assert torch.cuda.is_available()
x=torch.randn(32,32,device="cuda");x@x;torch.cuda.synchronize()
start=time.perf_counter()
model=ChatterboxTurboTTS.from_pretrained(device="cuda")
load_seconds=time.perf_counter()-start
start=time.perf_counter()
model.prepare_conditionals(str(root/"chatterbox-reference.wav"))
torch.cuda.synchronize();reference_seconds=time.perf_counter()-start
texts=[
"Something has been moving in the arcade after closing time. Find the locked junction box, and see whether your friends have discovered its key.",
"The switch is up. Give the machines four seconds to wake, then bring the matching token to your cabinet.",
"Tell your partner which symbol is flashing. They can move the controls, but only you can see the target."]
parts=[];timings=[]
for i,text in enumerate(texts):
start=time.perf_counter()
wav=model.generate(text)
torch.cuda.synchronize()
elapsed=time.perf_counter()-start
samples=wav.squeeze().detach().cpu().numpy()
sf.write(root/f"chatterbox-hint-{i+1}.wav",samples,model.sr)
duration=len(samples)/model.sr
timings.append(dict(text=text,generation_seconds=elapsed,audio_seconds=duration,real_time_factor=elapsed/duration))
parts.append(samples)
print(json.dumps(timings[-1]),flush=True)
joined=[]
for i,part in enumerate(parts):
if i: joined.append(np.zeros(int(model.sr*.45),dtype=np.float32))
joined.append(part)
sf.write(root/"teddy-chatterbox-turbo.wav",np.concatenate(joined),model.sr)
report=dict(engine="Chatterbox Turbo",reference="chatterbox-reference.wav",model_load_seconds=load_seconds,reference_seconds=reference_seconds,sample_rate=model.sr,device=torch.cuda.get_device_name(0),torch_version=torch.__version__,chunks=timings)
(root/"chatterbox-report.json").write_text(json.dumps(report,indent=2),encoding="utf-8")
print("Comparison complete",flush=True)
+56
View File
@@ -0,0 +1,56 @@
# Glitch: the Dark Machine - design updates
## October 18, 2026: opening, reveal, and cooperative cabinet play
These updates supplement the imported GDD and implementation contract. They record current design direction; they do not claim that the running simulation implements these changes. Preserve the imported references as the baseline.
### Confirmed opening: restore apparent room power
Players enter to investigate strange happenings in an arcade believed to be haunted. Teddy is the only apparently powered interactive character at entry. He delivers the opening story, establishes the mood, and can guide players toward their first task. Cabinets and other show-controlled effects initially appear inactive. Real electrical supply remains on; this is a room-sequencing state, not a request to interrupt building power.
The opening objective is:
1. Locate the junction box.
2. Find the physical key.
3. Use the key to remove the lock preventing operation of the large switch.
4. Physically move the switch upward.
5. The controller accepts the switch observation and begins the powered arcade portion of the experience.
Unlocking alone does not restore apparent power. The switch action is the completion trigger. Do not assume that key discovery or lock removal has an electronic sensor; the mechanical lock may enforce those prerequisites. Confirm the switch input hardware before assigning a GPIO or I2C channel. BCM23 remains an LED output, not this input.
Suggested controller states: prepared, opening investigation, power restoration accepted, arcade active. Opening narration and hint delivery must work before power restoration. Starting the GM session and starting powered arcade play are distinct actions. Whether the room timer starts on entry or on switch activation remains a design decision.
A proposed GM panel shows the reported switch position, observation freshness, and room-power story state separately. Provide an explicit, logged override for a failed prop. Repeated switch observations must not replay the opening or reset progress. A switch already up when a new run starts should produce a setup warning; do not silently skip the puzzle. The effect of lowering the switch during an active run remains to be decided; it must not implicitly erase progress.
Restoring room power does not automatically award RFID credits or bypass individual cabinet prerequisites. Decide which cabinet boot sequences and lighting cues accompany restoration when authoring the opening sequence.
### Confirmed narrative direction: haunted appearance, alien explanation
Early activity supports a plausible haunted-arcade interpretation: unusual machine behavior, unexplained sounds, and unsettling coincidences. Teddy sets the tone without explicitly explaining an alien cause. The alien manipulation is revealed when the secret room opens.
Review player-facing Teddy dialogue, puzzle text, cabinet art, and hint categories for premature alien, spacecraft, or launch references. GM-only information can describe the complete plot. Pre-reveal hints should remain available without exposing post-reveal material. After the reveal, prior events can be reinterpreted in light of what players have discovered.
### Proposal for playtesting: one cabinet controls another
During the later return to the arcade cabinets, a player's controls could operate a task displayed at another cabinet. The player with the useful view must communicate with the player controlling that view. This is a proposed mechanic, not yet a fixed routing plan or acceptance requirement.
Start with a two-cabinet test: player A operates controls while player B sees the target and gives directions; then exchange roles. Give both players meaningful feedback without showing the operator enough information to solve alone. Keep routing stable during an attempt and clearly announce changes between rounds. A larger circular arrangement can be tested after the pair works reliably.
Retain the secret-room navigator's role by giving that player the objective or target information while the arcade players coordinate its execution. Test whether this creates useful collaboration rather than excessive layers of instruction. Account for sightlines, room noise, latency, available controls, and smaller parties. Avoid requiring all six cabinets simultaneously for a small group.
Implementation candidate: the room controller authorizes source-to-target assignments for each round; cabinet clients relay bounded gameplay input through the agreed transport. Discard stale-round input and release held controls on disconnect, pause, or reassignment. This routing is for gameplay, not arbitrary remote physical outputs. GM diagnostics should show the control route and both cabinet connection states.
### Confirmed content direction: original arcade identities
Replace the current recognizable game names and sprites with original names and artwork for the room's arcade games. Track final names, character designs, marquees, screen art, sound, music, and text as authored assets rather than assuming a sprite swap completes the work. Existing game identifiers can remain internal during migration so saves, scenarios, and hardware assignments do not break unnecessarily.
This records the intended creative direction, not a legal clearance determination. Do not describe renaming and reskinning alone as a guarantee of clearance.
### Next simulation work
- Add the opening power gate, switch simulation, and logged GM override.
- Keep Teddy available while the arcade appears unpowered.
- Add stage-aware, spoiler-aware hint eligibility before the secret-room reveal.
- Test repeated switch events, startup with switch already up, recovery, and no automatic RFID credit.
- Prototype paired cabinet control with a clear GM routing display and small-party fallback.
- Inventory replacement game presentation assets without changing stable scenario IDs prematurely.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,782 @@
{
"design_version": "1.0",
"status": "proposed_semantic_contract_not_existing_wire_schema",
"target_architecture": {
"authority": "Rust Glitch room controller",
"transport": "NATS",
"operator_ui": "Flutter",
"game_runtime": "Godot",
"persistence_proposal": "durable transactional journal and outbox with JetStream observation delivery"
},
"profiles": {
"guided": {
"time_limit_ms": 3600000,
"passes_per_station": 1,
"response_window_ms": 60000,
"stable_target_hold_ms": 1000,
"final_ack_window_ms": 30000,
"hint_1_inactivity_ms": 90000,
"next_hint_interval_ms": 60000,
"score_target_multiplier": 0.6,
"frogger_homes": 1
},
"standard": {
"time_limit_ms": 3600000,
"passes_per_station": 2,
"response_window_ms": 40000,
"stable_target_hold_ms": 2000,
"final_ack_window_ms": 20000,
"hint_1_inactivity_ms": 120000,
"next_hint_interval_ms": 90000,
"score_target_multiplier": 1.0,
"frogger_homes": 1
},
"challenge": {
"time_limit_ms": 3600000,
"passes_per_station": 3,
"response_window_ms": 25000,
"stable_target_hold_ms": 3000,
"final_ack_window_ms": 12000,
"hint_1_inactivity_ms": 180000,
"next_hint_interval_ms": 120000,
"score_target_multiplier": 1.4,
"frogger_homes": 1
}
},
"entities": [
{
"cabinet_id": "cabinet_01",
"first_state_id": "A01",
"game": "pacman",
"token_id": "T01",
"cell_id": "CELL01",
"socket_id": "SOCKET01",
"status": "existing_assignment"
},
{
"cabinet_id": "cabinet_02",
"first_state_id": "A02",
"game": "galaga",
"token_id": "T02",
"cell_id": "CELL02",
"socket_id": "SOCKET02",
"status": "existing_assignment"
},
{
"cabinet_id": "cabinet_03",
"first_state_id": "A03",
"game": "tempest",
"token_id": "T03",
"cell_id": "CELL03",
"socket_id": "SOCKET03",
"status": "existing_assignment"
},
{
"cabinet_id": "cabinet_04",
"first_state_id": "A04",
"game": "missile_command",
"token_id": "T04",
"cell_id": "CELL04",
"socket_id": "SOCKET04",
"status": "existing_assignment"
},
{
"cabinet_id": "cabinet_05",
"first_state_id": "A05",
"game": "frogger",
"token_id": "T05",
"cell_id": "CELL05",
"socket_id": "SOCKET05",
"status": "proposed_assignment"
},
{
"cabinet_id": "cabinet_06",
"first_state_id": "A06",
"game": "space_invaders",
"token_id": "T06",
"cell_id": "CELL06",
"socket_id": "SOCKET06",
"status": "proposed_assignment"
}
],
"boss": {
"cabinet_id": "cabinet_07",
"id": "B07",
"game_proposal": "robotron_or_bespoke_variant",
"unlock": "all_six_distinct_first_state_wins"
},
"durations_ms": {
"charge": 30000,
"passage_debounce": 300,
"cell_debounce": 500,
"ship_power_stable": 2000,
"retry_delay": 3000,
"countdown": 5000,
"agent_stale": 3000,
"agent_offline": 8000,
"command_accept": 2000,
"command_expiry": 5000,
"boot_completion": 10000
},
"staffing": {
"operator_count_rule": "max(1, min(party_size - 1, 6))",
"calibration_concurrency": "operator_count",
"solo": "assisted sequential ship/arcade interaction",
"finale": "sequential station arm, group readiness, navigator commit",
"solo_final_ack_window_ms": 90000
},
"event_envelope_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Proposed Glitch normalized event envelope",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"event_id",
"type",
"room_id",
"run_id",
"entity_id",
"source_id",
"boot_id",
"source_seq",
"assignment_id",
"occurred_at_ms",
"received_at_ms",
"payload"
],
"properties": {
"schema_version": {
"type": "integer",
"minimum": 0,
"const": 1
},
"event_id": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"minLength": 1
},
"room_id": {
"type": "string",
"minLength": 1
},
"run_id": {
"type": "string",
"minLength": 1
},
"entity_id": {
"type": "string",
"minLength": 1
},
"source_id": {
"type": "string",
"minLength": 1
},
"boot_id": {
"type": "string",
"minLength": 1
},
"source_seq": {
"type": "integer",
"minimum": 0
},
"assignment_id": {
"type": [
"string",
"null"
]
},
"occurred_at_ms": {
"type": "integer",
"minimum": 0
},
"received_at_ms": {
"type": "integer",
"minimum": 0
},
"payload": {
"type": "object"
},
"correlation_id": {
"type": "string",
"minLength": 1
},
"causation_id": {
"type": "string",
"minLength": 1
},
"actor_id": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
}
},
"room_state_example": {
"schema_version": 1,
"room_id": "glitch",
"run_id": "example-run-001",
"revision": 0,
"config_hash": "EXAMPLE_REPLACE_WITH_CONTENT_HASH",
"lifecycle": "READY",
"phase": "BRIEFING",
"outcome": null,
"active_elapsed_ms": 0,
"time_limit_ms": 3600000,
"difficulty": "standard",
"party_size": 2,
"controller_epoch": 1,
"seed": 42,
"cabinet_first_wins": [],
"cells_installed": [],
"calibration_passes": {},
"launch": {
"launch_epoch": 0,
"armed_stations": [],
"acknowledged_groups": [],
"committed": false
}
},
"simulation_cases": [
{
"id": "SIM01",
"scenario": "Golden path with 6 players, standard",
"assertion": "All acts, six cells and two calibration passes; one launch commit",
"execution_status": "specified_not_executed"
},
{
"id": "SIM02",
"scenario": "2 players, guided; one roaming operator",
"assertion": "All six stations completed; no simultaneous multi-station human input required",
"execution_status": "specified_not_executed"
},
{
"id": "SIM03",
"scenario": "1 assisted player; sequential ship and arcade interactions",
"assertion": "No deadlock; clearly assisted outcome/metadata",
"execution_status": "specified_not_executed"
},
{
"id": "SIM04",
"scenario": "8 players, challenge; six stations active",
"assertion": "Unused helper never required for a guard; three passes per station",
"execution_status": "specified_not_executed"
},
{
"id": "SIM05",
"scenario": "Wrong token, repeated scan, stale historical scan",
"assertion": "No credit for wrong/stale; only one authorization for correct scan",
"execution_status": "specified_not_executed"
},
{
"id": "SIM06",
"scenario": "Lose A03 twice then retry",
"assertion": "No extra token needed; other wins and charge retained",
"execution_status": "specified_not_executed"
},
{
"id": "SIM07",
"scenario": "Duplicate A01 win and missing A06 win",
"assertion": "Boss remains dark; unique set count stays 5",
"execution_status": "specified_not_executed"
},
{
"id": "SIM08",
"scenario": "Claw reward ACK but token delivery jam",
"assertion": "Reward pending; GM hand-delivery logged; no duplicate dispense",
"execution_status": "specified_not_executed"
},
{
"id": "SIM09",
"scenario": "Boss won; passage ACK without open sensor",
"assertion": "Reveal hold after 5 s; no assumed opening",
"execution_status": "specified_not_executed"
},
{
"id": "SIM10",
"scenario": "Wrong socket, duplicate cell identity, missing cell",
"assertion": "Ship power guard false; explicit diagnostic per conflict",
"execution_status": "specified_not_executed"
},
{
"id": "SIM11",
"scenario": "Cell removed during calibration and countdown",
"assertion": "Current wave/countdown canceled; completed passes retained; fresh IDs",
"execution_status": "specified_not_executed"
},
{
"id": "SIM12",
"scenario": "Late Ready for old challenge; Execute exactly at deadline",
"assertion": "Both rejected; current wave remains consistent",
"execution_status": "specified_not_executed"
},
{
"id": "SIM13",
"scenario": "One final group late, then retry",
"assertion": "Acknowledgements clear; calibration retained; launch once on valid retry",
"execution_status": "specified_not_executed"
},
{
"id": "SIM14",
"scenario": "Restart controller after durable boss win before output publish",
"assertion": "Recovered outbox sends same command ID; no repeated physical release",
"execution_status": "specified_not_executed"
},
{
"id": "SIM15",
"scenario": "Agent restarts after uncertain bay release",
"assertion": "No blind pulse replay; reconcile or GM inspect",
"execution_status": "specified_not_executed"
},
{
"id": "SIM16",
"scenario": "Network drop, duplicate and reordered events",
"assertion": "No regression/double reward; snapshot reconciliation reaches same state",
"execution_status": "specified_not_executed"
},
{
"id": "SIM17",
"scenario": "Teddy unavailable or stale queued hint",
"assertion": "Fallback text; stale hint discarded; gameplay can finish",
"execution_status": "specified_not_executed"
},
{
"id": "SIM18",
"scenario": "Pause 5 min; expire action while paused",
"assertion": "Active timer frozen; expired wall-time command rejected",
"execution_status": "specified_not_executed"
},
{
"id": "SIM19",
"scenario": "Time expires in calibration; extend 5 min",
"assertion": "TIME_EXPIRED then preserved phase; no automatic normal win",
"execution_status": "specified_not_executed"
},
{
"id": "SIM20",
"scenario": "Reset and inject old-run win/scan/launch",
"assertion": "All rejected; fresh run remains pristine",
"execution_status": "specified_not_executed"
}
],
"calibration_selections": {
"cabinet_01": {
"min": 0,
"max": 3
},
"cabinet_02": {
"min": 1,
"max": 5
},
"cabinet_03": {
"min": 0,
"max": 7
},
"cabinet_04": {
"min": 1,
"max": 3
},
"cabinet_05": {
"min": 1,
"max": 4
},
"cabinet_06": {
"min": 1,
"max": 4
}
},
"payload_schemas": {
"puzzle.solved": {
"type": "object",
"additionalProperties": false,
"properties": {
"puzzle_id": {
"type": "string",
"minLength": 1
},
"evidence": {
"type": "string",
"minLength": 1
}
},
"required": [
"puzzle_id",
"evidence"
]
},
"reward.delivered": {
"type": "object",
"additionalProperties": false,
"properties": {
"reward_id": {
"type": "string",
"minLength": 1
},
"evidence": {
"type": "string",
"minLength": 1
}
},
"required": [
"reward_id",
"evidence"
]
},
"token.observed": {
"type": "object",
"additionalProperties": false,
"properties": {
"reader_id": {
"type": "string",
"minLength": 1
},
"token_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"reader_id",
"token_id"
]
},
"game.started": {
"type": "object",
"additionalProperties": false,
"properties": {
"attempt_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"attempt_id"
]
},
"game.progress": {
"type": "object",
"additionalProperties": false,
"properties": {
"attempt_id": {
"type": "string",
"minLength": 1
},
"value": {
"type": "integer",
"minimum": 0
}
},
"required": [
"attempt_id",
"value"
]
},
"game.won": {
"type": "object",
"additionalProperties": false,
"properties": {
"attempt_id": {
"type": "string",
"minLength": 1
},
"objective_id": {
"type": "string",
"minLength": 1
},
"value": {
"type": "integer",
"minimum": 0
}
},
"required": [
"attempt_id",
"objective_id",
"value"
]
},
"game.lost": {
"type": "object",
"additionalProperties": false,
"properties": {
"attempt_id": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
},
"required": [
"attempt_id",
"reason"
]
},
"cell.observed": {
"type": "object",
"additionalProperties": false,
"properties": {
"cell_id": {
"type": "string",
"minLength": 1
},
"location": {
"type": "string",
"minLength": 1
},
"present": {
"type": "boolean"
}
},
"required": [
"cell_id",
"location",
"present"
]
},
"passage.observed": {
"type": "object",
"additionalProperties": false,
"properties": {
"open": {
"type": "boolean"
},
"sensor_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"open",
"sensor_id"
]
},
"calibration.ready": {
"type": "object",
"additionalProperties": false,
"properties": {
"challenge_id": {
"type": "string",
"minLength": 1
},
"selection": {
"type": "integer",
"minimum": 0
},
"stable_for_ms": {
"type": "integer",
"minimum": 0
}
},
"required": [
"challenge_id",
"selection",
"stable_for_ms"
]
},
"calibration.ready_revoked": {
"type": "object",
"additionalProperties": false,
"properties": {
"challenge_id": {
"type": "string",
"minLength": 1
},
"reason": {
"type": "string",
"minLength": 1
}
},
"required": [
"challenge_id",
"reason"
]
},
"calibration.execute": {
"type": "object",
"additionalProperties": false,
"properties": {
"challenge_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"challenge_id"
]
},
"launch.group_ready": {
"type": "object",
"additionalProperties": false,
"properties": {
"launch_epoch": {
"type": "integer",
"minimum": 0
},
"group_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"launch_epoch",
"group_id"
]
},
"launch.commit_requested": {
"type": "object",
"additionalProperties": false,
"properties": {
"launch_epoch": {
"type": "integer",
"minimum": 0
}
},
"required": [
"launch_epoch"
]
},
"teddy.playback_status": {
"type": "object",
"additionalProperties": false,
"properties": {
"cue_id": {
"type": "string",
"minLength": 1
},
"status": {
"enum": [
"accepted",
"started",
"completed",
"failed"
]
}
},
"required": [
"cue_id",
"status"
]
}
},
"payload_validation": "Validate envelope, then select payload schema by event.type. Reject unknown event types unless separately defined as controller-internal events. Apply semantic guards after schema validation.",
"command_envelope_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"schema_version": {
"const": 1
},
"command_id": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"minLength": 1
},
"room_id": {
"type": "string",
"minLength": 1
},
"run_id": {
"type": "string",
"minLength": 1
},
"target_entity": {
"type": "string",
"minLength": 1
},
"target_boot_id": {
"type": "string",
"minLength": 1
},
"controller_epoch": {
"type": "integer",
"minimum": 0
},
"expected_revision": {
"type": "integer",
"minimum": 0
},
"expires_at_ms": {
"type": "integer",
"minimum": 0
},
"payload": {
"type": "object"
}
},
"required": [
"schema_version",
"command_id",
"type",
"room_id",
"run_id",
"target_entity",
"target_boot_id",
"controller_epoch",
"expected_revision",
"expires_at_ms",
"payload"
]
},
"command_result_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"command_id": {
"type": "string",
"minLength": 1
},
"status": {
"enum": [
"ACCEPTED",
"REJECTED",
"COMPLETED",
"FAILED",
"DELIVERY_UNKNOWN"
]
},
"reason_code": {
"type": "string",
"minLength": 1
},
"snapshot_revision": {
"type": "integer",
"minimum": 0
}
},
"required": [
"command_id",
"status",
"reason_code",
"snapshot_revision"
]
},
"event_example": {
"schema_version": 1,
"event_id": "evt-example-001",
"type": "game.won",
"room_id": "glitch",
"run_id": "example-run-001",
"entity_id": "cabinet_01",
"source_id": "agent-cabinet-01",
"boot_id": "boot-example-001",
"source_seq": 14,
"assignment_id": "assignment-a01-001",
"occurred_at_ms": 1792260000000,
"received_at_ms": 1792260000010,
"payload": {
"attempt_id": "attempt-a01-001",
"objective_id": "score_500",
"value": 500
}
}
}
+8
View File
@@ -0,0 +1,8 @@
"""Export Piper with its legacy dynamic-axis ONNX exporter on newer PyTorch."""
import functools
import torch
from piper.train.export_onnx import main
# Piper's exporter specifies dynamic_axes/opset 15 and predates the dynamo default.
torch.onnx.export = functools.partial(torch.onnx.export, dynamo=False)
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
/// Null means outside the current context; all parallel unfinished branches qualify.
String? hintReason(Map<String,dynamic> hint, Map<String,dynamic> room) {
final phase = room['phase'];
if (hint['phase'] != 'ALL' && hint['phase'] != phase) return null;
final target = hint['target'];
if (target == 'room') return hint['phase'] == 'ALL' ? 'General room hint' : 'Current phase: $phase';
final isPuzzle = (target as String).startsWith('P');
final items = (room[isPuzzle ? 'puzzles' : 'stations'] as List?) ?? [];
final matches = items.where((p) => p['id'] == target);
if (matches.isEmpty) return null;
final item = matches.first;
final complete = item[isPuzzle ? 'solved' : 'won'] == true;
if (hint['condition'] == 'complete' && !complete) return null;
if (hint['condition'] == 'incomplete') {
if (complete || phase != 'ARCADE') return null;
bool flag(String id, String field) => ((room['puzzles'] as List?) ?? []).any((p) => p['id'] == id && p[field] == true);
if (target == 'P02A' && !flag('P01','solved')) return null;
if (target == 'P02B' && !flag('P02A','delivered')) return null;
}
return '$target: ${complete ? 'completed' : 'unfinished'}';
}
class HintPanel extends StatefulWidget {
const HintPanel({super.key, required this.room, required this.client, required this.base, required this.onSpeak, required this.canSpeak});
final Map<String,dynamic> room;
final http.Client client;
final String base;
final Future<void> Function(String text) onSpeak;
final bool canSpeak;
@override
State<HintPanel> createState() => _HintPanelState();
}
class _HintPanelState extends State<HintPanel> {
List<Map<String,dynamic>> hints = [];
int revision = 0;
bool loading = false, suggested = true;
String category = 'All categories', query = '', message = '';
final draft = TextEditingController();
@override
void initState() { super.initState(); load(); }
@override
void dispose() { draft.dispose(); super.dispose(); }
Future<void> load() async {
if (loading) return;
setState(() => loading = true);
try {
final response = await widget.client.get(Uri.parse('${widget.base}/hints')).timeout(const Duration(seconds:5));
final body = jsonDecode(response.body);
if (response.statusCode != 200 || body['hints'] is! List) throw Exception('Hint library unavailable. Restart the updated lab gateway.');
if (mounted) setState(() {
hints = (body['hints'] as List).map((h)=>Map<String,dynamic>.from(h)).toList();
revision = body['revision']; message = '';
});
} catch (e) { if (mounted) setState(()=>message=e.toString()); }
finally { if (mounted) setState(()=>loading=false); }
}
Future<void> edit([Map<String,dynamic>? original, String? initialText]) async {
final title = TextEditingController(text:original?['title'] ?? '');
final text = TextEditingController(text:original?['text'] ?? initialText ?? '');
final tags = TextEditingController(text:((original?['categories'] as List?) ?? ['General']).join(', '));
var phase = original?['phase'] ?? 'ALL';
var target = original?['target'] ?? 'room';
var condition = original?['condition'] ?? 'any';
var level = original?['level'] ?? 1;
var error = '';
var saving = false;
var editRevision = revision;
await showDialog<void>(context:context,builder:(context)=>StatefulBuilder(builder:(context,update) {
Widget pick(String label, dynamic value, List<dynamic> values, void Function(dynamic) change) =>
DropdownButtonFormField<dynamic>(initialValue:value,isExpanded:true,
decoration:InputDecoration(labelText:label),
items:values.map((v)=>DropdownMenuItem<dynamic>(value:v,child:Text('$v'))).toList(),
onChanged:saving ? null : (v)=>update(()=>change(v)));
return AlertDialog(
title:Text(original == null ? 'Create saved hint' : 'Edit saved hint'),
content:SizedBox(width:580,child:SingleChildScrollView(child:Column(mainAxisSize:MainAxisSize.min,children:[
TextField(controller:title,maxLength:100,decoration:const InputDecoration(labelText:'Hint title')),
TextField(controller:text,maxLength:1500,minLines:3,maxLines:6,decoration:const InputDecoration(labelText:'What Teddy should say')),
TextField(controller:tags,decoration:const InputDecoration(labelText:'Categories, separated by commas')),
pick('Room phase',phase,['ALL','BRIEFING','ARCADE','BOSS','REVEAL','CELLS','CALIBRATION_TUTORIAL','CALIBRATION','LAUNCH_ARM','COUNTDOWN','DEBRIEF'],(v)=>phase=v),
pick('Puzzle or cabinet',target,['room','P01','P02A','P02B','P03','P04','P05','P06',...List.generate(6,(i)=>'cabinet_0${i+1}')],(v)=>target=v),
pick('Show when target is',condition,['any','incomplete','complete'],(v)=>condition=v),
pick('Hint strength: 1 subtle, 2 direct, 3 solution',level,[1,2,3],(v)=>level=v),
if (error.isNotEmpty) Text(error,style:TextStyle(color:Theme.of(context).colorScheme.error)),
]))),
actions:[
TextButton(onPressed:saving?null:()=>Navigator.pop(context),child:const Text('Cancel')),
FilledButton(onPressed:saving?null:() async {
update(()=>saving=true);
try {
final result=await widget.client.post(Uri.parse('${widget.base}/hints'),
headers:{'Content-Type':'application/json'},
body:jsonEncode({'revision':editRevision,'hint':{
'id':original?['id'] ?? '', 'title':title.text,'text':text.text,
'categories':tags.text.split(',').map((s)=>s.trim()).where((s)=>s.isNotEmpty).toList(),
'phase':phase,'target':target,'condition':condition,'level':level,
}})).timeout(const Duration(seconds:6));
final body=jsonDecode(result.body);
if (result.statusCode != 200) {
if (result.statusCode == 409) {
await load(); editRevision=revision;
throw Exception('Library changed on another device. Your draft is kept. Review it, then Save again to apply your version.');
}
throw Exception(body['error'] ?? 'Save failed');
}
if (mounted) setState(() {
hints=(body['hints'] as List).map((h)=>Map<String,dynamic>.from(h)).toList();
revision=body['revision']; message='Hint saved on the host PC.';
});
if (context.mounted) Navigator.pop(context);
} catch(e) { if(context.mounted) update(()=>error=e.toString()); }
finally { if(context.mounted) update(()=>saving=false); }
},child:Text(saving?'Saving...':'Save hint')),
],
);
}));
// Controllers are released after the dialog route's closing animation.
await Future<void>.delayed(const Duration(milliseconds:250));
title.dispose(); text.dispose(); tags.dispose();
}
@override
Widget build(BuildContext context) {
final categories = {'All categories',...hints.expand((h)=>(h['categories'] as List).cast<String>())}.toList()..sort();
final shown = hints.where((h) {
if(suggested && hintReason(h,widget.room)==null) return false;
if(category!='All categories' && !(h['categories'] as List).contains(category)) return false;
return '${h['title']} ${h['text']} ${h['categories']}'.toLowerCase().contains(query.toLowerCase());
}).toList()..sort((a,b)=>(a['level'] as int).compareTo(b['level'] as int));
return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
const SizedBox(height:24),
Text('Hint library',style:Theme.of(context).textTheme.titleLarge),
Text('Room phase: ${widget.room['phase']}. Suggestions include all eligible parallel puzzles. These are suggestions; the GM chooses what to say.'),
Wrap(spacing:10,runSpacing:8,children:[
FilterChip(label:const Text('Relevant now'),selected:suggested,onSelected:(v)=>setState(()=>suggested=v)),
OutlinedButton(onPressed:()=>edit(),child:const Text('New saved hint')),
TextButton(onPressed:loading?null:load,child:const Text('Reload library')),
]),
DropdownButton<String>(isExpanded:true,value:categories.contains(category)?category:'All categories',
items:categories.map((c)=>DropdownMenuItem(value:c,child:Text(c))).toList(),
onChanged:(v)=>setState(()=>category=v!)),
TextField(decoration:const InputDecoration(labelText:'Search hints'),onChanged:(v)=>setState(()=>query=v)),
if(message.isNotEmpty) Text(message),
if(loading) const LinearProgressIndicator(),
if(!loading && shown.isEmpty) const Text('No matching hints. Turn off Relevant now to browse the whole library.'),
...shown.map((h)=>Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text(h['title'],style:Theme.of(context).textTheme.titleMedium),
Text('${(h['categories'] as List).join(' / ')} · Level ${h['level']} · ${hintReason(h,widget.room) ?? 'Outside current context'}'),
Text(h['text']),
Wrap(spacing:8,children:[
FilledButton(onPressed:widget.canSpeak?()=>widget.onSpeak(h['text']):null,child:const Text('Say now')),
TextButton(onPressed:()=>edit(h),child:const Text('Edit')),
]),
])))),
const SizedBox(height:16),
TextField(controller:draft,minLines:2,maxLines:5,maxLength:1500,
decoration:const InputDecoration(labelText:'Write a one-off hint',hintText:'Type exactly what Teddy should say')),
Wrap(spacing:8,children:[
FilledButton(onPressed:widget.canSpeak?() {
if(draft.text.trim().isNotEmpty) widget.onSpeak(draft.text.trim());
}:null,child:const Text('Say typed hint now')),
OutlinedButton(onPressed:()=>edit(null,draft.text),child:const Text('Save typed hint...')),
]),
]);
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'room_page.dart';
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() => runApp(const GlitchLab());
class GlitchLab extends StatelessWidget {
const GlitchLab({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Glitch GM Lab',
theme: ThemeData.dark(useMaterial3: true).copyWith(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.cyan, brightness: Brightness.dark)),
home: Uri.base.queryParameters['room'] == '1' ? const RoomPage() : const CabinetPage(),
);
}
class CabinetPage extends StatefulWidget {
const CabinetPage({super.key, this.client});
final http.Client? client;
@override
State<CabinetPage> createState() => _CabinetPageState();
}
class _CabinetPageState extends State<CabinetPage> {
late final http.Client client;
Timer? timer;
Map<String, dynamic>? cabinet;
String? error;
String notice = 'Connecting to the local simulation...';
bool pending = false;
bool polling = false;
int sequence = 0;
int revision = 0;
static const base = 'http://127.0.0.1:18766';
@override
void initState() {
super.initState();
client = widget.client ?? http.Client();
refresh();
timer = Timer.periodic(const Duration(seconds: 1), (_) => refresh());
}
Future<void> refresh() async {
if (polling || pending) return;
polling = true;
final requestRevision = revision;
try {
final response = await client.get(Uri.parse('$base/state')).timeout(const Duration(seconds: 3));
if (response.statusCode != 200) throw Exception('Cabinet or controller unavailable');
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (mounted && !pending && requestRevision == revision) setState(() { cabinet = data['state']; error = null; });
} catch (_) {
if (mounted && requestRevision == revision) setState(() => error = 'Disconnected - controls disabled. Check the lab terminals.');
} finally { polling = false; }
}
Future<void> command(String action) async {
if (pending || error != null || cabinet == null) return;
revision++;
setState(() { pending = true; notice = 'Waiting for cabinet acknowledgement...'; });
try {
final response = await client.post(Uri.parse('$base/command'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${sequence++}',
'session': cabinet!['session'],
'expires_ms': DateTime.now().millisecondsSinceEpoch + 10000,
'action': action,
})).timeout(const Duration(seconds: 4));
if (response.statusCode != 200) throw Exception('No acknowledgement');
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (mounted) setState(() {
cabinet = data['state'];
notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}';
error = null;
});
} catch (_) {
if (mounted) setState(() {
error = 'Delivery unconfirmed. Refresh state before trying again.';
notice = 'No success acknowledgement received.';
});
} finally { if (mounted) setState(() => pending = false); }
}
@override
void dispose() { timer?.cancel(); client.close(); super.dispose(); }
@override
Widget build(BuildContext context) {
final phase = cabinet?['phase'] ?? 'unknown';
final available = cabinet != null && error == null && !pending;
Widget control(String label, String action, bool enabled) => FilledButton(
onPressed: available && enabled ? () => command(action) : null, child: Text(label));
return Scaffold(
appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')),
body: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 900),
child: ListView(padding: const EdgeInsets.all(24), children: [
Text(cabinet?['simulated'] == false ? 'INTEGRATION LAB - REAL GODOT CABINET' : 'INTEGRATION LAB - SIMULATED CABINET', style: const TextStyle(color: Colors.amber)),
const SizedBox(height: 16),
const Text('Cabinet 1 / Pac-Man', style: TextStyle(fontSize: 28)),
Text(cabinet?['simulated'] == false ? 'Real Pac-Man. Target: 500 points. No reward outputs. RFID injection: ${cabinet?['rfid_injection'] == true ? 'enabled' : 'disabled'}.' : 'No physical RFID, LEDs, GPIO, or arcade game is connected.'),
const SizedBox(height: 24),
Card(child: Padding(padding: const EdgeInsets.all(24), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(phase.replaceAll('_', ' ').toUpperCase(), style: const TextStyle(fontSize: 32)),
Text('Credits: ${cabinet?['credits'] ?? '-'} Accepted scans: ${cabinet?['scans'] ?? '-'}'),
const SizedBox(height: 12),
Text(phase == 'powered_off' ? 'Waiting for operator' :
phase == 'booting' ? 'Four-second boot sequence...' :
phase == 'ready' ? 'Scan a card, then press Start' :
phase == 'playing' ? 'Game in progress' :
phase == 'won' ? 'Objective completed' : phase == 'lost' ? 'Game lost - reset the test to retry' : 'Awaiting state'),
]))),
const SizedBox(height: 16),
Wrap(spacing: 12, runSpacing: 12, children: [
control('Power on', 'power_on', phase == 'powered_off'),
control('Simulate RFID scan', 'scan', phase == 'ready' && (cabinet?['simulated'] == true || cabinet?['rfid_injection'] == true)),
control('Start game', 'start', phase == 'ready' && cabinet?['credits'] == 1),
control('Simulate win', 'win', phase == 'playing' && cabinet?['simulated'] == true),
control('Reset test', 'reset', true),
]),
const SizedBox(height: 24),
Text(error ?? notice, style: TextStyle(color: error == null ? Colors.white70 : Colors.orange)),
const SizedBox(height: 16),
const Text('Flutter → local JSON gateway → NATS / MessagePack → simulated cabinet'),
]))),
);
}
}
+437
View File
@@ -0,0 +1,437 @@
import 'dart:async';
import 'dart:convert';
import 'teddy_audio.dart';
import 'hint_panel.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
class RoomPage extends StatefulWidget {
const RoomPage({super.key, this.client});
final http.Client? client;
@override
State<RoomPage> createState() => _RoomPageState();
}
class _RoomPageState extends State<RoomPage> {
static final base = (Uri.base.scheme == 'http' || Uri.base.scheme == 'https') ? Uri.base.origin : 'http://127.0.0.1:18777';
late final http.Client client;
Timer? timer;
Map<String, dynamic>? room;
bool pending = false, polling = false;
String? error;
String notice = 'Connecting to the isolated room controller...';
String profile = 'standard';
int party = 6, tab = 0, serial = 0, generation = 0;
final Map<String, int> selections = {};
int? selectionWave;
Map<String, dynamic>? trace;
String speechStatus = 'Teddy audio not enabled (captions available)';
bool speechPolling = false;
final audio = TeddyAudio();
String? readyAudioUrl;
Future<void> refreshSpeech() async {
if (speechPolling) return;
speechPolling = true;
var status = 'Teddy audio not enabled. Start lab.py start-room --speech.';
try {
final response = await client.get(Uri.parse('$base/speech/health')).timeout(const Duration(seconds: 2));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final teddy = room?['teddy'];
final valid = error == null && data['output'] == 'browser' &&
data['run_id'] == room?['run_id'] && data['cue_id'] != null &&
data['cue_id'] == teddy?['current']?['id'] &&
teddy?['muted'] != true && teddy?['online'] == true &&
(teddy?['takeover'] != true || (teddy?['current']?['key'] as String? ?? '').startsWith('gm-'));
readyAudioUrl = valid && data['audio_url'] is String ? '$base/speech${data['audio_url']}' : null;
audio.sync(readyAudioUrl);
status = 'Teddy: ${data['status']} · ${data['voice']} — ${data['message']}';
} else { readyAudioUrl=null; audio.sync(null); }
} catch (_) { readyAudioUrl=null; audio.sync(null); /* Speech is optional; room controls stay available. */ }
if (mounted) setState(() => speechStatus = status);
speechPolling = false;
}
@override
void initState() {
super.initState();
client = widget.client ?? http.Client();
refresh();
refreshSpeech();
timer = Timer.periodic(const Duration(seconds: 1), (_) { refresh(); refreshSpeech(); });
}
@override
void dispose() { timer?.cancel(); audio.dispose(); client.close(); super.dispose(); }
Future<void> refresh() async {
if (pending || polling) return;
polling = true;
final gen = generation;
try {
final response = await client.get(Uri.parse('$base/room/state')).timeout(const Duration(seconds: 6));
if (response.statusCode != 200) throw Exception();
final data = jsonDecode(response.body);
if (data['state'] is! Map) throw Exception();
if (mounted && gen == generation) setState(() { if (room == null || error != null) notice = 'Connected. The room timer runs after briefing; Pause freezes gameplay.'; room = Map<String, dynamic>.from(data['state']); error = null; });
} catch (_) {
if (mounted && gen == generation) setState(() => error = 'Room simulator disconnected. Start lab.py start-room; controls are disabled.');
} finally { polling = false; }
}
Future<void> act(String action, [String entity = '', dynamic value]) async {
if (pending || room == null || error != null) return;
generation++;
setState(() => pending = true);
try {
final response = await client.post(Uri.parse('$base/room/command'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command_id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${serial++}',
'run_id': room!['run_id'], 'expected_revision': room!['revision'],
'expires_at_ms': DateTime.now().millisecondsSinceEpoch + 10000,
'input': {'action': action, 'entity': entity, 'value': value},
})).timeout(const Duration(seconds: 7));
if (response.statusCode != 200) throw Exception();
final data = jsonDecode(response.body);
if (data['state'] is! Map) throw Exception();
if (mounted) setState(() {
room = Map<String, dynamic>.from(data['state']);
notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}';
if (room?['teddy']?['current'] == null) audio.sync(null);
});
} catch (_) {
if (mounted) setState(() => error = 'Delivery unconfirmed. Refresh the room state before retrying.');
} finally { if (mounted) setState(() => pending = false); }
}
Future<void> confirmReset() async {
final confirmed = await showDialog<bool>(context: context, builder: (context) => AlertDialog(
title: const Text('Reset this simulated run?'),
content: const Text('Clears tokens, wins, charge, calibration and launch readiness. No physical room is affected. The current process trace retains the reset.'),
actions: [TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Reset simulation'))]));
if (confirmed == true) await act('reset', '', {'confirmed': true});
}
Future<void> withReason(String action, String entity) async {
final controller = TextEditingController();
final reason = await showDialog<String>(context: context, builder: (context) => AlertDialog(
title: Text(action == 'deliver' ? 'Confirm GM hand-delivery' : 'Finish as assisted rescue'),
content: TextField(controller: controller, autofocus: true,
decoration: const InputDecoration(labelText: 'Staff name and reason')),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(onPressed: () {
if (controller.text.trim().isNotEmpty) Navigator.pop(context, controller.text.trim());
}, child: const Text('Record intervention'))]));
controller.dispose();
if (reason != null) await act(action, entity, action == 'deliver' ? {'hand': true, 'reason': reason} : reason);
}
Future<void> loadTrace() async {
try {
final response = await client.get(Uri.parse('$base/room/trace')).timeout(const Duration(seconds: 6));
if (response.statusCode != 200) throw Exception();
final data = jsonDecode(response.body);
if (mounted) setState(() => trace = Map<String, dynamic>.from(data));
} catch (_) { if (mounted) setState(() => notice = 'Could not fetch trace.'); }
}
bool get enabled => room != null && error == null && !pending;
List<dynamic> get stations => room?['stations'] ?? [];
String get phase => room?['phase'] ?? 'UNKNOWN';
String get lifecycle => room?['lifecycle'] ?? 'UNKNOWN';
Widget button(String label, String action, {String entity = '', dynamic value, bool allow = true}) =>
OutlinedButton(onPressed: enabled && allow ? () => act(action, entity, value) : null, child: Text(label));
Widget panel(String title, List<Widget> children, {Color? color}) => SizedBox(width: double.infinity, child: Card(
color: color, child: Padding(padding: const EdgeInsets.all(18),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
const SizedBox(height: 12), ...children]))));
Widget wrap(List<Widget> children) => Wrap(spacing: 8, runSpacing: 8, children: children);
Widget fact(String text) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(text));
Widget badge(String text, [Color color = Colors.cyan]) => Chip(
label: Text(text), side: BorderSide(color: color.withValues(alpha: .5)));
String blocker() {
if ((room?['hold'] ?? '') != '') return room!['hold'];
switch (phase) {
case 'BRIEFING': return 'Start the run, then finish briefing to enable the discovery puzzles.';
case 'POWER_RESTORE': return 'Find the junction box and key, remove the lock, then raise the large switch. Teddy is awake; cabinets are off.';
case 'BOOTING': return 'Power restored. All arcade cabinets are running their four-second startup check.';
case 'ARCADE': return 'Find six tokens, authorize their cabinets, and achieve six distinct first-state wins.';
case 'BOSS': return 'All six wins are latched. Start and defeat The Dark Machine.';
case 'REVEAL': return 'Passage release is requested. ACK is not an open sensor. Confirm opening and advance 300 ms.';
case 'CELLS': return 'Confirm charged bays open, remove each cell, and install it in its matching socket. Advance 2.5 seconds for stability.';
case 'CALIBRATION_TUTORIAL': return 'Complete two readback/Ready/Execute practices before scored waves.';
case 'CALIBRATION': return 'Match ship targets, simulate stable Ready at each active station, then Execute.';
case 'LAUNCH_ARM': return 'Arm all six stations sequentially, acknowledge each operator group, then navigator Commit.';
case 'COUNTDOWN': return 'Five-second countdown. Removing a cell or losing a required station cancels launch.';
case 'DEBRIEF': return 'Run finished. Review the trace or reset the simulation.';
default: return 'Waiting for a room snapshot.';
}
}
Widget navigation() => ListView(children: [
const Padding(padding: EdgeInsets.all(20), child: Text('ROOM OPERATIONS', style: TextStyle(fontWeight: FontWeight.bold))),
for (final entry in ['Room Flow','Cabinets','Ship & Cells','Calibration & Launch','Teddy','Diagnostics'].asMap().entries)
ListTile(selected: tab == entry.key, leading: Icon([Icons.account_tree,Icons.sports_esports,Icons.battery_charging_full,Icons.rocket_launch,Icons.record_voice_over,Icons.monitor_heart][entry.key]),
title: Text(entry.value), onTap: () { setState(() => tab=entry.key); if (MediaQuery.sizeOf(context).width < 900) Navigator.pop(context); }),
]);
Widget overview() => Column(children: [
panel('What happens next', [
fact(blocker()),
wrap([
button('Start run', 'start_run', allow: lifecycle == 'READY'),
button('Briefing complete', 'briefing_done', allow: lifecycle == 'RUNNING' && phase == 'BRIEFING'),
button('Flip power switch up', 'power_switch', value: true, allow: lifecycle == 'RUNNING' && phase == 'POWER_RESTORE'),
button('Start boss', 'boss_start', allow: lifecycle == 'RUNNING' && room?['boss'] == 'READY'),
button('Simulate boss victory', 'boss_win', allow: lifecycle == 'RUNNING' && room?['boss'] == 'PLAYING'),
button('Observe passage open', 'passage', value: true, allow: phase == 'REVEAL'),
]),
]),
panel('Room flow · conceptual layout', [
const Text('Discovery arcade → Dark Machine → hidden ship → calibration → launch. This is a flow diagram, not a measured floor plan.'),
const SizedBox(height: 12),
wrap([
badge('Puzzles ${(room?['puzzles'] as List? ?? []).where((p) => p['solved'] == true).length}/7'),
badge('First wins ${stations.where((s) => s['won'] == true).length}/6'),
badge('Boss ${room?['boss']}'),
badge('Passage ${room?['passage_open'] == true ? 'OPEN' : 'UNCONFIRMED'}'),
badge('Ship power ${room?['ship_power'] == true ? 'STABLE' : 'WAITING'}'),
badge('Launches ${room?['launches']}'),
]),
]),
panel('Discovery puzzles and rewards', [
const Text('These are the supplied placeholder puzzle contracts. Solve and physical delivery are separate observations.'),
const SizedBox(height: 12),
...((room?['puzzles'] as List?) ?? []).map((p) {
const labels = {'P01':'Restore claw controls','P02A':'Claw stage A','P02B':'Claw stage B',
'P03':'Record and sleeve','P04':'Service-panel symbols','P05':'Ticket values','P06':'Poster overlay'};
final effects = (room?['effects'] as List? ?? []).where((e) => e['target'] == p['token']).toList();
return Padding(padding: const EdgeInsets.only(bottom: 14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${p['id']} · ${labels[p['id']]} ${p['token']}',
style: const TextStyle(fontWeight: FontWeight.bold)),
Text(p['delivered'] == true ? 'Delivered' : p['solved'] == true ?
(p['token'] == '' ? 'Solved' : 'Solved · reward pending') : 'Unsolved'),
wrap([
button('Simulate solve', 'solve', entity: p['id'], allow: phase == 'ARCADE' && p['solved'] != true),
if (p['token'] != '') button('Observe delivery', 'deliver', entity: p['token'], allow: p['solved'] == true && p['delivered'] != true),
if (effects.isNotEmpty) button('ACK only / jam test', 'ack', entity: effects.first['id'], allow: effects.first['status'] == 'REQUESTED'),
if (p['solved'] == true && p['token'] != '' && p['delivered'] != true)
TextButton(onPressed: enabled ? () => withReason('deliver', p['token']) : null, child: const Text('GM hand-delivery...')),
]),
]));
}),
]),
]);
Widget cabinets() => LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth > 1000 ? (constraints.maxWidth - 24) / 3 :
constraints.maxWidth > 650 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth;
return wrap(stations.map<Widget>((s) => SizedBox(width: width, child: panel(
'${s['id']} · ${s['game'].toString().replaceAll('_', ' ')}', [
wrap([badge(s['status'], s['won'] == true ? Colors.green : Colors.cyan),
badge(s['online'] == true ? 'ONLINE' : 'OFFLINE', s['online'] == true ? Colors.green : Colors.red)]),
if (s['status'] == 'BOOTING') ...[const LinearProgressIndicator(), const Text('ROM check / startup - four seconds')],
if (s['status'] == 'TOKEN_WAIT') const Text('ATTRACT MODE - insert token'),
fact('Token ${s['token']} · ${s['authorized'] == true ? 'authorized for retries' : 'not authorized'}'),
fact('Objective ${s['progress']} / ${s['target']} · attempt ${s['attempt']} · losses ${s['losses']}'),
LinearProgressIndicator(value: (s['charge_ms'] as num) / 30000),
fact('${s['cell']} charge ${((s['charge_ms'] as num) / 300).round()}% · calibration passes ${s['passes']}'),
wrap([
button('Scan ${s['token']}', 'token', entity: s['id'], value: {'token':s['token'],'age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'),
button('Wrong token', 'token', entity: s['id'], value: {'token':'WRONG','age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'),
button('Start attempt', 'start', entity: s['id'], allow: s['status'] == 'READY'),
button('Simulate target reached', 'win', entity: s['id'], value: {'attempt':s['attempt'],'progress':s['target']}, allow: s['status'] == 'PLAYING'),
button('Simulate loss', 'lose', entity: s['id'], value: {'attempt':s['attempt']}, allow: s['status'] == 'PLAYING'),
button(s['online'] == true ? 'Disconnect' : 'Reconnect', 'online', entity:s['id'], value:s['online'] != true),
]),
]))).toList());
});
Widget ship() {
final locations = Map<String, dynamic>.from(room?['locations'] ?? {});
final faults = room?['cell_faults'] as List? ?? [];
return Column(children: [
panel('Passage and ship power', [
fact('Passage: ${room?['passage_open'] == true ? 'observed open' : 'unconfirmed'} · ship power: ${room?['ship_power']}'),
if (faults.isNotEmpty) Text(faults.join('\n'), style: const TextStyle(color: Colors.amber)),
]),
...stations.map<Widget>((s) {
final bay = 'BAY${s['cell'].toString().substring(4)}';
final here = locations.entries.where((e) => e.value == s['cell']).map((e) => e.key).join(', ');
return panel('${s['cell']} → ${s['socket']}', [
fact('Location: ${here.isEmpty ? 'carried / unobserved' : here} · bay open: ${s['bay_open']}'),
wrap([
button('Observe bay open', 'bay', entity:s['id'], allow: room?['passage_open'] == true && s['charge_ms'] == 30000 && s['bay_open'] != true),
button('Remove from bay', 'cell', entity:bay, value:{'cell':s['cell'],'present':false}, allow: s['bay_open'] == true && locations[bay] == s['cell']),
button('Insert matching socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':true}, allow: room?['passage_open'] == true && locations[s['socket']] != s['cell']),
button('Remove from socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':false}, allow: locations[s['socket']] == s['cell']),
]),
]);
}),
]);
}
Widget calibration() {
final wave = room?['wave'] as Map?;
if (wave?['id'] != selectionWave) {
selectionWave = wave?['id'];
selections.clear();
}
final targets = Map<String, dynamic>.from(wave?['targets'] ?? {});
const ranges = {'cabinet_01':[0,3],'cabinet_02':[1,5],'cabinet_03':[0,7],
'cabinet_04':[1,3],'cabinet_05':[1,4],'cabinet_06':[1,4]};
final hold = {'guided':1000,'standard':2000,'challenge':3000}[room?['profile']] ?? 2000;
return Column(children: [
panel('Navigator and operator practice', [
fact('Party ${room?['party']} · ${room?['operators']} operator groups · practices ${room?['tutorial']}/2'),
fact(blocker()),
if (wave != null) ...[
fact('Challenge ${wave['id']} · active-time deadline ${wave['deadline'] ?? 'untimed practice'} ms'),
...targets.entries.map((entry) {
final range = ranges[entry.key]!;
final selected = selections[entry.key] ?? range.first;
return Padding(padding: const EdgeInsets.only(bottom: 14), child: wrap([
SizedBox(width: 230, child: Text('${entry.key}\nShip target: ${entry.value}\nReadback: ${wave['ready'][entry.key] ?? 'waiting'}')),
DropdownButton<int>(value:selected, items:[for (int i = range.first; i <= range.last; i++)
DropdownMenuItem(value:i, child:Text('Selection $i'))],
onChanged:enabled ? (v) => setState(() => selections[entry.key] = v!) : null),
button('Simulate held Ready', 'ready', entity:entry.key,
value:{'challenge':wave['id'],'selection':selected,'stable_ms':hold}),
button('Revoke Ready', 'revoke', entity:entry.key, value:wave['id']),
]));
}),
button('Navigator Execute', 'execute', value:wave['id']),
],
const SizedBox(height: 12),
wrap(stations.map<Widget>((s) => badge('${s['id']}: ${s['passes']} passes')).toList()),
]),
panel('Launch barrier', [
fact('Epoch ${room?['launch_epoch']} · Armed ${(room?['armed'] as List? ?? []).length}/6'),
wrap(stations.map<Widget>((s) => button('Arm ${s['id']}', 'arm', entity:s['id'], value:room?['launch_epoch'],
allow:phase == 'LAUNCH_ARM' && !(room?['armed'] as List? ?? []).contains(s['id']))).toList()),
const SizedBox(height: 12),
wrap([for (int i=0; i < (room?['operators'] ?? 1); i++)
button('Group ${i+1} ready', 'group_ready', value:{'epoch':room?['launch_epoch'],'group':i},
allow:phase == 'LAUNCH_ARM' && !(room?['groups'] as List? ?? []).contains(i))]),
fact('One operator can arm several stations in sequence. No six-person simultaneous button press is required.'),
button('Navigator Commit', 'commit', value:room?['launch_epoch'], allow:phase == 'LAUNCH_ARM'),
if (phase == 'COUNTDOWN') button('Advance five-second countdown', 'advance', value:5000),
]),
]);
}
Widget teddy() {
final t = room?['teddy'] as Map? ?? {};
return panel('Teddy Ruxpin · hints and speech', [
wrap([badge(t['mode'] ?? 'DORMANT'), badge(t['online'] == true ? 'ONLINE' : 'FAULT'),
badge(t['muted'] == true ? 'MUTED' : 'CAPTIONS + OPTIONAL PIPER'), if (t['takeover'] == true) badge('GM TAKEOVER')]),
Text(speechStatus),
Text(audio.status),
OutlinedButton(onPressed: () async {
if (audio.enabled) { setState(() => audio.disable()); }
else { await audio.enable(); if (mounted) { setState(() {}); await refreshSpeech(); } }
}, child: Text(audio.enabled ? 'Disable audio on this device' : 'Enable audio and test speaker')),
Wrap(spacing:8,runSpacing:8,children:[
OutlinedButton(onPressed:() async { await audio.enable(); if(mounted) setState(() {}); },child:const Text('Test speaker tone')),
FilledButton(onPressed:readyAudioUrl == null ? null : () { audio.playNow(readyAudioUrl!); setState(() {}); },child:const Text('Play current hint here')),
]),
if (t['takeover'] == true) const Text('GM takeover: automatic narration is paused. Say now and Replay still work.'),
if (t['muted'] == true || t['online'] != true) const Text('Teddy is muted or offline. Unmute/reconnect before sending hints.'),
const Text('With browser speech enabled, sound plays on each device that opts in. Keep the page open. Listen before advancing virtual time: advancing may stop or replace the cue. Doll movement remains simulated.'),
const SizedBox(height: 18),
SelectableText(t['transcript'] == '' ? 'Waiting for a cue...' : t['transcript'] ?? '',
style: const TextStyle(fontSize: 22)),
const SizedBox(height: 18),
fact('Current cue: ${t['current']?['key'] ?? 'none'} · Hint level: ${t['hint_level'] ?? 0}'),
wrap([
button('Quick phase hint now', 'hint', allow:['ARCADE','CALIBRATION','CALIBRATION_TUTORIAL'].contains(phase) && t['online'] == true && t['muted'] != true),
button(t['muted'] == true ? 'Unmute' : 'Mute', 'teddy', value:'mute'),
button('Stop cue', 'teddy', value:'stop'),
button('Replay transcript cue', 'teddy', value:'replay'),
button(t['online'] == true ? 'Simulate Teddy failure' : 'Reconnect Teddy', 'teddy', value:'offline'),
button(t['takeover'] == true ? 'Release to autopilot' : 'GM takeover', 'teddy',
value:t['takeover'] == true ? 'release' : 'takeover'),
]),
const SizedBox(height: 18),
const Text('Queued cues'),
...((t['queue'] as List?) ?? []).map((c) => ListTile(title:Text(c['key']), subtitle:Text(c['transcript']))),
HintPanel(key:const ValueKey('hint-library'),room:room!,client:client,base:base,canSpeak:enabled && t['online'] == true && t['muted'] != true,
onSpeak:(text)=>act('speak','',{'text':text})),
]);
}
Widget diagnostics() => panel('Diagnostics and replay trace', [
const Text('Fast-forward the simulation clock for testing:'),
wrap([
button('+ 1 second','advance',value:1000),
button('+ 3 seconds','advance',value:3000),
button('+ 30 seconds','advance',value:30000),
button('+ 2 minutes','advance',value:120000),
]),
fact('Run ${room?['run_id']} · revision ${room?['revision']}'),
const Text('In-memory simulation trace. Save it before closing the controller. Durable recovery and physical output replay are not implemented.'),
wrap([
OutlinedButton(onPressed: enabled ? loadTrace : null, child: const Text('Refresh trace')),
OutlinedButton(onPressed: trace == null ? null : () async {
await Clipboard.setData(ClipboardData(text:const JsonEncoder.withIndent(' ').convert(trace)));
if (mounted) setState(() => notice = 'Trace copied. Save it as a JSON file for replay.');
}, child: const Text('Copy trace JSON')),
]),
if (trace != null) fact('Trace events: ${(trace!['trace'] as List).length} · final SHA-256: ${trace!['final_hash']}'),
...((room?['effects'] as List?) ?? []).map((e) => ListTile(
title:Text('${e['kind']} · ${e['target']}'), subtitle:Text('${e['status']} — ${e['id']}'),
trailing:button('Simulate ACK', 'ack', entity:e['id'], allow:e['status']=='REQUESTED'))),
if (trace != null) ...((trace!['trace'] as List).reversed.take(20)).map((e) => ListTile(
leading:Icon(e['accepted'] == true ? Icons.check_circle_outline : Icons.block),
title:Text('${e['request']['input']['action']} ${e['request']['input']['entity']}'),
subtitle:Text(e['message']))),
]);
@override
Widget build(BuildContext context) {
final remaining = ((room?['limit_ms'] ?? 3600000) - (room?['active_ms'] ?? 0)) ~/ 1000;
return Scaffold(
appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')),
drawer: Drawer(child: SafeArea(child: navigation())),
body: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (MediaQuery.sizeOf(context).width >= 900) SizedBox(width: 230, child: navigation()),
Expanded(child: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 1450),
child: ListView(padding: const EdgeInsets.all(20), children: [
const Text('LAB SIMULATION • LIVE TIMER AFTER BRIEFING • NO GPIO',
style: TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
wrap([badge(lifecycle),badge(phase),badge('${room?['profile'] ?? 'standard'} / ${room?['party'] ?? 6} players'),badge('Remaining ${remaining ~/ 60}:${(remaining % 60).toString().padLeft(2,'0')}'),
badge(room?['assisted'] == true ? 'ASSISTED' : 'UNASSISTED'),
if (room?['outcome'] != null) badge(room!['outcome'],Colors.green)]),
if (lifecycle == 'READY') wrap([
DropdownButton<String>(value:profile,items:['guided','standard','challenge'].map((p) =>
DropdownMenuItem(value:p,child:Text(p))).toList(),onChanged:enabled ? (v)=>setState(()=>profile=v!):null),
DropdownButton<int>(value:party,items:[for(int i=1;i<=8;i++) DropdownMenuItem(value:i,child:Text('$i players'))],
onChanged:enabled ? (v)=>setState(()=>party=v!):null),
button('Apply profile', 'prepare', value:{'profile':profile,'party':party,'seed':42}),
]),
wrap([
button('Pause','pause',allow:lifecycle=='RUNNING'),
button('Resume','resume',allow:['PAUSED','RECOVERY_HOLD'].contains(lifecycle)),
button('Extend 5 minutes','extend',value:5),
TextButton(onPressed:enabled ? ()=>withReason('finish_assisted',''):null,child:const Text('Finish assisted...')),
TextButton(onPressed:enabled ? confirmReset:null,child:const Text('Reset simulation...')),
]),
const SizedBox(height: 10),
Text(error ?? notice,style:TextStyle(color:error==null ? Colors.white70:Colors.orange)),
const Divider(height: 30),
const SizedBox(height: 16),
if (room == null) const LinearProgressIndicator()
else [overview,cabinets,ship,calibration,teddy,diagnostics][tab](),
])))),
]),
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'teddy_audio_stub.dart' if (dart.library.html) 'teddy_audio_web.dart';
@@ -0,0 +1,9 @@
class TeddyAudio {
bool enabled = false;
String status = 'Audio disabled on this device';
Future<void> enable() async { enabled = true; }
void sync(String? url) {}
void playNow(String url) { enabled = true; }
void disable() { enabled = false; }
void dispose() {}
}
@@ -0,0 +1,62 @@
// ignore_for_file: deprecated_member_use
import 'dart:html' as html;
import 'dart:convert';
import 'dart:typed_data';
import 'dart:math' as math;
class TeddyAudio {
final html.AudioElement _audio = html.AudioElement();
bool enabled = false;
String status = 'Audio disabled on this device';
String? _url;
int _generation = 0;
TeddyAudio() {
_audio.onEnded.listen((_) { status = 'Cue finished'; });
_audio.onError.listen((_) { status = 'Browser audio error ${_audio.error?.code}. Use Play current hint here to retry.'; });
}
Future<void> enable() async {
// Unlock this exact media element in the user's tap handler (required on phones).
final data = ByteData(16044);
void text(int offset, String value) { for (var i=0;i<value.length;i++) { data.setUint8(offset+i,value.codeUnitAt(i)); } }
text(0,'RIFF'); data.setUint32(4,16036,Endian.little); text(8,'WAVEfmt ');
data.setUint32(16,16,Endian.little); data.setUint16(20,1,Endian.little);
data.setUint16(22,1,Endian.little); data.setUint32(24,8000,Endian.little);
data.setUint32(28,16000,Endian.little); data.setUint16(32,2,Endian.little);
data.setUint16(34,16,Endian.little); text(36,'data'); data.setUint32(40,16000,Endian.little);
for (var i=0;i<8000;i++) { data.setInt16(44+i*2,(4000*math.sin(2*math.pi*440*i/8000)).round(),Endian.little); }
_audio.src = 'data:audio/wav;base64,${base64Encode(data.buffer.asUint8List())}';
try {
await _audio.play();
enabled = true;
_url = null;
status = 'Speaker test playing (one-second tone)';
} catch (_) {
enabled = false;
status = 'Browser blocked audio. Tap Enable audio again.';
}
}
void playNow(String url) { enabled = true; sync(url, force:true); }
void sync(String? url, {bool force=false}) {
if (!enabled || (!force && url == _url)) return;
_generation++;
final generation = _generation;
_audio.pause();
_url = url;
if (url == null) { status = 'Waiting for Teddy'; return; }
_audio.src = url;
status = 'Loading Teddy audio';
_audio.play().then((_) {
if (generation == _generation) status = 'Playing on this device';
}).catchError((Object error) {
if (generation == _generation) status = 'Playback blocked or unavailable. Tap Play current hint here.';
});
}
void disable() {
_generation++;
enabled = false;
_url = null;
_audio.pause();
status = 'Audio disabled on this device';
}
void dispose() { disable(); _audio.src = ''; }
}
+221
View File
@@ -0,0 +1,221 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
url: "https://pub.dev"
source: hosted
version: "15.4.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.11.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
+15
View File
@@ -0,0 +1,15 @@
name: glitch_gm_lab
description: Isolated Glitch game master integration experiment.
publish_to: none
version: 0.1.0+1
environment:
sdk: '>=3.9.2 <4.0.0'
dependencies:
flutter:
sdk: flutter
http: ^1.5.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
+1
View File
@@ -0,0 +1 @@
{"state":{"active_ms":10000,"armed":[],"assisted":false,"boss":"DARK","cell_faults":["SOCKET01 needs CELL01","CELL01 is not charged","SOCKET02 needs CELL02","CELL02 is not charged","SOCKET03 needs CELL03","CELL03 is not charged","SOCKET04 needs CELL04","CELL04 is not charged","SOCKET05 needs CELL05","CELL05 is not charged","SOCKET06 needs CELL06","CELL06 is not charged"],"config_hash":"38b54e5bdcdc9df3b9852cf204bcb5b1973018d94bc4ad04565b7bb959f68d20","countdown":null,"effects":[{"id":"sim-1792303569839-next:token.dispense:T01","kind":"token.dispense","status":"OBSERVED","target":"T01"}],"groups":[],"hold":"","last_progress":0,"launch_epoch":0,"launches":0,"lifecycle":"RUNNING","limit_ms":3600000,"location_since":{},"locations":{"BAY01":"CELL01","BAY02":"CELL02","BAY03":"CELL03","BAY04":"CELL04","BAY05":"CELL05","BAY06":"CELL06"},"operators":5,"outcome":null,"party":6,"passage_open":false,"passage_since":null,"phase":"ARCADE","power_since":null,"profile":"standard","puzzles":[{"delivered":false,"id":"P01","solved":true,"token":""},{"delivered":true,"id":"P02A","solved":true,"token":"T01"},{"delivered":false,"id":"P02B","solved":false,"token":"T02"},{"delivered":false,"id":"P03","solved":false,"token":"T03"},{"delivered":false,"id":"P04","solved":false,"token":"T04"},{"delivered":false,"id":"P05","solved":false,"token":"T05"},{"delivered":false,"id":"P06","solved":false,"token":"T06"}],"ready_deadline":null,"reveal_at":0,"revision":100,"run_id":"sim-1792303569839-next","seed":42,"ship_power":false,"simulation":true,"stations":[{"attempt":1,"authorized":true,"bay_open":false,"cell":"CELL01","charge_ms":10000,"charging":true,"game":"pacman","id":"cabinet_01","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET01","status":"PLAYING","target":500,"token":"T01","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL02","charge_ms":0,"charging":false,"game":"galaga","id":"cabinet_02","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET02","status":"TOKEN_WAIT","target":500,"token":"T02","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL03","charge_ms":0,"charging":false,"game":"tempest","id":"cabinet_03","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET03","status":"TOKEN_WAIT","target":500,"token":"T03","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL04","charge_ms":0,"charging":false,"game":"missile_command","id":"cabinet_04","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET04","status":"TOKEN_WAIT","target":500,"token":"T04","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL05","charge_ms":0,"charging":false,"game":"frogger","id":"cabinet_05","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET05","status":"TOKEN_WAIT","target":1,"token":"T05","won":false},{"attempt":0,"authorized":false,"bay_open":false,"cell":"CELL06","charge_ms":0,"charging":false,"game":"space_invaders","id":"cabinet_06","last_target":null,"losses":0,"online":true,"passes":0,"progress":0,"retry_at":0,"socket":"SOCKET06","status":"TOKEN_WAIT","target":500,"token":"T06","won":false}],"teddy":{"current":null,"hint_level":0,"mode":"GUIDE","muted":false,"next_voice_at":0,"online":true,"played":["welcome"],"queue":[],"takeover":false,"transcript":"I remember this place full of people. The machines remember too. Find their tokens. Each one belongs to only one machine."},"tutorial":0,"wall_ms":10000,"wave":null,"wave_serial":0}}
@@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:glitch_gm_lab/hint_panel.dart';
void main() {
test('suggestions follow parallel branches and claw prerequisites', () {
final room=<String,dynamic>{'phase':'ARCADE','puzzles':[
{'id':'P01','solved':false,'delivered':false},
{'id':'P02A','solved':false,'delivered':false},
{'id':'P02B','solved':false,'delivered':false},
{'id':'P03','solved':false,'delivered':false},
{'id':'P04','solved':false,'delivered':false},
]};
Map<String,dynamic> hint(String target) => {'phase':'ARCADE','target':target,'condition':'incomplete'};
expect(hintReason(hint('P01'),room),isNotNull);
expect(hintReason(hint('P03'),room),isNotNull);
expect(hintReason(hint('P04'),room),isNotNull);
expect(hintReason(hint('P02A'),room),isNull);
(room['puzzles'] as List)[0]['solved']=true;
expect(hintReason(hint('P01'),room),isNull);
expect(hintReason(hint('P02A'),room),isNotNull);
expect(hintReason(hint('P02B'),room),isNull);
(room['puzzles'] as List)[1]['delivered']=true;
expect(hintReason(hint('P02B'),room),isNotNull);
room['phase']='CELLS';
expect(hintReason(hint('P03'),room),isNull);
expect(hintReason({'phase':'ALL','target':'room','condition':'any'},room),isNotNull);
});
test('cabinet first wins remove incomplete hints', () {
final room=<String,dynamic>{'phase':'ARCADE','stations':[{'id':'cabinet_01','won':false}]};
final hint=<String,dynamic>{'phase':'ALL','target':'cabinet_01','condition':'incomplete'};
expect(hintReason(hint,room),isNotNull);
(room['stations'] as List)[0]['won']=true;
expect(hintReason(hint,room),isNull);
});
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:glitch_gm_lab/room_page.dart';
void main() {
for (final width in [390.0, 900.0, 1400.0]) {
testWidgets('room panels fit at width $width and use authoritative revision', (tester) async {
WidgetController.hitTestWarningShouldBeFatal = true;
addTearDown(() => WidgetController.hitTestWarningShouldBeFatal = false);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = Size(width, 1000);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final data = jsonDecode(File('test/fixtures/room.json').readAsStringSync());
final state = data['state'] as Map<String, dynamic>;
Map<String, dynamic>? sent;
final client = MockClient((request) async {
if (request.method == 'POST') {
sent = jsonDecode(request.body);
return http.Response(jsonEncode({'accepted':true,'message':'test accepted','state':state}), 200);
}
if (request.url.path.endsWith('trace')) {
return http.Response(jsonEncode({'trace':[],'final_hash':'fixture'}),200);
}
return http.Response(jsonEncode({'state':state}),200);
});
await tester.pumpWidget(MaterialApp(home:RoomPage(client:client)));
await tester.pump();
expect(find.textContaining('Connected. The room timer'), findsOneWidget);
expect(tester.takeException(), isNull);
Future<void> select(String name) async {
if (width < 900) { await tester.tap(find.byTooltip('Open navigation menu')); await tester.pumpAndSettle(); }
await tester.ensureVisible(find.text(name)); await tester.tap(find.text(name)); await tester.pumpAndSettle();
}
await select('Diagnostics');
await tester.ensureVisible(find.text('+ 1 second'));
await tester.tap(find.text('+ 1 second'));
await tester.pump();
expect(sent?['expected_revision'], state['revision']);
expect(sent?['run_id'], state['run_id']);
for (final tab in ['Cabinets','Ship & Cells','Calibration & Launch','Teddy','Diagnostics','Room Flow']) {
await select(tab);
expect(tester.takeException(), isNull, reason:tab);
}
state['phase']='CALIBRATION';
state['wave']={'id':99,'targets':{'cabinet_01':2},'ready':{},'deadline':40000,'retry_at':0};
await tester.pump(const Duration(seconds:1));
await tester.pump();
await select('Calibration & Launch');
expect(find.text('Simulate held Ready'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
testWidgets('disconnected room disables mutating controls', (tester) async {
await tester.pumpWidget(MaterialApp(home:RoomPage(client:MockClient((_) async => http.Response('{}',503)))));
await tester.pump();
expect(find.textContaining('disconnected'),findsOneWidget);
for (final button in tester.widgetList<OutlinedButton>(find.byType(OutlinedButton))) {
expect(button.onPressed,isNull);
}
await tester.pumpWidget(const SizedBox());
});
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:glitch_gm_lab/main.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
testWidgets('commands remain disabled without cabinet state', (tester) async {
final client = MockClient((_) async => http.Response('{}', 503));
await tester.pumpWidget(MaterialApp(home: CabinetPage(client: client)));
await tester.pump();
expect(find.text('INTEGRATION LAB - SIMULATED CABINET'), findsOneWidget);
expect(find.text('Power on'), findsOneWidget);
for (final button in tester.widgetList<FilledButton>(find.byType(FilledButton))) {
expect(button.onPressed, isNull);
}
expect(find.textContaining('Disconnected'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
}
@@ -0,0 +1,7 @@
{{flutter_js}}
{{flutter_build_config}}
// The live lab must not retain an older operator UI across local rebuilds.
for (const build of _flutter.buildConfig.builds) {
if (build.mainJsPath) build.mainJsPath += '?v=' + Date.now();
}
_flutter.loader.load();
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Glitch GM Lab | Escape Gallery</title>
</head>
<body><script>
const bootstrap = document.createElement('script');
bootstrap.src = 'flutter_bootstrap.js?v=' + Date.now();
bootstrap.async = true;
document.body.appendChild(bootstrap);
</script></body>
</html>
+104
View File
@@ -0,0 +1,104 @@
"""Real Godot / Rust / NATS smoke test; no physical hardware. Stop other labs first."""
import os
from pathlib import Path
import shutil
import socket
import subprocess
import tempfile
import time
import urllib.error
import lab
ROOT = Path(__file__).resolve().parent
PROJECT = ROOT.parents[1]
def main():
for port in (*lab.PORTS, 18768):
with socket.socket() as sock:
sock.bind(("127.0.0.1", port))
nats = shutil.which("nats-server")
godot = os.environ.get("GLITCH_LAB_GODOT") or str(PROJECT / "Godot_v4.7.2-stable_win64_console.exe")
binary = ROOT / "target/debug" / ("glitch-odin-lab.exe" if os.name == "nt" else "glitch-odin-lab")
if not nats or not Path(godot).is_file() or not binary.is_file():
raise RuntimeError("Need nats-server, built Rust lab, and Godot (or GLITCH_LAB_GODOT)")
children = []
with tempfile.TemporaryFile(mode="w+b") as log:
def spawn(args):
p = subprocess.Popen(args, cwd=ROOT, stdout=log, stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
children.append(p)
return p
def launch_game(inject):
return spawn([godot, "--headless", "--path", str(PROJECT),
"res://tests/odin_lab/cabinet.tscn", "--", "--glitch-lab",
*(["--lab-inject-rfid"] if inject else [])])
def state():
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
try:
return lab.api("/state")["state"]
except (OSError, ValueError):
time.sleep(.1)
raise RuntimeError("No cabinet state after startup")
try:
spawn([nats, "-c", str(ROOT / "nats.conf")])
deadline = time.monotonic() + 10
while True:
try:
with socket.create_connection(("127.0.0.1", 14222), timeout=.2):
break
except OSError:
if time.monotonic() > deadline: raise
time.sleep(.1)
game = launch_game(True)
spawn([str(binary), "godot"])
spawn([str(binary), "controller"])
initial = state()
assert initial["simulated"] is False and initial["phase"] == "powered_off"
session = initial["session"]
def send(action):
return lab.api("/command", lab.command(action, session))
assert not send("start")["accepted"]
assert send("power_on")["state"]["phase"] == "booting"
assert not send("scan")["accepted"]
deadline = time.monotonic() + 8
while state()["phase"] != "ready":
assert time.monotonic() < deadline, "Boot did not finish"
time.sleep(.1)
assert not send("start")["accepted"]
scan = lab.command("scan", session)
assert lab.api("/command", scan)["state"]["credits"] == 1
replay = lab.api("/command", scan)
assert replay["duplicate"] and replay["state"]["scans"] == 1
assert send("start")["state"]["phase"] == "playing"
assert not send("win")["accepted"], "Remote synthetic win must be disabled"
assert send("reset")["state"]["phase"] == "powered_off"
game.terminate()
game.wait(timeout=5)
try:
lab.api("/state")
raise AssertionError("Offline cabinet falsely returned state")
except urllib.error.HTTPError as error:
assert error.code == 503
launch_game(False)
restarted = state()
assert restarted["session"] != session and not restarted["rfid_injection"]
assert not send("power_on")["accepted"], "Old session accepted"
session = restarted["session"]
assert not send("scan")["accepted"]
print("PASS: HTTP -> Rust -> NATS -> sidecar -> real Godot, boot/credit/start/reset, offline and restart")
finally:
for child in reversed(children):
if child.poll() is None:
child.terminate()
for child in children:
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
log.seek(0)
print(log.read().decode("utf-8", errors="replace")[-4000:])
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""Persistent, revision-checked hint library for the lab. Text is data, never executable."""
import json
import os
from pathlib import Path
import threading
import uuid
ROOT = Path(__file__).resolve().parent
PATH = ROOT / ".lab-data" / "hints.json"
LOCK = threading.Lock()
PHASES = ["ALL","BRIEFING","ARCADE","BOSS","REVEAL","CELLS","CALIBRATION_TUTORIAL","CALIBRATION","LAUNCH_ARM","COUNTDOWN","DEBRIEF"]
TARGETS = ["room"] + ["P01","P02A","P02B","P03","P04","P05","P06"] + [f"cabinet_{i:02}" for i in range(1,7)]
def defaults():
hints=[]
for id,title,text in [
("P01","Claw controls","Start by investigating the claw machine controls."),
("P02A","First claw reward","Now that the controls work, try retrieving the first token."),
("P02B","Second claw reward","The first token has arrived. The claw still has another reward."),
("P03","Records and sleeves","Look at the records together with their sleeves."),
("P04","Service panel","Compare the symbols on the service panel."),
("P05","Ticket values","Look closely at the values on the tickets."),
("P06","Poster clues","There may be more to the poster when you look at its layers.")]:
hints.append({"id":"default-"+id,"title":title,"text":text,"categories":["Discovery"],"phase":"ARCADE","target":id,"condition":"incomplete","level":1})
for id,title,text,phase,category in [
("tokens","Matching tokens","Each token belongs to one machine. Look for the cabinet name.","ARCADE","Arcade"),
("cells","Power cells","Charge each cell, remove it from its bay, then place it in its matching ship socket.","CELLS","Ship"),
("practice","Practice together","Name the station, repeat the target, hold it steady, press Ready, then Execute.","CALIBRATION_TUTORIAL","Calibration"),
("calibration","Team coordination","Read back the target before pressing Ready. The navigator presses Execute when the team is ready.","CALIBRATION","Calibration"),
("launch","Final launch","Arm the stations in order. Confirm each group is ready before the navigator commits the launch.","LAUNCH_ARM","Finale")]:
hints.append({"id":"default-"+id,"title":title,"text":text,"categories":[category],"phase":phase,"target":"room","condition":"any","level":1})
return {"revision":0,"hints":hints}
def read():
with LOCK:
return _read()
def _read():
if not PATH.exists(): return defaults()
data=json.loads(PATH.read_text(encoding="utf-8"))
if not isinstance(data,dict) or not isinstance(data.get("revision"),int) or not isinstance(data.get("hints"),list):
raise ValueError("Hint file is invalid; it was not overwritten")
return data
class Conflict(ValueError): pass
def validate(hint):
if not isinstance(hint,dict): raise ValueError("Missing hint")
clean={}
for key,limit in (("title",100),("text",1500)):
value=hint.get(key)
if not isinstance(value,str) or not 1<=len(value.strip())<=limit:
raise ValueError(f"{key} must contain 1-{limit} characters")
clean[key]=value.strip()
categories=hint.get("categories",[])
if not isinstance(categories,list) or not 1<=len(categories)<=8 or any(not isinstance(x,str) or not 1<=len(x.strip())<=40 for x in categories):
raise ValueError("Use 1-8 category names, each up to 40 characters")
clean["categories"]=list(dict.fromkeys(x.strip() for x in categories))
for key,allowed in (("phase",PHASES),("target",TARGETS),("condition",["any","incomplete","complete"]),("level",[1,2,3])):
if hint.get(key) not in allowed: raise ValueError("Invalid "+key)
clean[key]=hint[key]
return clean
def save(body):
if not isinstance(body,dict): raise ValueError("Expected a hint object")
hint=validate(body.get("hint"))
with LOCK:
data=_read()
if body.get("revision")!=data["revision"]: raise Conflict("Hints changed on another device. Reload the library, then save your draft again.")
id=body["hint"].get("id","")
if id:
index=next((i for i,h in enumerate(data["hints"]) if h["id"]==id),None)
if index is None: raise ValueError("Hint no longer exists")
data["hints"][index]={"id":id,**hint}
else:
if len(data["hints"])>=1000: raise ValueError("Library is limited to 1000 hints")
data["hints"].append({"id":uuid.uuid4().hex,**hint})
data["revision"]+=1
PATH.parent.mkdir(parents=True,exist_ok=True)
temp=PATH.with_suffix(".tmp")
with temp.open("w",encoding="utf-8") as stream:
json.dump(data,stream,ensure_ascii=False,indent=2)
stream.flush()
os.fsync(stream.fileno())
if PATH.exists():
PATH.with_suffix(".bak").write_bytes(PATH.read_bytes())
temp.replace(PATH)
return data
+235
View File
@@ -0,0 +1,235 @@
"""Local-only Glitch lab orchestration; never contacts a Pi or Odin."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parent
BASE = "http://127.0.0.1:18766"
TOOLS = ("cargo", "flutter", "nats-server")
PORTS = (14222, 18766, 18767)
def preflight():
missing = []
for name in TOOLS:
path = shutil.which(name)
print(f"{name}: {path or 'MISSING - install and add to PATH'}")
if not path:
missing.append(name)
if missing:
print("See README.md prerequisites. Nothing was started.")
return False
return True
def run_checked(args, cwd=ROOT):
# Flutter ships as a batch file on Windows; cmd is used solely for its fixed CLI.
if os.name == "nt" and str(args[0]).lower().endswith((".bat", ".cmd")):
args = ["cmd.exe", "/d", "/c", *args]
subprocess.run(args, cwd=cwd, check=True)
def api(path, body=None, base=BASE):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(base + path, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=5) as response:
return json.load(response)
def command(action, session, command_id=None):
return {"id": command_id or str(uuid.uuid4()), "session": session,
"expires_ms": int(time.time() * 1000) + 10000, "action": action}
def smoke():
state = api("/state")["state"]
assert state["simulated"] is True, "Refusing to test a real cabinet"
session = state["session"]
def send(action):
return api("/command", command(action, session))
assert send("reset")["accepted"]
assert not send("start")["accepted"], "Start must require power and credit"
power = command("power_on", session)
assert api("/command", power)["state"]["phase"] == "booting"
assert api("/command", power)["duplicate"], "Duplicate command must be acknowledged"
assert not send("scan")["accepted"], "Boot-period scans must not grant credit"
deadline = time.monotonic() + 6
while time.monotonic() < deadline:
state = api("/state")["state"]
if state["phase"] == "ready":
break
time.sleep(0.1)
assert state["phase"] == "ready" and state["credits"] == 0
assert not send("start")["accepted"]
scan = command("scan", session)
assert api("/command", scan)["state"]["credits"] == 1
replay = api("/command", scan)
assert replay["duplicate"] and replay["state"]["scans"] == 1
assert send("start")["state"]["phase"] == "playing"
assert send("win")["state"]["phase"] == "won"
assert not api("/command", command("reset", "stale-session"))["accepted"]
expired = command("reset", session)
expired["expires_ms"] = 1
assert not api("/command", expired)["accepted"]
assert send("reset")["state"]["phase"] == "powered_off"
print("PASS: real HTTP -> Rust -> NATS -> MessagePack -> simulated cabinet flow")
def build():
run_checked([shutil.which("cargo"), "test"])
run_checked([shutil.which("cargo"), "build"])
flutter = shutil.which("flutter")
run_checked([flutter, "pub", "get"], ROOT / "gm")
run_checked([flutter, "analyze"], ROOT / "gm")
run_checked([flutter, "test"], ROOT / "gm")
run_checked([flutter, "build", "web", "--release"], ROOT / "gm")
def start(godot=False, room=False, speech=False, lan=False, speech_output="browser", speech_backend="chatterbox"):
if speech:
import teddy_speech
if not room:
raise RuntimeError("--speech requires start-room")
if speech_backend == "chatterbox":
from chatterbox_client import REFERENCE
if not REFERENCE.exists():
raise RuntimeError("Missing Teddy Chatterbox reference; see TEDDY_TRAINING.md")
if speech_backend == "piper" and (not teddy_speech.python_path().exists() or not teddy_speech.MODEL.exists()):
raise RuntimeError("Install the lab voice first: py -3 lab.py setup-speech")
# Fail before launching anything if another process owns a lab port.
ports = (14232, 18776, 18777) if room else PORTS
check_ports = (*ports, 18768) if godot else ports
if speech:
check_ports = (*check_ports, 18778)
for port in check_ports:
with socket.socket() as sock:
sock.bind(("127.0.0.1", port))
binary = ROOT / "target" / "debug" / ("glitch-odin-lab.exe" if os.name == "nt" else "glitch-odin-lab")
if not binary.exists() or not (ROOT / "gm/build/web/index.html").exists():
raise RuntimeError("Build the lab first: python lab.py build")
processes = []
def spawn(args):
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
process = subprocess.Popen(args, cwd=ROOT, creationflags=flags)
processes.append(process)
return process
try:
broker = spawn([shutil.which("nats-server"), "-c", str(ROOT / ("room-nats.conf" if room else "nats.conf"))])
deadline = time.monotonic() + 5
while True:
if broker.poll() is not None:
raise RuntimeError("NATS exited; see its output above")
try:
with socket.create_connection(("127.0.0.1", ports[0]), timeout=0.2):
break
except OSError:
if time.monotonic() >= deadline:
raise RuntimeError("NATS did not become ready")
time.sleep(0.1)
if room:
spawn([str(binary), "room"])
elif godot:
executable = ROOT.parents[1] / "Godot_v4.7.2-stable_win64_console.exe"
godot_path = os.environ.get("GLITCH_LAB_GODOT") or str(executable)
if not Path(godot_path).is_file():
raise RuntimeError("Set GLITCH_LAB_GODOT to the Godot executable path")
spawn([godot_path, "--path", str(ROOT.parents[1]), "--windowed",
"res://tests/odin_lab/cabinet.tscn", "--", "--glitch-lab", "--lab-inject-rfid"])
spawn([str(binary), "godot"])
else:
spawn([str(binary), "cabinet"])
if not room:
spawn([str(binary), "controller"])
if room:
spawn([sys.executable, str(ROOT / "room_web.py"), "--bind", "0.0.0.0" if lan else "127.0.0.1"])
else:
spawn([sys.executable, "-m", "http.server", str(ports[2]), "--bind", "127.0.0.1",
"--directory", str(ROOT / "gm/build/web")])
deadline = time.monotonic() + 10
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("A lab process exited during startup")
try:
api("/room/state" if room else "/state", base=f"http://127.0.0.1:{ports[1]}")
break
except (OSError, ValueError):
if time.monotonic() >= deadline:
raise RuntimeError("Controller/cabinet did not become ready")
time.sleep(0.2)
if speech:
spawn([sys.executable, str(ROOT / "teddy_speech.py"), "follow", "--backend", speech_backend, "--output-device", speech_output])
deadline = time.monotonic() + 180
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("Teddy speech failed to start")
try:
health = api("/health", base="http://127.0.0.1:18778")
if health.get("status") == "error":
raise RuntimeError(health.get("message", "Teddy failed to load"))
if health.get("status") != "starting":
break
if time.monotonic() >= deadline:
raise RuntimeError("Teddy model loading timed out; see .speech/chatterbox.log")
time.sleep(.2)
except (OSError, ValueError):
if time.monotonic() >= deadline:
raise RuntimeError("Teddy speech did not become ready")
time.sleep(0.2)
url = f"http://127.0.0.1:{ports[2]}/" + ("?room=1" if room else "")
print(f"READY: {url} - press Ctrl+C to stop all lab processes", flush=True)
if lan:
print("LAN DEMO: open http://<this-PC-Wi-Fi-IPv4>:18777/?room=1 on your phone. Shared simulation controls; enable browser audio on each listening device.", flush=True)
while True:
if any(p.poll() is not None for p in processes):
raise RuntimeError("A lab process exited; stopping this lab")
time.sleep(0.5)
finally:
for process in reversed(processes):
if process.poll() is None:
process.terminate()
for process in processes:
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("preflight", "build", "start", "start-godot", "start-room", "setup-speech", "smoke"))
parser.add_argument("--speech", action="store_true", help="Play Teddy cues using Chatterbox (default) or Piper")
parser.add_argument("--lan", action="store_true", help="Expose only the room demo webpage/gateway to the LAN")
parser.add_argument("--speech-backend", choices=("chatterbox", "piper"), default="chatterbox")
parser.add_argument("--speech-output", choices=("browser","local"), default="browser")
args = parser.parse_args()
mode = args.mode
if args.lan and mode != "start-room":
parser.error("--lan is only supported with start-room")
if args.speech and mode != "start-room":
parser.error("--speech is only supported with start-room")
if mode == "setup-speech":
import teddy_speech
teddy_speech.setup()
return 0
if mode == "smoke":
smoke()
return 0
if not preflight():
return 1
if mode == "build":
build()
elif mode in ("start", "start-godot", "start-room"):
start(godot=mode == "start-godot", room=mode == "start-room", speech=args.speech, lan=args.lan, speech_output=args.speech_output, speech_backend=args.speech_backend)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nLab stopped.")
except Exception as error:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)
+3
View File
@@ -0,0 +1,3 @@
host: "127.0.0.1"
port: 14222
server_name: "glitch-local-lab"
+86
View File
@@ -0,0 +1,86 @@
"""Create conservative draft Piper clips using local ASR word timestamps.
Run with .training/venv/Scripts/python.exe. No audio is uploaded.
Only sentences matching the supplied transcript exactly after normalization
are exported. Review timing and excluded sentences before training.
"""
import difflib
import json
import re
from pathlib import Path
import numpy as np
from scipy.io import wavfile
from scipy.signal import resample_poly
from faster_whisper import WhisperModel
ROOT = Path(__file__).resolve().parent / ".training" / "teddy-v1"
def tokens(text):
return re.findall(r"[a-z0-9]+(?:'[a-z]+)?", text.lower().replace("’", "'"))
def main():
text = (ROOT / "teddy-transcript.txt").read_text(encoding="utf-8-sig")
cache = ROOT / "asr-words.json"
if cache.exists():
words = json.loads(cache.read_text(encoding="utf-8"))
else:
model = WhisperModel("small.en", device="cpu", compute_type="int8", cpu_threads=8,
download_root=str(ROOT.parent / "models"))
rate, source = wavfile.read(ROOT / "TeddyFull.wav")
assert source.ndim == 1 and source.dtype == np.int16
recognition_audio = resample_poly(source.astype(np.float32) / 32768, 16000, rate)
segments, _ = model.transcribe(recognition_audio, language="en",
word_timestamps=True, vad_filter=True, beam_size=5)
words = []
for segment in segments:
for word in segment.words:
words.append({"text":word.word, "start":word.start, "end":word.end})
print(f"Aligned through {segment.end:.1f} seconds", flush=True)
cache.write_text(json.dumps(words, indent=2), encoding="utf-8")
recognized = []
for word in words:
for token in tokens(word["text"]):
recognized.append(dict(word, token=token))
reference = tokens(text)
matcher = difflib.SequenceMatcher(None, reference, [w["token"] for w in recognized], autojunk=False)
mapping = {}
for block in matcher.get_matching_blocks():
for offset in range(block.size):
mapping[block.a+offset] = block.b+offset
rate, pcm = wavfile.read(ROOT / "TeddyFull.wav")
assert pcm.ndim == 1 and pcm.dtype == np.int16
audio = pcm.astype(np.float64) / 32768
clips = ROOT / "audio"
clips.mkdir(exist_ok=True)
accepted, excluded, rows = [], [], []
cursor = 0
for index, sentence in enumerate(re.split(r"(?<=[.!?])\s+", text.strip()), 1):
count = len(tokens(sentence))
positions = [mapping.get(i) for i in range(cursor,cursor+count)]
cursor += count
if not count: continue
if any(i is None for i in positions) or positions != list(range(positions[0],positions[0]+count)):
excluded.append({"sentence":index,"text":sentence,"reason":"Transcript / recognition mismatch; review required"})
continue
first,last = positions[0],positions[-1]
start = max(0,recognized[first]["start"]-.10)
end = min(len(audio)/rate,recognized[last]["end"]+.10)
# Do not include neighboring spoken words in the padding.
if first: start=max(start,(recognized[first-1]["end"]+recognized[first]["start"])/2)
if last+1<len(recognized): end=min(end,(recognized[last]["end"]+recognized[last+1]["start"])/2)
if not .7 <= end-start <= 15:
excluded.append({"sentence":index,"text":sentence,"reason":"Duration outside 0.7-15 seconds"})
continue
clip = resample_poly(audio[int(start*rate):int(end*rate)],22050,rate)
filename=f"teddy_{index:04}.wav"
wavfile.write(clips/filename,22050,np.clip(clip*32768,-32768,32767).astype(np.int16))
rows.append(filename+"|"+sentence.replace("\n"," "))
accepted.append({"file":filename,"start":start,"end":end,"text":sentence})
(ROOT/"metadata.draft.csv").write_text("\n".join(rows)+"\n",encoding="utf-8")
report={"status":"DRAFT - inspect timing and transcript before training", "matched_word_ratio":len(mapping)/len(reference),
"source_seconds":len(audio)/rate,"source_peak":float(np.max(np.abs(audio))),
"source_clipped_samples":int(np.sum(np.abs(audio)>=32767/32768)),
"clip_count":len(accepted),"clip_seconds":sum(c["end"]-c["start"] for c in accepted),
"clips":accepted,"excluded":excluded}
(ROOT/"alignment-report.json").write_text(json.dumps(report,indent=2),encoding="utf-8")
print(json.dumps({k:v for k,v in report.items() if k not in ("clips","excluded")},indent=2))
print(f"Excluded {len(excluded)} sentences for review")
if __name__ == "__main__": main()
+3
View File
@@ -0,0 +1,3 @@
host: "127.0.0.1"
port: 14232
server_name: "glitch-room-simulation"
+154
View File
@@ -0,0 +1,154 @@
"""Owns an isolated room broker/controller for a full simulated run and trace replay."""
import argparse
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parent
BASE = "http://127.0.0.1:18776"
PORTS = (14232, 18776, 18777)
def api(path, body=None):
req = urllib.request.Request(BASE + path,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as response:
return json.load(response)
def action(name, entity="", value=None):
state = api("/room/state")["state"]
payload = {"command_id":str(uuid.uuid4()),"run_id":state["run_id"],
"expected_revision":state["revision"],"expires_at_ms":int(time.time()*1000)+10000,
"input":{"action":name,"entity":entity,"value":value}}
result = api("/room/command", payload)
assert result["accepted"], (name, result["message"])
return result["state"]
def golden():
action("prepare", value={"profile":"standard","party":6,"seed":42})
action("start_run")
action("briefing_done")
for puzzle, token in [("P01",None),("P02A","T01"),("P02B","T02"),("P03","T03"),("P04","T04"),("P05","T05"),("P06","T06")]:
action("solve", puzzle)
if token: action("deliver", token)
for i in range(1,7):
cabinet=f"cabinet_{i:02}"
action("token", cabinet, {"token":f"T{i:02}","age_ms":0})
action("start", cabinet)
state=action("advance", value=30000)
for station in state["stations"]:
action("win", station["id"], {"attempt":station["attempt"],"progress":station["target"]})
action("boss_start")
state=action("boss_win")
effect=next(e for e in state["effects"] if e["kind"]=="passage.release")
assert not action("ack", effect["id"])["passage_open"]
action("passage", value=True)
state=action("advance", value=300)
for station in state["stations"]:
action("bay", station["id"])
action("cell","BAY"+station["cell"][4:],{"cell":station["cell"],"present":False})
action("cell",station["socket"],{"cell":station["cell"],"present":True})
state=action("advance", value=2500)
while state["phase"] in ("CALIBRATION_TUTORIAL","CALIBRATION"):
wave=state["wave"]
for station,target in wave["targets"].items():
action("ready",station,{"challenge":wave["id"],"selection":target,"stable_ms":2000})
state=action("execute", value=wave["id"])
assert state["phase"]=="LAUNCH_ARM"
epoch=state["launch_epoch"]
for station in state["stations"]:
action("arm",station["id"],epoch)
for group in range(state["operators"]):
action("group_ready",value={"epoch":epoch,"group":group})
action("commit",value=epoch)
state=action("advance",value=5000)
assert state["lifecycle"]=="WON" and state["launches"]==1
assert all(s["passes"]==2 for s in state["stations"])
return state
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("--preview",action="store_true",help="After tests, serve GM UI for up to 10 minutes; create qa.stop to stop early")
args=parser.parse_args()
for port in PORTS:
with socket.socket() as sock: sock.bind(("127.0.0.1",port))
nats=shutil.which("nats-server")
binary=ROOT/"target/debug"/("glitch-odin-lab.exe" if os.name=="nt" else "glitch-odin-lab")
if not nats or not binary.is_file(): raise RuntimeError("Install NATS and build the Rust lab first")
children=[]
output=ROOT/"evidence"
output.mkdir(exist_ok=True)
with tempfile.TemporaryFile(mode="w+b") as log:
def spawn(argv):
child=subprocess.Popen(argv,cwd=ROOT,stdout=log,stderr=log,
creationflags=subprocess.CREATE_NO_WINDOW if os.name=="nt" else 0)
children.append(child)
return child
try:
spawn([nats,"-c",str(ROOT/"room-nats.conf")])
deadline=time.monotonic()+10
while True:
try:
with socket.create_connection(("127.0.0.1",14232),timeout=.2): break
except OSError:
if time.monotonic()>deadline: raise
time.sleep(.1)
spawn([str(binary),"room"])
deadline=time.monotonic()+20
while True:
try:
api("/room/state")
break
except OSError:
if time.monotonic()>deadline: raise
time.sleep(.1)
state=golden()
trace=api("/room/trace")
trace_path=output/"room-golden-trace.json"
trace_path.write_text(json.dumps(trace,indent=2),encoding="utf-8")
subprocess.run([str(binary),"replay-room",str(trace_path)],check=True)
(output/"room-result.json").write_text(json.dumps({
"status":"passed","transport":"HTTP -> Rust -> NATS -> Rust room reducer",
"lifecycle":state["lifecycle"],"launches":state["launches"],
"passes":[s["passes"] for s in state["stations"]],
"final_hash":trace["final_hash"],"trace_events":len(trace["trace"])
},indent=2),encoding="utf-8")
print("PASS: complete seven-cabinet room, puzzles, cells, practices, calibration, launch, and offline trace replay",flush=True)
if args.preview:
action("reset",value={"confirmed":True})
action("start_run")
action("briefing_done")
action("solve","P01")
action("solve","P02A")
action("deliver","T01")
action("token","cabinet_01",{"token":"T01","age_ms":0})
action("start","cabinet_01")
action("advance",value=10000)
spawn([sys.executable,str(ROOT/"room_web.py")])
stop=ROOT/"qa.stop"
if stop.exists(): stop.unlink()
print("PREVIEW: http://127.0.0.1:18777/?room=1",flush=True)
deadline=time.monotonic()+600
while time.monotonic()<deadline and not stop.exists():
time.sleep(.2)
if stop.exists(): stop.unlink()
finally:
for p in reversed(children):
if p.poll() is None: p.terminate()
for p in children:
try: p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill();p.wait()
log.seek(0)
text=log.read().decode("utf-8",errors="replace")
(output/"room-runtime.log").write_text(text,encoding="utf-8")
if __name__=="__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
"""Same-origin static UI/gateway for the isolated room demo; never proxies arbitrary URLs."""
import argparse
import json
import hint_library
import re
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
import urllib.request
import urllib.error
ROOT = Path(__file__).resolve().parent
ROUTES = {"/room/state": (18776, "/room/state"), "/room/trace": (18776, "/room/trace"),
"/room/command": (18776, "/room/command"), "/speech/health": (18778, "/health")}
class Handler(SimpleHTTPRequestHandler):
def list_directory(self, path):
self.send_error(403)
def relay(self):
path = urlsplit(self.path).path
route = ROUTES.get(path)
if re.fullmatch(r"/speech/audio/[a-f0-9]{64}\.wav", path):
route = (18778, path.removeprefix("/speech"))
if not route or (self.command == "POST" and path != "/room/command") or (self.command == "GET" and path == "/room/command"):
self.send_error(404)
return
if self.command == "POST":
origin = self.headers.get("Origin")
if origin and origin != "http://" + self.headers.get("Host", ""):
self.send_error(403, "Use the room demo page to send commands")
return
try:
size = int(self.headers.get("Content-Length", "0"))
if size < 0 or size > 65536:
self.send_error(413)
return
body = self.rfile.read(size) if self.command == "POST" else None
request = urllib.request.Request(f"http://127.0.0.1:{route[0]}{route[1]}",
data=body, method=self.command, headers={"Content-Type":"application/json", **({"Range":self.headers["Range"]} if "Range" in self.headers else {})})
try:
with urllib.request.urlopen(request, timeout=8) as response:
data, status = response.read(), response.status
content_type = response.headers.get("Content-Type","application/json")
content_range = response.headers.get("Content-Range")
except urllib.error.HTTPError as error:
data, status = error.read(), error.code
content_type, content_range = "text/plain", None
self.send_response(status)
self.send_header("Content-Type",content_type)
self.send_header("Accept-Ranges","bytes")
if content_range:
self.send_header("Content-Range",content_range)
self.send_header("Content-Length",str(len(data)))
self.send_header("Cache-Control","no-store")
self.end_headers()
self.wfile.write(data)
except (OSError, ValueError):
self.send_error(503, "Local lab service unavailable")
def hints(self):
try:
if self.command == "POST":
origin=self.headers.get("Origin")
if origin and origin != "http://" + self.headers.get("Host",""):
self.send_error(403)
return
size=int(self.headers.get("Content-Length","0"))
if not 0<size<=16384:
self.send_error(413)
return
result=hint_library.save(json.loads(self.rfile.read(size)))
else:
result=hint_library.read()
status=200
except hint_library.Conflict as error:
result,status={"error":str(error)},409
except (ValueError, OSError) as error:
result,status={"error":str(error)},400
data=json.dumps(result).encode()
self.send_response(status)
self.send_header("Content-Type","application/json")
self.send_header("Content-Length",str(len(data)))
self.send_header("Cache-Control","no-store")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if urlsplit(self.path).path == "/hints":
return self.hints()
if urlsplit(self.path).path.startswith(("/room/", "/speech/")):
self.relay()
else:
super().do_GET()
def do_POST(self):
if urlsplit(self.path).path == "/hints":
return self.hints()
self.relay()
def end_headers(self):
self.send_header("X-Content-Type-Options","nosniff")
self.send_header("Cache-Control","no-store")
super().end_headers()
def log_message(self,*_): pass
if __name__ == "__main__":
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bind",default="127.0.0.1")
args=parser.parse_args()
server=ThreadingHTTPServer((args.bind,18777),partial(Handler,directory=str(ROOT/"gm/build/web")))
print(f"Room demo UI/gateway: http://{args.bind}:18777/?room=1",flush=True)
try: server.serve_forever()
finally: server.server_close()
+71
View File
@@ -0,0 +1,71 @@
"""Real Piper playback test against isolated room-state fixtures. Uses free loopback ports."""
import array
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import subprocess
import threading
import time
import urllib.request
import wave
import teddy_speech as speech
ROOT = Path(__file__).resolve().parent
STATE = {"run_id":"speech-smoke", "teddy":{"online":True,"muted":False,"takeover":False,"current":None}}
class Fixture(BaseHTTPRequestHandler):
def do_GET(self):
data=json.dumps({"state":STATE}).encode()
self.send_response(200)
self.send_header("Content-Length",str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self,*_): pass
def wait(status,port,seconds=60):
until=time.monotonic()+seconds
while time.monotonic()<until:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health",timeout=1) as response:
if json.load(response)["status"]==status: return
except OSError: pass
time.sleep(.2)
raise AssertionError("Timed out waiting for audio: "+status)
def run():
import socket
fixture=ThreadingHTTPServer(("127.0.0.1",0),Fixture)
threading.Thread(target=fixture.serve_forever,daemon=True).start()
with socket.socket() as sock:
sock.bind(("127.0.0.1",0))
status_port=sock.getsockname()[1]
with (speech.DATA/"smoke.log").open("w",encoding="utf-8") as log:
process=subprocess.Popen([str(speech.python_path()),str(ROOT/"teddy_speech.py"),"follow",
"--backend","piper","--room-port",str(fixture.server_port),"--status-port",str(status_port)],cwd=ROOT,
stdout=log,stderr=log,creationflags=subprocess.CREATE_NO_WINDOW if os.name=="nt" else 0)
try:
wait("idle",status_port)
cue={"id":"welcome","started":0,"transcript":"Welcome to Glitch, the Dark Machine. I am Teddy. Let us bring these arcade machines back to life."}
STATE["teddy"]["current"]=cue
wait("playing",status_port)
print("Piper is playing the test welcome cue.",flush=True)
time.sleep(2)
STATE["teddy"]["muted"]=True
wait("idle",status_port)
STATE["teddy"]["muted"]=False
STATE["teddy"]["current"]={**cue,"id":"replay"}
wait("playing",status_port)
STATE["teddy"]["current"]=None
wait("idle",status_port)
wavs=list((speech.DATA/"cache").glob("*.wav"))
with wave.open(str(max(wavs,key=lambda p:p.stat().st_mtime))) as wav:
samples=array.array("h",wav.readframes(wav.getnframes()))
assert wav.getnframes()>22050 and max(abs(x) for x in samples)>100
print(f"Verified non-silent PCM WAV: {wav.getframerate()} Hz; {wav.getnframes()/wav.getframerate():.1f}s")
print("PASS: room-state fixture -> real Piper -> local playback; mute/replay/stop")
finally:
process.terminate()
process.wait(timeout=5)
fixture.shutdown()
fixture.server_close()
if __name__=="__main__":
run()
+37
View File
@@ -0,0 +1,37 @@
//! Local JSON sidecar boundary; NATS/MessagePack remains in Rust.
use anyhow::{Context, Result};
use futures_util::StreamExt;
use glitch_odin_lab::*;
use tokio::{io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, net::TcpStream};
use std::time::Duration;
async fn forward(command: Command) -> Result<Reply> {
let mut stream = TcpStream::connect("127.0.0.1:18768").await?;
let mut bytes = serde_json::to_vec(&command)?;
bytes.push(b'\n');
stream.write_all(&bytes).await?;
let mut line = String::new();
BufReader::new(stream.take(65536)).read_line(&mut line).await?;
let reply: Reply = serde_json::from_str(&line)?;
anyhow::ensure!(!reply.state.simulated, "Expected real Godot cabinet");
Ok(reply)
}
pub async fn run(client: async_nats::Client) -> Result<()> {
let mut subscription = client.subscribe(SUBJECT).await?;
client.flush().await?;
println!("Godot sidecar ready: 127.0.0.1:18768; no simulated responder");
while let Some(message) = subscription.next().await {
if let Some(subject) = message.reply {
let result = async {
let command: Command = rmp_serde::from_slice(&message.payload)?;
tokio::time::timeout(Duration::from_millis(1500), forward(command))
.await.context("Godot reply timeout")?
}.await;
match result {
Ok(reply) => client.publish(subject, rmp_serde::to_vec_named(&reply)?.into()).await?,
Err(error) => eprintln!("Godot request unconfirmed: {error:#}"),
}
}
}
Ok(())
}
+157
View File
@@ -0,0 +1,157 @@
//! Experimental lab contract. NOT Odin's generated production protocol.
//! Named MessagePack fields deliberately avoid depending on Odin tuple order.
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
pub const SUBJECT: &str = "escape_gallery.glitch.lab.v1.cabinet_01.request";
pub const NATS_URL: &str = "nats://127.0.0.1:14222";
pub const HTTP_ADDR: &str = "127.0.0.1:18766";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Action { Snapshot, PowerOn, Scan, Start, Win, Reset }
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Command {
pub id: String,
pub session: String,
pub expires_ms: u64,
pub action: Action,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct CabinetState {
pub session: String,
pub cabinet_id: String,
pub phase: String,
pub credits: u32,
pub scans: u32,
pub simulated: bool,
#[serde(default)]
pub rfid_injection: bool,
#[serde(default)]
pub score: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Reply {
pub accepted: bool,
pub duplicate: bool,
pub message: String,
pub state: CabinetState,
}
pub struct Cabinet {
pub state: CabinetState,
boot_until: Option<u64>,
seen: VecDeque<(String, Command, bool, String)>,
}
impl Cabinet {
pub fn new(session: String) -> Self {
Self {
state: CabinetState { session, cabinet_id: "cabinet_01".into(),
phase: "powered_off".into(), credits: 0, scans: 0, simulated: true, rfid_injection: false, score: 0 },
boot_until: None, seen: VecDeque::new(),
}
}
pub fn tick(&mut self, now: u64) {
if self.boot_until.is_some_and(|end| now >= end) {
self.boot_until = None;
self.state.phase = "ready".into();
}
}
fn reply(&self, accepted: bool, duplicate: bool, message: &str) -> Reply {
Reply { accepted, duplicate, message: message.into(), state: self.state.clone() }
}
pub fn handle(&mut self, cmd: Command, now: u64) -> Reply {
self.tick(now);
if cmd.action == Action::Snapshot {
return self.reply(true, false, "Current simulated state");
}
if cmd.id.is_empty() || cmd.id.len() > 128 || cmd.session != self.state.session {
return self.reply(false, false, "Invalid command ID or stale session; refresh state");
}
if let Some((_, previous, accepted, message)) = self.seen.iter().find(|v| v.0 == cmd.id) {
if previous.action != cmd.action || previous.expires_ms != cmd.expires_ms {
return self.reply(false, false, "Command ID reused with different contents");
}
return self.reply(*accepted, true, message);
}
if cmd.expires_ms <= now || cmd.expires_ms.saturating_sub(now) > 30_000 {
return self.reply(false, false, "Expired command or expiry exceeds 30 seconds");
}
let accepted = match cmd.action {
Action::PowerOn if self.state.phase == "powered_off" => {
self.state.phase = "booting".into();
self.boot_until = Some(now + 4_000); true
},
Action::Scan if self.state.phase == "ready" => {
self.state.credits = 1; self.state.scans += 1; true
},
Action::Start if self.state.phase == "ready" && self.state.credits > 0 => {
self.state.credits -= 1; self.state.phase = "playing".into(); true
},
Action::Win if self.state.phase == "playing" => {
self.state.phase = "won".into(); true
},
Action::Reset => {
self.state.phase = "powered_off".into();
self.state.credits = 0; self.state.scans = 0; self.boot_until = None; true
},
_ => false,
};
let message = if accepted { "Executed by simulated cabinet" }
else { "Action is not valid in the current cabinet state" };
self.seen.push_back((cmd.id.clone(), cmd, accepted, message.into()));
// Bounded replay window: not persistent delivery or production idempotency.
if self.seen.len() > 256 { self.seen.pop_front(); }
self.reply(accepted, false, message)
}
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.expect("system clock before epoch").as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
fn command(id: &str, action: Action, now: u64) -> Command {
Command { id: id.into(), session: "test".into(), expires_ms: now + 10_000, action }
}
#[test]
fn boot_credit_start_win() {
let mut c = Cabinet::new("test".into());
assert!(!c.handle(command("early", Action::Start, 0), 0).accepted);
assert!(c.handle(command("power", Action::PowerOn, 0), 0).accepted);
assert!(!c.handle(command("boot_scan", Action::Scan, 100), 100).accepted);
c.tick(3999); assert_eq!(c.state.phase, "booting");
c.tick(4000); assert_eq!(c.state.phase, "ready");
assert_eq!(c.state.credits, 0);
assert!(!c.handle(command("no_credit", Action::Start, 4000), 4000).accepted);
assert!(c.handle(command("scan", Action::Scan, 4000), 4000).accepted);
assert!(c.handle(command("start", Action::Start, 4001), 4001).accepted);
assert_eq!(c.state.credits, 0);
assert!(c.handle(command("win", Action::Win, 4002), 4002).accepted);
assert_eq!(c.state.phase, "won");
}
#[test]
fn replay_stale_expired_and_reset() {
let mut c = Cabinet::new("test".into());
let cmd = command("power", Action::PowerOn, 0);
c.handle(cmd.clone(), 0);
assert!(c.handle(cmd, 1).duplicate);
let mut stale = command("stale", Action::Reset, 1); stale.session = "old".into();
assert!(!c.handle(stale, 1).accepted);
assert!(!c.handle(command("expired", Action::Reset, 0), 10_001).accepted);
c.handle(command("reset", Action::Reset, 2), 2);
c.tick(5000); assert_eq!(c.state.phase, "powered_off");
}
#[test]
fn messagepack_roundtrip() {
let original = command("roundtrip", Action::Scan, 100);
let bytes = rmp_serde::to_vec_named(&original).unwrap();
let decoded: Command = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(decoded.action, Action::Scan);
assert_eq!(decoded.session, original.session);
}
}
pub mod room;
+80
View File
@@ -0,0 +1,80 @@
mod room_server;
mod godot;
use anyhow::{Context, Result};
use axum::{extract::State, http::{Method, StatusCode, HeaderValue}, routing::{get, post}, Json, Router};
use futures_util::StreamExt;
use glitch_odin_lab::*;
use std::time::Duration;
use tower_http::cors::CorsLayer;
async fn request(client: &async_nats::Client, command: Command) -> Result<Reply> {
let payload = rmp_serde::to_vec_named(&command)?;
let message = tokio::time::timeout(Duration::from_secs(2),
client.request(SUBJECT, payload.into())).await
.context("Cabinet reply timed out")??;
Ok(rmp_serde::from_slice(&message.payload)?)
}
async fn send(State(client): State<async_nats::Client>, Json(command): Json<Command>)
-> Result<Json<Reply>, (StatusCode, Json<serde_json::Value>)> {
request(&client, command).await.map(Json).map_err(|error|
(StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({
"error": format!("Cabinet unavailable: {error}")
}))))
}
async fn snapshot(State(client): State<async_nats::Client>)
-> Result<Json<Reply>, (StatusCode, Json<serde_json::Value>)> {
send(State(client), Json(Command { id: String::new(), session: String::new(),
expires_ms: 0, action: Action::Snapshot })).await
}
#[tokio::main]
async fn main() -> Result<()> {
let mode = std::env::args().nth(1).unwrap_or_default();
if mode == "replay-room" {
let path=std::env::args().nth(2).context("replay-room requires a trace JSON path")?;
let value=serde_json::from_slice(&std::fs::read(path)?)?;
let hash=glitch_odin_lab::room::replay_export(value).map_err(anyhow::Error::msg)?;
println!("REPLAY PASS: {hash}");
return Ok(());
}
if mode != "controller" && mode != "cabinet" && mode != "godot" && mode != "room" {
anyhow::bail!("Usage: glitch-odin-lab controller|cabinet|godot|room (isolated local lab)");
}
let client = async_nats::connect(if mode == "room" {"nats://127.0.0.1:14232"} else {NATS_URL}).await.context("Start lab NATS on 127.0.0.1:14222 first")?;
if mode == "room" {
return room_server::run(client).await;
}
if mode == "controller" {
// Explicit local web origins; never open this unauthenticated lab on the room LAN.
let cors = CorsLayer::new()
.allow_origin([
HeaderValue::from_static("http://127.0.0.1:18767"),
HeaderValue::from_static("http://localhost:18767"),
])
.allow_methods([Method::GET, Method::POST])
.allow_headers([axum::http::header::CONTENT_TYPE]);
let app = Router::new().route("/state", get(snapshot))
.route("/command", post(send)).layer(cors).with_state(client);
let listener = tokio::net::TcpListener::bind(HTTP_ADDR).await?;
println!("Glitch LAB controller: http://{HTTP_ADDR} (isolated lab)");
axum::serve(listener, app).await?;
} else if mode == "godot" {
godot::run(client).await?;
} else {
let mut subscription = client.subscribe(SUBJECT).await?;
client.flush().await?;
let mut cabinet = Cabinet::new(format!("lab-{}-{}", now_ms(), std::process::id()));
println!("Simulated cabinet_01 ready; no Godot or GPIO connected");
while let Some(message) = subscription.next().await {
if let Some(reply_subject) = message.reply {
match rmp_serde::from_slice::<Command>(&message.payload) {
Ok(command) => {
let reply = cabinet.handle(command, now_ms());
client.publish(reply_subject, rmp_serde::to_vec_named(&reply)?.into()).await?;
}
Err(error) => eprintln!("Rejected malformed lab message: {error}"),
}
}
}
}
Ok(())
}
+580
View File
@@ -0,0 +1,580 @@
//! Whole-room lab reducer. Virtual observations only; no production output adapter.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
pub const CONTRACT: &str = include_str!("../design/glitch_implementation_contract.json");
pub fn contract() -> &'static Value {
static CONFIG: std::sync::OnceLock<Value> = std::sync::OnceLock::new();
CONFIG.get_or_init(||serde_json::from_str(CONTRACT).expect("checked design contract"))
}
pub fn fingerprint<T: Serialize>(value: &T) -> String {
format!("{:x}", Sha256::digest(serde_json::to_vec(value).unwrap()))
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Station {
pub id: String, pub game: String, pub token: String, pub cell: String, pub socket: String,
pub status: String, pub authorized: bool, pub won: bool, pub online: bool,
pub target: u64, pub progress: u64, pub attempt: u64, pub losses: u64,
pub retry_at: u64, pub charging: bool, pub charge_ms: u64,
pub bay_open: bool, pub passes: u64, pub last_target: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Puzzle { pub id: String, pub token: String, pub solved: bool, pub delivered: bool }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Wave {
pub id: u64, pub targets: BTreeMap<String, u64>, pub ready: BTreeMap<String, u64>,
pub deadline: Option<u64>, pub retry_at: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Cue {
pub id: String, pub key: String, pub transcript: String, pub phase: String,
pub expires: u64, pub started: Option<u64>, pub priority: u8,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Teddy {
pub mode: String, pub online: bool, pub muted: bool, pub takeover: bool,
pub current: Option<Cue>, pub queue: Vec<Cue>, pub played: BTreeSet<String>,
pub transcript: String, pub next_voice_at: u64, pub hint_level: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Effect { pub id: String, pub kind: String, pub target: String, pub status: String }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RoomState {
pub run_id: String, pub revision: u64, pub config_hash: String, pub simulation: bool,
pub lifecycle: String, pub phase: String, pub hold: String, pub outcome: Option<String>,
pub profile: String, pub party: u64, pub operators: u64, pub assisted: bool, pub seed: u64,
pub boot_until: Option<u64>,
pub wall_ms: u64, pub active_ms: u64, pub limit_ms: u64,
pub stations: Vec<Station>, pub puzzles: Vec<Puzzle>,
pub boss: String, pub passage_open: bool, pub passage_since: Option<u64>, pub reveal_at: u64,
pub locations: BTreeMap<String, String>, pub location_since: BTreeMap<String, u64>,
pub cell_faults: Vec<String>, pub power_since: Option<u64>, pub ship_power: bool,
pub tutorial: u64, pub wave_serial: u64, pub wave: Option<Wave>,
pub launch_epoch: u64, pub armed: BTreeSet<String>, pub groups: BTreeSet<u64>,
pub ready_deadline: Option<u64>, pub countdown: Option<u64>, pub launches: u64,
pub effects: Vec<Effect>, pub teddy: Teddy, pub last_progress: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Input {
pub action: String,
#[serde(default)] pub entity: String,
#[serde(default)] pub value: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
pub command_id: String, pub run_id: String, pub expected_revision: u64,
pub expires_at_ms: u64, pub input: Input,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Response { pub accepted: bool, pub duplicate: bool, pub message: String, pub state: RoomState }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TraceEntry {
pub request: Request, pub received_at_ms: u64, pub accepted: bool,
pub message: String, pub final_hash: String,
}
pub struct Room {
pub state: RoomState, pub trace: Vec<TraceEntry>,
initial_run: String, seen: BTreeMap<String, (String, bool, String)>,
}
impl RoomState {
pub fn new(run_id: String, profile: &str, party: u64, seed: u64) -> Self {
let c=contract(); let p=&c["profiles"][profile];
let stations=c["entities"].as_array().unwrap().iter().map(|e| {
let game=e["game"].as_str().unwrap().to_string();
Station { id:e["cabinet_id"].as_str().unwrap().into(), token:e["token_id"].as_str().unwrap().into(),
cell:e["cell_id"].as_str().unwrap().into(), socket:e["socket_id"].as_str().unwrap().into(),
target:if game=="frogger" {p["frogger_homes"].as_u64().unwrap()} else {(500.0*p["score_target_multiplier"].as_f64().unwrap()) as u64},
game, status:"OFF".into(), authorized:false, won:false, online:true, progress:0,
attempt:0, losses:0, retry_at:0, charging:false, charge_ms:0, bay_open:false,
passes:0,last_target:None }
}).collect::<Vec<_>>();
let locations=stations.iter().map(|s| (format!("BAY{}", &s.cell[4..]),s.cell.clone())).collect();
Self { run_id,revision:0,config_hash:fingerprint(&c),simulation:true,
lifecycle:"READY".into(),phase:"BRIEFING".into(),hold:String::new(),outcome:None,
profile:profile.into(),party,operators:party.saturating_sub(1).clamp(1,6),assisted:party==1,seed,
boot_until:None,wall_ms:0,active_ms:0,limit_ms:p["time_limit_ms"].as_u64().unwrap(),stations,
puzzles:[("P01",""),("P02A","T01"),("P02B","T02"),("P03","T03"),("P04","T04"),("P05","T05"),("P06","T06")]
.iter().map(|(id,token)|Puzzle{id:(*id).into(),token:(*token).into(),solved:false,delivered:false}).collect(),
boss:"DARK".into(),passage_open:false,passage_since:None,reveal_at:0,
locations,location_since:BTreeMap::new(),cell_faults:vec![],power_since:None,ship_power:false,
tutorial:0,wave_serial:0,wave:None,launch_epoch:0,armed:BTreeSet::new(),groups:BTreeSet::new(),
ready_deadline:None,countdown:None,launches:0,effects:vec![],
teddy:Teddy{mode:"DORMANT".into(),online:true,muted:false,takeover:false,current:None,
queue:vec![],played:BTreeSet::new(),transcript:String::new(),next_voice_at:0,hint_level:0},
last_progress:0 }
}
pub fn tuning(&self, name:&str)->u64 { contract()["profiles"][&self.profile][name].as_u64().unwrap() }
fn require(&self, condition:bool, reason:&str)->Result<(),String> {
if condition {Ok(())} else {Err(reason.into())}
}
fn station(&self,id:&str)->Result<usize,String> {
self.stations.iter().position(|s|s.id==id).ok_or("Unknown station".into())
}
fn effect(&mut self,kind:&str,target:&str) {
let id=format!("{}:{kind}:{target}",self.run_id);
if !self.effects.iter().any(|e|e.id==id) {
self.effects.push(Effect{id,kind:kind.into(),target:target.into(),status:"REQUESTED".into()});
}
}
fn cue(&mut self,key:&str,text:&str,priority:u8) {
if self.teddy.played.contains(key) || self.teddy.queue.iter().any(|c|c.key==key)
|| self.teddy.current.as_ref().is_some_and(|c|c.key==key) {return}
let cue=Cue{id:format!("{}:{key}",self.run_id),key:key.into(),transcript:text.into(),
phase:self.phase.clone(),expires:self.wall_ms+60_000,started:None,priority};
if priority>=8 {
self.teddy.current=None;
self.teddy.next_voice_at=self.wall_ms;
}
self.teddy.queue.push(cue);
self.teddy.queue.sort_by_key(|c|std::cmp::Reverse(c.priority));
}
fn phase(&mut self, phase:&str) {
self.phase=phase.into(); self.last_progress=self.active_ms;
self.teddy.hint_level=0;
self.teddy.mode=match phase {
"ARCADE"=>"GUIDE","BOSS"=>"GLITCH","REVEAL"|"CELLS"=>"REVEAL",
"CALIBRATION_TUTORIAL"|"CALIBRATION"=>"COACH","LAUNCH_ARM"|"COUNTDOWN"|"DEBRIEF"=>"LAUNCH",_=>"GUIDE"
}.into();
}
fn cancel_wave(&mut self) {
self.wave=None; self.wave_serial+=1; self.armed.clear(); self.groups.clear();
self.countdown=None;self.ready_deadline=None;self.launch_epoch+=1;
if self.phase=="COUNTDOWN" {self.phase("LAUNCH_ARM");}
}
fn technical_hold(&mut self,reason:&str) {
if self.lifecycle=="WON" {return}
self.lifecycle="RECOVERY_HOLD".into();self.hold=reason.into();self.cancel_wave();
}
fn next_wave(&mut self) {
if self.wave.is_some() {return}
let tutorial=self.phase=="CALIBRATION_TUTORIAL";
if !tutorial && self.phase!="CALIBRATION" {return}
let passes=self.tuning("passes_per_station");
let mut candidates:Vec<_>=self.stations.iter().filter(|s|tutorial || s.passes<passes)
.map(|s|(s.passes,s.id.clone())).collect();
candidates.sort();
if candidates.is_empty() {
self.phase("LAUNCH_ARM"); self.launch_epoch+=1;return
}
self.wave_serial+=1;
let mut targets=BTreeMap::new();
for (_,id) in candidates.into_iter().take(if tutorial {1}else{self.operators as usize}) {
let range=&contract()["calibration_selections"][&id];
let min=range["min"].as_u64().unwrap();let max=range["max"].as_u64().unwrap();
let i=self.station(&id).unwrap();
let mut target=min+(self.seed+self.wave_serial+self.stations[i].passes)%(max-min+1);
if self.stations[i].last_target==Some(target) {target=min+(target-min+1)%(max-min+1);}
self.stations[i].last_target=Some(target);
targets.insert(id,target);
}
self.wave=Some(Wave{id:self.wave_serial,targets,ready:BTreeMap::new(),
deadline:if tutorial {None}else{Some(self.active_ms+self.tuning("response_window_ms"))},retry_at:self.active_ms});
}
fn power_check(&mut self) {
let mut faults=vec![];
for s in &self.stations {
let places:Vec<_>=self.locations.iter().filter(|(_,cell)|*cell==&s.cell).map(|(p,_)|p).collect();
if places.len()>1 {faults.push(format!("{} appears in multiple places",s.cell));}
if self.locations.get(&s.socket)!=Some(&s.cell) {faults.push(format!("{} needs {}",s.socket,s.cell));}
if s.charge_ms<30_000 {faults.push(format!("{} is not charged",s.cell));}
if !s.online {faults.push(format!("{} is offline",s.id));}
if self.locations.get(&s.socket)==Some(&s.cell) &&
self.wall_ms.saturating_sub(*self.location_since.get(&s.socket).unwrap_or(&self.wall_ms))<500 {
faults.push(format!("{} awaiting debounce",s.socket));
}
}
self.cell_faults=faults;
if !self.cell_faults.is_empty() {
let had_power=self.ship_power;
self.ship_power=false;self.power_since=None;
if had_power && ["CALIBRATION_TUTORIAL","CALIBRATION","LAUNCH_ARM","COUNTDOWN"].contains(&self.phase.as_str()) {
self.technical_hold("Cell identity/power changed; restore cells, then Resume");
}
} else {
let since=self.power_since.get_or_insert(self.wall_ms);
self.ship_power=self.wall_ms.saturating_sub(*since)>=2000;
}
}
fn voice_tick(&mut self) {
self.teddy.queue.retain(|c|c.phase==self.phase && c.expires>self.wall_ms);
if self.teddy.current.as_ref().is_some_and(|c|c.phase!=self.phase || c.expires<=self.wall_ms) {
self.teddy.current=None;
}
if self.teddy.current.as_ref().is_some_and(|c| c.started.is_some_and(|t| self.wall_ms>=t+5000+(c.transcript.split_whitespace().count() as u64)*600)) {
self.teddy.current=None; self.teddy.next_voice_at=self.wall_ms+8000;
}
if self.teddy.current.is_none() && self.wall_ms>=self.teddy.next_voice_at && (!self.teddy.takeover || self.teddy.queue.first().is_some_and(|c|c.key.starts_with("gm-"))) {
if !self.teddy.queue.is_empty() {
let mut cue=self.teddy.queue.remove(0);
self.teddy.transcript=cue.transcript.clone();
self.teddy.played.insert(cue.key.clone());
if self.teddy.online && !self.teddy.muted {
cue.started=Some(self.wall_ms);self.teddy.current=Some(cue);
} else {self.teddy.next_voice_at=self.wall_ms+8000;}
}
}
}
fn step(&mut self,ms:u64) {
self.wall_ms+=ms;
if self.lifecycle=="RUNNING" {
if self.phase!="BRIEFING" { self.active_ms+=ms; }
if self.phase=="BOOTING" && self.boot_until.is_some_and(|t|self.active_ms>=t) {
self.phase("ARCADE");
for s in &mut self.stations {s.status="TOKEN_WAIT".into();}
}
for s in &mut self.stations {
if s.charging && s.online {s.charge_ms=(s.charge_ms+ms).min(30_000);}
if s.status=="RETRY_READY" && self.active_ms>=s.retry_at {s.status="READY".into();}
}
if self.active_ms>=self.limit_ms {self.lifecycle="TIME_EXPIRED".into();self.hold="Time expired; extend or finish assisted".into();self.cancel_wave();}
else if self.phase=="REVEAL" && !self.passage_open && self.active_ms>=self.reveal_at+5000 {
self.technical_hold("Passage release unconfirmed; observe open, then Resume");
}
}
if self.phase=="REVEAL" && self.passage_since.is_some_and(|t|self.wall_ms>=t+300) {
self.passage_open=true;self.phase("CELLS");
self.cue("reveal","It was a call for help. Bring the six light cores to the ship. Match their symbols.",8);
}
if self.passage_open {
let charged:Vec<_>=self.stations.iter().filter(|s|s.charge_ms==30_000).map(|s|s.cell.clone()).collect();
for cell in charged {self.effect("bay.release",&cell);}
}
self.power_check();
if self.lifecycle=="RUNNING" {
if self.phase=="CELLS" && self.ship_power {
self.phase("CALIBRATION_TUTORIAL");
for s in &mut self.stations {s.status="CAL_READY".into();}
self.cue("training","Navigator, name a station and its target. Operator, repeat it and press Ready. Navigator, check it, then Execute.",7);
self.next_wave();
}
if self.wave.as_ref().is_some_and(|w|w.deadline.is_some_and(|t|self.active_ms>=t)) {
self.wave=None;self.next_wave();
if let Some(w)=&mut self.wave {w.retry_at=self.active_ms+3000;}
}
if self.ready_deadline.is_some_and(|t|self.active_ms>=t) && self.countdown.is_none() {
self.groups.clear();self.ready_deadline=None;
}
if self.countdown.is_some_and(|t|self.active_ms>=t) && self.ship_power && self.stations.iter().all(|s|s.online) {
self.launches+=1;self.countdown=None;self.lifecycle="WON".into();
self.outcome=Some("WON".into());self.phase("DEBRIEF");self.effect("finale","room");
self.cue("ending","A signal has become a journey. You gave our visitor a way home.",9);
}
let threshold=self.tuning("hint_1_inactivity_ms")+self.teddy.hint_level*self.tuning("next_hint_interval_ms");
if self.active_ms.saturating_sub(self.last_progress)>=threshold && self.teddy.hint_level<2 &&
["ARCADE","CALIBRATION"].contains(&self.phase.as_str()) {
self.hint();
}
}
self.voice_tick();
}
fn hint(&mut self) {
self.teddy.hint_level=(self.teddy.hint_level+1).min(3);
let text=if self.phase=="ARCADE" {
if self.teddy.hint_level==1 {"Each token belongs to one machine. Look for the cabinet name."}
else {"Check the claw controls, record sleeves, service panel, tickets and poster for the remaining tokens."}
} else {"The ship has the target. Name the station, repeat the target, hold it steady, press Ready, then Execute."};
let key=format!("gm-hint:{}",self.revision);
self.cue(&key,text,9);
}
pub fn apply(&mut self,input:&Input)->Result<String,String> {
let v=&input.value;
// Time, sensor observations and GM diagnostics remain available during holds.
let operational=["clock","advance","pause","resume","extend","reset","prepare","teddy","hint","speak","online","cell","passage","ack","deliver","finish_assisted"];
if !operational.contains(&input.action.as_str()) && input.action!="start_run" {
self.require(self.lifecycle=="RUNNING","Room is not running")?;
}
if ["WON","ASSISTED_RESCUE","ABORTED"].contains(&self.lifecycle.as_str()) {
self.require(["clock","advance","reset","teddy","speak","online","cell","ack"].contains(&input.action.as_str()),"Run is terminal; reset before new gameplay")?;
}
match input.action.as_str() {
"prepare" => {
self.require(self.lifecycle=="READY","Reset before changing the run profile")?;
let profile=v["profile"].as_str().ok_or("Missing profile")?;
self.require(contract()["profiles"].get(profile).is_some(),"Unknown profile")?;
let party=v["party"].as_u64().ok_or("Missing party size")?;
self.require((1..=8).contains(&party),"Party must be 1–8")?;
let revision=self.revision;
*self=Self::new(self.run_id.clone(),profile,party,v["seed"].as_u64().unwrap_or(42));
self.revision=revision;
}
"start_run" => {
self.require(self.lifecycle=="READY","Run is already started")?;
self.lifecycle="RUNNING".into();self.teddy.mode="GUIDE".into();
self.cue("welcome","Hello there. You came to investigate the strange happenings, did you? I have heard these machines whisper when nobody was playing. For now, I seem to be the only one awake. Find the junction box and the key to its lock. Then lift the large switch. Let us see what wakes up.",6);
}
"briefing_done" => {
self.require(self.phase=="BRIEFING","Briefing already complete")?;
self.phase("POWER_RESTORE");
self.cue("power-search","Find the junction box. A lock is keeping its switch down. Find the key, remove the lock, and lift the switch to restore power.",8);
}
"power_switch" => {
self.require(self.phase=="POWER_RESTORE","Power restoration is not awaiting a switch")?;
self.require(v.as_bool()==Some(true),"Raise the switch to restore power")?;
self.phase("BOOTING");self.boot_until=Some(self.active_ms+4000);
for s in &mut self.stations {s.status="BOOTING".into();}
}
"solve" => {
self.require(self.phase=="ARCADE","Discoveries are only active in the arcade")?;
let idx=self.puzzles.iter().position(|p|p.id==input.entity).ok_or("Unknown puzzle")?;
let p=&self.puzzles[idx];
self.require(!p.solved,"Puzzle already solved")?;
if p.id=="P02A" {self.require(self.puzzles[0].solved,"Restore claw controls P01 first")?;}
if p.id=="P02B" {self.require(self.puzzles[1].delivered,"Confirm T01 delivery before claw stage B")?;}
self.puzzles[idx].solved=true;
let token=self.puzzles[idx].token.clone();
if !token.is_empty() {self.effect("token.dispense",&token);}
self.last_progress=self.active_ms;
}
"deliver" => {
let idx=self.puzzles.iter().position(|p|p.token==input.entity).ok_or("Unknown token reward")?;
self.require(self.puzzles[idx].solved,"Solve the puzzle before confirming delivery")?;
self.require(!self.puzzles[idx].delivered,"Already delivered")?;
self.puzzles[idx].delivered=true;
if v["hand"].as_bool()==Some(true) {
self.require(v["reason"].as_str().is_some_and(|s|!s.trim().is_empty()),"Hand-delivery requires a reason")?;
self.assisted=true;
}
if let Some(e)=self.effects.iter_mut().find(|e|e.target==input.entity) {e.status="OBSERVED".into();}
self.last_progress=self.active_ms;
}
"token" => {
self.require(self.phase=="ARCADE","Token admission only in arcade phase")?;
let i=self.station(&input.entity)?;let s=&self.stations[i];
self.require(s.online && s.status=="TOKEN_WAIT","Station is not waiting for a token")?;
self.require(v["token"].as_str()==Some(s.token.as_str()),"Wrong token for this cabinet")?;
self.require(v["age_ms"].as_u64().is_some_and(|n|n<2000),"Stale token observation")?;
self.stations[i].authorized=true;self.stations[i].status="READY".into();
self.last_progress=self.active_ms;
}
"start" => {
let i=self.station(&input.entity)?;let s=&self.stations[i];
self.require(self.phase=="ARCADE" && s.online && s.authorized && s.status=="READY","Cabinet needs correct token / retry delay")?;
let s=&mut self.stations[i];s.status="PLAYING".into();s.attempt+=1;s.progress=0;s.charging=true;
self.last_progress=self.active_ms;
}
"win" | "lose" => {
let i=self.station(&input.entity)?;let s=&self.stations[i];
self.require(self.phase=="ARCADE" && s.online && s.status=="PLAYING","No active first-state attempt")?;
self.require(v["attempt"].as_u64()==Some(s.attempt),"Stale attempt")?;
if input.action=="lose" {
let s=&mut self.stations[i];s.losses+=1;s.status="RETRY_READY".into();s.retry_at=self.active_ms+3000;
} else {
self.require(v["progress"].as_u64().is_some_and(|p|p>=s.target),"Objective not reached")?;
let s=&mut self.stations[i];s.progress=s.target;s.won=true;s.status="WON".into();
self.last_progress=self.active_ms;
let count=self.stations.iter().filter(|s|s.won).count();
let lesson=match count {1=>Some(("name","One signal restored. Use the name on the cabinet when you call out.")),
2=>Some(("echo","Say the instruction back before you act.")),3=>Some(("hold","That pattern looks like stars. Keep a good setting steady.")),
5=>Some(("ready","Ready means waiting for the same moment. It does not mean go.")),_=>None};
if let Some((key,text))=lesson {self.cue(key,text,4);}
if count==6 {self.phase("BOSS");self.boss="READY".into();
self.cue("boss","The dark machine is listening now. Break through, and we may hear who is calling.",8);}
}
}
"boss_start" => {self.require(self.phase=="BOSS" && self.boss=="READY","Six unique wins required")?;self.boss="PLAYING".into();}
"boss_win" => {
self.require(self.phase=="BOSS" && self.boss=="PLAYING","Boss is not playing")?;
self.boss="WON".into();self.phase("REVEAL");self.reveal_at=self.active_ms;
self.effect("passage.release","passage");
}
"ack" => {
let e=self.effects.iter_mut().find(|e|e.id==input.entity).ok_or("Unknown effect")?;
if e.status=="REQUESTED" {e.status="ACKNOWLEDGED".into();}
}
"passage" => {
self.require(self.phase=="REVEAL","No reveal pending")?;
self.passage_since=if v.as_bool()==Some(true) {Some(self.wall_ms)}else{None};
}
"bay" => {
let i=self.station(&input.entity)?;
self.require(self.passage_open && self.stations[i].charge_ms==30_000,"Bay requires reveal and full charge")?;
self.stations[i].bay_open=true;
let cell=self.stations[i].cell.clone();
if let Some(e)=self.effects.iter_mut().find(|e|e.kind=="bay.release" && e.target==cell) {e.status="OBSERVED".into();}
}
"cell" => {
self.require(self.passage_open,"Cells are not accessible before reveal")?;
let cell=v["cell"].as_str().ok_or("Missing cell identity")?;
self.require(self.stations.iter().any(|s|s.cell==cell),"Unknown cell")?;
self.require(self.stations.iter().any(|s|s.socket==input.entity || format!("BAY{}",&s.cell[4..])==input.entity),"Unknown location")?;
if v["present"].as_bool()==Some(true) {
self.locations.insert(input.entity.clone(),cell.into());self.location_since.insert(input.entity.clone(),self.wall_ms);
} else {
if input.entity.starts_with("BAY") {
let s=self.stations.iter().find(|s|s.cell==cell).unwrap();
self.require(s.bay_open,"Bay open sensor not confirmed")?;
}
self.require(self.locations.get(&input.entity).map(String::as_str)==Some(cell),"Cell is not at this location")?;
self.locations.remove(&input.entity);self.location_since.remove(&input.entity);
}
self.power_check();
}
"ready" => {
self.require(self.ship_power,"Ship power not stable")?;
let w=self.wave.as_ref().ok_or("No current challenge")?;
self.require(v["challenge"].as_u64()==Some(w.id),"Old challenge")?;
self.require(self.active_ms>=w.retry_at && w.deadline.is_none_or(|d|self.active_ms<d),"Challenge window closed")?;
self.require(w.targets.contains_key(&input.entity),"Station not in this wave")?;
let selection=v["selection"].as_u64().ok_or("Missing selection")?;
let range=&contract()["calibration_selections"][&input.entity];
self.require(selection>=range["min"].as_u64().unwrap() && selection<=range["max"].as_u64().unwrap(),"Selection outside station range")?;
let hold=if self.phase=="CALIBRATION_TUTORIAL" && self.tutorial==0 {0}else{self.tuning("stable_target_hold_ms")};
self.require(v["stable_ms"].as_u64().unwrap_or(0)>=hold,"Selection has not been held long enough")?;
self.wave.as_mut().unwrap().ready.insert(input.entity.clone(),selection);
}
"revoke" => {let w=self.wave.as_mut().ok_or("No current challenge")?;
if v.as_u64()!=Some(w.id) {return Err("Old challenge".into())}
w.ready.remove(&input.entity);}
"execute" => {
let w=self.wave.as_ref().ok_or("No current challenge")?;
self.require(v.as_u64()==Some(w.id),"Old challenge")?;
self.require(self.active_ms>=w.retry_at && w.deadline.is_none_or(|d|self.active_ms<d),"Challenge deadline closed")?;
if w.targets!=w.ready {
self.wave=None;self.next_wave();
if let Some(w)=&mut self.wave {w.retry_at=self.active_ms+3000;}
return Ok("Wave failed: readback not ready/correct. New challenge issued; retry in 3 seconds".into());
}
let ids:Vec<_>=w.targets.keys().cloned().collect();self.wave=None;
if self.phase=="CALIBRATION_TUTORIAL" {self.tutorial+=1;
if self.tutorial==2 {self.phase("CALIBRATION");}
} else {for id in ids {let i=self.station(&id)?;self.stations[i].passes+=1;}}
self.last_progress=self.active_ms;self.next_wave();
}
"arm" => {self.require(self.phase=="LAUNCH_ARM" && self.ship_power,"Calibration and ship power required")?;
let i=self.station(&input.entity)?;self.require(self.stations[i].online,"Station offline")?;
self.require(v.as_u64()==Some(self.launch_epoch),"Old launch epoch")?;self.armed.insert(input.entity.clone());}
"group_ready" => {
self.require(self.phase=="LAUNCH_ARM" && self.ship_power,"Launch is not arming")?;
self.require(v["epoch"].as_u64()==Some(self.launch_epoch),"Old launch epoch")?;
let group=v["group"].as_u64().ok_or("Missing operator group")?;
self.require(group<self.operators,"Unassigned operator group")?;
let assigned=self.stations.iter().enumerate().filter(|(i,_)|*i as u64%self.operators==group);
self.require(assigned.clone().all(|(_,s)|self.armed.contains(&s.id)),"Group stations not all armed")?;
if self.ready_deadline.is_none() {self.ready_deadline=Some(self.active_ms+if self.party==1 {90_000}else{self.tuning("final_ack_window_ms")});}
self.require(self.active_ms<self.ready_deadline.unwrap(),"Readiness window closed")?;
self.groups.insert(group);
}
"commit" => {
self.require(self.phase=="LAUNCH_ARM" && self.ship_power,"Launch unavailable")?;
self.require(v.as_u64()==Some(self.launch_epoch),"Old launch epoch")?;
self.require(self.armed.len()==6 && self.groups.len()==self.operators as usize,"Missing station arms or crew acknowledgements")?;
self.require(self.ready_deadline.is_some_and(|d|self.active_ms<d),"Readiness deadline closed")?;
self.phase("COUNTDOWN");self.countdown=Some(self.active_ms+5000);
self.cue(&format!("launch:{}",self.launch_epoch),"Every station is holding. Crew ready. Five, four, three, two, one.",9);
}
"clock" | "advance" => {
let amount=v.as_u64().ok_or("Advance requires milliseconds")?;
self.require(amount<=3_600_000,"Advance at most one hour")?;
let mut remaining=amount;while remaining>0 {let step=remaining.min(100);self.step(step);remaining-=step;}
}
"pause" => {self.require(self.lifecycle=="RUNNING","Only a running room can pause")?;
self.lifecycle="PAUSED".into();self.hold="GM pause".into();
if self.phase=="COUNTDOWN" {self.cancel_wave();}}
"resume" => {
self.require(["PAUSED","RECOVERY_HOLD"].contains(&self.lifecycle.as_str()),"Room is not paused or held")?;
self.require(self.stations.iter().all(|s|s.online),"Restore offline stations first")?;
if self.phase=="REVEAL" {self.require(self.passage_open,"Passage still unconfirmed")?;}
if ["CALIBRATION_TUTORIAL","CALIBRATION","LAUNCH_ARM"].contains(&self.phase.as_str()) {self.require(self.ship_power,"Restore ship power first")?;}
self.lifecycle="RUNNING".into();self.hold.clear();self.next_wave();
}
"extend" => {
let minutes=v.as_u64().ok_or("Minutes required")?;
self.require((1..=60).contains(&minutes),"Extend by 1–60 minutes")?;
self.limit_ms+=minutes*60_000;self.assisted=true;
if self.lifecycle=="TIME_EXPIRED" {self.lifecycle="RECOVERY_HOLD".into();self.hold="Time extended; reconcile then Resume".into();}
}
"online" => {
let i=self.station(&input.entity)?;let online=v.as_bool().ok_or("Boolean required")?;
self.stations[i].online=online;
if !online && self.lifecycle=="RUNNING" {self.technical_hold("Station offline; restore connection and Resume");}
}
"teddy" => match v.as_str().ok_or("Teddy action required")? {
"mute"=>{self.teddy.muted=!self.teddy.muted;self.teddy.current=None;},
"offline"=>{self.teddy.online=!self.teddy.online;self.teddy.current=None;},
"takeover"=>{self.teddy.takeover=true;self.teddy.queue.clear();self.teddy.current=None;},
"release"=>{self.teddy.takeover=false;},
"stop"=>{self.teddy.current=None;self.teddy.next_voice_at=self.wall_ms+8000;},
"replay"=>{let text=self.teddy.transcript.clone();let key=format!("gm-replay:{}",self.revision);self.cue(&key,&text,9);},
_=>return Err("Unknown Teddy action".into()),
},
"speak" => {
let text=v["text"].as_str().unwrap_or("").trim();
self.require(!text.is_empty() && text.chars().count()<=1500,"Hint must contain 1-1500 characters")?;
self.require(self.teddy.online && !self.teddy.muted,"Unmute or reconnect Teddy before speaking")?;
self.teddy.queue.retain(|c|!c.key.starts_with("gm-"));
let key=format!("gm-custom:{}",self.revision);
self.cue(&key,text,9);
},
"hint" => {self.require(["ARCADE","CALIBRATION","CALIBRATION_TUTORIAL"].contains(&self.phase.as_str()),"Hints suppressed in this phase")?;self.hint();}
"finish_assisted" => {
self.require(v.as_str().is_some_and(|r|!r.trim().is_empty()),"Reason required")?;
self.lifecycle="ASSISTED_RESCUE".into();self.outcome=Some("ASSISTED_RESCUE".into());
self.assisted=true;self.cancel_wave();self.phase("DEBRIEF");
}
"reset" => {
self.require(v["confirmed"].as_bool()==Some(true),"Confirm reset of simulated run")?;
let next=format!("{}-next",self.run_id);let revision=self.revision;
*self=Self::new(next,&self.profile,self.party,self.seed);self.revision=revision;
}
_=>return Err("Unknown simulation action".into()),
}
self.voice_tick();
Ok("Accepted simulated observation/control".into())
}
}
impl Room {
pub fn new(run:String)->Self {
Self {state:RoomState::new(run.clone(),"standard",6,42),trace:vec![],initial_run:run,seen:BTreeMap::new()}
}
pub fn handle(&mut self,req:Request,now:u64)->Response {
let key=fingerprint(&req);
if let Some((old,ok,msg))=self.seen.get(&req.command_id).filter(|_|req.run_id==self.state.run_id).cloned() {
let response=Response{accepted:old==key && ok,duplicate:old==key,
message:if old==key {msg}else{"Command ID collision".into()},state:self.state.clone()};
self.trace.push(TraceEntry{request:req,received_at_ms:now,accepted:response.accepted,
message:response.message.clone(),final_hash:fingerprint(&self.state)});
return response;
}
let result=if req.command_id.is_empty() {Err("Missing command ID".into())}
else if req.run_id!=self.state.run_id {Err("Stale run".into())}
else if req.expected_revision!=self.state.revision {Err("Stale GM revision; refresh state".into())}
else if req.expires_at_ms<=now || req.expires_at_ms>now+30_000 {Err("Command expired or expiry too far ahead".into())}
else {
let mut candidate=self.state.clone();
match candidate.apply(&req.input) {
Ok(message)=>{if req.input.action!="clock" {candidate.revision+=1;}self.state=candidate;Ok(message)}
Err(e)=>Err(e),
}
};
let accepted=result.is_ok();let message=result.unwrap_or_else(|e|e);
self.seen.insert(req.command_id.clone(),(key,accepted,message.clone()));
self.trace.push(TraceEntry{request:req,received_at_ms:now,accepted,message:message.clone(),final_hash:fingerprint(&self.state)});
Response{accepted,duplicate:false,message,state:self.state.clone()}
}
pub fn export(&self)->Value {json!({"simulation":true,"contract_version":"1.0","initial_run":self.initial_run,
"config_hash":self.state.config_hash,"trace":self.trace,"final_hash":fingerprint(&self.state)})}
}
#[cfg(test)]
mod tests;
pub fn replay_export(value:Value)->Result<String,String> {
if value["config_hash"].as_str()!=Some(fingerprint(&contract()).as_str()) {
return Err("Contract hash differs from the recorded fixture".into())
}
let initial=value["initial_run"].as_str().ok_or("Missing initial run")?;
let entries:Vec<TraceEntry>=serde_json::from_value(value["trace"].clone()).map_err(|e|e.to_string())?;
let mut room=Room::new(initial.into());
for (i,entry) in entries.iter().enumerate() {
let response=room.handle(entry.request.clone(),entry.received_at_ms);
if response.accepted!=entry.accepted || fingerprint(&room.state)!=entry.final_hash {
return Err(format!("Replay divergence at entry {i}"))
}
}
let hash=fingerprint(&room.state);
if value["final_hash"].as_str()!=Some(hash.as_str()) {return Err("Final hash mismatch".into())}
Ok(hash)
}
+221
View File
@@ -0,0 +1,221 @@
use super::*;
fn input(action:&str,entity:&str,value:Value)->Input {Input{action:action.into(),entity:entity.into(),value}}
fn send(r:&mut Room,action:&str,entity:&str,value:Value)->Response {
let req=Request{command_id:format!("cmd-{}",r.trace.len()),run_id:r.state.run_id.clone(),
expected_revision:r.state.revision,expires_at_ms:20_000,input:input(action,entity,value)};
r.handle(req,10_000)
}
fn ok(r:&mut Room,action:&str,entity:&str,value:Value) {
let result=send(r,action,entity,value);assert!(result.accepted,"{action} {entity}: {}",result.message);
}
fn init(profile:&str,party:u64)->Room {
let mut r=Room::new("fixture".into());
ok(&mut r,"prepare","",json!({"profile":profile,"party":party,"seed":42}));
ok(&mut r,"start_run","",Value::Null);ok(&mut r,"briefing_done","",Value::Null);ok(&mut r,"power_switch","",json!(true));ok(&mut r,"advance","",json!(4000));r
}
fn arcade(r:&mut Room) {
for p in ["P01","P02A","P02B","P03","P04","P05","P06"] {
ok(r,"solve",p,Value::Null);
let token=r.state.puzzles.iter().find(|v|v.id==p).unwrap().token.clone();
if !token.is_empty() {ok(r,"deliver",&token,Value::Null);}
}
for s in r.state.stations.clone() {
ok(r,"token",&s.id,json!({"token":s.token,"age_ms":0}));
ok(r,"start",&s.id,Value::Null);
}
ok(r,"advance","",json!(30_000));
for s in r.state.stations.clone() {ok(r,"win",&s.id,json!({"attempt":1,"progress":s.target}));}
assert_eq!(r.state.phase,"BOSS");
}
fn cells(r:&mut Room) {
ok(r,"boss_start","",Value::Null);ok(r,"boss_win","",Value::Null);
let release=r.state.effects.iter().find(|e|e.kind=="passage.release").unwrap().id.clone();
ok(r,"ack",&release,Value::Null);assert!(!r.state.passage_open);
ok(r,"passage","",json!(true));ok(r,"advance","",json!(300));
for s in r.state.stations.clone() {
ok(r,"bay",&s.id,Value::Null);
ok(r,"cell",&format!("BAY{}",&s.cell[4..]),json!({"cell":s.cell,"present":false}));
ok(r,"cell",&s.socket,json!({"cell":s.cell,"present":true}));
}
ok(r,"advance","",json!(2500));assert_eq!(r.state.phase,"CALIBRATION_TUTORIAL");
}
fn wave(r:&mut Room) {
let w=r.state.wave.clone().unwrap();
let hold=r.state.tuning("stable_target_hold_ms");
for (id,target) in w.targets {
ok(r,"ready",&id,json!({"challenge":w.id,"selection":target,"stable_ms":hold}));
}
ok(r,"execute","",json!(w.id));
}
fn calibration(r:&mut Room) {
while ["CALIBRATION_TUTORIAL","CALIBRATION"].contains(&r.state.phase.as_str()) {wave(r);}
assert_eq!(r.state.phase,"LAUNCH_ARM");
assert!(r.state.stations.iter().all(|s|s.passes==r.state.tuning("passes_per_station")));
}
fn arm(r:&mut Room) {
let epoch=r.state.launch_epoch;
for s in r.state.stations.clone() {ok(r,"arm",&s.id,json!(epoch));}
for group in 0..r.state.operators {ok(r,"group_ready","",json!({"epoch":epoch,"group":group}));}
}
#[test]
fn sim01_04_golden_profiles_and_replay() {
for (profile,party) in [("standard",6),("guided",2),("guided",1),("challenge",8)] {
let mut r=init(profile,party);arcade(&mut r);cells(&mut r);calibration(&mut r);arm(&mut r);
let epoch=r.state.launch_epoch;
ok(&mut r,"commit","",json!(epoch));ok(&mut r,"advance","",json!(5000));
assert_eq!(r.state.lifecycle,"WON");assert_eq!(r.state.launches,1);
assert_eq!(r.state.assisted,party==1);
assert!(r.state.effects.iter().filter(|e|e.kind=="finale").count()==1);
let mut replay=Room::new("fixture".into());
for entry in &r.trace {
let response=replay.handle(entry.request.clone(),entry.received_at_ms);
assert_eq!(response.accepted,entry.accepted);
assert_eq!(fingerprint(&replay.state),entry.final_hash);
}
assert_eq!(r.state,replay.state);
assert_eq!(replay_export(r.export()).unwrap(),fingerprint(&r.state));
}
}
#[test]
fn sim05_07_tokens_retries_and_distinct_wins() {
let mut r=init("standard",6);
assert!(!send(&mut r,"token","cabinet_01",json!({"token":"T02","age_ms":0})).accepted);
assert!(!send(&mut r,"token","cabinet_01",json!({"token":"T01","age_ms":2000})).accepted);
for i in 1..=5 {
let id=format!("cabinet_{i:02}");
ok(&mut r,"token",&id,json!({"token":format!("T{i:02}"),"age_ms":0}));
assert!(!send(&mut r,"token",&id,json!({"token":format!("T{i:02}"),"age_ms":0})).accepted);
ok(&mut r,"start",&id,Value::Null);
if i==3 {
for attempt in 1..=2 {
ok(&mut r,"lose",&id,json!({"attempt":attempt}));
assert!(!send(&mut r,"start",&id,Value::Null).accepted);
ok(&mut r,"advance","",json!(3000));ok(&mut r,"start",&id,Value::Null);
}
}
let attempt=r.state.stations[i-1].attempt;let target=r.state.stations[i-1].target;
ok(&mut r,"win",&id,json!({"attempt":attempt,"progress":target}));
assert!(!send(&mut r,"win",&id,json!({"attempt":attempt,"progress":target})).accepted);
}
assert_eq!(r.state.stations.iter().filter(|s|s.won).count(),5);assert_eq!(r.state.boss,"DARK");
assert!(!send(&mut r,"boss_start","",Value::Null).accepted);
}
#[test]
fn sim08_09_ack_is_not_observation() {
let mut r=init("standard",6);ok(&mut r,"solve","P01",Value::Null);ok(&mut r,"solve","P02A",Value::Null);
let effect=r.state.effects[0].id.clone();ok(&mut r,"ack",&effect,Value::Null);
assert!(!r.state.puzzles[1].delivered);
assert!(!send(&mut r,"solve","P02B",Value::Null).accepted);
ok(&mut r,"deliver","T01",json!({"hand":true,"reason":"GM inspected claw jam and handed token"}));
assert!(r.state.assisted);assert_eq!(r.state.effects.len(),1);
let mut r=init("standard",6);arcade(&mut r);ok(&mut r,"boss_start","",Value::Null);ok(&mut r,"boss_win","",Value::Null);
let e=r.state.effects.iter().find(|e|e.kind=="passage.release").unwrap().id.clone();ok(&mut r,"ack",&e,Value::Null);
ok(&mut r,"advance","",json!(5000));
assert_eq!(r.state.lifecycle,"RECOVERY_HOLD");assert!(!r.state.passage_open);
let elapsed=r.state.active_ms;ok(&mut r,"advance","",json!(300_000));assert_eq!(r.state.active_ms,elapsed);
assert!(!send(&mut r,"resume","",Value::Null).accepted);
}
#[test]
fn sim10_11_cell_conflicts_and_interruption() {
let mut r=init("guided",2);arcade(&mut r);cells(&mut r);wave(&mut r);wave(&mut r);wave(&mut r);
let passes=r.state.stations[0].passes;let old=r.state.wave.as_ref().unwrap().id;
ok(&mut r,"cell","SOCKET02",json!({"cell":"CELL01","present":true}));
assert_eq!(r.state.lifecycle,"RECOVERY_HOLD");assert!(!r.state.ship_power);
assert!(r.state.cell_faults.iter().any(|s|s.contains("multiple")));
ok(&mut r,"cell","SOCKET02",json!({"cell":"CELL02","present":true}));
ok(&mut r,"advance","",json!(2500));ok(&mut r,"resume","",Value::Null);
assert!(r.state.wave.as_ref().unwrap().id>old);assert_eq!(r.state.stations[0].passes,passes);
calibration(&mut r);arm(&mut r);let epoch=r.state.launch_epoch;
ok(&mut r,"commit","",json!(epoch));
ok(&mut r,"cell","SOCKET01",json!({"cell":"CELL01","present":false}));
ok(&mut r,"advance","",json!(5000));assert_eq!(r.state.launches,0);assert!(r.state.countdown.is_none());
}
#[test]
fn sim12_13_deadlines_and_launch_retry() {
let mut r=init("guided",2);arcade(&mut r);cells(&mut r);wave(&mut r);wave(&mut r);
let w=r.state.wave.clone().unwrap();let duration=r.state.tuning("response_window_ms");
ok(&mut r,"advance","",json!(duration));
assert!(!send(&mut r,"execute","",json!(w.id)).accepted);
assert!(!send(&mut r,"ready","cabinet_01",json!({"challenge":w.id,"selection":0,"stable_ms":1000})).accepted);
ok(&mut r,"advance","",json!(3000));calibration(&mut r);arm(&mut r);
let epoch=r.state.launch_epoch;let window=r.state.tuning("final_ack_window_ms");
ok(&mut r,"advance","",json!(window));assert!(r.state.groups.is_empty());
assert!(!send(&mut r,"commit","",json!(epoch)).accepted);
arm(&mut r);ok(&mut r,"commit","",json!(epoch));ok(&mut r,"advance","",json!(5000));assert_eq!(r.state.launches,1);
}
#[test]
fn sim17_teddy_fallback_and_no_voice_gate() {
let mut r=init("guided",2);ok(&mut r,"teddy","",json!("offline"));
ok(&mut r,"hint","",Value::Null);assert!(!r.state.teddy.transcript.is_empty());
arcade(&mut r);cells(&mut r);calibration(&mut r);arm(&mut r);
let epoch=r.state.launch_epoch;ok(&mut r,"commit","",json!(epoch));ok(&mut r,"advance","",json!(5000));
assert_eq!(r.state.lifecycle,"WON");assert!(r.state.teddy.current.is_none());
assert!(r.state.teddy.queue.iter().all(|c|c.phase==r.state.phase));
}
#[test]
fn sim18_20_pause_expiry_extension_reset_and_conflicts() {
let mut r=init("standard",6);ok(&mut r,"pause","",Value::Null);
let elapsed=r.state.active_ms;ok(&mut r,"advance","",json!(300_000));assert_eq!(elapsed,r.state.active_ms);
let req=Request{command_id:"expired".into(),run_id:r.state.run_id.clone(),expected_revision:r.state.revision,
expires_at_ms:10000,input:input("resume","",Value::Null)};
assert!(!r.handle(req,10000).accepted);
ok(&mut r,"resume","",Value::Null);arcade(&mut r);cells(&mut r);wave(&mut r);wave(&mut r);
ok(&mut r,"advance","",json!(3_600_000));assert_eq!(r.state.lifecycle,"TIME_EXPIRED");
let phase=r.state.phase.clone();ok(&mut r,"extend","",json!(5));ok(&mut r,"resume","",Value::Null);
assert_eq!(r.state.phase,phase);assert_eq!(r.state.launches,0);
let old=Request{command_id:"old".into(),run_id:r.state.run_id.clone(),expected_revision:r.state.revision,
expires_at_ms:20000,input:input("win","cabinet_01",json!({"attempt":1,"progress":500}))};
ok(&mut r,"reset","",json!({"confirmed":true}));
for action in ["win","token","commit"] {
let mut stale=old.clone();stale.command_id=format!("old-{action}");stale.input.action=action.into();
assert!(!r.handle(stale,10000).accepted);
}
assert!(r.state.stations.iter().all(|s|!s.won && !s.authorized));
let req=Request{command_id:"dup".into(),run_id:r.state.run_id.clone(),expected_revision:r.state.revision,
expires_at_ms:20000,input:input("start_run","",Value::Null)};
assert!(r.handle(req.clone(),10000).accepted);assert!(r.handle(req.clone(),10000).duplicate);
let mut conflicting=req;conflicting.command_id="conflict".into();
assert!(!r.handle(conflicting,10000).accepted);
}
#[test]
fn manual_hints_bypass_takeover_and_virtual_gap() {
let mut r=init("standard",6);
ok(&mut r,"teddy","",json!("takeover"));
r.state.teddy.next_voice_at=r.state.wall_ms+8000;
ok(&mut r,"speak","",json!({"text":"Find the record sleeve."}));
assert!(r.state.teddy.takeover);
assert_eq!(r.state.teddy.current.as_ref().unwrap().transcript,"Find the record sleeve.");
let first=r.state.teddy.current.as_ref().unwrap().id.clone();
ok(&mut r,"teddy","",json!("stop"));
ok(&mut r,"teddy","",json!("replay"));
assert_ne!(r.state.teddy.current.as_ref().unwrap().id,first);
ok(&mut r,"hint","",Value::Null);
assert!(r.state.teddy.current.as_ref().unwrap().key.starts_with("gm-hint:"));
assert!(send(&mut r,"speak","",json!({"text":" "})).accepted==false);
ok(&mut r,"teddy","",json!("mute"));
assert!(!send(&mut r,"speak","",json!({"text":"Must not speak muted"})).accepted);
assert!(r.state.teddy.current.is_none());
}
#[test]
fn power_opening_and_live_clock() {
let mut r=Room::new("opening".into());
ok(&mut r,"start_run","",Value::Null);
ok(&mut r,"clock","",json!(2000));
assert_eq!(r.state.active_ms,0);
assert!(r.state.stations.iter().all(|s|s.status=="OFF"));
ok(&mut r,"briefing_done","",Value::Null);
assert_eq!(r.state.phase,"POWER_RESTORE");
assert!(!send(&mut r,"token","cabinet_01",json!({"token":"T01","age_ms":0})).accepted);
ok(&mut r,"power_switch","",json!(true));
assert!(!send(&mut r,"power_switch","",json!(true)).accepted);
ok(&mut r,"clock","",json!(3999));
assert!(r.state.stations.iter().all(|s|s.status=="BOOTING"));
ok(&mut r,"clock","",json!(1));
assert!(r.state.stations.iter().all(|s|s.status=="TOKEN_WAIT" && !s.authorized));
ok(&mut r,"pause","",Value::Null);
let time=r.state.active_ms;
ok(&mut r,"clock","",json!(1000));assert_eq!(r.state.active_ms,time);
assert!(replay_export(r.export()).is_ok());
}
+84
View File
@@ -0,0 +1,84 @@
use anyhow::Result;
use axum::{extract::State, http::{HeaderValue,Method,StatusCode}, routing::{get,post}, Json,Router};
use futures_util::StreamExt;
use glitch_odin_lab::{room::{Room,Request}};
use serde::{Serialize,Deserialize};
use serde_json::{json,Value};
use std::time::Duration;
use tower_http::cors::CorsLayer;
const HTTP_ADDR:&str="127.0.0.1:18776";
const SUBJECT:&str="escape_gallery.glitch.lab.v1.room.simulation.request";
#[derive(Serialize,Deserialize)]
struct Wire { op:String, request:Option<Request> }
async fn exchange(client:async_nats::Client,wire:Wire)->Result<Json<Value>,(StatusCode,String)> {
let result=async {
let response=tokio::time::timeout(Duration::from_secs(5),
client.request(SUBJECT,rmp_serde::to_vec_named(&wire)?.into())).await??;
Ok::<Value,anyhow::Error>(rmp_serde::from_slice(&response.payload)?)
}.await;
result.map(Json).map_err(|e|(StatusCode::SERVICE_UNAVAILABLE,e.to_string()))
}
async fn state(State(client):State<async_nats::Client>)->Result<Json<Value>,(StatusCode,String)> {
exchange(client,Wire{op:"state".into(),request:None}).await
}
async fn trace(State(client):State<async_nats::Client>)->Result<Json<Value>,(StatusCode,String)> {
exchange(client,Wire{op:"trace".into(),request:None}).await
}
async fn command(State(client):State<async_nats::Client>,Json(request):Json<Request>)->Result<Json<Value>,(StatusCode,String)> {
exchange(client,Wire{op:"apply".into(),request:Some(request)}).await
}
pub async fn run(client:async_nats::Client)->Result<()> {
let mut sub=client.subscribe(SUBJECT).await?;
client.flush().await?;
let sender=client.clone();
let worker=tokio::spawn(async move {
let mut room=Room::new(format!("sim-{}",glitch_odin_lab::now_ms()));
let mut ticker=tokio::time::interval(Duration::from_millis(250));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last=std::time::Instant::now();
let mut clock_id=0u64;
loop {
let message=tokio::select! {
_=ticker.tick()=>{
let elapsed=last.elapsed().as_millis() as u64;last=std::time::Instant::now();
if room.state.lifecycle=="RUNNING" {
clock_id+=1;
let now=glitch_odin_lab::now_ms();
room.handle(Request{command_id:format!("clock-{clock_id}"),run_id:room.state.run_id.clone(),
expected_revision:room.state.revision,expires_at_ms:now+10000,
input:glitch_odin_lab::room::Input{action:"clock".into(),entity:String::new(),value:json!(elapsed.min(3600000))}},now);
}
continue;
}
next=sub.next()=>match next {Some(message)=>message,None=>break},
};
if let Some(reply)=message.reply {
let result=match rmp_serde::from_slice::<Wire>(&message.payload) {
Ok(wire)=>match wire.op.as_str() {
"state"=>json!({"state":room.state}),
"trace"=>room.export(),
"apply"=>match wire.request {
Some(request)=>serde_json::to_value(room.handle(request,glitch_odin_lab::now_ms())).unwrap(),
None=>json!({"error":"Missing request"}),
},
_=>json!({"error":"Unknown simulation operation"}),
},
Err(e)=>json!({"error":e.to_string()}),
};
if let Err(error)=sender.publish(reply,rmp_serde::to_vec_named(&result).unwrap().into()).await {
eprintln!("Room reply failed: {error}");
}
}
}
});
let cors=CorsLayer::new()
.allow_origin([HeaderValue::from_static("http://127.0.0.1:18777"),HeaderValue::from_static("http://localhost:18777")])
.allow_methods([Method::GET,Method::POST]).allow_headers([axum::http::header::CONTENT_TYPE]);
let app=Router::new().route("/room/state",get(state)).route("/room/command",post(command))
.route("/room/trace",get(trace)).layer(cors).with_state(client);
println!("WHOLE-ROOM SIMULATION ONLY: http://{HTTP_ADDR}/room/state");
let result=axum::serve(tokio::net::TcpListener::bind(HTTP_ADDR).await?,app).await;
worker.abort();
result?;
Ok(())
}
+254
View File
@@ -0,0 +1,254 @@
"""Local Teddy audio follower (Chatterbox or Piper). Reads simulated state; never changes game state."""
import argparse
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import threading
import time
import urllib.request
import wave
ROOT = Path(__file__).resolve().parent
DATA = ROOT / ".speech"
VOICE = "en_US-ljspeech-medium"
MODEL = DATA / "voices" / (VOICE + ".onnx")
AUDIO = {}
OUTPUT = "local"
STATUS = {"status": "starting", "voice": VOICE, "message": "Loading speech follower"}
def python_path():
return DATA / "venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
def setup():
import venv
DATA.mkdir(exist_ok=True)
if not python_path().exists():
venv.EnvBuilder(with_pip=True).create(DATA / "venv")
subprocess.run([str(python_path()), "-m", "pip", "install", "piper-tts==1.8.0"], check=True)
MODEL.parent.mkdir(exist_ok=True)
base = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ljspeech/medium/"
for name in (MODEL.name, MODEL.name + ".json", "MODEL_CARD"):
target = MODEL.parent / name
if not target.exists():
temp = target.with_suffix(target.suffix + ".download")
print("Downloading " + name, flush=True)
urllib.request.urlretrieve(base + name, temp)
temp.replace(target)
print("Piper ready. Run: py -3 lab.py start-room --speech")
def synth(text_file, output):
from piper import PiperVoice
voice = PiperVoice.load(str(MODEL))
temp = Path(output).with_suffix(".partial")
with wave.open(str(temp), "wb") as stream:
voice.synthesize_wav(Path(text_file).read_text(encoding="utf-8"), stream)
temp.replace(output)
class BrowserPlayer:
def stop(self): pass
def play(self, path): pass
class Player:
def __init__(self):
self.process = None
if os.name != "nt":
self.command = shutil.which("afplay" if sys.platform == "darwin" else "aplay")
if not self.command:
raise RuntimeError("Install aplay (alsa-utils) for Linux audio playback")
def stop(self):
if os.name == "nt":
import winsound
winsound.PlaySound(None, 0)
elif self.process and self.process.poll() is None:
self.process.terminate()
self.process.wait(timeout=3)
self.process = None
def play(self, path):
self.stop()
if os.name == "nt":
import winsound
winsound.PlaySound(str(path), winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT)
else:
self.process = subprocess.Popen([self.command, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def cue_for(state):
teddy = state.get("teddy", {})
cue = teddy.get("current")
if not cue or teddy.get("muted") or (teddy.get("takeover") and not cue.get("key", "").startswith("gm-")) or not teddy.get("online"):
return None
return (state["run_id"], cue["id"], cue.get("started"), cue["transcript"])
class Follower:
def __init__(self, player, engine=None):
self.engine = engine
self.player = player
self.current = None
self.job = None
self.output = None
self.end = None
self.failed = False
(DATA / "cache").mkdir(parents=True, exist_ok=True)
def status(self, value, message):
global STATUS
STATUS = {"status": value, "voice": VOICE, "message": message}
def cancel(self):
global AUDIO
AUDIO = {}
if self.job and self.job.poll() is None:
self.job.terminate()
self.job.wait(timeout=5)
self.job = None
self.end = None
self.player.stop()
def update(self, state):
cue = cue_for(state) if state else None
if cue != self.current:
self.cancel()
self.current = cue
self.failed = False
if cue:
digest = hashlib.sha256((VOICE + "\n" + cue[3]).encode()).hexdigest()
self.output = DATA / "cache" / (digest + ".wav")
if self.output.exists():
self.play()
else:
text_file = self.output.with_suffix(".txt")
text_file.write_text(cue[3], encoding="utf-8")
self.job = self.engine.submit(cue[3], self.output) if self.engine else subprocess.Popen([str(python_path()), str(Path(__file__).resolve()),
"synth", "--text-file", str(text_file), "--output", str(self.output)],
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
self.status("generating", "Generating Teddy's current cue")
if self.job and self.job.poll() is not None:
result = self.job.returncode
self.job = None
if result == 0:
self.play()
else:
self.failed = True
self.status("error", "Speech synthesis failed; captions remain available. Replay to retry.")
if self.end is not None and time.monotonic() >= self.end:
self.end = None
self.status("finished", "Cue finished. Advance virtual time to release the next queued cue.")
if not cue:
self.status("idle" if state else "disconnected",
"Waiting for a cue" if state else "Room connection lost; audio stopped")
def play(self):
try:
with wave.open(str(self.output)) as audio:
duration = audio.getnframes() / audio.getframerate()
self.player.play(self.output)
global AUDIO
identity = hashlib.sha256(repr(self.current[:3]).encode()).hexdigest()
AUDIO = {"path":str(self.output), "audio_url":"/audio/" + identity + ".wav",
"cue_id":self.current[1], "run_id":self.current[0]}
self.end = None if OUTPUT == "browser" else time.monotonic() + duration
self.status("ready" if OUTPUT == "browser" else "playing",
"Audio ready for enabled browsers" if OUTPUT == "browser" else "Playing on the lab computer's default audio output")
except Exception as error:
self.failed = True
self.status("error", "Audio playback failed: " + str(error))
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
origin = self.headers.get("Origin", "")
allowed = ("http://127.0.0.1:18777", "http://localhost:18777")
if origin and origin not in allowed:
self.send_error(403)
return
audio = AUDIO.copy()
status = 200
content_range = None
if self.path == "/health":
data = json.dumps({**STATUS, "output":OUTPUT,
**{k:v for k,v in audio.items() if k != "path"}}).encode()
content_type = "application/json"
elif audio and self.path == audio.get("audio_url"):
data = Path(audio["path"]).read_bytes()
content_type = "audio/wav"
byte_range = self.headers.get("Range")
if byte_range:
import re
match = re.fullmatch(r"bytes=(\d+)-(\d*)", byte_range)
start = int(match[1]) if match else len(data)
end = min(int(match[2]) if match and match[2] else len(data)-1,len(data)-1)
if start > end:
self.send_error(416)
return
content_range = f"bytes {start}-{end}/{len(data)}"
data = data[start:end+1]
status = 206
else:
self.send_error(404)
return
self.send_response(status)
if origin:
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Content-Type", content_type)
self.send_header("Accept-Ranges", "bytes")
if content_range:
self.send_header("Content-Range", content_range)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *_):
pass
def follow(room_port=18776, status_port=18778, output="local", backend="chatterbox"):
global OUTPUT, VOICE
OUTPUT = output
engine = None
if backend == "chatterbox":
from chatterbox_client import Engine, voice_id
VOICE = voice_id()
engine = Engine()
follower = Follower(BrowserPlayer() if output == "browser" else Player(), engine)
server = ThreadingHTTPServer(("127.0.0.1", status_port), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
follower.status("starting" if engine else "idle", "Loading Teddy voice" if engine else "Waiting for a cue")
print(f"TEDDY AUDIO: {backend}, {output} output, status port {status_port}", flush=True)
try:
while True:
if engine and engine.error:
follower.cancel()
follower.status("error", "Chatterbox unavailable; see .speech/chatterbox.log. Restart speech to retry.")
time.sleep(1)
continue
if engine and not engine.ready.is_set():
time.sleep(.2)
continue
try:
with urllib.request.urlopen(f"http://127.0.0.1:{room_port}/room/state", timeout=1) as response:
state = json.load(response)["state"]
except Exception:
state = None
follower.update(state)
time.sleep(0.2)
finally:
follower.cancel()
if engine:
engine.close()
server.shutdown()
server.server_close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("setup", "follow", "synth"))
parser.add_argument("--backend", choices=("chatterbox", "piper"), default="chatterbox")
parser.add_argument("--text-file")
parser.add_argument("--output")
parser.add_argument("--room-port", type=int, default=18776)
parser.add_argument("--status-port", type=int, default=18778)
parser.add_argument("--output-device", choices=("local","browser"), default="local")
args = parser.parse_args()
if args.mode == "setup":
setup()
elif args.mode == "synth":
synth(args.text_file, args.output)
else:
follow(args.room_port, args.status_port, args.output_device, args.backend)
+39
View File
@@ -0,0 +1,39 @@
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import hint_library as lib
class HintLibraryTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory()
self.path=Path(self.temp.name)/"hints.json"
self.patch=patch.object(lib,"PATH",self.path); self.patch.start()
def tearDown(self):
self.patch.stop(); self.temp.cleanup()
def hint(self):
return {"title":"Find the records","text":"Look at the sleeve — then the record.","categories":["Records","Discovery"],
"phase":"ARCADE","target":"P03","condition":"incomplete","level":1}
def test_persists_and_updates_with_stable_id(self):
data=lib.save({"revision":0,"hint":self.hint()})
self.assertEqual(lib.read(),data)
added=data["hints"][-1]
added["text"]="A clearer hint"
updated=lib.save({"revision":1,"hint":added})
self.assertEqual(len(data["hints"]),len(updated["hints"]))
self.assertEqual(lib.read()["hints"][-1]["text"],"A clearer hint")
self.assertTrue(self.path.with_suffix(".bak").exists())
def test_conflict_preserves_other_device_edits(self):
lib.save({"revision":0,"hint":self.hint()})
before=self.path.read_bytes()
with self.assertRaises(lib.Conflict): lib.save({"revision":0,"hint":self.hint()})
self.assertEqual(self.path.read_bytes(),before)
def test_bad_input_and_corrupt_file_are_not_overwritten(self):
hint=self.hint(); hint["text"]="x"*1501
with self.assertRaises(ValueError): lib.save({"revision":0,"hint":hint})
self.path.write_text("broken",encoding="utf-8")
with self.assertRaises(ValueError): lib.save({"revision":0,"hint":self.hint()})
self.assertEqual(self.path.read_text(),"broken")
if __name__=="__main__": unittest.main()
+38
View File
@@ -0,0 +1,38 @@
"""Tests for lab startup failure handling. No SDK or network required."""
import contextlib
import io
import unittest
from unittest.mock import patch, Mock
import lab
class RunnerTests(unittest.TestCase):
def test_missing_sdk_fails_before_starting(self):
with patch.object(lab.shutil, "which", return_value=None), \
patch.object(lab.subprocess, "Popen") as spawn, \
patch.object(lab.sys, "argv", ["lab.py", "start"]), \
contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(lab.main(), 1)
spawn.assert_not_called()
def test_cleanup_when_cabinet_launch_fails(self):
broker = Mock()
broker.poll.return_value = None
with patch.object(lab.socket, "socket"), \
patch.object(lab.socket, "create_connection"), \
patch.object(lab.Path, "exists", return_value=True), \
patch.object(lab.shutil, "which", return_value="nats-server"), \
patch.object(lab.subprocess, "Popen", side_effect=[broker, OSError("launch failed")]):
with self.assertRaises(OSError):
lab.start()
broker.terminate.assert_called_once()
broker.wait.assert_called_once()
def test_command_identity_and_expiry(self):
first = lab.command("scan", "session")
second = lab.command("scan", "session")
self.assertNotEqual(first["id"], second["id"])
self.assertEqual(first["session"], "session")
self.assertGreater(first["expires_ms"], int(lab.time.time() * 1000))
if __name__ == "__main__":
unittest.main()
+92
View File
@@ -0,0 +1,92 @@
"""Audio cancellation/deduplication checks without speakers or Piper."""
import copy
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
import teddy_speech as speech
def state(key="welcome", run="run1"):
return {"run_id": run, "teddy": {"online": True, "muted": False, "takeover": False,
"current": {"id": key, "started": 0, "transcript": "Hello player"}}}
class SpeechTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.data = patch.object(speech, "DATA", Path(self.temp.name))
self.data.start()
self.player = Mock()
self.follower = speech.Follower(self.player)
self.job = Mock()
self.job.poll.return_value = None
self.spawn = patch.object(speech.subprocess, "Popen", return_value=self.job).start()
def tearDown(self):
patch.stopall()
self.temp.cleanup()
def test_poll_does_not_repeat_and_stale_generation_cannot_play(self):
self.follower.update(state())
self.follower.update(state())
self.assertEqual(self.spawn.call_count, 1)
self.follower.update(None)
self.job.terminate.assert_called_once()
self.player.play.assert_not_called()
self.assertEqual(speech.STATUS["status"], "disconnected")
def test_mute_failure_takeover_reset_and_replacement_cancel(self):
for gate in ("muted", "takeover", "online", "current", "reset", "replace"):
with self.subTest(gate=gate):
self.follower.current = None
self.follower.job = None
self.job.reset_mock()
self.job.poll.return_value = None
self.follower.update(state())
changed = copy.deepcopy(state())
if gate == "reset":
changed = {"run_id": "run2", "teddy": {"current": None}}
elif gate == "replace":
changed = state("new")
else:
changed["teddy"][gate] = False if gate == "online" else (None if gate == "current" else True)
self.follower.update(changed)
self.job.terminate.assert_called_once()
self.player.play.assert_not_called()
def test_completed_generation_plays_once(self):
self.follower.update(state())
with speech.wave.open(str(self.follower.output), "wb") as wav:
wav.setparams((1, 2, 22050, 0, "NONE", "not compressed"))
wav.writeframes(bytes(44100))
self.job.poll.return_value = 0
self.job.returncode = 0
self.follower.update(state())
self.follower.update(state())
self.player.play.assert_called_once()
self.assertEqual(speech.STATUS["status"], "playing")
def test_resident_worker_cancelled_result_cannot_play_after_replacement(self):
from chatterbox_client import Job
engine = Mock()
engine.submit.side_effect = lambda text, output: Job(text, output)
follower = speech.Follower(self.player, engine)
follower.update(state())
first = follower.job
follower.update(state("replacement"))
second = follower.job
self.assertTrue(first.cancelled.is_set())
first.returncode = 0 # Old GPU work finished after replacement.
follower.update(state("replacement"))
self.player.play.assert_not_called()
self.assertIs(follower.job, second)
engine.close.assert_not_called()
follower.update(None)
self.assertTrue(second.cancelled.is_set())
self.assertEqual(speech.AUDIO, {})
def test_synthesis_failure_keeps_caption_only(self):
self.follower.update(state())
self.job.poll.return_value = 1
self.job.returncode = 1
self.follower.update(state())
self.assertEqual(speech.STATUS["status"], "error")
self.player.play.assert_not_called()
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -0,0 +1,8 @@
"""Local Piper training entry point: mel checkpoints only; MOS is disabled."""
from piper.train import __main__ as training
# Upstream installs a val_mos checkpoint even with --model.mos_metric none.
# Lightning raises after epoch one when that optional metric is absent.
training._DEFAULT_CALLBACKS[:] = [callback for callback in training._DEFAULT_CALLBACKS
if callback.monitor != "val_mos"]
if __name__ == "__main__":
training.main()
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Run inside Ubuntu after the isolated Piper environment and dataset are prepared.
set -euo pipefail
cd "$HOME/teddy-training/piper"
source "$HOME/teddy-training/venv/bin/activate"
export OMP_NUM_THREADS=4
python /mnt/d/Projects/escape-gallery/room_glitch/labs/glitch_odin/train_teddy_entry.py fit \
--seed_everything 42 \
--data.voice_name teddy_experiment_v1 \
--data.csv_path "$HOME/teddy-training/data/metadata.csv" \
--data.audio_dir "$HOME/teddy-training/data/audio" \
--data.cache_dir "$HOME/teddy-training/cache" \
--data.config_path "$HOME/teddy-training/teddy-config.json" \
--data.espeak_voice en-us \
--data.batch_size 4 \
--data.num_workers 0 \
--data.validation_split 0.1 \
--data.num_test_examples 2 \
--model.sample_rate 22050 \
--model.warmstart_ckpt "$HOME/teddy-training/checkpoints/ljspeech-medium.ckpt" \
--model.learning_rate 0.0001 \
--model.learning_rate_d 0.00005 \
--model.mos_metric none \
--trainer.accelerator gpu \
--trainer.devices 1 \
--trainer.max_epochs 60 \
--trainer.max_steps 1000 \
--trainer.enable_progress_bar false \
--trainer.log_every_n_steps 5 \
--trainer.default_root_dir "$HOME/teddy-training/runs/teddy-v1" \
"$@"