Add Pac-Man power-on flow and document Odin integration
- Add a four-second arcade-style boot sequence followed by the Character / Nickname introduction. - Separate cabinet power-on from RFID credit and player Start. - Add operator Power on control and cabinet power-state reporting. - Prevent repeated power commands from restarting boot or gameplay. - Ignore RFID scans received before the cabinet is ready. - Add power-state, RFID, controller, and GPIO feedback regression tests. - Expand the master walkthrough with export, packaging, file transfer, fresh-Pi setup, RFID compilation, and end-to-end testing instructions. - Correct tester hardware documentation: BCM23/J20 drives an LED; physical buttons connect through a separate I2C1 input board. - Document operator-triggered power-on as the current test workflow and retain the temporary 2.4 configuration compatibility notes. - Record successful physical testing of power-on, RFID credit, and player Start. - Add an Odin integration README describing the proposed Rust/NATS bridge, shared messages, Flutter GM interface, autopilot sequencing, hardware ownership, and Starfish SSH-management role. - Define migration milestones and information needed from the framework owner; link the integration guide from the root README. Validation: 69 controller tests, 10 GPIO tests, Pac-Man power/RFID checks, managed-cabinet lifecycle checks, and visual screen inspection.
This commit is contained in:
1 parent
c1b2195834
commit
41ef12f954
27 files changed
+577
-48
No files matched your search
@@ -12,6 +12,7 @@ The Arcade software is separate from Odin. Its RFID helper follows the tester PC
|
||||
|
||||
| Task | Guide |
|
||||
| --- | --- |
|
||||
| Plan integration with Rust/NATS, Odin autopilot, and Flutter | [Target architecture and next requirements](docs/odin_integration/README.md) |
|
||||
| Export and package a matching release | [Master walkthrough, section 3](docs/pacman_rfid_first_setup.md#3-export-package-and-copy-one-complete-release) |
|
||||
| Run Cabinet 1 with RFID credits | [Full master walkthrough](docs/pacman_rfid_first_setup.md) |
|
||||
| Configure cabinet controls | [Launcher and control setup](games/launcher/README.md) |
|
||||
@@ -118,14 +119,15 @@ Optional device panels show RFID identifiers, digital input activity, electrical
|
||||
|
||||
Select **Cabinet 1: Pac-Man RFID credit** (`pacman_rfid_credit_test`):
|
||||
|
||||
1. Prepare Cabinet 1: Pac-Man displays **SCAN CARD / CREDIT 0**.
|
||||
2. A new accepted scan on **J1 / cab1_rfid1** grants **CREDIT 1** and plays the credit sound.
|
||||
3. The player presses mapped Start, consumes the credit, and begins play.
|
||||
4. Clear one board to win; reset/re-prepare for another session.
|
||||
1. Prepare Cabinet 1: Pac-Man displays **WAITING FOR POWER**.
|
||||
2. Press **J20 / BCM23** (`cab1_btn_ylw`) or operator **Power on**. After the four-second ROM-check recreation, Pac-Man shows its Character / Nickname introduction.
|
||||
3. A new accepted scan on **J1 / cab1_rfid1** grants **CREDIT 1** and plays the credit sound.
|
||||
4. The player presses mapped Start, consumes the credit, and begins play.
|
||||
5. Clear one board to win; reset/re-prepare for another session.
|
||||
|
||||
Credit is capped at one. Historical/stale samples, duplicate events, scans on J2, and scans during gameplay do not bank extra credits. The initial scenario accepts any compatible readable card; `rfid_allowed_uids` optionally restricts accepted identifiers. No card-writing step is required. No motor/relay reward is configured for this test.
|
||||
|
||||
This needs **arcade-cabinet-2.3**, real GPIO feedback, the updated bridge, and the compiled RFID helper. The helper follows Odin's 32-bit identifier representation and is not full-length UID support for every card type. Compilation and physical reader testing on the tester Pi remain to be verified.
|
||||
This needs **arcade-cabinet-2.4**, real GPIO feedback, the updated bridge, and the compiled RFID helper. The helper follows Odin's 32-bit identifier representation and is not full-length UID support for every card type. Compilation and physical reader testing on the tester Pi remain to be verified.
|
||||
|
||||
## Configuration and credentials
|
||||
|
||||
@@ -162,7 +164,7 @@ Use the [master walkthrough's release workflow](docs/pacman_rfid_first_setup.md#
|
||||
4. Configure site-specific files separately; compile the RFID helper on the cabinet.
|
||||
5. Validate launcher settings, start the services, and run the physical acceptance test before enabling startup.
|
||||
|
||||
The existing `setup/pc` and `setup/pi` folders are manual staging areas, **not proof of a current build**. Their checked-in artifacts are not automatically rebuilt when source changes. Do not assume they contain the 2.3 RFID feature until explicitly exported and verified. Exclude staged exports from the game PCK.
|
||||
The existing `setup/pc` and `setup/pi` folders are manual staging areas, **not proof of a current build**. Their checked-in artifacts are not automatically rebuilt when source changes. Do not assume they contain the 2.4 power/RFID features until explicitly exported and verified. Exclude staged exports from the game PCK.
|
||||
|
||||
The deployment tools provide a terminal setup wizard, separate site credentials, SSH enrollment, per-cabinet preflight, versioned uploads with manifest hashes, explicit activation, and service/status/log commands. They do not install a private DHCP/firewall configuration or offer a browser provisioning wizard. RFID-only provisioning still needs the manual route described above.
|
||||
|
||||
@@ -195,7 +197,7 @@ Last recorded verification during this work: 66 controller tests and the RFID cr
|
||||
| Unauthorized | Correct role/token and cabinet ID; check inherited environment overrides. |
|
||||
| Cabinet offline | Master URL/8766, network, running client, matching export version. |
|
||||
| Game unavailable | Cabinet capabilities and matching exported catalog/resources. |
|
||||
| RFID scenario rejected | Cabinet 2.3, real GPIO, matching reader ID in the exported PCB inventory. |
|
||||
| RFID scenario rejected | Cabinet 2.4, real GPIO, matching reader ID in the exported PCB inventory. |
|
||||
| RFID pin conflict | Explicit empty `pins =` for RFID-only testing. BCM17/27/22/20 and SPI0 are reserved for readers. |
|
||||
| UID visible but no credit | Prepare the RFID scenario, then remove/re-present at J1; check the accepted-card list. |
|
||||
| Output delivery unconfirmed | GPIO acknowledgement/electrical state does not confirm physical movement; use an appropriate sensor. |
|
||||
@@ -223,3 +225,5 @@ tests/ Godot/Python checks and isolated fixtures
|
||||
## Remaining work
|
||||
|
||||
Physical RFID commissioning, complete RFID support in the fleet wizard, browser doorway rendering, additional cabinet artwork, general sensor-to-scenario rules, detailed puzzle-step views, coordinated finale variants, and room timers/hints remain future work. The hub can link to other services; it is not yet a full multi-room orchestration system.
|
||||
|
||||
Pac-Man power-button wiring, the `devices.json` input entry, and the complete power → boot → RFID → Start acceptance test are included in the [master walkthrough](docs/pacman_rfid_first_setup.md).
|
||||
@@ -1,5 +1,5 @@
|
||||
extends Node
|
||||
const VERSION := "arcade-cabinet-2.3"
|
||||
const VERSION := "arcade-cabinet-2.4"
|
||||
var cabinet_id := "cabinet_01"
|
||||
var database: ProjectDatabase = preload("res://data/project_database.tres")
|
||||
var cabinet: CabinetProfile
|
||||
@@ -55,12 +55,12 @@ func _ready() -> void:
|
||||
func poll() -> void:
|
||||
if busy or executing: return
|
||||
busy = true
|
||||
if game != null and phase == "ready" and game.has_method("receive_device_feedback") and HardwareManager.real_gpio and HardwareManager.client != null:
|
||||
if game != null and phase in ["ready", "powered_off", "booting"] and game.has_method("receive_device_feedback") and HardwareManager.real_gpio and HardwareManager.client != null:
|
||||
game.receive_device_feedback(HardwareManager.client.get_feedback())
|
||||
var sent := reports.duplicate(true)
|
||||
var result: Dictionary = await connection.post("/cabinet/poll", {
|
||||
"cabinet": cabinet_id, "protocol": 2, "boot": boot, "version": VERSION,
|
||||
"features": ["assignment_config_v1", "device_feedback_v1", "rfid_credit_v1"],
|
||||
"features": ["assignment_config_v1", "device_feedback_v1", "rfid_credit_v1", "pacman_power_v1"],
|
||||
"feedback": HardwareManager.client.get_feedback() if HardwareManager.real_gpio and HardwareManager.client != null else {},
|
||||
"gpio": "real" if HardwareManager.real_gpio else "simulation", "reports": sent,
|
||||
})
|
||||
@@ -127,14 +127,19 @@ func execute(command: Dictionary) -> void:
|
||||
if str(definition.game_id) == "missile_command": game.set("relative_mouse_aim", true)
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if str(definition.game_id) in ["missile_command", "tempest"] else Input.MOUSE_MODE_VISIBLE
|
||||
add_child(game)
|
||||
if game.has_signal("power_state_changed"):
|
||||
game.power_state_changed.connect(_power_changed)
|
||||
game.configure(command.get("game_config", {}).duplicate(true))
|
||||
if game.has_method("receive_device_feedback") and HardwareManager.client != null:
|
||||
game.receive_device_feedback(HardwareManager.client.get_feedback())
|
||||
game.game_started.connect(func(): event("started"))
|
||||
game.game_won.connect(func(): event("won"))
|
||||
game.game_lost.connect(func(): event("lost"))
|
||||
phase = "ready"
|
||||
acknowledge(command, true, definition.display_name + " loaded and ready")
|
||||
phase = {"off": "powered_off", "booting": "booting", "ready": "ready"}.get(str(game.get("power_state")), "ready") if game.has_signal("power_state_changed") else "ready"
|
||||
acknowledge(command, true, definition.display_name + " loaded: " + phase)
|
||||
"power_on":
|
||||
var ok: bool = game != null and phase == "powered_off" and game.has_method("power_on") and game.power_on()
|
||||
acknowledge(command, ok, "Power-on requested" if ok else "Cabinet is not powered off")
|
||||
"start":
|
||||
var ok: bool = game != null and phase == "ready" and game.start_game()
|
||||
acknowledge(command, ok, "Game started" if ok else "Cabinet was not ready")
|
||||
@@ -158,3 +163,8 @@ func execute(command: Dictionary) -> void:
|
||||
acknowledge(command, ok, "GPIO bridge accepted pulse; delivery unconfirmed" if ok else "GPIO failed or acknowledgement lost; inspect cabinet")
|
||||
_:
|
||||
acknowledge(command, false, "Unsupported command")
|
||||
|
||||
func _power_changed(state: String) -> void:
|
||||
if state == "off": return # prepare acknowledgement establishes this state
|
||||
phase = "booting" if state == "booting" else "ready"
|
||||
event("power_booting" if state == "booting" else "power_ready")
|
||||
@@ -73,4 +73,6 @@ func get_feedback() -> Dictionary:
|
||||
result["fresh"] = feedback_received_ms > 0 and Time.get_ticks_msec() - feedback_received_ms < 4000
|
||||
for device in result.get("devices", []):
|
||||
device["age_ms"] = int(device.get("age_ms", 999999)) + maxi(0, Time.get_ticks_msec() - feedback_received_ms)
|
||||
for entry in result.get("events", []):
|
||||
entry["age_ms"] = int(entry.get("age_ms", 999999)) + maxi(0, Time.get_ticks_msec() - feedback_received_ms)
|
||||
return result
|
||||
@@ -1,15 +1,22 @@
|
||||
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3]
|
||||
[gd_resource type="Resource" script_class="ScenarioDefinition" format=3 uid="uid://bnn41ffo1lhvy"]
|
||||
|
||||
[ext_resource type="Script" path="res://core/scenarios/scenario_definition.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://core/scenarios/scenario_assignment.gd" id="2"]
|
||||
[ext_resource type="Resource" path="res://data/cabinets/cabinet_01.tres" id="3"]
|
||||
[ext_resource type="Resource" path="res://data/games/pacman.tres" id="4"]
|
||||
[ext_resource type="Script" uid="uid://d3pt6qa05kcnl" path="res://core/scenarios/scenario_definition.gd" id="1"]
|
||||
[ext_resource type="Script" uid="uid://ibujr0ervhhq" path="res://core/scenarios/scenario_assignment.gd" id="2"]
|
||||
[ext_resource type="Resource" uid="uid://cl4j1vtv388pt" path="res://data/cabinets/cabinet_01.tres" id="3"]
|
||||
[ext_resource type="Script" uid="uid://lixrugm1v2d2" path="res://core/scenarios/scenario_event_mapping.gd" id="3_ut8lr"]
|
||||
[ext_resource type="Resource" uid="uid://c3v8o4nubjakg" path="res://data/games/pacman.tres" id="4"]
|
||||
|
||||
[sub_resource type="Resource" id="Assignment"]
|
||||
script = ExtResource("2")
|
||||
cabinet = ExtResource("3")
|
||||
game = ExtResource("4")
|
||||
game_config = {"rfid_credit_device": "cab1_rfid1", "rfid_allowed_uids": [], "boards_to_win": 1, "score_to_win": 0}
|
||||
game_config = {
|
||||
"boards_to_win": 1,
|
||||
"power_on_device": "cab1_btn_ylw",
|
||||
"rfid_allowed_uids": [],
|
||||
"rfid_credit_device": "cab1_rfid1",
|
||||
"score_to_win": 0
|
||||
}
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# Escape Gallery: Arcade Cabinet Integration
|
||||
|
||||
## Purpose and status
|
||||
|
||||
The target architecture combines the existing Godot arcade cabinet framework with the Rust room-control framework developed for Odin. Rust owns room sequencing, NATS carries control and state messages, and Flutter provides the game-master interface. Starfish is the proposed tool for managing Raspberry Pi SSH access.
|
||||
|
||||
**This document describes the intended integration, not a completed deployment.** The current arcade installation uses a Python room controller and browser operator interface. A physical Pac-Man test has successfully demonstrated operator power-on, the boot sequence, RFID credit, and player Start on a Raspberry Pi. The NATS bridge, Flutter cabinet controls, and shared autopilot integration remain to be implemented.
|
||||
|
||||
Glitch: the Dark Machine and Odin's Dilemma remain separate room implementations. Shared infrastructure, drivers, and messaging can be reused without mixing their puzzle rules, assets, device identities, or deployment configuration.
|
||||
|
||||
## Target system
|
||||
|
||||
```text
|
||||
Staff laptops / tablets
|
||||
|
|
||||
v
|
||||
Flutter GM interface
|
||||
|
|
||||
v
|
||||
Rust room controller / autopilot
|
||||
|
|
||||
v
|
||||
NATS messaging
|
||||
|
|
||||
v
|
||||
Cabinet Rust integration (Godot GDExtension)
|
||||
|
|
||||
v
|
||||
Godot cabinet application
|
||||
| Game, graphics, sound, controls, credits and objectives
|
||||
| Cabinet hardware interface
|
||||
v
|
||||
RFID / I2C inputs / LEDs / configured outputs
|
||||
```
|
||||
|
||||
This diagram shows the logical control flow. The Flutter-to-controller transport and browser connection path must be confirmed against the owner's current framework. Hardware may run in a local service rather than inside the Godot process; each device must have one owner.
|
||||
|
||||
Starfish sits alongside this runtime architecture for SSH access management. It is not the game-message transport. Release packaging, service installation, update activation, and rollback need an agreed deployment workflow; SSH-key management alone does not provide those functions.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
| Component | Intended responsibility |
|
||||
| --- | --- |
|
||||
| Godot cabinet application | Game selection, rendering, game audio, joystick/trackball/button mappings, boot presentation, credits, objectives, and game lifecycle. |
|
||||
| Cabinet hardware layer | RFID and I2C drivers, input interpretation, configured output actions, and hardware status. |
|
||||
| Rust Godot extension | NATS connectivity, generated message serialization, command validation and translation, state publication, and connection reporting. |
|
||||
| Rust room controller / autopilot | Room sessions, puzzle sequencing, timed actions, dependencies between cabinets, room-wide effects, and GM overrides. |
|
||||
| Flutter GM interface | Room layout, cabinet/device views, progress, diagnostics, and operator commands. |
|
||||
| Shared message definitions and generator | Compatible command and state types for Rust, Dart, and any additional required languages. |
|
||||
| Starfish | Proposed management of Pi SSH identities and access, subject to confirming its workflow. |
|
||||
|
||||
Room sequencing has one authoritative owner: the Rust controller. A cabinet owns execution of its assigned game and local interactions. For example, an accepted RFID scan can grant a local credit under the current assignment, while a completed game is reported to autopilot to determine which room puzzle activates next.
|
||||
|
||||
## Fit with the existing cabinet framework
|
||||
|
||||
The existing games remain in GDScript. The Rust extension provides methods and signals that GDScript can call or observe; it does not require rewriting gameplay in Rust or C#.
|
||||
|
||||
The current framework already supplies:
|
||||
|
||||
- Arcade games with configurable objectives and mapped player controls.
|
||||
- Cabinet profiles, compatible-game assignments, and a visual PCB/device inventory.
|
||||
- Pac-Man software power, boot, credit, Start, and completion behavior.
|
||||
- A local Python GPIO bridge and Rust RFID helper.
|
||||
- A managed-cabinet command/state integration point.
|
||||
- An operator interface, room-layout authoring, and deployment tools useful during migration.
|
||||
|
||||
The existing HTTP integration can remain available for development and rollback while a NATS backend is introduced. Each deployed cabinet session must select one controlling backend; the Python controller and Rust autopilot must not independently issue competing assignments or advance the same room sequence.
|
||||
|
||||
The Godot editor remains useful for designing cabinets, defining devices, and authoring game objectives. An agreed export format should carry those definitions into the Rust/Flutter system so staff do not maintain conflicting copies manually. Existing room and cabinet visuals can inform the Flutter implementation, but the current HTML interface is not automatically reusable as Flutter widgets.
|
||||
|
||||
## Rust, NATS, and shared messages
|
||||
|
||||
A [godot-rust](https://godot-rust.github.io/book/) GDExtension is the proposed integration mechanism. It can use the existing Rust message types and NATS libraries while exposing a small cabinet API to Godot. If message serialization stays inside Rust, C# or GDScript message generation is not required for the initial integration.
|
||||
|
||||
The local Odin repository already contains:
|
||||
|
||||
- `msgs/*.geno` message definitions.
|
||||
- Generator commands producing Rust types and Dart JSON models.
|
||||
- Rust prop agents and puzzle controllers.
|
||||
- An autopilot that observes puzzle state and schedules actions.
|
||||
- Flutter services and controls for room operation.
|
||||
|
||||
**The inspected version uses MessagePack on prop-facing connections and JSON on game/UI-facing connections.** The current upstream schemas and wire formats must be confirmed before implementation. Sharing the name MessagePack is not enough: field order, optional values, enum encoding, and schema compatibility must match the generated implementation.
|
||||
|
||||
Networking and hardware operations must not block Godot's rendering/input loop. The extension should queue incoming work and deliver game-facing actions on the appropriate Godot thread. Build and export checks must cover the project's actual Godot version, Windows development builds, and Linux ARM64 cabinet builds.
|
||||
|
||||
### Proposed cabinet contract
|
||||
|
||||
The following are capabilities to agree on, not existing Odin message names:
|
||||
|
||||
| Direction | Capability examples |
|
||||
| --- | --- |
|
||||
| Controller to cabinet | Assign a compatible game, configure its objective/credit policy, power on, reset, request full state, invoke a configured hardware action. |
|
||||
| Cabinet to controller | Identity/version, assignment, power/boot/play state, credit count, objective progress, win/loss, input changes, RFID observations, device faults. |
|
||||
| Command result | Accepted, rejected, completed, or failed, with a command identifier and explanation. |
|
||||
|
||||
Commands and events should identify the room, cabinet, and active session. Define command IDs, expiry, duplicate handling, acknowledgement meaning, reconnect behavior, and state resynchronization before enabling sequencing or physical outputs.
|
||||
|
||||
Core NATS does not retain messages for offline subscribers. A one-time win event alone must not be the only record of completion. Agree on authoritative state snapshots, acknowledgement/retry behavior, and whether JetStream persistence is needed. Retries must not repeat credit grants or physical pulses.
|
||||
|
||||
## Hardware integration
|
||||
|
||||
The cabinet can present one hardware interface to gameplay even when the underlying drivers are split across services. Initially, the working Python bridge and Rust RFID helper can remain behind that interface. Individual drivers can then move to shared Rust components without changing each game's logic.
|
||||
|
||||
For the tester:
|
||||
|
||||
- J1 and J2 are the RFID reader connections; J1 currently grants Pac-Man credit.
|
||||
- BCM23 / J20 is connected to an LED, **not the power button**.
|
||||
- The physical buttons connect through a separate board on I2C1.
|
||||
- Operator power-on is the current tested method until that input board is integrated.
|
||||
|
||||
The I2C board needs its model, address, channel mapping, polarity, and matching driver identified. Existing I2C support elsewhere in Odin does not establish compatibility with this particular board. The previously authored J20 button mapping is obsolete and must be corrected during configuration migration; BCM23 must not be configured as a button input.
|
||||
|
||||
Only one process may own each GPIO line, shared reader selector/reset arrangement, or hardware interface. Output commands should refer to configured device actions rather than allow arbitrary network pin writes. Define startup, disconnect, shutdown, and process-failure behavior for connected outputs.
|
||||
|
||||
## GM access and network layout
|
||||
|
||||
The required staff experience is a GM interface accessible from laptops and tablets on the building's local network. Flutter Web is the proposed browser target, subject to checking the current UI and its dependencies. A working Flutter desktop application does not by itself establish browser compatibility.
|
||||
|
||||
The master can host the Rust controller, NATS, and web-serving components while connecting to both the venue network and a private cabinet network. Staff devices access the GM interface; cabinets use the private network. The exact browser transport, authentication, NATS permissions, and required listening ports must be defined without opening general routing between those networks.
|
||||
|
||||
Keep these credentials distinct:
|
||||
|
||||
- SSH identities for installation and maintenance.
|
||||
- NATS credentials and permissions for runtime messaging.
|
||||
- GM authentication for staff operations.
|
||||
|
||||
## First integration milestone
|
||||
|
||||
Reproduce the already tested Pac-Man experience through the shared system:
|
||||
|
||||
1. Flutter assigns Pac-Man to Cabinet 1 and displays Waiting for power.
|
||||
2. A GM Power on command reaches the cabinet through Rust/NATS.
|
||||
3. Godot runs its four-second boot sequence and reports Ready.
|
||||
4. A fresh accepted RFID scan grants one credit and plays the credit sound.
|
||||
5. The player presses Start; the cabinet consumes the credit and reports Playing.
|
||||
6. The player completes the configured objective; the cabinet reports completion.
|
||||
7. Glitch autopilot activates the next configured puzzle or cabinet.
|
||||
|
||||
Acceptance includes reconnecting the network, restarting a cabinet, repeating commands, resetting a session, and verifying that no old scan, win, or output action is applied to a new session. Add the physical I2C button after its driver and mapping are confirmed.
|
||||
|
||||
## What is needed next
|
||||
|
||||
| Needed from the framework owner | Purpose |
|
||||
| --- | --- |
|
||||
| Current Odin/shared-framework repository, revision, and build instructions | Establish the actual integration baseline and private dependencies. |
|
||||
| Message-generator source or installation instructions, plus current schemas | Generate compatible types and agree on cabinet command/state definitions. |
|
||||
| NATS subject conventions, connection configuration, credentials/permissions, and persistence policy | Integrate cabinets with the existing messaging architecture. |
|
||||
| Proposed Rust/Godot bridge API and implementation ownership | Decide who builds the extension, what GDScript calls, and how releases are maintained. |
|
||||
| Hardware schematics, I2C board identity/address/channel mapping, and driver source | Integrate the tester's buttons and LED correctly. |
|
||||
| Current Flutter build targets and browser-access expectations | Confirm laptop/iPad access and the required web transport. |
|
||||
| Starfish enrollment/key-management workflow | Align Pi maintenance access with the owner's tooling. |
|
||||
| Deployment conventions and ownership of room definitions | Agree on service names, release packaging, configuration generation, updates, and rollback. |
|
||||
|
||||
## Migration approach
|
||||
|
||||
1. Agree on the cabinet contract and hardware ownership with the framework owner.
|
||||
2. Implement and test the Rust/Godot bridge using one Pac-Man cabinet.
|
||||
3. Add matching Glitch controller logic and Flutter cabinet controls.
|
||||
4. Transfer sequencing authority to autopilot for the test scenario.
|
||||
5. Integrate the confirmed I2C driver and remaining cabinet devices.
|
||||
6. Expand to additional games/cabinets, then port room-map and diagnostic views.
|
||||
7. Adopt the agreed Starfish and release workflow while retaining a tested rollback path.
|
||||
|
||||
The existing working installation remains the reference until the new path passes the same physical tests. No deployment or runtime migration is performed by this document.
|
||||
|
||||
## References
|
||||
|
||||
- [godot-rust / GDExtension](https://godot-rust.github.io/book/)
|
||||
- [NATS](https://nats.io/) and [Core NATS delivery behavior](https://docs.nats.io/learn/core-nats/)
|
||||
- [MessagePack](https://msgpack.org/index.html)
|
||||
- [Starfish repository supplied by the owner](https://github.com/jlyonsmith/starfish) — implementation not reviewed here.
|
||||
- [Current arcade master setup walkthrough](../pacman_rfid_first_setup.md) — updated for operator power-on, RFID-only live inventory, and the corrected BCM23 LED / I2C button arrangement.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This guide sets up **Glitch: the Dark Machine** with a master Pi hosting the operator webpage and Cabinet 1 running Pac-Man with the custom GPIO PCB. Follow the steps in order. Commands marked **PC**, **master**, or **cabinet** run on that machine. Replace `YOUR_USER` with the actual Pi login.
|
||||
|
||||
The supplied scenario is **Cabinet 1: Pac-Man RFID credit** (`pacman_rfid_credit_test`). It prepares Pac-Man with CREDIT 0. A new accepted scan on **J1 / cab1_rfid1** plays `games/pacman/audio/credit.wav` and changes the display to CREDIT 1. The player presses their mapped Start button, consuming the credit. It wins after one cleared board. It has no automatic win timer and no relay/motor rewards.
|
||||
The supplied scenario is **Cabinet 1: Pac-Man RFID credit** (`pacman_rfid_credit_test`). It prepares Pac-Man in **Waiting for power**. Click the operator **Power on** button to run a four-second cosmetic ROM-check sequence followed by the Character / Nickname screen with CREDIT 0. A new accepted scan on **J1 / cab1_rfid1** plays `games/pacman/audio/credit.wav` and changes the display to CREDIT 1. The player presses their mapped Start button, consuming the credit. It wins after one cleared board. It has no automatic win timer and no relay/motor rewards.
|
||||
|
||||
The first test accepts any readable card. Credit is capped at one; scans during play do not bank credits. After a win/loss, reset and prepare the scenario again. The ordinary free-start Pac-Man assignment remains available separately.
|
||||
|
||||
@@ -72,14 +72,14 @@ This is the release-building part of the master walkthrough. Build everything fr
|
||||
Open `D:\Projects\escape-gallery\room_glitch` in the project's Godot version (currently **4.7.2**). Save scenes and resources before running command-line exports. Check:
|
||||
|
||||
- Cabinet 1 has ID `cabinet_01`, and its PCB identifies J1 as `cab1_rfid1` and J2 as `cab1_rfid2`.
|
||||
- `data/project_database.tres` includes **Cabinet 1: Pac-Man RFID credit**. Its assignment uses Pac-Man and `rfid_credit_device = cab1_rfid1`.
|
||||
- `data/project_database.tres` includes **Cabinet 1: Pac-Man RFID credit**. Its assignment uses Pac-Man, `rfid_credit_device = cab1_rfid1`, and `power_on_device = cab1_btn_ylw`. The power setting and corresponding saved PCB entry are legacy metadata from the earlier button assumption; retain them temporarily for compatibility with the current 2.4 scenario validation. They do not describe the actual wiring. Do not include that legacy button in the bridge's `devices.json`. See the software-power notes at the end of this guide.
|
||||
- The assignment has no output mappings for this initial reader test. `rfid_allowed_uids = []` accepts any readable card.
|
||||
- The Linux preset is **ARM64**, with **Embed PCK disabled**. The existing Windows Desktop preset is x86_64.
|
||||
- Export templates matching the editor version are installed (Editor > Manage Export Templates). An editor executable alone is not sufficient to export.
|
||||
|
||||
Use a release folder **outside the Godot project**. Do not export back into the project or mix old `setup/pi` exports with current source. In each preset's Resources tab, exclude the existing `setup/*` folder from the game export: it contains old exported artifacts and copied tools that do not belong inside a game PCK. Keep the actual `core`, `data`, `games`, `ui`, and runtime resources included. Do not add INIs, private keys, or SQLite files to the export's non-resource include filter.
|
||||
|
||||
The runtime for this scenario must advertise **arcade-cabinet-2.3** and `rfid_credit_v1`. Python files or a fresh catalog cannot add that feature to an old game PCK.
|
||||
The runtime for this scenario must advertise **arcade-cabinet-2.4** and both `rfid_credit_v1` and `pacman_power_v1`. Python files or a fresh catalog cannot add that feature to an old game PCK.
|
||||
|
||||
### 3.2 Create a fresh release directory — Windows PowerShell
|
||||
|
||||
@@ -200,6 +200,35 @@ The resulting cabinet layout includes:
|
||||
|
||||
The extra controller files inside the cabinet bundle are harmless; do not start a second controller there. Continue with network setup in step 4, RFID build/devices.json in step 5, and the separate master/cabinet INIs in step 6. The `.sh` file, source repository, and Godot editor are not required on the cabinet.
|
||||
|
||||
### Optional exact copy commands from Windows
|
||||
|
||||
While both fresh Pis are still on venue Wi-Fi, record their Wi-Fi addresses with `hostname -I` on each Pi. Use those actual addresses here, not the later private Ethernet addresses. Keep the PowerShell release variables from section 3.2. Substitute your Pi usernames and current Wi-Fi IPs:
|
||||
|
||||
```powershell
|
||||
$masterLogin = 'YOUR_MASTER_USER'
|
||||
$masterHost = 'MASTER_WIFI_IP'
|
||||
$cabinetLogin = 'YOUR_CABINET_USER'
|
||||
$cabinetHost = 'CABINET_WIFI_IP'
|
||||
tar -czf "$releaseDir\master.tar.gz" -C "$releaseDir\master" .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Master archive failed.' }
|
||||
scp "$releaseDir\master.tar.gz" "${masterLogin}@${masterHost}:master.tar.gz"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Master copy failed.' }
|
||||
scp "$releaseDir\$releaseName.tar.gz" "${cabinetLogin}@${cabinetHost}:$releaseName.tar.gz"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Cabinet copy failed.' }
|
||||
```
|
||||
|
||||
These SSH copies can use the Pi login passwords set in Imager; SSH key enrollment is not required for the first manual test. On first connection, verify the host fingerprint against the intended Pi before accepting it. Do not copy the operator token into an SSH password prompt.
|
||||
|
||||
On the **fresh master**, extract the master archive:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/arcade
|
||||
tar -xzf ~/master.tar.gz -C ~/arcade
|
||||
ls ~/arcade/tools/room_controller/server.py
|
||||
```
|
||||
|
||||
On the **fresh cabinet**, use section 3.5's cabinet extraction commands. Compile and test the RFID helper in section 5 **before** section 4 disables cabinet Wi-Fi. After that, laptop access is to the master only; use the master to SSH to `192.168.77.11` if needed.
|
||||
|
||||
### 3.6 Preflight before calling the release ready
|
||||
|
||||
After completing steps 4–6, validate each machine without starting it:
|
||||
@@ -220,7 +249,7 @@ python3 tools/launch/launch.py cabinet --config cabinet.ini --check
|
||||
|
||||
These checks validate configuration/dependencies/port availability. They do not prove card communication, display rendering, audio, or successful gameplay. Stop any existing stack first if the check reports its ports are occupied.
|
||||
|
||||
Use step 7's physical test as the acceptance gate: correct version, fresh J1 readings, CREDIT 0 -> scan/sound -> CREDIT 1 -> physical Start -> CREDIT 0, and no unintended outputs. Verify again after the reboot/startup test in step 9. Only then label the release ready for the room.
|
||||
Use step 7's physical test as the acceptance gate: correct version, fresh J1 readings, Waiting for power -> operator Power on -> four-second boot -> CREDIT 0 -> scan/sound -> CREDIT 1 -> physical Start -> CREDIT 0, and no unintended outputs. Verify again after the reboot/startup test in step 9. Only then label the release ready for the room.
|
||||
|
||||
### 3.7 Optional fleet deployment: current RFID limitation
|
||||
|
||||
@@ -350,7 +379,11 @@ Create `~/arcade/devices.json` with this minimal RFID-only inventory:
|
||||
]
|
||||
```
|
||||
|
||||
J2 is visible for diagnostics but grants no credit in this scenario. This minimal inventory intentionally excludes existing buttons/outputs so the first test owns only the readers. Later you can extract the saved PCB catalog using `device_feedback.py` as documented in the GPIO guide.
|
||||
J2 is visible for diagnostics but grants no credit in this scenario. **BCM23 / J20 is connected to an LED, not a button.** Do not wire a button there or configure it as an input. This test does not drive that LED or any other output; leave the output pin allowlist empty.
|
||||
|
||||
The tester's physical buttons connect through a separate input board on **I2C1**. Its model, I2C address, channel mapping, and matching driver still need to be confirmed before those buttons can control the game. For now, use operator **Power on**. The mapped arcade Start control is separate from this unimplemented I2C input board.
|
||||
|
||||
If following an older version of this guide, stop the cabinet stack, remove only the `cab1_btn_ylw` / J20 input entry from `devices.json`, and restart the stack. Keep the two RFID entries and preserve any unrelated site-specific devices. Do not regenerate the live inventory blindly from the saved PCB catalog: its legacy J20 button entry has not yet been migrated to the corrected hardware model.
|
||||
|
||||
## 6. Generate credentials and create the two INIs
|
||||
|
||||
@@ -425,14 +458,16 @@ python3 tools/launch/launch.py cabinet --config cabinet.ini
|
||||
|
||||
The cabinet launcher starts both the local GPIO bridge/reader helper and the game. Do not also start the standalone reader or another bridge. In a staff browser open `http://MASTER_VENUE_IP:8766` and log in with the **operator** token.
|
||||
|
||||
1. Confirm Cabinet 1 is online, reports `arcade-cabinet-2.3`, and GPIO is real.
|
||||
1. Confirm Cabinet 1 is online, reports `arcade-cabinet-2.4`, and GPIO is real.
|
||||
2. In Room control select **Cabinet 1: Pac-Man RFID credit** and prepare it. Reset an already active cabinet first if requested.
|
||||
3. Wait for the game to show **SCAN CARD / CREDIT 0** and the operator Hardware view to receive fresh readings. Historical scans at preparation are deliberately ignored. Keep the card away until ready.
|
||||
3. Wait for **Waiting for power** and fresh device readings. In **Cabinets**, select Cabinet 1 and click **Power on**. The operator should change to **Booting**, then **Ready** after four seconds; the cabinet shows the Character / Nickname introduction and **SCAN CARD / CREDIT 0**. Keep the card away until Ready; historical scans and scans during boot are discarded.
|
||||
4. Press the cabinet Start button without a card: nothing should start.
|
||||
5. Scan J1: hear the credit sound and see **CREDIT 1 / PRESS START**. Hold/re-scan the card: it must stay at one credit and not repeatedly sound for the same accepted credit.
|
||||
6. Press the mapped Start button: Pac-Man starts and shows **CREDIT 0**. The operator changes to Playing. Operator Start is disabled for this scenario because the player must start it.
|
||||
7. Scan during gameplay: no credit is banked. Clear one board to win. No physical reward is configured.
|
||||
8. Reset/re-prepare to test again. Scanning J2 must never grant credit. Disconnecting the reader/bridge must not create credits; feedback becomes stale/error.
|
||||
8. Operator Power on should be disabled during boot and play. Reset/re-prepare to return to Waiting for power and test again. Scanning J2 must never grant credit. Disconnecting the reader/bridge must not create credits; feedback becomes stale/error.
|
||||
|
||||
Use **Prepare assigned cabinets** for the named scenario, not the separate manual **Prepare game** action: ordinary Pac-Man boots automatically and does not wait for operator power-on.
|
||||
|
||||
The game uses the `arcade_start` input mapping. If the physical button is wrong, use the launcher Controls setup to map it and save, then return to managed mode. Keyboard/controller mapping is independent of the RFID reader.
|
||||
|
||||
@@ -451,7 +486,7 @@ score_to_win: 0
|
||||
|
||||
Replace the examples with your actual uppercase eight-character values. An empty array accepts any readable card. Changing this list does not write to the card. Keep a separate list of card labels and identifiers for your staff.
|
||||
|
||||
Save, re-export the controller catalog, and copy the updated catalog to the master. Restart the controller between sessions and re-prepare the scenario. For changes only to this assignment's allowlist/objective, the compatible 2.3 cabinet runtime accepts the new configuration without another game export. Game code, cabinet profiles, and new assets still require exporting the game.
|
||||
Save, re-export the controller catalog, and copy the updated catalog to the master. Restart the controller between sessions and re-prepare the scenario. For changes only to this assignment's allowlist/objective, the compatible 2.4 cabinet runtime accepts the new configuration without another game export. Game code, cabinet profiles, and new assets still require exporting the game.
|
||||
|
||||
## 9. Enable startup after the manual test passes
|
||||
|
||||
@@ -463,7 +498,7 @@ python3 tools/launch/pi_service.py install --config "$HOME/arcade/cabinet.ini"
|
||||
systemctl --user start arcade-cabinet.service
|
||||
```
|
||||
|
||||
Stop the manually launched cabinet stack with Ctrl+C before installing. The installer captures the current desktop environment. The service intentionally has no boot enable target because the display must be ready first. For automatic start after desktop login, create `~/arcade/start-cabinet.sh`:
|
||||
Stop the manually launched cabinet stack with Ctrl+C before installing. The installer captures the current desktop environment. The service intentionally has no boot enable target because the display must be ready first. For automatic start after desktop login, first run `mkdir -p ~/.config/autostart`, then create `~/arcade/start-cabinet.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
@@ -530,13 +565,23 @@ Run each command on the corresponding Pi. If startup is already managed by the d
|
||||
| Reader unresponsive | Ribbon orientation, reader wiring/power, correct J1/J2 slots, no competing Odin process. |
|
||||
| Blank/unprogrammed cards not reading | Check card technology; no card data-writing is required by this test. |
|
||||
| Pin conflict on startup | Explicit blank output `pins`; RFID reserves BCM17/27/22/20 and SPI0. |
|
||||
| Scenario rejected | Updated master catalog, cabinet 2.3 export, real GPIO, matching `cab1_rfid1` inventory. |
|
||||
| Operator Power on is greyed out | Prepare the named RFID scenario using Prepare assigned cabinets; Cabinet 1 must be online, Waiting for power, and have no pending command. Check version 2.4 and preparation errors. |
|
||||
| I2C buttons do nothing | The separate I2C input board is not integrated yet; use operator Power on and the mapped arcade Start control. |
|
||||
| Scenario rejected | Updated master catalog, cabinet 2.4 export, real GPIO, matching `cab1_rfid1` inventory. |
|
||||
| Live UID but no credit | Prepare this scenario, remove/re-present card after initial feedback, use J1, verify allowed UID. |
|
||||
| Credit but no start | Verify `arcade_start` mapping and physical button. |
|
||||
| Unauthorized | Operator token in browser; matching Cabinet 1 token on master/cabinet; check inherited environment. |
|
||||
| Cabinet offline | Cabinet URL uses private master IP and `http://...:8766`; 8765 is only local GPIO. |
|
||||
| Staff page inaccessible | Master venue address, venue client isolation/firewall, server bound to 0.0.0.0. |
|
||||
|
||||
Software regression tests cover credit gating, fresh versus historical/stale feedback, UID/reader filtering, duplicates, credit consumption, and non-RFID behavior. Physical reader/audio/controller/network behavior must still be checked on this installation. No Pi, GPIO, network, or live deployment was changed while preparing this guide.
|
||||
Software regression tests cover credit gating, fresh versus historical/stale feedback, UID/reader filtering, duplicates, credit consumption, and non-RFID behavior. The operator power-on, RFID credit, and player Start flow has been physically confirmed by the user on the tester. Repeat the acceptance test for each fresh installation; the I2C input board and BCM23 LED control remain unverified and outside this test. No Pi, GPIO, network, or live deployment was changed while preparing this guide.
|
||||
|
||||
References: [Raspberry Pi networking/configuration](https://www.raspberrypi.com/documentation/computers/configuration.html), [Rust installation](https://doc.rust-lang.org/book/ch01-01-installation.html), [NXP MFRC522 supported card families](https://www.nxp.com/docs/en/data-sheet/MFRC522.pdf), [project GPIO guide](../tools/pi_gpio/README.md), [fleet deployment guide](../tools/deploy/README.md).
|
||||
|
||||
### Pac-Man software power and updates
|
||||
|
||||
This is a software state, not a mains relay. Operator **Power on** starts the cosmetic ROM-check sequence; it does not energize the BCM23 LED. Other games retain their existing startup behavior. The boot pattern is a visual recreation, not a checksum or hardware diagnostic.
|
||||
|
||||
**Temporary 2.4 compatibility:** keep `power_on_device = cab1_btn_ylw` in the scenario and its corresponding legacy input entry in the saved cabinet/catalog inventory for now. The current controller validates that reference, and the game uses its presence to remain in Waiting for power. Removing the setting makes Pac-Man boot immediately; removing only its catalog entry makes scenario validation fail. This is legacy configuration metadata, not permission to configure BCM23 as an input. The bridge's live `devices.json` must contain only the reader entries shown above for this test. A future change should introduce an explicit operator-only power mode and correct the saved PCB inventory together.
|
||||
|
||||
For a fresh release, export the Pi game, re-export the catalog, and package the controller and bridge from the same source version. A Windows game export is optional for PC testing. On an existing working 2.4 installation, removing the incorrect J20 entry from `devices.json` requires restarting the cabinet stack, not rebuilding the game or RFID helper. Preserve unrelated site-specific devices and settings. See section 3.8 for full software updates and rollback.
|
||||
+2
-2
@@ -11,8 +11,8 @@ dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../ArcadeEscape.arm64"
|
||||
exclude_filter="setup/*"
|
||||
export_path="setup/pi/ArcadeEscape.arm64"
|
||||
patches=PackedStringArray()
|
||||
patch_delta_encoding=false
|
||||
patch_delta_compression_level_zstd=19
|
||||
|
||||
+50
-2
@@ -13,6 +13,10 @@ var model = Model.new()
|
||||
var view
|
||||
var audio
|
||||
var accumulator := 0.0
|
||||
signal power_state_changed(state: String)
|
||||
const BOOT_SECONDS := 4.0
|
||||
var power_state := "off"
|
||||
var boot_elapsed := 0.0
|
||||
var credits := 0
|
||||
var rfid_seen: Dictionary = {}
|
||||
var rfid_initialized := false
|
||||
@@ -30,7 +34,11 @@ func receive_device_feedback(feedback: Dictionary) -> void:
|
||||
var ident := str(entry.get("id", ""))
|
||||
if ident.is_empty() or rfid_seen.has(ident): continue
|
||||
rfid_seen[ident] = true
|
||||
if device_id.is_empty() or is_running or credits > 0 or model.state != "idle": continue
|
||||
if power_state == "off" and entry.get("device") == run_config.get("power_on_device", "") and entry.get("kind") == "input" and entry.get("value") == true and str(entry.get("error", "")).is_empty():
|
||||
for sample in feedback.get("devices", []):
|
||||
if sample.get("id") == entry.get("device") and int(entry.get("age_ms", 999999)) < 2000 and int(sample.get("age_ms", 999999)) < 2000 and str(sample.get("error", "")).is_empty():
|
||||
power_on()
|
||||
if power_state != "ready" or device_id.is_empty() or is_running or credits > 0 or model.state != "idle": continue
|
||||
if entry.get("device") != device_id or entry.get("kind") != "rfid" or not str(entry.get("error", "")).is_empty(): continue
|
||||
var uid := str(entry.get("value", "")).to_upper()
|
||||
if uid.is_empty(): continue
|
||||
@@ -57,13 +65,40 @@ func _ready() -> void:
|
||||
model.sound_requested.connect(audio.play_effect)
|
||||
model.finished.connect(_on_finished)
|
||||
_apply_config()
|
||||
if str(run_config.get("power_on_device", "")).is_empty(): power_on()
|
||||
|
||||
func configure(config: Dictionary = {}) -> void:
|
||||
power_off()
|
||||
super.configure(config)
|
||||
credits = 0
|
||||
rfid_seen.clear()
|
||||
rfid_initialized = false
|
||||
_apply_config()
|
||||
if str(run_config.get("power_on_device", "")).is_empty(): power_on()
|
||||
|
||||
func power_on() -> bool:
|
||||
if power_state != "off": return false
|
||||
boot_elapsed = 0.0
|
||||
power_state = "booting"
|
||||
view.power_state = power_state
|
||||
view.startup_time = 0.0
|
||||
audio.stop_all()
|
||||
power_state_changed.emit(power_state)
|
||||
return true
|
||||
|
||||
func power_off() -> void:
|
||||
is_running = false
|
||||
credits = 0
|
||||
boot_elapsed = 0.0
|
||||
power_state = "off"
|
||||
model.reset()
|
||||
accumulator = 0.0
|
||||
if audio != null: audio.stop_all()
|
||||
if view != null:
|
||||
view.power_state = power_state
|
||||
view.startup_time = 0.0
|
||||
view.credits = 0
|
||||
power_state_changed.emit(power_state)
|
||||
|
||||
func _apply_config() -> void:
|
||||
if view != null:
|
||||
@@ -75,7 +110,7 @@ func _apply_config() -> void:
|
||||
model.extra_life_score = maxi(0, int(run_config.get("extra_life_score", extra_life_score)))
|
||||
|
||||
func start_game() -> bool:
|
||||
if is_running: return false
|
||||
if is_running or power_state != "ready": return false
|
||||
if not str(run_config.get("rfid_credit_device", "")).is_empty():
|
||||
if credits < 1: return false
|
||||
credits -= 1
|
||||
@@ -117,6 +152,19 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed(pair[0]): model.requested = pair[1]
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if power_state == "off": return
|
||||
if power_state == "booting":
|
||||
boot_elapsed = minf(BOOT_SECONDS, boot_elapsed + delta)
|
||||
view.startup_time = boot_elapsed
|
||||
if boot_elapsed >= BOOT_SECONDS:
|
||||
power_state = "ready"
|
||||
view.power_state = power_state
|
||||
view.startup_time = 0.0
|
||||
# Ignore scans collected while the ROM check was running.
|
||||
rfid_initialized = false
|
||||
power_state_changed.emit(power_state)
|
||||
return
|
||||
if not is_running: view.startup_time += delta
|
||||
if not is_running:
|
||||
model.clock += delta
|
||||
if is_running:
|
||||
|
||||
@@ -10,6 +10,8 @@ const DEATH_ANIMATION_SECONDS := 1.55
|
||||
const DEATH_POP_SECONDS := 0.24
|
||||
var model
|
||||
var debug_grid := false
|
||||
var power_state := "off"
|
||||
var startup_time := 0.0
|
||||
var credit_required := false
|
||||
var credits := 0
|
||||
|
||||
@@ -28,6 +30,17 @@ func _process(_dt: float) -> void:
|
||||
func _draw() -> void:
|
||||
draw_rect(_viewport_bounds(), Color.BLACK)
|
||||
if model == null: return
|
||||
if power_state == "off":
|
||||
_text("WAITING FOR POWER", Vector2(44, 136))
|
||||
return
|
||||
if power_state == "booting":
|
||||
_boot_screen()
|
||||
_draw_edge_mask(GAME_RECT)
|
||||
return
|
||||
if model.state == "idle":
|
||||
_character_intro()
|
||||
_draw_edge_mask(GAME_RECT)
|
||||
return
|
||||
if model.state == "intermission":
|
||||
_intermission()
|
||||
_draw_edge_mask(GAME_RECT)
|
||||
@@ -145,6 +158,57 @@ func _text(message: String, at: Vector2, color := Color.WHITE) -> void:
|
||||
if code >= 65 and code <= 79: region = Rect2((code - 65) * 8, 0, 8, 8)
|
||||
elif code >= 80 and code <= 90: region = Rect2((code - 80) * 8, 8, 8, 8)
|
||||
elif code >= 48 and code <= 57: region = Rect2((code - 48) * 8, 16, 8, 8)
|
||||
elif c == "/":
|
||||
for dot in range(6): draw_rect(Rect2(at + Vector2(i * 8 + 6 - dot, dot + 1), Vector2.ONE), color)
|
||||
continue
|
||||
elif c == "!": region = Rect2(88, 8, 8, 8)
|
||||
else: continue
|
||||
draw_texture_rect_region(FONT, Rect2(at + Vector2(i * 8, 0), Vector2(8, 8)), region, color)
|
||||
|
||||
# Cosmetic recreation of the original video-RAM test, not an actual ROM test.
|
||||
# All graphics remain on the native 224 x 288 pixel grid; no shader or noise texture.
|
||||
func _boot_screen() -> void:
|
||||
if startup_time < 0.12 or startup_time >= 3.75: return
|
||||
var colors := [Color.CYAN, Color.WHITE, Color.YELLOW, Color("ffb8ff"), Color("ffb852"), Color.RED]
|
||||
var blue := startup_time >= 1.8
|
||||
var sweep := int(startup_time * 16.0)
|
||||
for y in range(36):
|
||||
for x in range(28):
|
||||
var tile := (y * 7 + (x % 8) + sweep) % 32
|
||||
var color: Color = Color("2121de") if blue else colors[(x + y) % colors.size()]
|
||||
var at := Vector2(x * 8, y * 8)
|
||||
match y % 9:
|
||||
0, 1: pass
|
||||
2: _text("P" if tile % 3 else "0", at, color)
|
||||
3: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2(456 + (tile % 3) * 8, 0, 8, 8), color)
|
||||
4: draw_rect(Rect2(at + Vector2(2, 3), Vector2(2, 2)), Color.WHITE)
|
||||
5: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2((tile % 4) * 8, 0, 8, 8), color)
|
||||
6: _text("0", at, color)
|
||||
7: draw_texture_rect_region(SHEET, Rect2(at, Vector2(8, 8)), Rect2(456 + (tile % 4) * 8, 64, 8, 8), color)
|
||||
8: if x % 4 == 0: _text(str(tile % 10), at, color)
|
||||
|
||||
func _character_intro() -> void:
|
||||
_text("1UP", Vector2(24, 0))
|
||||
_text("HIGH SCORE", Vector2(80, 0))
|
||||
_text("00", Vector2(32, 8))
|
||||
_text(str(model.high_score).pad_zeros(2), Vector2(88, 8))
|
||||
_text("CHARACTER / NICKNAME", Vector2(40, 40))
|
||||
var names := ["SHADOW", "SPEEDY", "BASHFUL", "POKEY"]
|
||||
var nicknames := ["BLINKY", "PINKY", "INKY", "CLYDE"]
|
||||
var colors := [Color("ff0000"), Color("ffb8ff"), Color("00ffff"), Color("ffb852")]
|
||||
var elapsed := fmod(startup_time, 22.0)
|
||||
for i in range(4):
|
||||
var age := elapsed - float(i) * 1.6
|
||||
if age < 0.2: continue
|
||||
var y := 64 + i * 24
|
||||
_sprite(Rect2(456 + (int(startup_time * 8) % 2) * 16, 64 + i * 16, 16, 16), Vector2(24, y + 4))
|
||||
_text(names[i].left(clampi(int((age - 0.4) * 20), 0, names[i].length())), Vector2(48, y), colors[i])
|
||||
_text(nicknames[i].left(clampi(int((age - 0.9) * 20), 0, nicknames[i].length())), Vector2(136, y), colors[i])
|
||||
if elapsed > 7.0:
|
||||
draw_rect(Rect2(64, 196, 2, 2), Color("ffb8ae"))
|
||||
_text("10 PTS", Vector2(88, 192))
|
||||
if int(startup_time * 4) % 2 == 0: draw_circle(Vector2(65, 216), 3.5, Color("ffb8ae"))
|
||||
_text("50 PTS", Vector2(88, 212))
|
||||
_text("1980 NAMCO", Vector2(72, 240), Color("ffb8ff"))
|
||||
_text("SCAN CARD" if credit_required and credits == 0 else "PRESS START", Vector2(64, 264), Color.YELLOW)
|
||||
_text("CREDIT %d" % credits, Vector2(16, 280))
|
||||
Binary file not shown.
@@ -39,6 +39,8 @@ class FeedbackTests(unittest.TestCase):
|
||||
self.assertFalse(monitor.snapshot()['devices'][1]['value'])
|
||||
now[0]+=7
|
||||
self.assertGreater(monitor.snapshot()['devices'][0]['age_ms'],6000)
|
||||
self.assertGreater(monitor.snapshot()['events'][0]['age_ms'],6000)
|
||||
self.assertNotIn('stamp',monitor.snapshot()['events'][0])
|
||||
pin=monitor.inputs[0][1];monitor.close();outputs.close();self.assertTrue(pin.closed)
|
||||
def test_rfid_requires_helper(self):
|
||||
outputs=bridge.Outputs(Pin,'test',[25])
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
extends SceneTree
|
||||
func _initialize() -> void: call_deferred("run")
|
||||
func run() -> void:
|
||||
var agent = load("res://tests/pcb/power_agent.gd").new()
|
||||
root.add_child(agent)
|
||||
agent.session = "test"
|
||||
agent.boot = "test"
|
||||
agent.cabinet = load("res://data/cabinets/cabinet_01.tres")
|
||||
await agent.execute({"id": "prepare", "session": "test", "type": "prepare", "game": "pacman", "game_config": {"power_on_device": "cab1_btn_ylw", "rfid_credit_device": "cab1_rfid1"}})
|
||||
assert(agent.phase == "powered_off")
|
||||
assert(agent.outcomes.prepare.ok)
|
||||
agent.game.set_physics_process(false)
|
||||
await agent.execute({"id": "power", "session": "test", "type": "power_on"})
|
||||
assert(agent.phase == "booting" and agent.outcomes.power.ok)
|
||||
agent.game._physics_process(2.0)
|
||||
await agent.execute({"id": "power", "session": "test", "type": "power_on"})
|
||||
assert(agent.game.boot_elapsed == 2.0, "Duplicate commands must not restart boot")
|
||||
agent.game._physics_process(2.0)
|
||||
assert(agent.phase == "ready")
|
||||
assert(agent.reports.any(func(r): return r.get("event") == "power_ready"))
|
||||
await agent.execute({"id": "reset", "session": "test", "type": "reset"})
|
||||
assert(agent.phase == "idle" and agent.game == null)
|
||||
agent.queue_free()
|
||||
await process_frame
|
||||
print("MANAGED PACMAN POWER: PASS")
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://mofjts008nr7
|
||||
@@ -0,0 +1,40 @@
|
||||
extends SceneTree
|
||||
var failures := 0
|
||||
func check(ok: bool, message: String) -> void:
|
||||
if not ok:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
func packet(id: String, kind := "input", device := "cab1_btn_ylw", value: Variant = true, age := 0) -> Dictionary:
|
||||
return {"fresh": true, "events": [{"id": id, "kind": kind, "device": device, "value": value, "age_ms": age}], "devices": [{"id": device, "kind": kind, "value": false if kind == "input" else value, "age_ms": 0}]}
|
||||
func _initialize() -> void: call_deferred("run")
|
||||
func run() -> void:
|
||||
var game = load("res://games/pacman/pacman.gd").new()
|
||||
root.add_child(game)
|
||||
game.set_physics_process(false)
|
||||
game.configure({"power_on_device": "cab1_btn_ylw", "rfid_credit_device": "cab1_rfid1"})
|
||||
check(game.power_state == "off" and not game.start_game(), "Prepared cabinet waits for power")
|
||||
game.receive_device_feedback(packet("old"))
|
||||
game.receive_device_feedback(packet("stale", "input", "cab1_btn_ylw", true, 5000))
|
||||
game.receive_device_feedback(packet("wrong", "input", "other"))
|
||||
game.receive_device_feedback(packet("early-card", "rfid", "cab1_rfid1", "12345678"))
|
||||
check(game.power_state == "off" and game.credits == 0, "Historical/stale/wrong inputs and early cards are ignored")
|
||||
game.receive_device_feedback(packet("tap"))
|
||||
check(game.power_state == "booting", "A quick press released before polling still powers on")
|
||||
check(not game.power_on() and not game.start_game(), "Boot cannot be restarted or skipped")
|
||||
game._physics_process(3.99)
|
||||
game.receive_device_feedback(packet("boot-card", "rfid", "cab1_rfid1", "12345678"))
|
||||
check(game.power_state == "booting" and game.credits == 0, "Full four-second boot gates RFID")
|
||||
game._physics_process(0.02)
|
||||
check(game.power_state == "ready" and game.model.state == "idle", "Boot enters character intro, not gameplay")
|
||||
game.receive_device_feedback(packet("boot-card", "rfid", "cab1_rfid1", "12345678"))
|
||||
check(game.credits == 0, "First post-boot snapshot discards buffered scans")
|
||||
game.receive_device_feedback(packet("new-card", "rfid", "cab1_rfid1", "12345678"))
|
||||
check(game.credits == 1 and game.start_game(), "New scan then Start begins play")
|
||||
game.receive_device_feedback(packet("playing-press"))
|
||||
check(game.is_running and game.power_state == "ready", "Button during play cannot restart the cabinet")
|
||||
game.power_off()
|
||||
check(not game.is_running and game.credits == 0 and not game.start_game(), "Power off clears gameplay and credits")
|
||||
game.queue_free()
|
||||
await process_frame
|
||||
print("PACMAN POWER: ", "PASS" if failures == 0 else "FAIL")
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://u60u4vs3he40
|
||||
@@ -12,6 +12,8 @@ func run() -> void:
|
||||
var game = load("res://games/pacman/pacman.gd").new()
|
||||
root.add_child(game)
|
||||
game.configure({"rfid_credit_device": "cab1_rfid1", "rfid_allowed_uids": ["1234ABCD"]})
|
||||
game.set_physics_process(false)
|
||||
game._physics_process(4.0)
|
||||
check(not game.start_game(), "Start must require a credit")
|
||||
game.receive_device_feedback({})
|
||||
game.receive_device_feedback(sample("old"))
|
||||
@@ -35,6 +37,7 @@ func run() -> void:
|
||||
game.receive_device_feedback(sample("new-scan"))
|
||||
check(game.credits == 1, "New scan works after reset")
|
||||
game.configure({})
|
||||
game._physics_process(4.0)
|
||||
check(game.start_game(), "Ordinary non-RFID Pac-Man remains free start")
|
||||
game.queue_free()
|
||||
await process_frame
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://djqv118y4dxxd
|
||||
@@ -0,0 +1,3 @@
|
||||
extends "res://core/network/managed_cabinet.gd"
|
||||
func _ready() -> void: pass
|
||||
func _process(_delta: float) -> void: pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://bt644lr2kyehf
|
||||
@@ -0,0 +1,17 @@
|
||||
extends SceneTree
|
||||
func _initialize() -> void: call_deferred("run")
|
||||
func run() -> void:
|
||||
var game = load("res://games/pacman/pacman.gd").new()
|
||||
root.add_child(game)
|
||||
game.set_physics_process(false)
|
||||
game.configure({"power_on_device": "cab1_btn_ylw", "rfid_credit_device": "cab1_rfid1"})
|
||||
for state in ["off", "booting", "ready"]:
|
||||
game.view.power_state = state
|
||||
game.view.startup_time = 1.0 if state == "booting" else 9.0
|
||||
await process_frame
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
root.get_texture().get_image().save_png(OS.get_environment("TEMP").path_join("pacman-power-" + state + ".png"))
|
||||
game.queue_free()
|
||||
await process_frame
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b72d387gge7x7
|
||||
@@ -65,14 +65,30 @@ class ObjectiveTests(unittest.TestCase):
|
||||
self.fleet=test_fleet.server.Fleet(':memory:',self.catalog,lambda:self.now)
|
||||
self.poll('cabinet_01')
|
||||
room=self.fleet.rooms['cabinet_01']
|
||||
room.features=['assignment_config_v1','rfid_credit_v1']
|
||||
room.features=['assignment_config_v1','rfid_credit_v1','pacman_power_v1']
|
||||
room.gpio='real'
|
||||
self.fleet.operator('prepare_scenario', {'scenario':'pacman_rfid_credit_test'})
|
||||
command=self.poll('cabinet_01')['commands'][0]
|
||||
command=room.poll(dict(cabinet='cabinet_01',protocol=2,boot='cabinet_01',features=room.features,gpio='real',reports=[]))['commands'][0]
|
||||
self.assertEqual(command['game_config']['rfid_credit_device'],'cab1_rfid1')
|
||||
self.assertEqual(command['type'],'prepare')
|
||||
self.poll('cabinet_01',[dict(kind='ack',id=command['id'],session=command['session'],ok=True)])
|
||||
room.ack(dict(id=command['id'],ok=True))
|
||||
self.assertEqual(room.state['phase'],'powered_off')
|
||||
room.event(dict(id='power',event='power_booting'))
|
||||
self.assertEqual(room.state['phase'],'booting')
|
||||
room.event(dict(id='booted',event='power_ready'))
|
||||
self.assertEqual(room.state['phase'],'ready')
|
||||
self.assertFalse(any(c['type'] in ('output','reward','start') for c in room.state['commands']))
|
||||
self.poll('cabinet_01',[dict(kind='event',id='physical-start',session=command['session'],event='started')])
|
||||
self.assertEqual(room.state['phase'],'playing')
|
||||
|
||||
def test_power_validation(self):
|
||||
room=self.fleet.rooms['cabinet_01']
|
||||
room.gpio='real'
|
||||
self.catalog['cabinets']['cabinet_01']['devices']=[dict(id='button',kind='input')]
|
||||
item=dict(cabinet='cabinet_01',game='pacman',game_config=dict(power_on_device='button'))
|
||||
with self.assertRaisesRegex(ValueError,'2.4'): self.fleet.validate_assignment(item)
|
||||
room.features.append('pacman_power_v1')
|
||||
self.fleet.validate_assignment(item)
|
||||
for value in ['missing',True,17]:
|
||||
with self.assertRaises(ValueError): self.fleet.validate_assignment(dict(item,game_config=dict(power_on_device=value)))
|
||||
with self.assertRaises(ValueError): self.fleet.validate_assignment(dict(item,activate_after_win='cabinet_02'))
|
||||
@@ -0,0 +1,35 @@
|
||||
import unittest
|
||||
from test_room import server
|
||||
|
||||
class PowerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.room=server.Room(':memory:',lambda:100)
|
||||
self.room.poll(dict(cabinet='cabinet_01',protocol=2,boot='a',features=['pacman_power_v1'],reports=[]))
|
||||
self.room.state.update(game='pacman',game_config={'power_on_device':'button'},mappings={})
|
||||
def tearDown(self): self.room.db.close()
|
||||
def prepare(self):
|
||||
self.room.operator('prepare')
|
||||
command=self.room.state['commands'][-1]
|
||||
self.room.ack(dict(id=command['id'],ok=True))
|
||||
def test_operator_power_and_duplicate_events(self):
|
||||
self.prepare()
|
||||
self.assertEqual(self.room.state['phase'],'powered_off')
|
||||
with self.assertRaises(ValueError): self.room.operator('start')
|
||||
self.room.operator('power_on')
|
||||
command=self.room.state['commands'][-1]
|
||||
self.room.ack(dict(id=command['id'],ok=True))
|
||||
self.assertEqual(self.room.state['phase'],'booting')
|
||||
with self.assertRaises(ValueError): self.room.operator('start')
|
||||
self.room.event(dict(id='booted',event='power_ready'))
|
||||
self.assertEqual(self.room.state['phase'],'ready')
|
||||
with self.assertRaises(ValueError): self.room.operator('power_on')
|
||||
self.room.event(dict(id='booted',event='power_ready'))
|
||||
self.assertEqual(self.room.state['phase'],'ready')
|
||||
def test_dependency_autostart_waits_for_boot(self):
|
||||
self.room.state.update(game_config={},auto_start=True)
|
||||
self.prepare()
|
||||
self.assertEqual(self.room.state['phase'],'booting')
|
||||
self.assertFalse(any(c['type']=='start' for c in self.room.state['commands']))
|
||||
self.room.event(dict(id='booted',event='power_ready'))
|
||||
self.assertEqual(self.room.state['phase'],'starting')
|
||||
self.assertEqual(sum(c['type']=='start' for c in self.room.state['commands']),1)
|
||||
@@ -59,7 +59,7 @@ class Feedback:
|
||||
old.update(value=value,error=error,stamp=self.clock())
|
||||
if changed:
|
||||
self.sequence+=1
|
||||
self.events.append(dict(id=self.boot+':'+str(self.sequence),device=ident,value=value,error=error,kind=old['kind']))
|
||||
self.events.append(dict(id=self.boot+':'+str(self.sequence),device=ident,value=value,error=error,kind=old['kind'],stamp=self.clock()))
|
||||
def _readers(self, readers):
|
||||
slots={int(d['port'][1:])-1:d['id'] for d in readers}
|
||||
try:
|
||||
@@ -88,7 +88,7 @@ class Feedback:
|
||||
def snapshot(self):
|
||||
with self.lock:
|
||||
now=self.clock()
|
||||
return dict(devices=[{k:v for k,v in d.items() if k!='stamp'}|{'age_ms':int(max(0,now-d['stamp'])*1000)} for d in self.values.values()],events=list(self.events))
|
||||
return dict(devices=[{k:v for k,v in d.items() if k!='stamp'}|{'age_ms':int(max(0,now-d['stamp'])*1000)} for d in self.values.values()],events=[{k:v for k,v in e.items() if k!='stamp'}|{'age_ms':int(max(0,now-e['stamp'])*1000)} for e in self.events])
|
||||
def close(self):
|
||||
self.stop.set()
|
||||
if self.reader:
|
||||
|
||||
@@ -415,6 +415,7 @@
|
||||
"game": "pacman",
|
||||
"game_config": {
|
||||
"boards_to_win": 1,
|
||||
"power_on_device": "cab1_btn_ylw",
|
||||
"rfid_allowed_uids": [],
|
||||
"rfid_credit_device": "cab1_rfid1",
|
||||
"score_to_win": 0
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<div id="shell" hidden><aside class="sidebar"><div class="brand"><div class="mark">EG</div><div><strong id="siteName">Escape Gallery</strong><small>OPERATIONS</small></div></div><nav aria-label="Main navigation"><div class="nav-label">WORKSPACE</div><button class="nav" data-view="home"><span>◈</span>Hub overview</button><div class="nav-label">CURRENT ROOM</div><button class="nav" data-view="room"><span>▤</span>Room control</button><button class="nav" data-view="cabinets"><span>▣</span>Cabinets</button><button class="nav" data-view="hardware"><span>⌘</span>Hardware</button><button class="nav" data-view="activity"><span>≡</span>Activity</button></nav><footer><p id="sidebarRoom">Glitch: the Dark Machine</p><p>Building network · Operator access</p><button class="lock">Lock this device</button></footer></aside><div class="content"><header class="topbar"><div><small id="breadcrumb">Operations / Overview</small><strong id="roomName">Glitch: the Dark Machine</strong></div><div class="row"><span id="status" class="badge" role="status">Connecting</span><button id="refresh">Refresh</button><button class="lock" id="mobileLock">Lock</button></div></header><main><div id="error" class="notice" role="alert"></div><div id="toast" class="notice" role="status"></div><div id="stale" class="notice" hidden>Connection lost. Showing last received state; commands are disabled.</div>
|
||||
<section data-page="home"><div class="heading"><div class="eyebrow">Your operations, together</div><h1>Keep the experience moving.</h1><p>Open a room, check what needs attention, and get back to your players.</p></div><div class="row between"><h2>Experiences</h2><span class="badge">Local network</span></div><div id="experiences" class="grid"></div><div class="stats"><div class="stat"><strong id="onlineCount">—</strong><span>Cabinets online</span></div><div class="stat"><strong id="playingCount">—</strong><span>Games in progress</span></div><div class="stat"><strong id="readyCount">—</strong><span>Ready to start</span></div><div class="stat"><strong id="attentionCount">—</strong><span>Need attention</span></div></div><div class="columns"><section class="panel"><div class="row between"><h2>Needs attention</h2><button class="link" data-go="cabinets">View cabinets →</button></div><div id="attention"></div></section><section class="panel"><div class="row between"><h2>Recent activity</h2><button class="link" data-go="activity">View all →</button></div><ol id="recent" class="activity"></ol></section></div></section>
|
||||
<section data-page="room" hidden><div class="heading"><div class="eyebrow">Game-master workspace</div><h1 id="roomHeading">Glitch: the Dark Machine</h1><p>Follow the room layout and cabinet sequence. Select a station for its live reported state and controls.</p></div><div class="columns"><section class="panel"><h2>Scenario preparation</h2><label for="scenario">Scenario</label><select id="scenario" style="width:100%"></select><ol id="scenarioPlan" class="scenario-plan"></ol><button id="prepareScenario" class="primary" style="width:100%">Prepare assigned cabinets…</button><p class="small muted" style="margin-top:14px">Preparation can run configured activation outputs. Linked cabinets wait for the preceding win; independent cabinets are started individually.</p><div id="scenarioBlockers" class="notice"></div></section><section class="panel"><h2>Room layout</h2><svg id="map" role="group" aria-label="Room layout; select a cabinet for controls"></svg><div class="legend"><span><i style="background:#287c58"></i>Ready / won</span><span><i style="background:#426ca4"></i>Playing</span><span><i style="background:#9a762d"></i>Waiting / idle</span><span><i style="background:#89918d"></i>Offline</span></div><p class="small muted">Device markers are reference locations, not live sensor readings. Cabinet rings indicate pending or unconfirmed output commands.</p></section></div><div class="row between" style="margin:24px 0 14px"><h2 style="margin:0">Cabinet status</h2><button class="link" data-go="cabinets">Open controls →</button></div><div class="grid" id="roomCards"></div></section>
|
||||
<section data-page="cabinets" hidden><div class="heading"><div class="eyebrow">Connected stations</div><h1>Cabinets</h1><p>The marquee follows the assigned game. The screen represents reported state; it is not a live video feed.</p></div><div class="grid" id="cards"></div><section class="panel detail" id="detail"><div class="row between" style="margin-bottom:20px"><div><h2 id="cabinetTitle">Select a cabinet</h2><small id="connection"></small></div><span class="badge" id="phaseBadge"></span></div><div class="columns"><div><dl id="assignment" class="kv"></dl><div id="steps" class="steps"></div><div class="row"><button id="start" class="primary">Start game</button><button id="reset" class="danger">Reset cabinet…</button><button id="openHardware">Hardware →</button></div><p id="controlHint" class="small muted" style="margin-top:14px"></p><hr><h3>Manual game assignment</h3><p class="small muted">Runs a compatible game without scenario hardware rewards.</p><label for="game">Compatible game</label><div class="row"><select id="game"></select><button id="prepare">Prepare game…</button></div><h3 style="margin-top:24px">Win output</h3><p id="reward" class="small muted"></p><h3>Device feedback</h3><div id="cabinetFeedback" class="feedback-list"></div></div><div class="visual-panel"><div class="cabinet-visual" aria-label="Cabinet state illustration"><div class="marquee" id="marquee">GAME</div><div class="screen"><div class="screen-game" id="screenGame"></div><div class="screen-state" id="screenState"></div><div class="screen-detail" id="screenDetail"></div></div><div class="deck" aria-hidden="true"><div class="joystick"></div><div class="buttons"><i></i><i></i><i></i></div><div class="joystick"></div></div><div class="coin-door" aria-hidden="true"><div class="coin-slot"></div></div></div><p class="cabinet-caption">Assigned-game artwork · reported cabinet state</p></div></div></section></section>
|
||||
<section data-page="cabinets" hidden><div class="heading"><div class="eyebrow">Connected stations</div><h1>Cabinets</h1><p>The marquee follows the assigned game. The screen represents reported state; it is not a live video feed.</p></div><div class="grid" id="cards"></div><section class="panel detail" id="detail"><div class="row between" style="margin-bottom:20px"><div><h2 id="cabinetTitle">Select a cabinet</h2><small id="connection"></small></div><span class="badge" id="phaseBadge"></span></div><div class="columns"><div><dl id="assignment" class="kv"></dl><div id="steps" class="steps"></div><div class="row"><button id="powerOn">Power on</button><button id="start" class="primary">Start game</button><button id="reset" class="danger">Reset cabinet…</button><button id="openHardware">Hardware →</button></div><p id="controlHint" class="small muted" style="margin-top:14px"></p><hr><h3>Manual game assignment</h3><p class="small muted">Runs a compatible game without scenario hardware rewards.</p><label for="game">Compatible game</label><div class="row"><select id="game"></select><button id="prepare">Prepare game…</button></div><h3 style="margin-top:24px">Win output</h3><p id="reward" class="small muted"></p><h3>Device feedback</h3><div id="cabinetFeedback" class="feedback-list"></div></div><div class="visual-panel"><div class="cabinet-visual" aria-label="Cabinet state illustration"><div class="marquee" id="marquee">GAME</div><div class="screen"><div class="screen-game" id="screenGame"></div><div class="screen-state" id="screenState"></div><div class="screen-detail" id="screenDetail"></div></div><div class="deck" aria-hidden="true"><div class="joystick"></div><div class="buttons"><i></i><i></i><i></i></div><div class="joystick"></div></div><div class="coin-door" aria-hidden="true"><div class="coin-slot"></div></div></div><p class="cabinet-caption">Assigned-game artwork · reported cabinet state</p></div></div></section></section>
|
||||
<section data-page="hardware" hidden><div class="heading"><div class="eyebrow">Physical devices</div><h1>Hardware & outputs</h1><p>Trigger configured actions and review acknowledgements. Accepted does not prove that a lock moved or a reward was delivered.</p></div><section class="panel"><label for="hardwareCabinet">Cabinet</label><select id="hardwareCabinet"></select><p id="hardwareHint" class="small muted" style="margin-top:14px"></p><div class="grid" id="actions"></div><h2 style="margin-top:26px">Command history</h2><div class="table-wrap" id="output"></div><h2 style="margin-top:24px">Live device feedback</h2><div id="feedback" class="feedback-list"></div></section><section class="panel" style="margin-top:20px"><div class="row between"><h2>Room devices</h2><span class="badge">Integration pending</span></div><p class="small muted">Device feedback is listed above when a configured Pi bridge reports it. These room markers remain reference locations until mapped to sensor IDs.</p><div id="roomDevices" class="grid"></div></section></section>
|
||||
<section data-page="activity" hidden><div class="heading"><div class="eyebrow">Room history</div><h1>Activity</h1><p>Cabinet events, operator interventions, and command acknowledgements in one place.</p></div><section class="panel"><div class="row filters"><label class="visually-hidden" for="activityCabinet">Filter cabinet</label><select id="activityCabinet"></select><label class="visually-hidden" for="activitySearch">Search activity</label><input type="search" id="activitySearch" placeholder="Search events or actions…"><span id="eventCount" class="small muted"></span></div><ol class="activity" id="events"></ol><p class="small muted" style="margin-top:20px">Latest retained history: up to 100 events per cabinet. Operator entries record actions and reasons; individual staff accounts are not configured.</p></section></section>
|
||||
</main></div></div><dialog id="confirmDialog" aria-labelledby="confirmTitle"><form id="confirmForm"><div class="eyebrow">Review action</div><h2 id="confirmTitle"></h2><p id="confirmText" class="muted"></p><div id="confirmReason" hidden><label for="reason">Reason for this intervention</label><textarea id="reason" rows="3" maxlength="200"></textarea></div><div class="row"><button type="button" id="cancelConfirm">Cancel</button><button type="submit" class="primary" id="acceptConfirm">Confirm</button></div></form></dialog>
|
||||
<script>
|
||||
const $=id=>document.getElementById(id), phases={idle:'Idle',waiting:'Waiting',preparing:'Preparing',ready:'Ready',starting:'Starting',playing:'Playing',won:'Won',lost:'Lost',resetting:'Resetting',needs_prepare:'Needs preparation'};
|
||||
const $=id=>document.getElementById(id), phases={powered_off:'Waiting for power',booting:'Booting',idle:'Idle',waiting:'Waiting',preparing:'Preparing',ready:'Ready',starting:'Starting',playing:'Playing',won:'Won',lost:'Lost',resetting:'Resetting',needs_prepare:'Needs preparation'};
|
||||
let token='',state=null,selected='',view='home',connected=false,pending=false,refreshing=false,epoch=0,confirmation=null;
|
||||
const node=(tag,text='',cls='')=>{const n=document.createElement(tag);n.textContent=text;n.className=cls;return n};
|
||||
const name=id=>state?.cabinets[id]?.definition.name||id, gameName=id=>state?.catalog.games[id]?.name||id||'No game assigned';
|
||||
@@ -24,7 +24,7 @@ function cached(id,key,fn){const e=$(id),s=JSON.stringify(key);if(e.dataset.key!
|
||||
function navigate(next){view=['home','room','cabinets','hardware','activity'].includes(next)?next:'home';document.querySelectorAll('[data-page]').forEach(e=>e.hidden=e.dataset.page!==view);document.querySelectorAll('[data-view]').forEach(e=>e.setAttribute('aria-current',e.dataset.view===view?'page':'false'));$('breadcrumb').textContent='Operations / '+({home:'Overview',room:'Room control',cabinets:'Cabinets',hardware:'Hardware',activity:'Activity'}[view]);if(state)render()}
|
||||
function select(id,next='cabinets'){selected=id;navigate(next)}
|
||||
async function api(path,data={}){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),6000);try{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify(data),signal:controller.signal});const b=await r.json();if(!r.ok)throw Error(b.error||'Request failed');return b}finally{clearTimeout(timer)}}
|
||||
function enable(){const c=state?.cabinets[selected],ok=connected&&!pending;['start','reset','prepare','prepareScenario'].forEach(id=>$(id).disabled=true);if(c){$('start').disabled=!(ok&&c.online&&c.phase==='ready'&&!c.gate&&!c.game_config?.rfid_credit_device&&!busy(c));$('reset').disabled=!(ok&&c.online&&!busy(c));$('prepare').disabled=!(ok&&prep(c)&&$('game').value)}const plan=state?.catalog.scenarios[$('scenario').value];$('prepareScenario').disabled=!(ok&&plan?.assignments.length&&plan.assignments.every(a=>prep(state.cabinets[a.cabinet])));document.querySelectorAll('[data-output]').forEach(b=>b.disabled=!hardware(c));$('acceptConfirm').disabled=!ok;$('refresh').disabled=pending||refreshing}
|
||||
function enable(){const c=state?.cabinets[selected],ok=connected&&!pending;['powerOn','start','reset','prepare','prepareScenario'].forEach(id=>$(id).disabled=true);if(c){$('powerOn').disabled=!(ok&&c.online&&c.phase==='powered_off'&&!busy(c));$('start').disabled=!(ok&&c.online&&c.phase==='ready'&&!c.gate&&!c.game_config?.rfid_credit_device&&!busy(c));$('reset').disabled=!(ok&&c.online&&!busy(c));$('prepare').disabled=!(ok&&prep(c)&&$('game').value)}const plan=state?.catalog.scenarios[$('scenario').value];$('prepareScenario').disabled=!(ok&&plan?.assignments.length&&plan.assignments.every(a=>prep(state.cabinets[a.cabinet])));document.querySelectorAll('[data-output]').forEach(b=>b.disabled=!hardware(c));$('acceptConfirm').disabled=!ok;$('refresh').disabled=pending||refreshing}
|
||||
async function refresh(){if(!token||pending||refreshing)return;refreshing=true;const generation=epoch;try{const s=await api('/operator/state');if(generation!==epoch)return;state=s;connected=true;$('login').hidden=true;$('shell').hidden=false;$('loginError').textContent='';render()}catch(e){if(generation!==epoch)return;connected=false;$('loginError').textContent=e.message;enable()}finally{refreshing=false;$('status').textContent=connected?'Connected':'Disconnected';$('status').className='badge '+(connected?'good':'bad');$('stale').hidden=connected;enable()}}
|
||||
async function command(action,data){if(!connected||pending)return;pending=true;const generation=++epoch;enable();$('error').textContent='';$('toast').textContent='';try{const result=await api('/operator/command',{action,...data});if(generation!==epoch)return;state=result;render();$('toast').textContent='Command submitted. Follow its acknowledgement in Activity.'}catch(e){if(generation!==epoch)return;$('error').textContent=e.name==='AbortError'?'Reply timed out. Check Activity before repeating the action.':e.message}finally{pending=false;enable();refresh()}}
|
||||
function review(title,text,fn,reason=false){confirmation=fn;$('confirmTitle').textContent=title;$('confirmText').textContent=text;$('confirmReason').hidden=!reason;$('reason').value='';$('reason').required=reason;$('reason').minLength=reason?3:0;$('confirmDialog').showModal()}
|
||||
@@ -34,13 +34,13 @@ function cards(id){const root=$(id);for(const [key,c] of Object.entries(state.ca
|
||||
function home(){const cs=Object.values(state.cabinets);$('onlineCount').textContent=cs.filter(c=>c.online).length+' / '+cs.length;$('playingCount').textContent=cs.filter(c=>c.online&&c.phase==='playing').length;$('readyCount').textContent=cs.filter(c=>c.online&&c.phase==='ready').length;const attention=Object.entries(state.cabinets).filter(([,c])=>!c.online||c.phase==='needs_prepare'||c.commands.some(x=>['failed','unknown'].includes(x.status)));$('attentionCount').textContent=attention.length;cached('experiences',state.hub,e=>{const b=node('button','','experience');b.append(node('span','ARCADE ROOM','eyebrow'),node('h2',state.hub.room_name),node('p','Room layout, cabinet controls and hardware activity'),node('span','Open room →','link'));b.onclick=()=>navigate('room');e.replaceChildren(b);for(const s of state.hub.services){const a=node('a','','experience');a.href=s.url;a.target='_blank';a.rel='noopener noreferrer';a.append(node('span',s.kind.toUpperCase(),'eyebrow'),node('h2',s.name),node('p',s.description),node('small','External service · status not monitored'));e.append(a)}});cached('attention',attention.map(([id,c])=>[id,c.online,c.phase]),e=>{e.replaceChildren(...attention.map(([id,c])=>{const b=node('button',name(id)+' — '+(!c.online?'Offline':c.phase==='needs_prepare'?'Needs preparation':'Review command history'),'attention');b.onclick=()=>select(id);return b}));if(!attention.length)e.append(node('p','All cabinets are reporting normally.','muted'))});events('recent',allEvents().slice(0,6))}
|
||||
function scenario(){const s=state.catalog.scenarios[$('scenario').value];$('scenarioPlan').replaceChildren(...(s?.assignments||[]).map(a=>{const li=node('li');li.append(node('strong',name(a.cabinet)+' · '+gameName(a.game)),node('p',a.activate_after_win?'After '+name(a.activate_after_win)+' wins':'Ready for operator start'),node('small',Object.entries(a.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(' · ')));return li}));$('scenarioBlockers').textContent=(s?.assignments||[]).filter(a=>!prep(state.cabinets[a.cabinet])).map(a=>name(a.cabinet)+': must be online, reset, and free of pending commands').join('. ')}
|
||||
function map(){const layout=state.catalog.layout||{},cs=Object.entries(state.cabinets);cached('map',[layout,cs.map(([id,c])=>[id,c.online,c.phase,c.commands])],svg=>{svg.replaceChildren();const pts=[...(layout.boundary||[]),...(layout.walls||[]).flat(),...(layout.devices||[]).map(d=>d.position),...cs.map(([,c])=>c.definition.position)].filter(Boolean);if(!pts.length)return;const xs=pts.map(p=>p[0]),ys=pts.map(p=>p[1]),minX=Math.min(...xs)-.7,minY=Math.min(...ys)-.7,w=Math.max(...xs)-minX+.7,h=Math.max(...ys)-minY+.7;svg.setAttribute('viewBox',[minX,minY,w,h].join(' '));const draw=(tag,attrs,parent=svg)=>{const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const[k,v]of Object.entries(attrs))e.setAttribute(k,v);parent.append(e);return e};if(layout.boundary?.length)draw('polygon',{points:layout.boundary.map(p=>p.join(',')).join(' '),fill:'#edf0e9',stroke:'#b6beb5','stroke-width':.04});for(const wall of layout.walls||[])draw('polyline',{points:wall.map(p=>p.join(',')).join(' '),fill:'none',stroke:'#728176','stroke-width':.07});for(const d of layout.devices||[]){if(!d.position)continue;const e=draw('rect',{x:d.position[0]-.05,y:d.position[1]-.05,width:.1,height:.1,fill:'#555'});draw('title',{},e).textContent=d.name+' — reference location; feedback unavailable'}for(const[id,c]of cs){const p=c.definition.position;if(!p)continue;const g=draw('g',{role:'button',tabindex:0,'aria-label':name(id)+': '+(c.online?c.phase:'offline')});g.style.cursor='pointer';g.onclick=()=>select(id);g.onkeydown=e=>{if(['Enter',' '].includes(e.key)){e.preventDefault();select(id)}};draw('circle',{cx:p[0],cy:p[1],r:.17,fill:!c.online?'#89918d':c.phase==='playing'?'#426ca4':['ready','won'].includes(c.phase)?'#287c58':'#9a762d'},g);const output=c.commands.filter(x=>['output','reward'].includes(x.type)).at(-1);if(output&&['queued','unknown','failed'].includes(output.status)){const ring=draw('circle',{cx:p[0],cy:p[1],r:.23,fill:'none',stroke:output.status==='queued'?'#b28934':'#bd4a3e','stroke-width':.035},g);draw('title',{},ring).textContent='Output command '+output.status+'; physical state unverified'}draw('text',{x:p[0],y:p[1]-.3,'text-anchor':'middle','font-size':.16,fill:'#263f32'},g).textContent=name(id)}})}
|
||||
function detail(){renderFeedback('cabinetFeedback');const c=state.cabinets[selected];$('detail').hidden=!c;if(!c)return;$('cabinetTitle').textContent=name(selected);$('connection').textContent=selected+' · '+c.version+' · GPIO: '+c.gpio;$('phaseBadge').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('assignment').replaceChildren(...Object.entries({Game:gameName(c.game),Assignment:c.scenario||'None',Activation:c.gate?'Waiting for '+name(c.gate.cabinet)+' to win':'Independent',Objectives:Object.entries(c.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(', ')||'Game defaults'}).flatMap(([k,v])=>[node('dt',k),node('dd',v)]));$('steps').replaceChildren(...['preparing','ready','playing','won'].map(p=>node('span',phases[p],c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,gameName(id)]));$('reward').textContent=c.reward||'No win output reported.';$('controlHint').textContent=!c.online?'Reconnect this cabinet to send commands.':busy(c)?'Waiting for command acknowledgement.':c.gate?'This cabinet starts automatically after its dependency wins.':c.game_config?.rfid_credit_device?'Scan a card, then press Start on the cabinet.':'Start a ready game, or reset before changing its assignment.';renderCabinetArt(c);$('screenState').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('screenDetail').textContent=c.gate?'Waiting for '+name(c.gate.cabinet):busy(c)?'Command pending':'Reported state · '+selected}
|
||||
function detail(){renderFeedback('cabinetFeedback');const c=state.cabinets[selected];$('detail').hidden=!c;if(!c)return;$('cabinetTitle').textContent=name(selected);$('connection').textContent=selected+' · '+c.version+' · GPIO: '+c.gpio;$('phaseBadge').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('assignment').replaceChildren(...Object.entries({Game:gameName(c.game),Assignment:c.scenario||'None',Activation:c.gate?'Waiting for '+name(c.gate.cabinet)+' to win':'Independent',Objectives:Object.entries(c.game_config||{}).map(([k,v])=>k.replaceAll('_',' ')+': '+v).join(', ')||'Game defaults'}).flatMap(([k,v])=>[node('dt',k),node('dd',v)]));$('steps').replaceChildren(...['preparing','powered_off','booting','ready','playing','won'].filter(p=>c.game==='pacman'||!['powered_off','booting'].includes(p)).map(p=>node('span',phases[p],c.phase===p?'current':'')));options('game',c.compatible_games.map(id=>[id,gameName(id)]));$('reward').textContent=c.reward||'No win output reported.';$('controlHint').textContent=!c.online?'Reconnect this cabinet to send commands.':busy(c)?'Waiting for command acknowledgement.':c.gate?'This cabinet starts automatically after its dependency wins.':c.phase==='powered_off'?'Press the configured PCB power button, or use Power on.':c.phase==='booting'?'Pac-Man is running its four-second startup check.':c.game_config?.rfid_credit_device?'Scan a card, then press Start on the cabinet.':'Start a ready game, or reset before changing its assignment.';renderCabinetArt(c);$('screenState').textContent=c.online?(phases[c.phase]||c.phase):'Offline';$('screenDetail').textContent=c.gate?'Waiting for '+name(c.gate.cabinet):busy(c)?'Command pending':'Reported state · '+selected}
|
||||
function hardwareView(){renderFeedback();const c=state.cabinets[selected];$('hardwareCabinet').value=selected;$('hardwareHint').textContent=c?name(selected)+' · GPIO: '+c.gpio+' · '+(hardware(c)?'Configured outputs available.':'Requires an online, prepared cabinet with real GPIO and no pending commands.'):'No cabinet selected';cached('actions',[selected,c?.definition.actions],e=>{e.replaceChildren(...(c?.definition.actions||[]).map(action=>{const box=node('div','','panel'),b=node('button','Trigger…');b.dataset.output=action;b.onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Trigger '+action+'?',name(id)+' will run this configured physical action. Check the connected device before proceeding.',reason=>{const bytes=crypto.getRandomValues(new Uint8Array(16));command('trigger_output',{cabinet:id,expected_session:session,action_id:action,reason,request_id:Array.from(bytes,x=>x.toString(16).padStart(2,'0')).join('')})},true)};box.append(node('h3',action),node('p','Configured cabinet action','small muted'),b);return box}));if(!e.children.length)e.append(node('p','No actions configured.','empty'))});cached('output',[selected,c?.commands],e=>{const list=(c?.commands||[]).filter(x=>['reward','output'].includes(x.type)).slice().reverse();e.replaceChildren(...list.map(x=>node('p',(x.action_id||x.type)+' · '+(x.source==='operator'?'Operator':'Scenario / win output')+' · '+x.status+(x.status==='accepted'?' — physical delivery unconfirmed':''))));if(!list.length)e.append(node('p','No hardware commands yet.','muted'))});cached('roomDevices',state.catalog.layout?.devices,e=>e.replaceChildren(...(state.catalog.layout?.devices||[]).map(d=>{const n=node('div','','panel');n.append(node('strong',d.name),node('p',d.id+' · feedback unavailable','small muted'));return n})))}
|
||||
function activity(){const id=$('activityCabinet').value,q=$('activitySearch').value.toLowerCase();const list=allEvents().filter(e=>(!id||e.id===id)&&(!q||(e.message+' '+name(e.id)).toLowerCase().includes(q)));$('eventCount').textContent=list.length+' events';events('events',list)}
|
||||
function render(){if(!state?.cabinets)return;if(!state.cabinets[selected])selected=Object.keys(state.cabinets)[0];const hub=state.hub||{name:'Escape Gallery',room_name:'Glitch: the Dark Machine',services:[]};state.hub=hub;$('siteName').textContent=hub.name;for(const id of ['sidebarRoom','roomName','roomHeading'])$(id).textContent=hub.room_name;document.title=hub.name+' · Operations';options('scenario',Object.entries(state.catalog.scenarios).map(([id,s])=>[id,s.name||id]));options('hardwareCabinet',Object.keys(state.cabinets).map(id=>[id,name(id)]));options('activityCabinet',[['','All cabinets'],...Object.keys(state.cabinets).map(id=>[id,name(id)])]);if(view==='home')home();if(view==='room'){scenario();map();cards('roomCards')}if(view==='cabinets'){cards('cards');detail()}if(view==='hardware')hardwareView();if(view==='activity')activity();enable()}
|
||||
$('login').onsubmit=e=>e.preventDefault();$('connect').onclick=()=>{token=$('token').value.trim();if(token)refresh()};$('token').onkeydown=e=>{if(e.key==='Enter')$('connect').click()};$('refresh').onclick=refresh;document.querySelectorAll('.lock').forEach(b=>b.onclick=()=>{++epoch;token='';state=null;connected=false;$('token').value='';$('shell').hidden=true;$('login').hidden=false;$('confirmDialog').close();$('token').focus()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>navigate(b.dataset.view));document.querySelectorAll('[data-go]').forEach(b=>b.onclick=()=>navigate(b.dataset.go));$('scenario').onchange=()=>{scenario();enable()};$('game').onchange=enable;$('hardwareCabinet').onchange=()=>select($('hardwareCabinet').value,'hardware');$('activityCabinet').onchange=activity;$('activitySearch').oninput=activity;$('openHardware').onclick=()=>navigate('hardware');$('start').onclick=()=>command('start',{cabinet:selected,expected_session:state.cabinets[selected].session});$('reset').onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Reset '+name(id)+'?','This ends its current game and clears its activation dependency.',()=>command('reset',{cabinet:id,expected_session:session}))};$('prepare').onclick=()=>{const id=selected,session=state.cabinets[id].session,game=$('game').value;review('Prepare '+gameName(game)+'?',name(id)+' will receive a manual assignment without scenario hardware rewards.',()=>command('prepare',{cabinet:id,expected_session:session,game}))};$('prepareScenario').onclick=()=>{const scenario=$('scenario').value;review('Prepare this scenario?','Assignments will be sent to its cabinets. Configured activation outputs may run. Linked cabinets wait for the preceding win.',()=>command('prepare_scenario',{scenario}))};$('cancelConfirm').onclick=()=>{$('confirmDialog').close();confirmation=null};$('confirmForm').onsubmit=e=>{e.preventDefault();if(!connected||pending)return;const reason=$('reason').value.trim();if(!$('confirmReason').hidden&&reason.length<3){$('reason').setCustomValidity('Enter at least three characters.');$('reason').reportValidity();return}const fn=confirmation;confirmation=null;$('confirmDialog').close();fn?.(reason)};$('reason').oninput=()=>$('reason').setCustomValidity('');navigate('home');setInterval(refresh,1500);
|
||||
$('login').onsubmit=e=>e.preventDefault();$('connect').onclick=()=>{token=$('token').value.trim();if(token)refresh()};$('token').onkeydown=e=>{if(e.key==='Enter')$('connect').click()};$('refresh').onclick=refresh;document.querySelectorAll('.lock').forEach(b=>b.onclick=()=>{++epoch;token='';state=null;connected=false;$('token').value='';$('shell').hidden=true;$('login').hidden=false;$('confirmDialog').close();$('token').focus()});document.querySelectorAll('[data-view]').forEach(b=>b.onclick=()=>navigate(b.dataset.view));document.querySelectorAll('[data-go]').forEach(b=>b.onclick=()=>navigate(b.dataset.go));$('scenario').onchange=()=>{scenario();enable()};$('game').onchange=enable;$('hardwareCabinet').onchange=()=>select($('hardwareCabinet').value,'hardware');$('activityCabinet').onchange=activity;$('activitySearch').oninput=activity;$('openHardware').onclick=()=>navigate('hardware');$('powerOn').onclick=()=>command('power_on',{cabinet:selected,expected_session:state.cabinets[selected].session});$('start').onclick=()=>command('start',{cabinet:selected,expected_session:state.cabinets[selected].session});$('reset').onclick=()=>{const id=selected,session=state.cabinets[id].session;review('Reset '+name(id)+'?','This ends its current game and clears its activation dependency.',()=>command('reset',{cabinet:id,expected_session:session}))};$('prepare').onclick=()=>{const id=selected,session=state.cabinets[id].session,game=$('game').value;review('Prepare '+gameName(game)+'?',name(id)+' will receive a manual assignment without scenario hardware rewards.',()=>command('prepare',{cabinet:id,expected_session:session,game}))};$('prepareScenario').onclick=()=>{const scenario=$('scenario').value;review('Prepare this scenario?','Assignments will be sent to its cabinets. Configured activation outputs may run. Linked cabinets wait for the preceding win.',()=>command('prepare_scenario',{scenario}))};$('cancelConfirm').onclick=()=>{$('confirmDialog').close();confirmation=null};$('confirmForm').onsubmit=e=>{e.preventDefault();if(!connected||pending)return;const reason=$('reason').value.trim();if(!$('confirmReason').hidden&&reason.length<3){$('reason').setCustomValidity('Enter at least three characters.');$('reason').reportValidity();return}const fn=confirmation;confirmation=null;$('confirmDialog').close();fn?.(reason)};$('reason').oninput=()=>$('reason').setCustomValidity('');navigate('home');setInterval(refresh,1500);
|
||||
|
||||
const PACMAN_ART={"screen": "data:image/png;base64,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 truncated
|
||||
function renderCabinetArt(c){document.querySelector('.cabinet-visual').dataset.game=c.game;const active=c.online&&c.phase==='playing';cached('marquee',[c.game],e=>{e.replaceChildren();if(c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.marquee;img.alt='Pac-Man marquee';e.append(img)}else e.textContent=gameName(c.game)});cached('screenGame',[c.game,c.phase,c.online],e=>{e.replaceChildren();if(active&&c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.screen;img.alt='Pac-Man gameplay illustration (not live video)';e.append(img)}else e.textContent=!c.online?'Cabinet offline':active?gameName(c.game):['won','lost'].includes(c.phase)?phases[c.phase]:'Waiting for operator'});}
|
||||
function renderCabinetArt(c){document.querySelector('.cabinet-visual').dataset.game=c.game;const active=c.online&&c.phase==='playing';cached('marquee',[c.game],e=>{e.replaceChildren();if(c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.marquee;img.alt='Pac-Man marquee';e.append(img)}else e.textContent=gameName(c.game)});cached('screenGame',[c.game,c.phase,c.online],e=>{e.replaceChildren();if(active&&c.game==='pacman'){const img=node('img');img.src=PACMAN_ART.screen;img.alt='Pac-Man gameplay illustration (not live video)';e.append(img)}else e.textContent=!c.online?'Cabinet offline':active?gameName(c.game):['won','lost','powered_off','booting'].includes(c.phase)?phases[c.phase]:c.phase==='ready'&&c.game_config?.rfid_credit_device?'Scan card, then Start':'Waiting for operator'});}
|
||||
function renderFeedback(target='feedback'){const c=state.cabinets[selected];const f=c?.feedback;cached(target,[selected,f,c?.online],e=>{e.replaceChildren();if(!f?.devices?.length){e.append(node('p',f?.error||'No configured device feedback received.','muted'));return}for(const d of f.devices){const box=node('div','','feedback-item'+(d.value===true?' active':'')+(!c.online||!f.fresh||d.age_ms>6000?' stale':''));box.append(node('strong',d.name||d.id),node('p',(d.port||'')+' · '+d.kind+' · '+(!c.online||!f.fresh||d.age_ms>6000?'STALE / unavailable':d.error||(typeof d.value==='boolean'?(d.value?'Active':'Inactive'):String(d.value))),'small'));if(d.kind==='output')box.append(node('small','Electrical output state; physical movement is unconfirmed.'));e.append(box)}})}
|
||||
</script></body></html>
|
||||
@@ -157,6 +157,11 @@ class Room:
|
||||
self.state.update(session=str(uuid.uuid4()), phase="preparing" if action == "prepare" else "resetting",
|
||||
reward="not requested", commands=[], seen=[])
|
||||
self.command(action)
|
||||
elif action == "power_on":
|
||||
if self.state["game"] != "pacman" or "pacman_power_v1" not in self.features or self.state["phase"] != "powered_off":
|
||||
raise ValueError("Power on requires a prepared, powered-off Pac-Man cabinet (2.4+)")
|
||||
self.state["phase"] = "booting"
|
||||
self.command("power_on")
|
||||
elif action == "start":
|
||||
if self.state.get("game_config", {}).get("rfid_credit_device"):
|
||||
raise ValueError("Scan a card and press Start on the cabinet for this RFID scenario")
|
||||
@@ -214,9 +219,13 @@ class Room:
|
||||
self.state["reward"] = "GPIO accepted (physical delivery unconfirmed)"
|
||||
else:
|
||||
self.state["reward"] = "requested"
|
||||
elif command["type"] == "power_on":
|
||||
if not success: self.state["phase"] = "needs_prepare"
|
||||
elif success:
|
||||
self.state["phase"] = {"prepare": "ready", "reset": "waiting" if self.state.get("gate") else "idle", "start": "playing"}[command["type"]]
|
||||
if command["type"] == "prepare" and self.state.pop("auto_start", False):
|
||||
if command["type"] == "prepare" and self.state["game"] == "pacman" and "pacman_power_v1" in self.features:
|
||||
self.state["phase"] = "powered_off" if self.state.get("game_config", {}).get("power_on_device") else "booting"
|
||||
if command["type"] == "prepare" and self.state["phase"] == "ready" and self.state.pop("auto_start", False):
|
||||
self.state["phase"] = "starting"
|
||||
self.command("start")
|
||||
if command["type"] == "prepare":
|
||||
@@ -232,7 +241,14 @@ class Room:
|
||||
self.state["seen"].append(event_id)
|
||||
self.state["seen"] = self.state["seen"][-500:]
|
||||
name = report.get("event")
|
||||
if name == "started" and self.state["phase"] in ("ready", "starting"):
|
||||
if name == "power_booting" and self.state["game"] == "pacman" and self.state["phase"] == "powered_off":
|
||||
self.state["phase"] = "booting"
|
||||
elif name == "power_ready" and self.state["game"] == "pacman" and self.state["phase"] == "booting":
|
||||
self.state["phase"] = "ready"
|
||||
if self.state.pop("auto_start", False):
|
||||
self.state["phase"] = "starting"
|
||||
self.command("start")
|
||||
elif name == "started" and self.state["phase"] in ("ready", "starting"):
|
||||
self.state["phase"] = "playing"
|
||||
elif name == "won" and self.state["phase"] == "playing":
|
||||
self.state["phase"] = "won"
|
||||
@@ -282,6 +298,18 @@ class Fleet:
|
||||
limits.update({"galaga": {"score_to_win": (0, 1000000), "stages_to_win": (0, 100)},
|
||||
"frogger": {"homes_to_win": (0, 5), "boards_to_win": (0, 99)},
|
||||
"pacman": {"boards_to_win": (0, 100), "score_to_win": (0, 1000000)}}.get(game, {}))
|
||||
power_device = settings.get("power_on_device")
|
||||
if power_device is not None:
|
||||
if game != "pacman" or not isinstance(power_device, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", power_device):
|
||||
raise ValueError("Invalid power-on input device")
|
||||
if not any(d.get("id") == power_device and d.get("kind") == "input" for d in self.catalog["cabinets"][cabinet].get("devices", [])):
|
||||
raise ValueError("Power-on device is missing from the cabinet PCB input inventory")
|
||||
if "pacman_power_v1" not in self.rooms[cabinet].features:
|
||||
raise ValueError("Update the game export: power-on inputs require arcade-cabinet-2.4")
|
||||
if self.rooms[cabinet].gpio != "real":
|
||||
raise ValueError("Power-on inputs require real GPIO feedback")
|
||||
if item.get("activate_after_win"):
|
||||
raise ValueError("Choose button power-on or automatic dependency activation")
|
||||
credit_device = settings.get("rfid_credit_device")
|
||||
if credit_device is not None:
|
||||
if game != "pacman" or not isinstance(credit_device, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", credit_device):
|
||||
@@ -299,7 +327,7 @@ class Fleet:
|
||||
if credit_device is None or not isinstance(uids, list) or len(uids) > 100 or any(not isinstance(uid, str) or not re.fullmatch(r"[0-9A-F]{8}", uid) for uid in uids):
|
||||
raise ValueError("RFID allowed UIDs must be a list of uppercase 8-digit hexadecimal IDs")
|
||||
for key, value in settings.items():
|
||||
if key in ("rfid_credit_device", "rfid_allowed_uids"): continue
|
||||
if key in ("rfid_credit_device", "rfid_allowed_uids", "power_on_device"): continue
|
||||
if key not in limits or type(value) not in (int, float) or not math.isfinite(value):
|
||||
raise ValueError("Unsupported game objective: " + str(key))
|
||||
low, high = limits[key]
|
||||
|
||||
Reference in new issue
Block a user