diff --git a/AUTHORING.md b/AUTHORING.md
index f5e5481..e3a24ca 100644
--- a/AUTHORING.md
+++ b/AUTHORING.md
@@ -11,12 +11,22 @@ be read start-to-finish by a person **or** pasted whole into an AI assistant.
[`_template`](modules/_template/) (start here) ·
[`door-keypad`](modules/door-keypad/) (replicated objects, a discrete event, a
deterministic animation from one timestamp, late-joiner state) ·
+ [`dungeon`](modules/dungeon/) (**the worked toolbox example**: `api.registerToolbox`
+ with a `#dungeon-panel` DOM fallback behind a feature-detect, a node that IS the
+ recipe, and a published `userData.play` contract other modules read) ·
+ [`dungeon-realms`](modules/dungeon-realms/) (a game as an OVERLAY on another module:
+ two modules cannot share code, so they share the scene — the Kit's
+ `userData.play` / `userData.kit` seam, read through `api.scene()`) ·
[`tutorial-room`](modules/tutorial-room/) (derived content: local geometry,
replicated *intent*) · [`sabers`](modules/sabers/) (per-frame pose streaming,
VR and desktop from one code path) · [`fps-player`](modules/fps-player/)
(input claims, `possess`, capability probing) ·
[`flow-toolkit`](modules/flow-toolkit/) (flow nodes) ·
- [`untangle`](modules/untangle/) (a full replicated game).
+ [`untangle`](modules/untangle/) (a full replicated game) ·
+ [`health`](modules/health/) (a number several peers can lower, converging with no
+ authority: pulses counted by core's Counter, a player's own peer row, the first-sight
+ rule) · [`waves`](modules/waves/) (a mechanic COMPOSED on another module's node types,
+ the wave derived from counters, a run log every peer appends identically).
- Missing something? [DEVX-REQUESTS.md](DEVX-REQUESTS.md) tracks known SDK gaps —
check it before you work around one.
@@ -246,11 +256,14 @@ Full signatures live in the docs site; this is the map.
| Netcode | `send(payload)`, `onMessage(fn)`, `registerStateSync({getState, applyState})`, `peerId()` |
| Input | `registerBindings(list)`, `input()`, `onInput(fn)`, `claimInput(scope)`, `releaseInput(scope)` |
| Physics | `physics.isInitiator()`, `applyImpulse`, `applyTorqueImpulse`, `setJointMotor`, `joints()` |
+| Knock | `onHit(cb)` — every knock this peer sees (its own hand's and every peer's `hit`) as `{uuid, by, at, speed, point, linvel, angvel, probe, local}`; returns the unsubscribe, torn down with the module · `hitLog()` — a COPY `{last, recent}` (last hit per live body, the last 32 in order; runtime state, a late joiner's starts empty) |
| Player | `possess(uuid, {camera})`, `releasePossess()`, `selectedUuid()` |
| UI | `registerMenu(label, action)`, `registerVRMenuEntry(entry)`, `toast(text)` |
| Misc | `THREE`, `assetUrl(path)`, `now()`, `sceneAssets()` |
-Two that are easy to miss:
+The worked example for the game SDK (`api.game`, `api.peerVars`, `onHit`, `registerStateSync`, `registerToolbox`) is [football](modules/football/): rules as flow nodes, last-touch attribution evaluated BY EACH PEER from `onHit` (no module message for a touch), the physics initiator as the one goal authority, and each player's goals written to their OWN `peerVars` row.
+
+Three that are easy to miss:
- **`registerClickHandler` covers VR too.** You do not write a second input
path for the headset; the trigger dispatches through the same handler with the
@@ -258,6 +271,9 @@ Two that are easy to miss:
- **`claimInput('keys' | 'locomotion')` pauses the editor's own consumers** so
your WASD does not also fly the camera. Always release it when your mode ends,
including on error paths.
+- **`onHit` fires on EVERY peer for every hit.** Attribution derived from it is
+ deterministic without a message of your own; a per-player counter must still bump
+ only when `hit.by === api.peerId()` (or `hit.local`), or every peer banks it.
---
@@ -391,6 +407,24 @@ list when something bites you.
- **`api.input()` fires while the user is typing in a panel.** Claim the scope
(`claimInput('keys')`) only while your mode is active, and ignore input when
it is not.
+- **An EFFECT node pins its target's pose.** The runtime re-seats an effect target's base
+ pose every frame while the effect is active (in play), so a `registerEffect` node on an
+ object that must move — a knocked crate, a walking enemy — fights every move, replicated
+ or not (football's "no node may target the ball"). If your node only needs to *know* its
+ object, make it a `registerValueNode` with an `{inputs: {target: 'object'}}` socket and
+ wire the Object Selector IN; hide/show the object yourself and restore only what you hid
+ (`health` does this).
+- **Two clocks.** `api.now()` and every trigger-log stamp are seconds of day on the synced
+ clock; `api.game.roundCutoff()` (the round's `startedAt`) is session **milliseconds**.
+ Convert (`(ms / 1000) % 86400`) before comparing, and never compare either to
+ `performance.now()`. A joiner's `api.now()` also re-bases on connect — decide "did I
+ witness this" by identity (the collectible's first-sight rule), never by clock.
+- **`api.flow.nodeValue` is ~6 Hz.** The live values republish every 150 ms, so a Counter
+ you just pulsed still reads the old count for a moment. Firing again "because the count
+ has not moved" doubles the pulse; remember what you fired (`waves` keeps a per-node
+ expectation) or derive from the last sweep's numbers (`health`'s kill credit).
+- **A second `installModule` on one peer needs the `/^User/` tab locator** — after an
+ install the tab reads "User (1)" and an exact match hangs (fixed in `helpers.cjs`).
- **Capping `dt` turns a slow frame rate into slow motion.** `Math.min(dt, 0.1)`
is the right way to stop a physics step tunnelling, but at 7fps (headless
Chromium, a background tab) it means sim time advances at 0.7x — a jump that
diff --git a/DEVX-REQUESTS.md b/DEVX-REQUESTS.md
index 268c77d..b5b2f5a 100644
--- a/DEVX-REQUESTS.md
+++ b/DEVX-REQUESTS.md
@@ -26,10 +26,14 @@ promise from core.
| 12 | `text` param kind for module nodes | `dungeon-realms` | yes — button labels derive from their action select |
| 13 | play contract is name-keyed to `'dungeon-module'` | `dungeon-realms` | yes — squats the core module's group name |
| 14 | grounded (no-fly) play-mode option | `dungeon-realms` | yes — window-capture swallows Q/E while the game runs |
-| 15 | peer roster + disconnect hook | `dungeon-realms` | partly — any player can free a stuck P1/P2 slot from the menu |
-| 16 | `api.flow.nodes()` carries no node POSITION | `collectible` | yes — the recipe derives its row from how many of its own nodes exist |
-| 17 | no change signal for the graph / game state | `collectible` | yes — a 500ms toolbox poll and a ~10Hz frame-task sweep |
+| 15 | peer roster + disconnect hook | `dungeon-realms`, `football` | yes — `api.peerIds()` (the replicated roster) diffed each second frees a vanished peer's slot; `football` does exactly this. The name half is `api.peerNames()` |
+| 16 | ~~`api.flow.nodes()` carries no node POSITION~~ | `collectible` | **SHIPPED** (core PR #224) — snapshots carry `x`/`y` and `api.flow.freeRegion({w, h, graphId})` answers where a block lands; the recipe uses it and falls back to its fixed rows on an older app |
+| 17 | ~~no change signal for the graph / game state~~ | `collectible` | **SHIPPED** (core PR #224) — `api.flow.onChange` (graph + trigger log), `api.game.onChange`, `api.peerVars.onChange`, coalesced to one call per frame; the manager listens and polls only on an older app (the ~10Hz touch/count sweep stays: it watches positions and the clock) |
| 18 | ~~the flow TRIGGER LOG has no handshake reply, so a late joiner never learns past pulses~~ | `collectible` | **SHIPPED** — `gettriggers`/`triggers` carries the log; the module gained a first-sight rule so arriving history is not banked |
+| 19 | `api.game.setState(state, outcome)` — a module cannot move the game shell | `football` | yes — a Football Event node fires `start`/`over`/`reset` and the template wires it to Set Game State |
+| 20 | a scene-physics block write (`api.physics.setScene({gravity, knock, …})`) | `football` | yes — the template carries the block; the "Build pitch" recipe toasts the Inspector rows to set |
+| 21 | the XR room bounds and the colocation `roomAnchor` on the api | `football` | yes — "Fit to room" / "Centre on room" feature-detect `api.xrReferenceSpace` / `api.colocation.roomAnchor` and fall back to sliders |
+| 22 | a **HUD Button cannot drive a module node's input** — `hudbutton` has no runtime value | `football` | yes — a `delay` node bridges the stamp into a numeric pulse |
---
@@ -291,7 +295,12 @@ vanished peer wedges the gate until someone frees their slot manually.
**Ask:** `api.peers()` → `[{id, name}]` + `api.onPeerConnected/Disconnected`.
The name half would also fix the HUD showing id prefixes instead of nicknames.
-## 16. `api.flow.nodes()` snapshots carry no POSITION
+## 16. `api.flow.nodes()` snapshots carry no POSITION — **SHIPPED**
+
+> Delivered both shapes (core PR #224): `x`/`y` on every snapshot, and
+> `api.flow.freeRegion({w, h, graphId})` — left-aligned under the lowest card — so the
+> placement rule is core's one copy (the HUD editor's bindings call the same function).
+> The collectible recipe asks it once per pair. The original request is kept below.
**Found in:** `modules/collectible` — the manager's "Make collectible" recipe
creates a node pair per selected object and has to lay the rows out, but a
@@ -307,7 +316,15 @@ deterministic and idempotent, and wrong the moment a user drags one of them.
takes them on the way in), or an `api.flow.freeRegion({w, h})` that answers
"somewhere empty" so layout stays core's problem.
-## 17. No change signal for the graph, the trigger log or the game state
+## 17. No change signal for the graph, the trigger log or the game state — **SHIPPED**
+
+> Delivered (core PR #224): `api.flow.onChange`, `api.game.onChange`,
+> `api.peerVars.onChange`, journalled like every `register*` and also returning an `off()`
+> for a toolbox that mounts and unmounts. Coalesced INSIDE the seam to one call per frame
+> (a microtask was measured NOT to fold thirty arriving peer edits). `flow.onChange` covers
+> the trigger log too, because a collected-state list changes when a node fires. The
+> manager now redraws on these and keeps a clock only for a live respawn countdown; an
+> idle panel writes nothing to the DOM (asserted). The original request is kept below.
**Found in:** `modules/collectible` — the manager toolbox and the debug line are
both views over the graph, so both POLL: the toolbox on a 500ms interval,
@@ -363,6 +380,116 @@ the same world as everyone else with no per-feature work at all.
Meanwhile `tests/module-collectible.test.cjs` ASSERTS the limitation (a joiner
reads 0 collected) rather than skipping it, so a core fix flips that check loudly.
+## 22. A HUD Button cannot drive a module node's input
+
+**Found in:** `modules/football` (24-B2), and it cost a red test-flight before it was
+understood — the wire LOOKS right in the editor and does nothing at runtime.
+
+`evalNode` has no `hudbutton` case: `flowRuntime.js` says so out loud, "hudbutton
+contributes no runtime value — its label is authored on the element". Core's own
+consumers of a press (`setgamestate`, `hudscreen`, `playanim`) do not read a value at
+all — they read the wired source's trigger STAMP through `triggerStampFor`. A MODULE
+node cannot: `registerEffect`'s declared inputs are resolved by `resolveInputs`, which
+injects only a VALUE, and `undefined` is skipped. So `hudbutton -> mymodulenode.press`
+is a silent no-op, while the visually identical `onclick -> mymodulenode.press` works
+(On Click DOES evaluate to 1 for its pulse window).
+
+This is the other half of #9. Module nodes can now OUTPUT an event
+(`registerValueNode(..., {vtype: 'event'})` + `fireNodeTrigger`); they still cannot
+RECEIVE one from the node that every game HUD is built out of.
+
+**Ask:** either give `hudbutton` the `onclick` pulse-window value (one `case` beside it —
+`{ pulse: 0.3 }` in its defaults and the same three lines), or hand module effects the
+resolved trigger of their wired inputs, e.g. `ctx.inputTrigger('press')` → `{stamp, age}`.
+The first is smaller and makes every existing `event -> number` coercion consistent.
+
+**Meanwhile:** the football template wires each HUD Button through a **Delay**
+(`seconds: 0.05`), which consumes the stamp and re-emits it as a numeric pulse the module
+reads as an ordinary rising edge. `pitch.js` carries the reasoning under "THE HUD-BUTTON
+BRIDGE" and a node test asserts both halves (every HUD button reaches a Match Button
+through a Delay; none wires straight in).
+
+## 19. A module cannot move the game shell
+
+**Found in:** `modules/football` (24-B). The match starts and ends INSIDE the module —
+the physics initiator sees the ball enter a gate, counts, and decides the match is over —
+but `api.game` is read-mostly (`roundCutoff`, `roundUnderway`, `playActive`, `getVar`,
+`setVar`): there is no `setState`, so the module cannot put the shell into `playing` or
+`over` and the DOM HUD's `showWhile` screens cannot follow the match on their own.
+
+**Ask:** `api.game.setState(state, outcome?)` over `gameState.setGameState` (replicated
+latest-wins, idempotent when already in that state — so several peers applying the same
+op is harmless).
+
+**Meanwhile:** the module registers an EVENT node (`fbevent`: start / over / reset /
+goal / serve / touch) pulsed through `api.fireNodeTrigger` on the peer where the event
+originated, and the template wires `start` → Set Game State (playing), `over` → (over),
+`reset` → (menu). The module also calls `api.game.setState?.()` when it appears.
+
+## 20. No scene-physics block write
+
+**Found in:** `modules/football`. The pitch is data: a zero-g block (`gravity: 0`,
+`ground.enabled: false`, damping, `ccd`, `knock.enabled`). `api.physics.set(uuid, patch)`
+writes ONE OBJECT's body params; the scene block (`scenePhysics.setScenePhysics`, the
+replicated `scenephysics` singleton) is not reachable, so the toolbox's "Build pitch"
+recipe can build the objects and the graph but cannot switch gravity off.
+
+**Ask:** `api.physics.setScene(partial)` = `setScenePhysics` (nested blocks merge, one
+normalize, replicated), plus `api.physics.scene()` to read it.
+
+**Meanwhile:** the template's `.tpscene` carries the block; the recipe toasts which
+Inspector rows to set; the flight sets it through the debug hook.
+
+## 21. XR room bounds and the colocation room anchor
+
+**Found in:** `modules/football` (B3, "Fit pitch to room" / "Centre pitch on room"). The
+bounded reference space (`XRBoundedReferenceSpace.boundsGeometry`) lives on the renderer's
+XR session and `roomAnchor` in `colocation.js`; neither is on the api, so a module cannot
+size a pitch to the play area or put the kick-off spot on the point two colocated players
+agreed on.
+
+**Ask:** `api.xrReferenceSpace()` (null outside a session) and
+`api.colocation.roomAnchor()` → `{position, quaternion, at} | null`.
+
+**Meanwhile:** both toolbox buttons feature-detect exactly those names and fall back to
+the Length / Width sliders (+ a toast), which replicate as ordinary `move`s.
+
+## 23. No way to move the PLAY-MODE player
+
+**Found in:** `modules/health` (death → respawn at a spawn point). `api.playerPosition()` is
+read-only; `api.flyTo` tweens the **editor** camera (and returns early in VR / spectator).
+In play the rig (`cameraParent`) owns the camera and re-seats it every frame, so a module
+cannot put a respawning player on their spawn pad — the one thing a respawn is for. The
+health module flies the editor camera (proven in its flight) and leaves play mode owed.
+
+**Ask:** `api.teleportPlayer(position, lookAt?)` — sets the play rig (and the editor camera
+outside play), local only, the same house rule as `flyTo`.
+
+**Meanwhile:** `respawnAt` works in the editor; in play the player comes back at full where
+they died.
+
+## 24. Live values are ~6 Hz, and a pulse's count cannot be read back synchronously
+
+**Found in:** `modules/health` (kill credit), `modules/waves` (heals into the next wave).
+`api.flow.nodeValue` reads `flowValues`, republished every 150 ms. A module that fires a
+pulse into a Counter and reads the Counter on its next sweep sees the OLD count and, if it
+decides from that, fires again — the waves module double-healed until it kept its own
+expectation per node, and the health module credits a kill from the last sweep's number
+minus what it just fired rather than from the counter.
+
+**Ask:** `api.flow.triggerCount(id)` beside `triggerStamp` (the `count` half of the same
+log entry — it is already in the map), or make `nodeValue` read a Counter live.
+
+## 25. Two clocks on the api
+
+**Found in:** `modules/waves` (the wave's start = the round's start). `api.now()` and every
+trigger-log stamp are seconds of day on the synced clock; `api.game.roundCutoff()` returns
+the round's `startedAt`, which is session **milliseconds**. The first flight compared them
+directly and the wave never started. It is documented nowhere on the api.
+
+**Ask:** either `api.game.roundStartedAt()` in the same seconds as `api.now()`, or a note in
+MODULES.md on `roundCutoff` saying "ms — compare to `api.now() * 1000`". Cheap either way.
+
---
## Core status (17-A1, 2026-08-09 — filed by the core window, branch `feat/module-platform`)
@@ -381,6 +508,7 @@ reads 0 collected) rather than skipping it, so a core fix flips that check loudl
| 12 | no `text` param kind | **SHIPPED** (21-A1) — `{key, kind: 'text', placeholder?, maxLength?}`. It writes on COMMIT (change/blur), never on `input`: a node edit replicates the whole node, so a per-keystroke write is one broadcast per character. |
| — | a module node cannot learn its own id | **SHIPPED** (21-A1) — an effect's 5th arg and a value node's 3rd are `{id, graphId}`. Additive, so a four-parameter effect is byte-unchanged. This is what lets one module host several instances of the same node type. |
| — | no module UI surface (the `#dr-gui` / `#dungeon-panel` workaround) | **SHIPPED** (21-A5) — `api.registerToolbox({id, title, mount, …})` over core's shared ToolboxWindow: write plain DOM and inherit header drag + position persistence, the width grip, z-band focus, the <=640px bottom sheet and the whole `.tbx-*` CSS contract. Opened from the sidebar's Modules section, the viewport menu and an optional `shortcut`. Retire the hand-rolled fixed overlays — they sit in z bands they do not own. |
+| 15 | peer roster + disconnect hook | **YES, per 24-B D2** — `api.peerIds()` (the replicated roster) polled each second is the disconnect signal (`football` frees a vanished player's slot this way) and `api.peerNames()` the name half. A push-style `onPeerConnected/Disconnected` stays unbuilt: no module has needed more than the diff. |
### 21-A (2026-08-18, core branch `feat/21-module-node-io`)
@@ -390,6 +518,25 @@ kept in your own replicated state becomes `registerValueNode` -> a HUD Text node
level cleared becomes `fireNodeTrigger` -> a Counter; your host settings become a
toolbox instead of an overlay at `z-index: 900`.
+### 21-C C6 (2026-09-19, modules `feat/29-c6-dungeon`) — what dungeon-realms 2.0 retired
+
+- **#11** — `api.isPlaying()` is the play gate (the `#dungeon-minimap` DOM watch is gone, kept only as the fallback on an older app).
+- **#13** — the Kit (`dungeon` 2.0) is the ONE publisher of `userData.play` on `'dungeon-module'`; Realms publishes nothing of its own and puts its gems/portals on the minimap through the Kit's `setMarkers` seam → `userData.play.markers`.
+- **#14** — Game Rules ▸ disableFlight writes `userData.play.grounded` (through the Kit); the capture-phase Q/E swallow is deleted.
+- **#9/#12** — Realms Value / Realms Event / Realms HUD Rows replace the `drhud` node: the HUD is core HUD elements the template authors.
+- The `#dungeon-panel` overlay is a registered toolbox (the SDK's worked example, AUTHORING.md).
+
+## 23. No play-mode MENU surface
+
+**Found in:** `modules/dungeon-realms` (21-C C6.2). The start / victory menu is a modal,
+keyboard-driven (↑↓ + Enter, because play mode holds pointer lock), focus-owning dialog.
+It is neither a HUD element (a HUD publishes values and reacts to presses; it does not own
+focus or arrow-key navigation) nor a toolbox (hidden in play mode by design). It stays
+module DOM (`#dr-menu`, restyled onto the app's card conventions).
+
+**Ask:** a `hudscreen` variant with `input: 'menu'` that OWNS keyboard focus and arrow
+navigation between its buttons while pointer-locked, or `api.registerPlayMenu({buttons})`.
+
Still open from this list: **#5** (replicated create/move — partly answered by the 17-A
world api: `api.create`/`api.moveObject` exist), **#7**, **#10** (answered differently:
a TEMPLATE carries the placed nodes, so the api needs no graph write path — closing),
diff --git a/README.md b/README.md
index 0555de8..9e48190 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,10 @@ npm run pack -- my-module # -> my-module.zip, ready to install
| [fps-player](modules/fps-player/) | Walk mode: mouse look, camera-relative WASD with sprint/jump/crouch, ground and wall raycasts, input claims that pause the editor's own WASD, one undo entry per ride. True first person where the app offers it (probed via `api.possessModes`), third-person chase otherwise. |
| [tutorial-room](modules/tutorial-room/) | An in-scene onboarding room built from one menu button: signs teaching select/move/gizmo, connect/invite, flow basics and VR entry, plus clickable demo interactables. Asset-free (primitives + canvas text). |
| [flow-toolkit](modules/flow-toolkit/) | Reference for flow v2: code-editable nodes shipped via `api.registerNodeDefs` (Wobble + Breathe) that pair with per-object flows and embedded Object Flow nodes. |
+| [football](modules/football/) | VR football on the knock: a floating ball, two team gates, last-touch attribution read from `api.onHit` on every peer, duel / teams / free-for-all / practice modes, per-player goals / touches / own goals in `peerVars` rows and a match log saved with the scene — every rule a flow node, the physics the template's data. The Football game in the scenes feed installs it. Needs core ≥ 1.13 (`api.onHit`, `api.hitLog`). |
| [collectible](modules/collectible/) | ONE node where core needed a seven-node recipe: click or walk into an object and it hides, counts and respawns. Shared or per-player scoring (the per-player half is `fireNodeTrigger(…, {replicate:false})` — the pulse never leaves the collector), a counting checkpoint that never hides, a manager toolbox that builds the graph as one undo entry, and a count node that also reads legacy chains. Needs the R3a game SDK seams (`api.game` / `api.peerVars` / `api.flow`). |
+| [health](modules/health/) | Hit points for objects and players. An object's hp is DERIVED from core Counters fed by `damage`/`heal` pulses (one trigger-log entry per pulse, the count riding the DEVX #18 handshake, so two peers and a late joiner read ONE number with no authority); a player's own hp rides their own `peerVars` row with regen derived from the clock. Sources: a wired event, click, touch, a zone, the knock feed (speed-scaled); death hides or respawns; a manager toolbox; HUD actions. Needs the R3a game seams. |
+| [waves](modules/waves/) | Wave survival composed on `health`: pre-placed enemies with health chains, the wave derived from their hit counters (each wave heals them back with local pulses), enemies walking spawn point → goal on the synced clock, `over` → the game shell, kills on each player's own row, the run log in `gameState.vars`. A late joiner lands on the same wave. Needs `health`. |
| [untangle](modules/untangle/) | The 190 test-flight GAME: drag the dots until no edges cross. Procedural guaranteed-solvable puzzles (seed-deterministic — determinism IS the netcode), desktop+VR drag via `api.pointerRay()`, replicated moves, lockstep win/level advance, generative WebAudio pad + SFX. Needs app ≥ the `api.pointerRay` SDK (core PR #37). |
## Module format
diff --git a/index.json b/index.json
index 9079cc0..c57b3cd 100644
--- a/index.json
+++ b/index.json
@@ -30,7 +30,7 @@
{
"id": "collectible",
"name": "Collectibles",
- "version": "1.0.0",
+ "version": "1.1.1",
"description": "One node makes an object collectible: click it or walk into it, it hides and counts. Shared or per-player score, with respawn and never-hiding checkpoints.",
"author": "theprototype-app",
"source": "modules/collectible",
@@ -59,23 +59,24 @@
},
{
"id": "dungeon",
- "name": "Dungeon Generator",
- "version": "1.1.0",
- "description": "Seed-replicated procedural dungeon (rooms, corridors, torches) with a key-and-door objective; publishes userData.play so the app walks it in play mode.",
+ "name": "Dungeon Kit",
+ "version": "2.0.0",
+ "description": "Level generation toolbox: a seeded multi-floor dungeon generator (rooms, corridors, decor, torches) with a toolbox, a Dungeon node and the userData.play contract the app walks in play mode. The playable game is Dungeon Realms.",
"author": "theprototype-app",
"source": "modules/dungeon",
"zip": "dungeon.zip",
"category": "tool",
"tags": [
"procedural",
- "level design"
+ "level design",
+ "toolbox"
]
},
{
"id": "dungeon-realms",
"name": "Dungeon Realms",
- "version": "1.0.0",
- "description": "Co-op dungeon crawl: seeded multi-floor generator, gem-gated portals, P1/P2 play - every rule editable as flow nodes.",
+ "version": "2.0.0",
+ "description": "Co-op dungeon crawl on the Dungeon Kit: gem-gated portals, P1/P2 play, travel-together floors - every rule and readout a flow node. Requires the dungeon (Dungeon Kit) module.",
"author": "theprototype-app",
"source": "modules/dungeon-realms",
"zip": "dungeon-realms.zip",
@@ -115,6 +116,23 @@
"authoring"
]
},
+ {
+ "id": "football",
+ "name": "Football",
+ "version": "1.0.0",
+ "description": "VR football on the knock: floating ball, two team gates, last-touch attribution, modes, per-player records and a saved match log - every rule a flow node.",
+ "author": "theprototype-app",
+ "source": "modules/football",
+ "zip": "football.zip",
+ "category": "game",
+ "tags": [
+ "vr",
+ "competitive",
+ "colocation",
+ "physics"
+ ],
+ "template": "games/football"
+ },
{
"id": "fps-player",
"name": "FPS Player",
@@ -129,6 +147,78 @@
"first person"
]
},
+ {
+ "id": "health",
+ "name": "Health",
+ "version": "1.0.0",
+ "description": "Hit points for objects and players: damage and heal nodes, death and respawn, converging on every peer with no authority - an object's hp from core Counters, a player's on their own peer row.",
+ "author": "theprototype-app",
+ "source": "modules/health",
+ "zip": "health.zip",
+ "category": "game",
+ "tags": [
+ "game",
+ "health",
+ "flow"
+ ]
+ },
+ {
+ "id": "music-dj",
+ "name": "Music DJ",
+ "version": "0.1.0",
+ "description": "Two decks and a crossfader as audio devices: drop a track on a deck, scrub the platter, ride the pitch, fade between them - every peer hears the same position.",
+ "author": "theprototype-app",
+ "source": "modules/music-dj",
+ "zip": "music-dj.zip",
+ "category": "instrument",
+ "tags": [
+ "audio",
+ "vr"
+ ]
+ },
+ {
+ "id": "music-fx",
+ "name": "Music FX",
+ "version": "0.1.0",
+ "description": "A four-channel mixer and five pedals - delay, reverb, filter, distortion, bitcrush - as audio devices. Cable them in any order; re-plug to re-order.",
+ "author": "theprototype-app",
+ "source": "modules/music-fx",
+ "zip": "music-fx.zip",
+ "category": "instrument",
+ "tags": [
+ "audio",
+ "effects",
+ "vr"
+ ]
+ },
+ {
+ "id": "music-lab",
+ "name": "Music Lab",
+ "version": "0.2.0",
+ "description": "A Piano, a Speaker, a Transport, a Drum machine and Sampler pads as audio devices: cable them, paint a beat, press Play - every peer hears the same beat.",
+ "author": "theprototype-app",
+ "source": "modules/music-lab",
+ "zip": "music-lab.zip",
+ "category": "instrument",
+ "tags": [
+ "audio",
+ "vr"
+ ]
+ },
+ {
+ "id": "music-voice",
+ "name": "Music Voice",
+ "version": "0.1.0",
+ "description": "A Mic, a Looper, a Synth and a Theremin as audio devices: sing into the graph, loop what is patched in on the bar, play chords, wave a hand - every peer hears the same thing.",
+ "author": "theprototype-app",
+ "source": "modules/music-voice",
+ "zip": "music-voice.zip",
+ "category": "instrument",
+ "tags": [
+ "audio",
+ "vr"
+ ]
+ },
{
"id": "piano",
"name": "Piano",
@@ -174,14 +264,30 @@
{
"id": "untangle",
"name": "Untangle",
- "version": "1.0.0",
- "description": "Untangle the graph: drag the dots until no edges cross. Procedural, replicated, with generative audio - built purely on the module SDK.",
+ "version": "2.0.0",
+ "description": "Untangle the graph: drag the dots until no edges cross. Procedural, replicated, with generative audio - board pose, level and readouts as flow nodes.",
"author": "theprototype-app",
"source": "modules/untangle",
"zip": "untangle.zip",
"category": "game",
"tags": [
"puzzle",
+ "co-op",
+ "procedural"
+ ]
+ },
+ {
+ "id": "waves",
+ "name": "Waves",
+ "version": "1.0.0",
+ "description": "Wave survival on the health module: enemies walk from spawn points to a goal, a wave ends when its last enemy dies, the run when the last wave does - derived on every peer, kills on each player's own row, the run log in the saved scene.",
+ "author": "theprototype-app",
+ "source": "modules/waves",
+ "zip": "waves.zip",
+ "category": "game",
+ "tags": [
+ "game",
+ "survival",
"co-op"
]
}
diff --git a/modules/collectible/README.md b/modules/collectible/README.md
index b8f9fa3..b12b70f 100644
--- a/modules/collectible/README.md
+++ b/modules/collectible/README.md
@@ -51,9 +51,11 @@ sixty gems — so the settings sit with whoever owns them:
Nothing in there is a second source of truth. The rows read the graph and the trigger
log, and every edit is written back through the same replicated `nodedata` path the node
editor's own cards use — so a change made here behaves exactly as if you had typed it
-into the card, peers included. A group-wide change is one such write **per member**,
-because there is no batched node-data write to reach for; members that already hold the
-value are skipped, so the cost follows the *difference* rather than the group size.
+into the card, peers included. A group-wide change is one such write **per member**;
+members that already hold the value are skipped, so the cost follows the *difference*
+rather than the group size. On core 1.15+ the whole press goes through
+`api.flow.setNodesData`, so **one Ctrl+Z undoes the whole group change**; an older core
+writes the members one by one.
Show the score with **Set Variable ▸ HUD Text** as usual, or the count itself with
the **Collectibles** value node (`left` / `collected` / `total`) — the HUD editor's
@@ -98,7 +100,7 @@ reason this module is thin.
| The counts | Derived from the **graph**, never from the score. A score only goes up, so `left` would go negative the first time something respawned. `collected + left === total` by construction. |
| Legacy scenes | The count node also finds core's old seven-node chains (`SetVariable ← Once ← event → Latch`) and reads each Latch through `api.flow.nodeValue` — core's own round-aware answer, not a second implementation. |
| Late joiners | No `registerStateSync` at all. Every bit of state is already replicated: the graph, the trigger log, the game singleton and the peer rows. AUTHORING.md §4.3 calls that the better design when you can get it. |
-| The toolbox | LOCAL, like every toolbox. What it *changes* goes through the replicated paths — `api.flow.setNodeData` for an edit, `api.flow.addNodes` for the recipe. |
+| The toolbox | LOCAL, like every toolbox. What it *changes* goes through the replicated paths — `api.flow.setNodeData` for an edit (`setNodesData` for a group edit, one undo step), `api.flow.addNodes` for the recipe. |
| Where a setting is edited | By **who owns it**, which is what a sixty-gem scene forces: `trigger` and `scope` are what a whole score's worth of pickups share, so they live on the **group** header; `respawn` is genuinely per-object, so it stays on the **row**. That is also why a group is a fold-away unit — sixty rows can be one line. |
| A group control over members that disagree | An **em-dash**, never one of the values (core's Inspector rule for a multi-selection). Showing "click" over a mixed set is a lie the next pointer trip silently makes true. Picking a value out of the mixed control applies it to everyone, which is the reason the control exists. |
diff --git a/modules/collectible/manifest.json b/modules/collectible/manifest.json
index 741283d..1c4eef7 100644
--- a/modules/collectible/manifest.json
+++ b/modules/collectible/manifest.json
@@ -1,7 +1,7 @@
{
"id": "collectible",
"name": "Collectibles",
- "version": "1.0.0",
+ "version": "1.1.1",
"format": 1,
"description": "One node makes an object collectible: click it or walk into it, it hides and counts. Shared or per-player, with respawn.",
"entry": "module.js",
diff --git a/modules/collectible/module.js b/modules/collectible/module.js
index af09dec..d739cb6 100644
--- a/modules/collectible/module.js
+++ b/modules/collectible/module.js
@@ -40,7 +40,7 @@
export default {
id: 'collectible',
name: 'Collectibles',
- version: '1.0.0',
+ version: '1.1.1',
description:
'One node makes an object collectible: click it or walk into it, it hides and counts.',
@@ -602,10 +602,11 @@ export default {
/**
* Apply one setting to every member of a group, through the SAME replicated path a
- * row edit uses — there is no batch write in the SDK and a module does not get to
- * invent one. So N members is N `nodedata` messages, and members that ALREADY agree
- * are skipped: making a mixed group uniform sends only the difference, and pressing a
- * value a group already holds sends nothing at all.
+ * row edit uses. Members that ALREADY agree are skipped: making a mixed group uniform
+ * sends only the difference, and pressing a value a group already holds sends nothing
+ * at all. Core 1.15's `api.flow.setNodesData` makes the whole press ONE undo step
+ * (still one `nodedata` per member on the wire); an older core has only the per-node
+ * call, so the fallback writes them one by one.
* @param {any[]} items @param {string} key @param {string} value @param {string} fallback
* @returns {number} how many members actually changed
*/
@@ -614,13 +615,13 @@ export default {
// writer that reaches here with it does nothing rather than writing an empty
// string over the whole group
if (value === MIXED_VALUE) return 0;
- let changed = 0;
- for (const item of items) {
- if (String(item.data?.[key] ?? fallback) === value) continue;
- api.flow.setNodeData(item.id, { [key]: value });
- changed++;
- }
- return changed;
+ const writes = items
+ .filter((item) => String(item.data?.[key] ?? fallback) !== value)
+ .map((item) => ({ id: item.id, patch: { [key]: value } }));
+ if (!writes.length) return 0;
+ if (typeof api.flow.setNodesData === 'function') api.flow.setNodesData(writes);
+ else for (const w of writes) api.flow.setNodeData(w.id, w.patch);
+ return writes.length;
}
/** rows grouped by variable, with the target object resolved for display */
@@ -663,8 +664,16 @@ export default {
const taken = new Set();
for (const node of collectibles)
for (const uuid of targetsOf(node, bySource, byId)) taken.add(uuid);
- // no positions in a flow snapshot (see DEVX-REQUESTS), so the row index comes
- // from how many collectibles the graph already holds — same answer every time
+ // WHERE each pair lands. Core answers it when it can (`api.flow.freeRegion`, the
+ // one placement rule, DEVX #16): under everything already in the graph, asked
+ // again per pair because the answer moves as the graph grows. An older app has
+ // no freeRegion and no positions in a snapshot, so there the row index comes
+ // from how many collectibles the graph already holds — deterministic, and wrong
+ // only once a user drags one, which is exactly what the seam fixed.
+ const place = (/** @type {number} */ row) =>
+ typeof api.flow.freeRegion === 'function'
+ ? api.flow.freeRegion({ w: COL + 150, h: 150, graphId: SCENE })
+ : { x: START.x, y: START.y + row * ROW };
let row = collectibles.length;
let built = 0;
let skipped = 0;
@@ -673,11 +682,11 @@ export default {
skipped++;
continue;
}
- const y = START.y + row * ROW;
+ const { x, y } = place(row);
api.flow.addNodes({
nodes: [
- { type: 'collectible', x: START.x, y, data: { ...options, perRound: true, whilePlaying: true } },
- { type: 'objectselector', x: START.x + COL, y, data: { selected: uuid } }
+ { type: 'collectible', x, y, data: { ...options, perRound: true, whilePlaying: true } },
+ { type: 'objectselector', x: x + COL, y, data: { selected: uuid } }
],
edges: [{ from: 0, to: 1 }]
});
@@ -732,7 +741,8 @@ export default {
'.cm-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
'.cm-status{flex:0 0 auto;font-size:10px;opacity:0.7;white-space:nowrap}' +
'.cm-respawn{flex:0 0 auto;width:38px}' +
- '.cm-empty{opacity:0.6;font-size:11px}'
+ '.cm-empty{opacity:0.6;font-size:11px}' +
+ '.cm-legacy{flex:1 1 100%;order:3;font-size:10px;opacity:0.6}'
})
);
@@ -749,12 +759,27 @@ export default {
const trigger = select(['click', 'touch'], 'click');
const hide = select(['on', 'off'], 'on');
const respawn = /** @type {HTMLInputElement} */ (
- elem('input', { type: 'number', min: '0', max: '120', step: '1', value: '0' }, INPUT_CSS)
+ elem('input', { type: 'number', min: '0', max: '120', step: '1', value: '0', className: 'cm-in-respawn' }, INPUT_CSS)
+ );
+ // S4: the touch RADIUS was fixed at 1.5 by this form and editable only on the node
+ // card afterwards. It only means something for a touch trigger, so it is disabled
+ // (with the reason) under click rather than hidden — the form keeps its shape.
+ const radius = /** @type {HTMLInputElement} */ (
+ elem('input', { type: 'number', min: '0.5', max: '10', step: '0.5', value: '1.5', className: 'cm-in-radius' }, INPUT_CSS)
);
+ const syncRadius = () => {
+ radius.disabled = trigger.value !== 'touch';
+ radius.title = radius.disabled
+ ? 'Only a touch trigger has a radius — switch Trigger to touch'
+ : 'How close (metres) you must walk to collect it';
+ };
+ trigger.addEventListener('change', syncRadius);
+ syncRadius();
const rows = [
['Counts into', variable],
['Scope', scope],
['Trigger', trigger],
+ ['Touch radius', radius],
['Hide', hide],
['Respawn (s)', respawn]
];
@@ -771,7 +796,7 @@ export default {
scope: scope.value,
trigger: trigger.value,
hide: hide.value,
- radius: 1.5,
+ radius: Math.max(0.5, Math.min(10, Number(radius.value) || 1.5)),
respawn: Math.max(0, Math.min(120, Number(respawn.value) || 0))
});
refresh(true);
@@ -868,17 +893,31 @@ export default {
const counts = elem('span', { className: 'cm-counts', textContent: '' });
countEls.set(name, counts);
disc.appendChild(counts);
+ // S4: the COUNTS include older recipe chains (21-F's seven-node shape) that
+ // count into the same variable, while only this module's nodes get rows — right
+ // by design (the variable is what a HUD reads), and confusing unless said.
+ const legacy = legacyLatches(name).length;
+ if (legacy)
+ head.appendChild(
+ elem('div', {
+ className: 'cm-legacy',
+ textContent:
+ '+' + legacy + ' older recipe chain' + (legacy === 1 ? '' : 's') +
+ ' counted here (edit in the node editor)'
+ })
+ );
disc.addEventListener('click', () => {
setCollapsed(name, !isCollapsed(name));
// the rows are DROPPED rather than hidden (see rebuild), so folding is a
- // rebuild — and the flag rides the signature, so the 500ms refresh cannot
+ // rebuild — and the flag rides the signature, so a later refresh cannot
// quietly unfold what you just closed
refresh(true);
});
head.appendChild(disc);
- // THE BULK CONTROLS. Both write through api.flow.setNodeData, per member — the
- // replicated path a row edit always used, just aimed at the whole group.
+ // THE BULK CONTROLS. Both write through bulkApply — the replicated path a row
+ // edit always used, aimed at the whole group, and one undo step where core has
+ // the batch call.
const bulk = elem('div', { className: 'cm-bulk' });
bulk.appendChild(elem('span', { className: 'cm-bulk-label', textContent: 'all' }));
const groupTrigger = bulkSelect(['click', 'touch'], agreedOn(items, 'trigger', 'click'), 'Trigger');
@@ -977,6 +1016,51 @@ export default {
}
refresh(true);
+
+ // WHEN TO REDRAW (DEVX #17). With core's change signals the panel stops polling:
+ // it redraws when the graph changes or a node FIRES (`api.flow.onChange` — both
+ // structure and collected state), and when the round moves (`api.game.onChange`,
+ // which retires perRound stamps). Core coalesces those to one call per frame.
+ // Two things are NOT events and keep a clock, each only while it is needed:
+ // - a respawn countdown ("back in 4s") ages with time, so a 1s tick runs only
+ // while some row is counting down;
+ // - an OLDER recipe chain's latch is read as a node VALUE, which core
+ // republishes a beat after the trigger, so a change is re-read once 300ms on.
+ // An object renamed or deleted in the viewport is neither; the panel re-reads on
+ // pointerenter, so it is fresh the moment you look at it.
+ // An older app has no onChange: the 500ms poll it always had.
+ /** @type {any} */ let settle = null;
+ /** @type {any} */ let countdown = null;
+ const counting = () => [...statusEls.values()].some((el) => /^back in /.test(el.textContent ?? ''));
+ const tickCountdown = () => {
+ countdown = null;
+ refresh();
+ if (counting()) countdown = setTimeout(tickCountdown, 1000);
+ };
+ const onSignal = () => {
+ refresh();
+ clearTimeout(settle);
+ settle = setTimeout(() => {
+ settle = null;
+ refresh();
+ if (!countdown && counting()) countdown = setTimeout(tickCountdown, 1000);
+ }, 300);
+ if (!countdown && counting()) countdown = setTimeout(tickCountdown, 1000);
+ };
+ if (typeof api.flow.onChange === 'function' && typeof api.game.onChange === 'function') {
+ const offs = [api.flow.onChange(onSignal), api.game.onChange(onSignal)];
+ const onEnter = () => refresh();
+ el.addEventListener('pointerenter', onEnter);
+ if (counting()) countdown = setTimeout(tickCountdown, 1000);
+ el.dataset.refresh = 'signal';
+ return () => {
+ for (const off of offs) if (typeof off === 'function') off();
+ clearTimeout(settle);
+ clearTimeout(countdown);
+ el.removeEventListener('pointerenter', onEnter);
+ };
+ }
+ el.dataset.refresh = 'poll';
const timer = setInterval(refresh, 500);
return () => clearInterval(timer);
}
diff --git a/modules/dungeon-realms/README.md b/modules/dungeon-realms/README.md
index d3fc522..b10a77b 100644
--- a/modules/dungeon-realms/README.md
+++ b/modules/dungeon-realms/README.md
@@ -1,28 +1,62 @@
# dungeon-realms — co-op dungeon crawl, every rule on the node canvas
-A multi-floor procedural dungeon you *play*: generate a campaign from one seed,
-press the app's red **Play** button, pick **Player 1 / Player 2** in the start
-menu, walk with WASD, collect gems to unseal the boss-room portal, stand on it
-together, and climb to the top floor to claim the dragon's hoard. Two peers get
-the identical dungeon from `{seed, params}` — determinism *is* the netcode.
+A multi-floor procedural dungeon you *play*, as an **overlay on the Dungeon Kit**
+(`modules/dungeon`, id `dungeon` — install both). The Kit generates and renders the
+world from one seed and publishes `userData.play`; this module reads that contract
+through `api.scene()` and adds the game: gems, gem-gated portals, P1/P2 slots,
+travel-together co-op and the start/victory menu. Press the app's red **Play**
+button, pick **Player 1 / Player 2**, walk with WASD, collect gems to unseal the
+boss-room portal, stand on it together, and climb to the top floor to claim the
+dragon's hoard. Two peers get the identical dungeon from the Kit's `{seed, params}`
+— determinism *is* the netcode.
```
modules/dungeon-realms/
- src/gen/ the 9-stage PURE-DATA generator (zero THREE/DOM — node-runnable)
- src/ renderer / game / GUI / nodes / audio (bundled by esbuild)
- test/ node tests: determinism, connectivity, perf (no app needed)
- module.js the bundled, self-contained entry (committed)
+ src/rules.js the PURE rules (gem share, objective text, markers, travel-together)
+ src/overlay.js gems + portals from the Kit's contract, in Realms' own group
+ src/game.js the rule state machine: observes the Kit, drives travel through it
+ src/nodes.js Game Rules · Start Menu · Prop Counter · Realms Value / HUD Rows / Event
+ src/gui.js the start / victory MENU (module DOM, keyboard-driven)
+ test/ node tests for the rules (no app needed)
+ module.js the bundled, self-contained entry (committed)
```
```bash
npm run build:dungeon-realms # src/ -> module.js (esbuild)
-npm run test:dungeon-realms # generator acceptance: 100-seed sweep, <50ms
-npm run pack -- dungeon-realms # -> dungeon-realms.zip (manifest + module.js only)
-APP_URL=https://localhost:5189/ npm test -- dungeon-realms # the test-flight
+npm run test:dungeon-realms # the pure rules
+npm run pack -- dungeon && npm run pack -- dungeon-realms
+APP_URL=https://localhost:5216/ npm test -- dungeon-realms # the test-flight (both zips)
```
-## The generator (C1)
-
+## How the two modules fit (21-C C6)
+
+An installed module's entry file may have **no imports**, so two modules cannot share
+code. They share the **scene**: the Kit owns the ONE scene-root group `dungeon-module`
+and publishes `userData.play` (raster, rooms, world-space props and portals, seed,
+floor) plus `userData.kit` (`generate` / `showFloor` / `setMarkers` / `setGrounded`).
+Realms OBSERVES `{seed, floorIndex}` every frame and rebuilds its overlay group
+`dungeon-realms` whenever the world changes — whoever changed it (a portal here, the
+Kit's toolbox, a Dungeon node, a late-join sync) — and DRIVES travel by calling
+`kit.showFloor`, which the Kit replicates itself. Realms replicates only rule events.
+
+- **Minimap:** uncollected gems + portals go through `kit.setMarkers` →
+ `userData.play.markers` (DEVX #13). A bare Kit puts nothing there.
+- **No flying:** Game Rules ▸ disableFlight writes `userData.play.grounded` through the
+ Kit (DEVX #14) — the old capture-phase Q/E swallow is gone.
+- **Play gate:** `api.isPlaying()` (DEVX #11).
+- **HUD:** DELETED as module DOM. The template authors core HUD elements and this
+ module publishes into them — **Realms Value** (a number: gems / need / total / level /
+ levels / players / started / won / sealed / score → a HUD Text, Compare, Gate),
+ **Realms HUD Rows** (lines into a HUD list element by id: objective / players / level /
+ gems / all) and **Realms Event** (start / gem / unseal / travel / victory / reset →
+ a Counter, Set Game State, a Sound).
+- **Menu:** stays module DOM (`#dr-menu`) — a modal, keyboard-driven, focus-owning
+ dialog under pointer lock is not a HUD (DEVX #23).
+
+## The generator (C1 — now in the Kit)
+
+The pipeline below lives in `modules/dungeon/src/gen/` since 21-C C6 (moved with its
+node tests); it is documented here because this is the game it was written for.
Spec-shaped pipeline, each stage a pure function on a per-stage forked
mulberry32 stream (inserting a draw in one stage never reshuffles another):
@@ -61,12 +95,11 @@ peers are already standing on the arrival portal (no teleport API needed).
## How movement works (and what the module does NOT do)
-The group is named `dungeon-module` and publishes `userData.play` in the exact
+The Kit's group `dungeon-module` publishes `userData.play` in the exact
`dungeonPlay.js` shape — the app's own play mode then provides WASD walking,
wall-slide collision, per-peer spawn rooms and the corner minimap for free
-(spawn rooms are ordered entrance-first, so P1/P2 start together). The module
-adds: gems, portal gating, the GUI, and swallows the Q/E fly keys while the
-game runs (Game Rules ▸ disableFlight). See DEVX-REQUESTS #13/#14.
+(spawn rooms are ordered entrance-first, so P1/P2 start together). This module
+adds: gems, portal gating, the menu, the markers and the grounded flag.
## The node family (group "Dungeon Realms")
@@ -78,33 +111,34 @@ scene flow. `range` params accept wired value inputs.
| Node | Owns | Params |
|---|---|---|
-| **Dungeon** | the campaign recipe | seed · roomCount (0 = auto ramp) · levelCount · gemDensity · apply |
| **Game Rules** | gameplay | gemShare (portion to unseal) · pickupRadius · allPlayersPortal · disableFlight |
| **Start Menu** | the play-mode menu | show (auto/always/never) · button1–4 (action selects) |
-| **Game HUD** | the top-left HUD | showGems/Level/Players/Objective · corner |
-| **Prop Counter** | an extra HUD counter | prop (score/keys/…) · initial · showInHud |
+| **Prop Counter** | an extra counter | prop (score/keys/…) · initial · showInHud |
+| **Realms Value** | a readout (number OUT) | read: gems · need · total · level · levels · players · started · won · sealed · score |
+| **Realms HUD Rows** | lines into a core HUD list | element (HUD list id) · show: objective / players / level / gems / all |
+| **Realms Event** | an event OUT | event: start · gem · unseal · travel · victory · reset |
+
+The recipe node is the Kit's **Dungeon** (group "Dungeon Kit"): seed · roomCount ·
+levelCount · loopChance · gemDensity · apply.
-Reference graph (build it in any object's flow — a module cannot seed node
-instances yet, DEVX #10):
+Reference graph — this is what the `games/dungeon-realms` TEMPLATE carries, wired
+(a template is `api.seedFlowGraph`, DEVX #10):
```
-[Number: 1337] ──seed──▶ [Dungeon (apply ✓)] [Game Rules (gemShare .7, all-players ✓)]
- [Start Menu (join-p1 · join-p2 · start · new-dungeon)]
- [Game HUD (top-left)] [Prop Counter (score)]
+[Number 1337] ──seed──▶ [Dungeon Kit ▸ Dungeon (apply ✓)] ──▶ [Object Selector: Entrance]
+[Game Rules (gemShare .7, all-players ✓)] [Start Menu (join-p1 · join-p2 · start · new-dungeon)]
+[Realms Value: gems] ──▶ [HUD Text "Gems {v}"] [Realms HUD Rows: objective ▸ list]
+[Realms Event: start] ──▶ [Set Game State: playing] [Realms Event: victory] ──▶ [Set Game State: over]
```
-Buttons "program what happens next" via their action select — trigger OUT
-sockets need core support (DEVX #9). GUI text params need a `text` kind
-(DEVX #12).
-
## Replication model
-Deterministic (golden rule 8): `{op:'generate', seed, params, checksum}` — every
-peer regenerates locally, the checksum only *detects* divergence (toast, never
-fight). Discrete events: `floor`, `gem`, `slot`, `start`, `onportal`, `prop`,
-`clear`. Late joiners get the full state via `registerStateSync` and rebuild
-mid-game. The Dungeon NODE path broadcasts nothing at all — the node's
-replicated data regenerates identically on every peer.
+The WORLD is the Kit's (deterministic, golden rule 8): it replicates `{op:'generate',
+seed, params, checksum}` / `{op:'floor'}` / `{op:'clear'}` and syncs `{seed, params,
+floorIndex}` to late joiners. This module replicates only discrete rule events: `gem`,
+`slot`, `start`, `reset`, `onportal`, `prop`, and syncs `{seed, floorIndex, collected,
+slots, started, wonAt, propValues}` — applied when the Kit shows that seed, whichever
+module's sync arrives first.
## Controls
diff --git a/modules/dungeon-realms/dungeon-realms.def.json b/modules/dungeon-realms/dungeon-realms.def.json
new file mode 100644
index 0000000..b276148
--- /dev/null
+++ b/modules/dungeon-realms/dungeon-realms.def.json
@@ -0,0 +1,1163 @@
+{
+ "kind": "game",
+ "slug": "dungeon-realms",
+ "title": "Dungeon Realms",
+ "description": "Co-op dungeon crawl: a seeded five-floor dungeon, gems to collect, portals that unseal when enough are found, and a dragon’s hoard at the top. Join as Player 1 / 2, walk it together.",
+ "license": "CC0-1.0",
+ "author": "theprototype",
+ "tags": [
+ "co-op",
+ "procedural",
+ "vr",
+ "dungeon"
+ ],
+ "modules": [
+ {
+ "id": "dungeon",
+ "version": "2.0.0"
+ },
+ {
+ "id": "dungeon-realms",
+ "version": "2.0.0"
+ }
+ ],
+ "installModules": [
+ "dungeon",
+ "dungeon-realms"
+ ],
+ "env": {
+ "preset": "night",
+ "exposure": 1.15
+ },
+ "physics": {
+ "play": {
+ "interaction": "click",
+ "grounded": true,
+ "simOnPlay": false
+ }
+ },
+ "post": {
+ "enabled": true,
+ "effects": [
+ {
+ "id": "tone",
+ "kind": "tonemapping",
+ "enabled": true,
+ "params": {
+ "mode": "AGX"
+ }
+ },
+ {
+ "id": "bloom",
+ "kind": "bloom",
+ "enabled": true,
+ "params": {
+ "intensity": 0.8,
+ "luminanceThreshold": 0.7
+ }
+ },
+ {
+ "id": "aa",
+ "kind": "smaa",
+ "enabled": true,
+ "params": {}
+ }
+ ],
+ "changedAt": 0
+ },
+ "graphs": {
+ "scene": {
+ "nodes": [
+ {
+ "id": "seed",
+ "type": "number",
+ "position": {
+ "x": 40,
+ "y": 40
+ },
+ "data": {
+ "label": "Seed",
+ "value": 1337,
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "dungeon",
+ "type": "dkdungeon",
+ "position": {
+ "x": 280,
+ "y": 40
+ },
+ "data": {
+ "label": "Dungeon Kit",
+ "seed": 1337,
+ "roomCount": 0,
+ "levelCount": 5,
+ "loopChance": 0.15,
+ "gemDensity": 1,
+ "apply": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selplinth",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 40
+ },
+ "data": {
+ "label": "Entrance plinth",
+ "selected": "Entrance plinth"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "rules",
+ "type": "drrules",
+ "position": {
+ "x": 280,
+ "y": 190
+ },
+ "data": {
+ "label": "Game Rules",
+ "gemShare": 0.7,
+ "pickupRadius": 0.9,
+ "allPlayersPortal": true,
+ "disableFlight": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "menu",
+ "type": "drmenu",
+ "position": {
+ "x": 280,
+ "y": 340
+ },
+ "data": {
+ "label": "Start Menu",
+ "show": "auto",
+ "button1": "join-p1",
+ "button2": "join-p2",
+ "button3": "start",
+ "button4": "new-dungeon"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "score",
+ "type": "drprop",
+ "position": {
+ "x": 280,
+ "y": 490
+ },
+ "data": {
+ "label": "Prop Counter",
+ "prop": "score",
+ "initial": 0,
+ "showInHud": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "vgems",
+ "type": "drvalue",
+ "position": {
+ "x": 40,
+ "y": 640
+ },
+ "data": {
+ "label": "Realms: gems",
+ "read": "gems"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tgems",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 640
+ },
+ "data": {
+ "label": "HUD dr-gems",
+ "element": "dr-gems",
+ "format": "{v}",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "vneed",
+ "type": "drvalue",
+ "position": {
+ "x": 40,
+ "y": 750
+ },
+ "data": {
+ "label": "Realms: need",
+ "read": "need"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tneed",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 750
+ },
+ "data": {
+ "label": "HUD dr-need",
+ "element": "dr-need",
+ "format": "/ {v} needed",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "vlevel",
+ "type": "drvalue",
+ "position": {
+ "x": 40,
+ "y": 860
+ },
+ "data": {
+ "label": "Realms: level",
+ "read": "level"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tlevel",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 860
+ },
+ "data": {
+ "label": "HUD dr-level",
+ "element": "dr-level",
+ "format": "LEVEL {v}",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "vlevels",
+ "type": "drvalue",
+ "position": {
+ "x": 40,
+ "y": 970
+ },
+ "data": {
+ "label": "Realms: levels",
+ "read": "levels"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tlevels",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 970
+ },
+ "data": {
+ "label": "HUD dr-levels",
+ "element": "dr-levels",
+ "format": "/ {v}",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "vplayers",
+ "type": "drvalue",
+ "position": {
+ "x": 40,
+ "y": 1080
+ },
+ "data": {
+ "label": "Realms: players",
+ "read": "players"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tplayers",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 1080
+ },
+ "data": {
+ "label": "HUD dr-player-count",
+ "element": "dr-player-count",
+ "format": "{v} in the party",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "robjective",
+ "type": "drrows",
+ "position": {
+ "x": 280,
+ "y": 1190
+ },
+ "data": {
+ "label": "Objective line",
+ "element": "dr-objective",
+ "show": "objective"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "rplayers",
+ "type": "drrows",
+ "position": {
+ "x": 520,
+ "y": 1190
+ },
+ "data": {
+ "label": "Player pills",
+ "element": "dr-players",
+ "show": "players"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "evgem",
+ "type": "drevent",
+ "position": {
+ "x": 40,
+ "y": 1340
+ },
+ "data": {
+ "label": "On gem",
+ "event": "gem"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "cgems",
+ "type": "counter",
+ "position": {
+ "x": 280,
+ "y": 1340
+ },
+ "data": {
+ "label": "Gems taken",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "ttaken",
+ "type": "hudtext",
+ "position": {
+ "x": 520,
+ "y": 1340
+ },
+ "data": {
+ "label": "HUD dr-taken",
+ "element": "dr-taken",
+ "format": "{v} gems taken",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "evstart",
+ "type": "drevent",
+ "position": {
+ "x": 40,
+ "y": 1490
+ },
+ "data": {
+ "label": "On start",
+ "event": "start"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "gostart",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 1490
+ },
+ "data": {
+ "label": "Realms: playing",
+ "state": "playing",
+ "outcome": "",
+ "reset": false
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "evwin",
+ "type": "drevent",
+ "position": {
+ "x": 40,
+ "y": 1600
+ },
+ "data": {
+ "label": "On victory",
+ "event": "victory"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "goover",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 1600
+ },
+ "data": {
+ "label": "Realms: over",
+ "state": "over",
+ "outcome": "The hoard is yours",
+ "reset": false
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "evreset",
+ "type": "drevent",
+ "position": {
+ "x": 40,
+ "y": 1710
+ },
+ "data": {
+ "label": "On new dungeon",
+ "event": "reset"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "gomenu",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 1710
+ },
+ "data": {
+ "label": "Realms: menu",
+ "state": "menu",
+ "outcome": "",
+ "reset": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "pkey",
+ "type": "keypress",
+ "position": {
+ "x": 40,
+ "y": 1860
+ },
+ "data": {
+ "label": "Press P",
+ "code": "KeyP",
+ "edge": "down",
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "pausetoggle",
+ "type": "hudscreen",
+ "position": {
+ "x": 280,
+ "y": 1860
+ },
+ "data": {
+ "label": "Toggle pause menu",
+ "screen": "pause",
+ "action": "toggle"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bresume",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 1970
+ },
+ "data": {
+ "label": "Resume button",
+ "element": "resume-btn"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "resumehide",
+ "type": "hudscreen",
+ "position": {
+ "x": 280,
+ "y": 1970
+ },
+ "data": {
+ "label": "Close pause menu",
+ "screen": "pause",
+ "action": "hide"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bquit",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 2080
+ },
+ "data": {
+ "label": "Quit to menu button",
+ "element": "quit-btn"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "doquit",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 2080
+ },
+ "data": {
+ "label": "Quit to menu",
+ "state": "menu",
+ "outcome": "",
+ "reset": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "quithide",
+ "type": "hudscreen",
+ "position": {
+ "x": 520,
+ "y": 2080
+ },
+ "data": {
+ "label": "Close pause on quit",
+ "screen": "pause",
+ "action": "hide"
+ },
+ "class": "w-[150px]"
+ }
+ ],
+ "edges": [
+ {
+ "id": "e-seed-dungeon.seed",
+ "source": "seed",
+ "target": "dungeon",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "e-dungeon-selplinth",
+ "source": "dungeon",
+ "target": "selplinth"
+ },
+ {
+ "id": "e-rules-selplinth",
+ "source": "rules",
+ "target": "selplinth"
+ },
+ {
+ "id": "e-menu-selplinth",
+ "source": "menu",
+ "target": "selplinth"
+ },
+ {
+ "id": "e-score-selplinth",
+ "source": "score",
+ "target": "selplinth"
+ },
+ {
+ "id": "e-vgems-tgems.value",
+ "source": "vgems",
+ "target": "tgems",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-vneed-tneed.value",
+ "source": "vneed",
+ "target": "tneed",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-vlevel-tlevel.value",
+ "source": "vlevel",
+ "target": "tlevel",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-vlevels-tlevels.value",
+ "source": "vlevels",
+ "target": "tlevels",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-vplayers-tplayers.value",
+ "source": "vplayers",
+ "target": "tplayers",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-robjective-selplinth",
+ "source": "robjective",
+ "target": "selplinth"
+ },
+ {
+ "id": "e-rplayers-selplinth",
+ "source": "rplayers",
+ "target": "selplinth"
+ },
+ {
+ "id": "e-evgem-cgems.trigger",
+ "source": "evgem",
+ "target": "cgems",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-cgems-ttaken.value",
+ "source": "cgems",
+ "target": "ttaken",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-evstart-gostart.trigger",
+ "source": "evstart",
+ "target": "gostart",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-evwin-goover.trigger",
+ "source": "evwin",
+ "target": "goover",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-evreset-gomenu.trigger",
+ "source": "evreset",
+ "target": "gomenu",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-pkey-pausetoggle.trigger",
+ "source": "pkey",
+ "target": "pausetoggle",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bresume-resumehide.trigger",
+ "source": "bresume",
+ "target": "resumehide",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bquit-doquit.trigger",
+ "source": "bquit",
+ "target": "doquit",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bquit-quithide.trigger",
+ "source": "bquit",
+ "target": "quithide",
+ "targetHandle": "trigger"
+ }
+ ]
+ }
+ },
+ "hud": {
+ "scene": {
+ "active": "",
+ "changedAt": 0,
+ "screens": [
+ {
+ "id": "menu",
+ "name": "Menu",
+ "showWhile": "menu",
+ "input": "game",
+ "elements": [
+ {
+ "id": "menu-panel",
+ "kind": "panel",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 16,
+ "w": 520,
+ "h": 96,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(12, 16, 24, 0.9)",
+ "radius": 14,
+ "border": "1px solid rgba(103, 232, 249, 0.25)"
+ }
+ },
+ {
+ "id": "title",
+ "kind": "text",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 26,
+ "w": 480,
+ "h": 40,
+ "z": 1,
+ "label": "DUNGEON REALMS",
+ "style": {
+ "size": 28,
+ "weight": "700",
+ "color": "#67e8f9",
+ "align": "center"
+ }
+ },
+ {
+ "id": "subtitle",
+ "kind": "text",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 66,
+ "w": 500,
+ "h": 40,
+ "z": 1,
+ "label": "Press ▶ Play. In the start menu: ↑↓ + Enter — join as Player 1 / 2, then Start. Collect gems, unseal the portal, stand on it together.",
+ "style": {
+ "size": 12,
+ "color": "#d8dee9",
+ "align": "center"
+ },
+ "wrap": true
+ }
+ ]
+ },
+ {
+ "id": "hud",
+ "name": "HUD",
+ "showWhile": "playing",
+ "input": "game",
+ "elements": [
+ {
+ "id": "hud-panel",
+ "kind": "panel",
+ "anchor": "top-left",
+ "x": 16,
+ "y": 64,
+ "w": 300,
+ "h": 150,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(12, 16, 24, 0.9)",
+ "radius": 14,
+ "border": "1px solid rgba(103, 232, 249, 0.25)"
+ }
+ },
+ {
+ "id": "dr-gems",
+ "kind": "text",
+ "anchor": "top-left",
+ "x": 28,
+ "y": 72,
+ "w": 60,
+ "h": 34,
+ "z": 1,
+ "label": "0",
+ "style": {
+ "size": 26,
+ "weight": "700",
+ "color": "#39e0c0",
+ "align": "left"
+ }
+ },
+ {
+ "id": "dr-need",
+ "kind": "text",
+ "anchor": "top-left",
+ "x": 88,
+ "y": 82,
+ "w": 220,
+ "h": 22,
+ "z": 1,
+ "label": "/ 0 needed",
+ "style": {
+ "size": 13,
+ "color": "#94a3b8",
+ "align": "left"
+ }
+ },
+ {
+ "id": "dr-level",
+ "kind": "text",
+ "anchor": "top-left",
+ "x": 28,
+ "y": 110,
+ "w": 110,
+ "h": 20,
+ "z": 1,
+ "label": "LEVEL 1",
+ "style": {
+ "size": 12,
+ "weight": "600",
+ "color": "#a5b4fc",
+ "align": "left"
+ }
+ },
+ {
+ "id": "dr-levels",
+ "kind": "text",
+ "anchor": "top-left",
+ "x": 118,
+ "y": 110,
+ "w": 60,
+ "h": 20,
+ "z": 1,
+ "label": "/ 5",
+ "style": {
+ "size": 12,
+ "color": "#a5b4fc",
+ "align": "left"
+ }
+ },
+ {
+ "id": "dr-player-count",
+ "kind": "text",
+ "anchor": "top-left",
+ "x": 180,
+ "y": 110,
+ "w": 130,
+ "h": 20,
+ "z": 1,
+ "label": "0 in the party",
+ "style": {
+ "size": 12,
+ "color": "#94a3b8",
+ "align": "left"
+ }
+ },
+ {
+ "id": "dr-players",
+ "kind": "list",
+ "anchor": "top-left",
+ "x": 28,
+ "y": 134,
+ "w": 280,
+ "h": 34,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 11,
+ "color": "#c8d0dc",
+ "align": "left"
+ }
+ },
+ {
+ "id": "dr-objective",
+ "kind": "list",
+ "anchor": "top-left",
+ "x": 28,
+ "y": 170,
+ "w": 280,
+ "h": 36,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 12,
+ "color": "#86efac",
+ "align": "left"
+ }
+ },
+ {
+ "id": "dr-taken",
+ "kind": "text",
+ "anchor": "top-right",
+ "x": 16,
+ "y": 64,
+ "w": 200,
+ "h": 24,
+ "z": 1,
+ "label": "0 gems taken",
+ "style": {
+ "size": 12,
+ "color": "#c8d0dc",
+ "align": "right"
+ }
+ },
+ {
+ "id": "play-hint",
+ "kind": "text",
+ "anchor": "bottom-center",
+ "x": 0,
+ "y": 12,
+ "w": 560,
+ "h": 20,
+ "z": 1,
+ "label": "WASD walk · walk over gems · stand on the unsealed portal together · P pauses",
+ "style": {
+ "size": 11,
+ "color": "#8b97a8",
+ "align": "center"
+ }
+ }
+ ]
+ },
+ {
+ "id": "pause",
+ "name": "Pause",
+ "input": "menu",
+ "elements": [
+ {
+ "id": "pause-panel",
+ "kind": "panel",
+ "anchor": "center",
+ "x": 0,
+ "y": 0,
+ "w": 380,
+ "h": 250,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(12, 16, 24, 0.9)",
+ "radius": 14,
+ "border": "1px solid rgba(103, 232, 249, 0.25)"
+ }
+ },
+ {
+ "id": "pause-title",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -75,
+ "w": 340,
+ "h": 36,
+ "z": 1,
+ "label": "PAUSED",
+ "style": {
+ "size": 26,
+ "weight": "700",
+ "color": "#e5e9f0",
+ "align": "center"
+ }
+ },
+ {
+ "id": "resume-btn",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": -10,
+ "w": 240,
+ "h": 42,
+ "z": 1,
+ "label": "Resume",
+ "enabled": true,
+ "style": {
+ "size": 15,
+ "weight": "600",
+ "bg": "#0ea5e9",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "quit-btn",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 42,
+ "w": 240,
+ "h": 42,
+ "z": 1,
+ "label": "Quit to menu",
+ "enabled": true,
+ "style": {
+ "size": 15,
+ "weight": "500",
+ "bg": "#3a4150",
+ "color": "#e5e9f0",
+ "radius": 10
+ }
+ }
+ ]
+ },
+ {
+ "id": "over",
+ "name": "Victory",
+ "showWhile": "over",
+ "input": "game",
+ "elements": [
+ {
+ "id": "over-panel",
+ "kind": "panel",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 16,
+ "w": 480,
+ "h": 96,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(12, 16, 24, 0.9)",
+ "radius": 14,
+ "border": "1px solid rgba(103, 232, 249, 0.25)"
+ }
+ },
+ {
+ "id": "over-title",
+ "kind": "text",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 26,
+ "w": 440,
+ "h": 40,
+ "z": 1,
+ "label": "THE HOARD IS YOURS",
+ "style": {
+ "size": 26,
+ "weight": "700",
+ "color": "#ffd45e",
+ "align": "center"
+ }
+ },
+ {
+ "id": "dr-taken-over",
+ "kind": "text",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 70,
+ "w": 440,
+ "h": 24,
+ "z": 1,
+ "label": "Play again from the menu — a new dungeon every time",
+ "style": {
+ "size": 13,
+ "color": "#c8d0dc",
+ "align": "center"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "objects": [
+ {
+ "type": "box",
+ "name": "Entrance plinth",
+ "color": 3817808,
+ "roughness": 0.9,
+ "size": [
+ 2.8,
+ 0.12,
+ 2.8
+ ],
+ "pos": [
+ 53.5,
+ 0.06,
+ -1.5
+ ]
+ },
+ {
+ "type": "box",
+ "name": "Arch left",
+ "color": 2962240,
+ "roughness": 0.85,
+ "size": [
+ 0.36,
+ 3,
+ 0.36
+ ],
+ "pos": [
+ 52.4,
+ 1.5,
+ -1.5
+ ]
+ },
+ {
+ "type": "box",
+ "name": "Arch right",
+ "color": 2962240,
+ "roughness": 0.85,
+ "size": [
+ 0.36,
+ 3,
+ 0.36
+ ],
+ "pos": [
+ 54.6,
+ 1.5,
+ -1.5
+ ]
+ },
+ {
+ "type": "box",
+ "name": "Arch lintel",
+ "color": 2962240,
+ "roughness": 0.85,
+ "size": [
+ 2.6,
+ 0.36,
+ 0.5
+ ],
+ "pos": [
+ 53.5,
+ 3.18,
+ -1.5
+ ]
+ },
+ {
+ "type": "sphere",
+ "name": "Arch lantern",
+ "color": 6809849,
+ "emissive": 6809849,
+ "emissiveIntensity": 1.6,
+ "r": 0.16,
+ "pos": [
+ 53.5,
+ 2.75,
+ -1.5
+ ],
+ "shadow": false
+ },
+ {
+ "type": "light",
+ "name": "Arch light",
+ "color": 6809849,
+ "intensity": 6,
+ "distance": 9,
+ "pos": [
+ 53.5,
+ 2.6,
+ -1.5
+ ]
+ }
+ ],
+ "view": {
+ "pos": [
+ 62.5,
+ 7,
+ 9.5
+ ],
+ "target": [
+ 53.5,
+ 1,
+ -1.5
+ ]
+ },
+ "thumb": {
+ "sceneGroups": [
+ "dungeon-module"
+ ]
+ }
+}
diff --git a/modules/dungeon-realms/emit-def.mjs b/modules/dungeon-realms/emit-def.mjs
new file mode 100644
index 0000000..648e057
--- /dev/null
+++ b/modules/dungeon-realms/emit-def.mjs
@@ -0,0 +1,18 @@
+// emit dungeon-realms.def.json beside the module — the orchestrator's input for the scenes
+// def (run by `npm run build:dungeon-realms`). The entrance arch is placed at the entrance
+// room the SEED produces, computed here with the Kit's own generator (node-side only: the
+// module entry never imports across modules; this emitter is a build step).
+import { writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { generateCampaign } from '../dungeon/src/gen/campaign.js';
+import { playPayload } from '../dungeon/src/contract.js';
+import { realmsDef, RECIPE } from './src/def.js';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const { seed, apply, ...params } = RECIPE;
+const play = playPayload(generateCampaign(seed, params), 1);
+const entrance = play.rooms[0];
+const def = realmsDef({ x: Math.round(entrance.cx * 100) / 100, z: Math.round(entrance.cz * 100) / 100 });
+writeFileSync(join(here, 'dungeon-realms.def.json'), JSON.stringify(def, null, '\t') + '\n');
+console.log('dungeon-realms.def.json written — entrance at', entrance.cx, entrance.cz, '(' + play.name + ', ' + play.rooms.length + ' rooms, floor checksum ' + play.checksum + ')');
diff --git a/modules/dungeon-realms/manifest.json b/modules/dungeon-realms/manifest.json
index 4166f2c..343f6f1 100644
--- a/modules/dungeon-realms/manifest.json
+++ b/modules/dungeon-realms/manifest.json
@@ -1,9 +1,9 @@
{
"id": "dungeon-realms",
"name": "Dungeon Realms",
- "version": "1.0.0",
+ "version": "2.0.0",
"format": 1,
- "description": "Co-op dungeon crawl: seeded multi-floor generator, gem-gated portals, P1/P2 play - every rule editable as flow nodes.",
+ "description": "Co-op dungeon crawl on the Dungeon Kit: gem-gated portals, P1/P2 play, travel-together floors - every rule and readout a flow node. Requires the dungeon (Dungeon Kit) module.",
"entry": "module.js",
"files": ["module.js"]
}
diff --git a/modules/dungeon-realms/module.js b/modules/dungeon-realms/module.js
index fd98424..8658a50 100644
--- a/modules/dungeon-realms/module.js
+++ b/modules/dungeon-realms/module.js
@@ -1,4 +1,45 @@
-// modules/dungeon-realms/src/gen/rng.js
+// modules/dungeon-realms/src/rules.js
+var DEFAULT_RULES = {
+ gemShare: 0.7,
+ pickupRadius: 0.9,
+ allPlayersPortal: true,
+ disableFlight: true
+};
+var DEFAULT_MENU = {
+ show: "auto",
+ button1: "join-p1",
+ button2: "join-p2",
+ button3: "start",
+ button4: "new-dungeon"
+};
+function gemTotals(total, collected, gemShare) {
+ if (!total) return { total: 0, need: 0, have: 0 };
+ const share = Math.min(1, Math.max(0.05, Number(gemShare) || 0));
+ const need = Math.max(1, Math.ceil(total * share));
+ return { total, need, have: Math.min(collected, total) };
+}
+function objectiveText(s) {
+ if (s.won) return "Victory! Press Esc to leave play mode.";
+ const missing = Math.max(0, s.need - s.have);
+ if (s.sealed)
+ return "Collect " + missing + " more gem" + (missing === 1 ? "" : "s") + (s.topFloor ? " to claim the dragon\u2019s hoard" : " to unseal the portal");
+ if (s.topFloor) return "The hoard is yours!";
+ return s.allPlayersPortal && s.players > 1 ? "Portal unsealed \u2014 stand on it together!" : "Portal unsealed \u2014 step through!";
+}
+function minimapMarkers(play, collected) {
+ if (!play) return [];
+ const out = [];
+ for (const p of play.props ?? []) if (p.kind === "gem" && !collected.has(p.index)) out.push({ x: p.wx, z: p.wz, kind: "gem" });
+ for (const p of play.portals ?? []) out.push({ x: p.wx, z: p.wz, kind: "door" });
+ return out;
+}
+function canTravelTogether(slots, onPortal, me, allPlayersPortal) {
+ if (!allPlayersPortal) return true;
+ const others = Object.values(slots).map((slot) => slot?.peerId).filter((peerId) => peerId && peerId !== me);
+ return others.every((peerId) => !!onPortal[peerId]);
+}
+
+// modules/dungeon-realms/src/hash.js
function hash32(seed, ...parts) {
let h = 2166136261 ^ seed >>> 0;
for (const part of parts) {
@@ -12,1106 +53,27 @@ function hash32(seed, ...parts) {
}
return h >>> 0;
}
-function makeRng(seed) {
- let a = seed >>> 0;
- const next = () => {
- a += 1831565813;
- let t = a;
- t = Math.imul(t ^ t >>> 15, t | 1);
- t ^= t + Math.imul(t ^ t >>> 7, t | 61);
- return ((t ^ t >>> 14) >>> 0) / 4294967296;
- };
- const rng = {
- seed: seed >>> 0,
- /** uniform [0,1) */
- next,
- /** uniform [a,b) @param {number} a @param {number} b */
- float: (a2, b2) => a2 + next() * (b2 - a2),
- /** integer [a,b] inclusive @param {number} a @param {number} b */
- int: (a2, b2) => a2 + Math.floor(next() * (b2 - a2 + 1)),
- /** @template T @param {T[]} arr @returns {T} */
- pick: (arr) => arr[Math.floor(next() * arr.length)],
- /** @param {number} p */
- chance: (p) => next() < p,
- /** independent child stream — same label, same stream, always @param {string} label */
- fork: (label) => makeRng(hash32(seed, label))
- };
- return rng;
-}
-function checksum32(arr, h = 2166136261) {
- for (let i = 0; i < arr.length; i++) {
- h ^= arr[i] & 255;
- h = Math.imul(h, 16777619);
- h ^= arr[i] >> 8 & 255;
- h = Math.imul(h, 16777619);
- }
- return h >>> 0;
-}
-
-// modules/dungeon-realms/src/gen/names.js
-var ADJECTIVES = [
- "Ashen",
- "Sunken",
- "Howling",
- "Gilded",
- "Mossy",
- "Broken",
- "Silent",
- "Ember",
- "Frozen",
- "Obsidian",
- "Weeping",
- "Forgotten",
- "Crimson",
- "Hollow",
- "Shattered",
- "Verdant",
- "Rusted",
- "Pale",
- "Umbral",
- "Thorned"
-];
-var PLACES = [
- "Vaults",
- "Halls",
- "Depths",
- "Catacombs",
- "Warrens",
- "Chambers",
- "Galleries",
- "Crypts",
- "Cisterns",
- "Foundry",
- "Reliquary",
- "Barrows",
- "Passages",
- "Sanctum",
- "Undercroft",
- "Mines",
- "Roost",
- "Maze"
-];
-var SYL_A = ["Vor", "Mal", "Kar", "Thu", "Gor", "Zan", "Bel", "Dra", "Ny", "Ul", "Sha", "Mor"];
-var SYL_B = ["gul", "thak", "ric", "mash", "dun", "zir", "goth", "ral", "ssk", "bar", "nox", "vek"];
-function dungeonName(rng) {
- const adjective = rng.pick(ADJECTIVES);
- const place = rng.pick(PLACES);
- if (rng.chance(0.6)) {
- const owner = rng.pick(SYL_A) + (rng.chance(0.5) ? "'" : "") + rng.pick(SYL_B);
- return "The " + adjective + " " + place + " of " + owner;
- }
- return "The " + adjective + " " + place;
-}
-
-// modules/dungeon-realms/src/gen/dungeon.js
-var VOID = 0;
-var FLOOR = 1;
-var WALL = 2;
-var DEFAULT_PARAMS = {
- roomCount: 34,
- loopChance: 0.15,
- decorDensity: 0.6,
- monsterDensity: 1,
- gemDensity: 1
-};
-var now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
-function scatterRooms(rng, roomCount) {
- const candidates = Math.ceil(roomCount * 1.4);
- const rx = 2.4 * Math.sqrt(roomCount) + 4;
- const ry = rx * 0.72;
- const rooms = [];
- for (let i = 0; i < candidates; i++) {
- let ux = 0, uy = 0;
- do {
- ux = rng.float(-1, 1);
- uy = rng.float(-1, 1);
- } while (ux * ux + uy * uy > 1);
- const roll = rng.next();
- const archetype = roll < 0.45 ? "small" : roll < 0.85 ? "medium" : "large";
- const [lo, hi] = archetype === "small" ? [5, 7] : archetype === "medium" ? [8, 12] : [13, 18];
- const shapeRoll = rng.next();
- const shape = shapeRoll < 0.6 ? "rect" : shapeRoll < 0.82 ? "ellipse" : "octagon";
- rooms.push({
- id: i,
- archetype,
- shape,
- w: rng.int(lo, hi),
- h: rng.int(lo, hi),
- fx: ux * rx,
- // float centers until separation snaps them
- fy: uy * ry
- });
- }
- let larges = rooms.filter((r) => r.archetype === "large").length;
- for (let i = rooms.length - 1; i >= 0 && larges < 2; i--) {
- if (rooms[i].archetype === "large") continue;
- rooms[i].archetype = "large";
- rooms[i].w = rng.int(13, 18);
- rooms[i].h = rng.int(13, 18);
- larges++;
- }
- return rooms;
-}
-function separateRooms(rooms, roomCount) {
- const PAD = 2;
- for (let iteration = 0; iteration < 300; iteration++) {
- let moved = false;
- for (let i = 0; i < rooms.length; i++) {
- for (let j = i + 1; j < rooms.length; j++) {
- const a = rooms[i], b = rooms[j];
- const overlapX = (a.w + b.w) / 2 + PAD - Math.abs(a.fx - b.fx);
- const overlapY = (a.h + b.h) / 2 + PAD - Math.abs(a.fy - b.fy);
- if (overlapX <= 0 || overlapY <= 0) continue;
- moved = true;
- if (overlapX < overlapY) {
- const push = overlapX / 2 + 0.05;
- const dir = a.fx < b.fx || a.fx === b.fx && i < j ? -1 : 1;
- a.fx += dir * push;
- b.fx -= dir * push;
- } else {
- const push = overlapY / 2 + 0.05;
- const dir = a.fy < b.fy || a.fy === b.fy && i < j ? -1 : 1;
- a.fy += dir * push;
- b.fy -= dir * push;
- }
- }
- }
- if (!moved) break;
- }
- rooms.forEach((r) => {
- r.x = Math.round(r.fx - r.w / 2);
- r.y = Math.round(r.fy - r.h / 2);
- delete r.fx;
- delete r.fy;
- });
- rooms.sort((a, b) => b.w * b.h - a.w * a.h || a.id - b.id);
- rooms.length = Math.min(rooms.length, roomCount);
- rooms.sort((a, b) => a.y - b.y || a.x - b.x || a.id - b.id);
- rooms.forEach((r, i) => r.id = i);
- return rooms;
-}
-function delaunayEdges(points) {
- if (points.length < 2) return [];
- if (points.length === 2) return [[0, 1]];
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- points.forEach((p) => {
- minX = Math.min(minX, p.x);
- minY = Math.min(minY, p.y);
- maxX = Math.max(maxX, p.x);
- maxY = Math.max(maxY, p.y);
- });
- const span = Math.max(maxX - minX, maxY - minY, 1) * 3;
- const midX = (minX + maxX) / 2, midY = (minY + maxY) / 2;
- const verts = [
- ...points,
- { x: midX - span * 3, y: midY - span },
- { x: midX + span * 3, y: midY - span },
- { x: midX, y: midY + span * 3 }
- ];
- const superA = points.length;
- const inCircumcircle = (tri, p) => {
- const a = verts[tri[0]], b = verts[tri[1]], c = verts[tri[2]];
- const ax = a.x - p.x, ay = a.y - p.y;
- const bx = b.x - p.x, by = b.y - p.y;
- const cx = c.x - p.x, cy = c.y - p.y;
- const det = (ax * ax + ay * ay) * (bx * cy - cx * by) - (bx * bx + by * by) * (ax * cy - cx * ay) + (cx * cx + cy * cy) * (ax * by - bx * ay);
- const area = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
- if (Math.abs(area) < 1e-9) return false;
- return area > 0 ? det > 1e-9 : det < -1e-9;
- };
- let triangles = [[superA, superA + 1, superA + 2]];
- for (let pi = 0; pi < points.length; pi++) {
- const bad = triangles.filter((t) => inCircumcircle(t, points[pi]));
- const edgeCount = /* @__PURE__ */ new Map();
- bad.forEach((t) => {
- [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]].forEach(([u, v]) => {
- const key = u < v ? u + ":" + v : v + ":" + u;
- edgeCount.set(key, (edgeCount.get(key) ?? 0) + 1);
- });
- });
- triangles = triangles.filter((t) => !bad.includes(t));
- edgeCount.forEach((count, key) => {
- if (count !== 1) return;
- const [u, v] = key.split(":").map(Number);
- triangles.push([u, v, pi]);
- });
- }
- const edges = /* @__PURE__ */ new Set();
- triangles.forEach((t) => {
- [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]].forEach(([u, v]) => {
- if (u >= points.length || v >= points.length) return;
- edges.add(u < v ? u + ":" + v : v + ":" + u);
- });
- });
- const list = [...edges].sort().map((key) => (
- /** @type {[number, number]} */
- key.split(":").map(Number)
- ));
- if (list.length === 0) {
- const order = points.map((p, i) => i).sort((a, b) => points[a].x - points[b].x || points[a].y - points[b].y);
- for (let i = 1; i < order.length; i++) list.push([Math.min(order[i - 1], order[i]), Math.max(order[i - 1], order[i])]);
- }
- return list;
-}
-function buildGraph(centers, delaunay, rng, loopChance) {
- const length = ([u, v]) => Math.hypot(centers[u].x - centers[v].x, centers[u].y - centers[v].y);
- const inTree = /* @__PURE__ */ new Set([0]);
- const mst = [];
- const remaining = [...delaunay];
- while (inTree.size < centers.length && remaining.length) {
- let best = -1, bestD = Infinity;
- for (let i = 0; i < remaining.length; i++) {
- const [u, v] = remaining[i];
- if (inTree.has(u) === inTree.has(v)) continue;
- const d = length(remaining[i]);
- if (d < bestD) {
- bestD = d;
- best = i;
- }
- }
- if (best < 0) break;
- const edge = remaining.splice(best, 1)[0];
- mst.push(edge);
- inTree.add(edge[0]);
- inTree.add(edge[1]);
- }
- const meanMst = mst.reduce((s, e) => s + length(e), 0) / Math.max(1, mst.length);
- const edges = mst.map(([a, b]) => ({ a, b, isLoop: false, isCritical: false }));
- let loops = 0;
- const rejected = [];
- remaining.forEach((edge) => {
- if (length(edge) > 2.2 * meanMst) return;
- if (rng.chance(loopChance)) {
- edges.push({ a: edge[0], b: edge[1], isLoop: true, isCritical: false });
- loops++;
- } else rejected.push(edge);
- });
- if (loops === 0 && rejected.length) {
- rejected.sort((e1, e2) => length(e1) - length(e2));
- edges.push({ a: rejected[0][0], b: rejected[0][1], isLoop: true, isCritical: false });
- loops = 1;
- }
- return { edges, loops };
-}
-function graphDistances(roomCount, edges, from) {
- const adjacency = Array.from({ length: roomCount }, () => []);
- edges.forEach((e) => {
- adjacency[e.a].push(e.b);
- adjacency[e.b].push(e.a);
- });
- const dist = new Int32Array(roomCount).fill(-1);
- dist[from] = 0;
- const queue = [from];
- for (let qi = 0; qi < queue.length; qi++) {
- const u = queue[qi];
- for (const v of adjacency[u]) if (dist[v] < 0) {
- dist[v] = dist[u] + 1;
- queue.push(v);
- }
- }
- return dist;
-}
-function assignSemantics(rooms, edges, rng) {
- const degree = new Int32Array(rooms.length);
- edges.forEach((e) => {
- degree[e.a]++;
- degree[e.b]++;
- });
- rooms.forEach((r, i) => r.degree = degree[i]);
- let boss = 0;
- rooms.forEach((r, i) => {
- if (r.w * r.h > rooms[boss].w * rooms[boss].h) boss = i;
- });
- const fromBoss = graphDistances(rooms.length, edges, boss);
- let entrance = -1;
- for (let i = 0; i < rooms.length; i++) {
- if (i === boss || degree[i] !== 1) continue;
- if (entrance < 0 || fromBoss[i] > fromBoss[entrance]) entrance = i;
- }
- let entranceFallback = false;
- if (entrance < 0) {
- entranceFallback = true;
- for (let i = 0; i < rooms.length; i++) {
- if (i === boss) continue;
- if (entrance < 0 || degree[i] < degree[entrance] || degree[i] === degree[entrance] && fromBoss[i] > fromBoss[entrance])
- entrance = i;
- }
- }
- const fromEntrance = graphDistances(rooms.length, edges, entrance);
- const maxDepth = Math.max(1, ...rooms.map((r, i) => fromEntrance[i]));
- rooms.forEach((r, i) => {
- r.depth = Math.max(0, fromEntrance[i]);
- r.difficulty = Math.min(1, 0.15 + 0.85 * (r.depth / maxDepth));
- });
- const adjacency = Array.from({ length: rooms.length }, () => []);
- edges.forEach((e, ei) => {
- adjacency[e.a].push({ to: e.b, edge: ei });
- adjacency[e.b].push({ to: e.a, edge: ei });
- });
- const critical = /* @__PURE__ */ new Set([boss]);
- let cursor = boss;
- let guard = rooms.length + 2;
- while (cursor !== entrance && guard-- > 0) {
- let step = null;
- for (const { to, edge } of adjacency[cursor]) {
- if (fromEntrance[to] === fromEntrance[cursor] - 1 && (step === null || to < step.to)) step = { to, edge };
- }
- if (!step) break;
- edges[step.edge].isCritical = true;
- critical.add(step.to);
- cursor = step.to;
- }
- rooms.forEach((r) => r.type = "combat");
- rooms[boss].type = "boss";
- rooms[entrance].type = "entrance";
- const leaves = rooms.map((r, i) => i).filter((i) => i !== boss && i !== entrance && degree[i] === 1).sort((a, b) => fromEntrance[b] - fromEntrance[a] || a - b);
- leaves.slice(0, 4).forEach((i) => rooms[i].type = "treasure");
- const shrineCandidates = rooms.map((r, i) => i).filter((i) => rooms[i].type === "combat" && !critical.has(i) && fromEntrance[i] >= 0.4 * maxDepth && fromEntrance[i] <= 0.7 * maxDepth);
- const shrineCount = Math.min(shrineCandidates.length, rng.int(1, 2));
- for (let k = 0; k < shrineCount; k++) {
- const pickIndex = rng.int(0, shrineCandidates.length - 1);
- rooms[shrineCandidates[pickIndex]].type = "shrine";
- shrineCandidates.splice(pickIndex, 1);
- }
- const eliteCandidates = rooms.map((r, i) => i).filter((i) => rooms[i].type === "combat" && critical.has(i) && rooms[i].archetype !== "small" && fromEntrance[i] >= 0.55 * maxDepth && fromEntrance[i] <= 0.85 * maxDepth);
- const eliteCount = Math.min(eliteCandidates.length, rng.int(1, 2));
- for (let k = 0; k < eliteCount; k++) {
- const pickIndex = rng.int(0, eliteCandidates.length - 1);
- rooms[eliteCandidates[pickIndex]].type = "elite";
- eliteCandidates.splice(pickIndex, 1);
- }
- rooms[boss].difficulty = 1;
- return { boss, entrance, entranceFallback, maxDepth, criticalRooms: critical };
-}
-function inRoomShape(room, x, y) {
- if (x < room.x || y < room.y || x >= room.x + room.w || y >= room.y + room.h) return false;
- if (room.shape === "rect") return true;
- const dx = x + 0.5 - (room.x + room.w / 2);
- const dy = y + 0.5 - (room.y + room.h / 2);
- if (room.shape === "ellipse") {
- const nx = dx / (room.w / 2);
- const ny = dy / (room.h / 2);
- return nx * nx + ny * ny <= 1;
- }
- const chamfer = Math.max(1, Math.floor(Math.min(room.w, room.h) / 3.5));
- const ex = Math.min(x - room.x, room.x + room.w - 1 - x);
- const ey = Math.min(y - room.y, room.y + room.h - 1 - y);
- return ex + ey >= chamfer;
-}
-function rasterize(rooms, edges, semantics, rng) {
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- rooms.forEach((r) => {
- minX = Math.min(minX, r.x);
- minY = Math.min(minY, r.y);
- maxX = Math.max(maxX, r.x + r.w);
- maxY = Math.max(maxY, r.y + r.h);
- });
- const MARGIN = 3;
- const shiftX = MARGIN - minX;
- const shiftY = MARGIN - minY;
- rooms.forEach((r) => {
- r.x += shiftX;
- r.y += shiftY;
- });
- const W = maxX - minX + MARGIN * 2;
- const H = maxY - minY + MARGIN * 2;
- const grid = new Uint8Array(W * H);
- const roomOf = new Int16Array(W * H).fill(-1);
- rooms.forEach((r) => {
- r.cx = r.x + (r.w >> 1);
- r.cy = r.y + (r.h >> 1);
- for (let y = r.y; y < r.y + r.h; y++)
- for (let x = r.x; x < r.x + r.w; x++)
- if (inRoomShape(r, x, y)) {
- grid[y * W + x] = FLOOR;
- roomOf[y * W + x] = r.id;
- }
- });
- const corridorCells = [];
- const stampCorridor = (x, y, width, horizontal) => {
- for (let o = 0; o < width; o++) {
- const off = o - (width >> 1);
- const cx2 = horizontal ? x : x + off;
- const cy2 = horizontal ? y + off : y;
- if (cx2 < 0 || cy2 < 0 || cx2 >= W || cy2 >= H) continue;
- const i = cy2 * W + cx2;
- if (grid[i] !== FLOOR) {
- grid[i] = FLOOR;
- corridorCells.push({ x: cx2, y: cy2 });
- }
- }
- };
- edges.forEach((edge) => {
- const a = rooms[edge.a], b = rooms[edge.b];
- const spur = a.type === "treasure" || b.type === "treasure";
- const width = edge.isCritical ? 3 : spur ? 1 : 2;
- const overlapX0 = Math.max(a.x, b.x), overlapX1 = Math.min(a.x + a.w, b.x + b.w);
- const overlapY0 = Math.max(a.y, b.y), overlapY1 = Math.min(a.y + a.h, b.y + b.h);
- if (overlapX1 - overlapX0 >= 3) {
- const x = overlapX0 + overlapX1 >> 1;
- const y0 = Math.min(a.cy, b.cy), y1 = Math.max(a.cy, b.cy);
- for (let y = y0; y <= y1; y++) stampCorridor(x, y, width, false);
- } else if (overlapY1 - overlapY0 >= 3) {
- const y = overlapY0 + overlapY1 >> 1;
- const x0 = Math.min(a.cx, b.cx), x1 = Math.max(a.cx, b.cx);
- for (let x = x0; x <= x1; x++) stampCorridor(x, y, width, true);
- } else {
- const horizontalFirst = rng.chance(0.5);
- const stepX = a.cx <= b.cx ? 1 : -1;
- const stepY = a.cy <= b.cy ? 1 : -1;
- if (horizontalFirst) {
- for (let x = a.cx; x !== b.cx + stepX; x += stepX) stampCorridor(x, a.cy, width, true);
- for (let y = a.cy; y !== b.cy + stepY; y += stepY) stampCorridor(b.cx, y, width, false);
- } else {
- for (let y = a.cy; y !== b.cy + stepY; y += stepY) stampCorridor(a.cx, y, width, false);
- for (let x = a.cx; x !== b.cx + stepX; x += stepX) stampCorridor(x, b.cy, width, true);
- }
- }
- });
- for (let y = 0; y < H; y++)
- for (let x = 0; x < W; x++) {
- if (grid[y * W + x] !== VOID) continue;
- let nearFloor = false;
- for (let dy = -1; dy <= 1 && !nearFloor; dy++)
- for (let dx = -1; dx <= 1 && !nearFloor; dx++) {
- const nx = x + dx, ny = y + dy;
- if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
- if (grid[ny * W + nx] === FLOOR) nearFloor = true;
- }
- if (nearFloor) grid[y * W + x] = WALL;
- }
- const doorways = [];
- const corridorSet = new Set(corridorCells.map((c) => c.y * W + c.x));
- corridorCells.forEach(({ x, y }) => {
- for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
- const ni = (y + dy) * W + (x + dx);
- if (grid[ni] === FLOOR && roomOf[ni] >= 0) {
- doorways.push({ x, y });
- return;
- }
- }
- });
- const entranceRoom = rooms[semantics.entrance];
- const bfs = new Int16Array(W * H).fill(-1);
- const start = entranceRoom.cy * W + entranceRoom.cx;
- bfs[start] = 0;
- const queue = new Int32Array(W * H);
- queue[0] = start;
- let head = 0, tail = 1;
- while (head < tail) {
- const i = queue[head++];
- const x = i % W, y = i / W | 0;
- for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
- const nx = x + dx, ny = y + dy;
- if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
- const ni = ny * W + nx;
- if (grid[ni] === FLOOR && bfs[ni] < 0) {
- bfs[ni] = bfs[i] + 1;
- queue[tail++] = ni;
- }
- }
- }
- return { W, H, grid, roomOf, corridorCells, corridorSet, doorways, bfs };
-}
-function decorate(rooms, raster, semantics, params, rng) {
- const { W, H, grid, roomOf, doorways, corridorSet } = raster;
- const props = [];
- const spawns = [];
- const taken = new Uint8Array(W * H);
- doorways.forEach(({ x, y }) => taken[y * W + x] = 1);
- const doorwayNear = (x, y, radius) => doorways.some((d) => Math.max(Math.abs(d.x - x), Math.abs(d.y - y)) < radius);
- const allFloor8 = (x, y) => {
- for (let dy = -1; dy <= 1; dy++)
- for (let dx = -1; dx <= 1; dx++)
- if (grid[(y + dy) * W + (x + dx)] !== FLOOR) return false;
- return true;
- };
- const place = (kind, x, y, roomId, extra = {}) => {
- taken[y * W + x] = 1;
- props.push({ kind, x, y, rot: 0, scale: 1, roomId, ...extra });
- };
- const freeCells = (room, filter) => {
- const cells = [];
- for (let y = room.y; y < room.y + room.h; y++)
- for (let x = room.x; x < room.x + room.w; x++) {
- const i = y * W + x;
- if (grid[i] !== FLOOR || roomOf[i] !== room.id || taken[i]) continue;
- if (filter && !filter(x, y)) continue;
- cells.push({ x, y });
- }
- return cells;
- };
- const wallAdjacent = (x, y) => [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => grid[(y + dy) * W + (x + dx)] === WALL);
- rooms.forEach((room) => {
- if (room.archetype !== "large") return;
- for (let y = room.y + 1; y < room.y + room.h - 1; y++)
- for (let x = room.x + 1; x < room.x + room.w - 1; x++) {
- if ((x - room.cx) % 3 !== 0 || (y - room.cy) % 3 !== 0) continue;
- if (x === room.cx && y === room.cy) continue;
- if (!allFloor8(x, y) || taken[y * W + x] || doorwayNear(x, y, 2)) continue;
- place("pillar", x, y, room.id);
- }
- });
- const torches = [];
- for (let y = 1; y < H - 1; y++)
- for (let x = 1; x < W - 1; x++) {
- if (grid[y * W + x] !== WALL) continue;
- const facing = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([dx, dy]) => grid[(y + dy) * W + (x + dx)] === FLOOR);
- if (!facing) continue;
- if (torches.some((t) => Math.max(Math.abs(t.x - x), Math.abs(t.y - y)) < 4)) continue;
- torches.push({ x, y, fx: facing[0], fy: facing[1] });
- }
- torches.forEach((t) => props.push({ kind: "torch", x: t.x, y: t.y, rot: 0, scale: 1, roomId: roomOf[t.y * W + t.x], fx: t.fx, fy: t.fy }));
- rooms.forEach((room) => {
- const cells = freeCells(room);
- const count = Math.round(cells.length * 0.05 * params.decorDensity * (1.4 - room.difficulty));
- for (let k = 0; k < count && cells.length; k++) {
- const cell = cells.splice(rng.int(0, cells.length - 1), 1)[0];
- place("debris", cell.x, cell.y, room.id, { rot: rng.float(0, 6.283), scale: rng.float(0.5, 1.1) });
- }
- });
- rooms.forEach((room) => {
- if (room.type !== "combat") return;
- const cells = freeCells(room, (x, y) => wallAdjacent(x, y) && !doorwayNear(x, y, 2));
- const count = Math.min(cells.length, Math.max(0, Math.round(rng.int(2, 4) * params.decorDensity)));
- for (let k = 0; k < count && cells.length; k++) {
- const cell = cells.splice(rng.int(0, cells.length - 1), 1)[0];
- place("crate", cell.x, cell.y, room.id);
- }
- });
- rooms.forEach((room) => {
- if (room.type === "boss") {
- const ring = freeCells(room, (x, y) => {
- const edgeX = Math.min(x - room.x, room.x + room.w - 1 - x);
- const edgeY = Math.min(y - room.y, room.y + room.h - 1 - y);
- return Math.min(edgeX, edgeY) === 1 && (x + y) % 3 === 0 && !doorwayNear(x, y, 2);
- });
- ring.forEach((cell) => place("brazier", cell.x, cell.y, room.id));
- } else if (room.type === "treasure") {
- const cells = freeCells(room, (x, y) => Math.abs(x - room.cx) <= 1 && Math.abs(y - room.cy) <= 1);
- if (cells.length) {
- const cell = cells[rng.int(0, cells.length - 1)];
- place("chest", cell.x, cell.y, room.id);
- }
- } else if (room.type === "shrine") {
- if (grid[room.cy * W + room.cx] === FLOOR && !taken[room.cy * W + room.cx])
- place("crystal", room.cx, room.cy, room.id);
- } else if (room.type === "entrance") {
- if (!taken[room.cy * W + room.cx]) place("ring", room.cx, room.cy, room.id);
- }
- });
- const gemCount = Math.max(4, Math.min(40, Math.round(rooms.length * 0.4 * (params.gemDensity ?? 1))));
- const gemRooms = rooms.filter((r) => r.type !== "entrance");
- const gemRng = rng.fork("gems");
- let gemsPlaced = 0;
- let attempts = gemCount * 8;
- while (gemsPlaced < gemCount && attempts-- > 0) {
- const weights = gemRooms.map((r) => 1 + r.depth);
- const total = weights.reduce((s, w2) => s + w2, 0);
- let roll = gemRng.float(0, total);
- let room = gemRooms[gemRooms.length - 1];
- for (let i = 0; i < gemRooms.length; i++) {
- roll -= weights[i];
- if (roll <= 0) {
- room = gemRooms[i];
- break;
- }
- }
- const cells = freeCells(room);
- if (!cells.length) continue;
- const cell = cells[gemRng.int(0, cells.length - 1)];
- place("gem", cell.x, cell.y, room.id, { index: gemsPlaced });
- gemsPlaced++;
- }
- const spawnRng = rng.fork("spawns");
- rooms.forEach((room) => {
- if (room.type !== "combat" && room.type !== "elite") return;
- const area = room.w * room.h;
- const count = Math.round(area / 18 * (0.5 + room.difficulty) * params.monsterDensity);
- const cells = freeCells(room, (x, y) => !corridorSet.has(y * W + x));
- for (let k = 0; k < count && cells.length; k++) {
- const cell = cells.splice(spawnRng.int(0, cells.length - 1), 1)[0];
- taken[cell.y * W + cell.x] = 1;
- spawns.push({ x: cell.x, y: cell.y, tier: room.type === "elite" ? "elite" : "normal", roomId: room.id });
- }
- });
- return { props, spawns, taken };
-}
-function validateDungeon(d) {
- const failures = [];
- const { W, grid, bfs } = d;
- let floorTiles = 0, reached = 0;
- for (let i = 0; i < grid.length; i++) {
- if (grid[i] !== FLOOR) continue;
- floorTiles++;
- if (bfs[i] >= 0) reached++;
- }
- if (reached !== floorTiles) failures.push("connectivity: " + reached + "/" + floorTiles + " floor cells reachable");
- const boss = d.rooms.find((r) => r.type === "boss" || r.type === "dragon");
- const entrance = d.rooms.find((r) => r.type === "entrance");
- if (!boss) failures.push("no boss room");
- if (!entrance) failures.push("no entrance room");
- if (entrance && entrance.degree !== 1 && !d.stats.entranceFallback) failures.push("entrance degree " + entrance.degree);
- if (boss && entrance) {
- let maxBfs = 0;
- for (let i = 0; i < bfs.length; i++) if (bfs[i] > maxBfs) maxBfs = bfs[i];
- const bossDepth = bfs[boss.cy * W + boss.cx];
- if (bossDepth < 0.6 * maxBfs) failures.push("boss depth " + bossDepth + " < 60% of " + maxBfs);
- }
- const loops = d.edges.filter((e) => e.isLoop).length;
- if (loops < 1) failures.push("no loops (cyclomatic 0)");
- const doorwaySet = new Set(d.doorways.map((c) => c.y * W + c.x));
- for (const p of d.props) {
- const i = p.y * W + p.x;
- if (grid[i] === FLOOR && doorwaySet.has(i)) failures.push("prop " + p.kind + " on doorway");
- else if (p.kind !== "torch" && grid[i] !== FLOOR) failures.push("prop " + p.kind + " off floor");
- else if (p.kind === "torch" && grid[i] !== WALL) failures.push("torch off wall");
- }
- for (const s of d.spawns) {
- const i = s.y * W + s.x;
- if (grid[i] !== FLOOR || doorwaySet.has(i)) failures.push("spawn off floor / on doorway");
- }
- for (const portal of d.portals ?? []) {
- const i = portal.y * W + portal.x;
- if (grid[i] !== FLOOR) failures.push("portal off floor");
- else if (bfs[i] < 0) failures.push("portal unreachable");
- }
- return failures;
-}
-function dungeonChecksum(d) {
- let h = checksum32(d.grid);
- for (const portal of d.portals ?? []) h = checksum32(new Uint8Array([portal.x & 255, portal.x >> 8, portal.y & 255, portal.y >> 8]), h);
- return h >>> 0;
-}
-function generateDungeon(seed, params = {}, floorIndex = 1) {
- const started = now();
- const merged = { ...DEFAULT_PARAMS, ...params };
- merged.roomCount = Math.min(Math.max(merged.roomCount, 4), 60);
- merged.loopChance = Math.min(Math.max(merged.loopChance, 0), 1);
- let failures = [];
- for (let attempt = 0; attempt < 5; attempt++) {
- const attemptSeed = attempt === 0 ? seed >>> 0 : hash32(seed, "attempt", attempt);
- const rng = makeRng(attemptSeed);
- const rooms = separateRooms(scatterRooms(rng.fork("rooms"), merged.roomCount), merged.roomCount);
- const centers = rooms.map((r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }));
- const { edges, loops } = buildGraph(centers, delaunayEdges(centers), rng.fork("graph"), merged.loopChance);
- const semantics = assignSemantics(rooms, edges, rng.fork("semantics"));
- const raster = rasterize(rooms, edges, semantics, rng.fork("carve"));
- const { props, spawns } = decorate(rooms, raster, semantics, merged, rng.fork("decor"));
- let floorTiles = 0, wallTiles = 0;
- for (let i = 0; i < raster.grid.length; i++) {
- if (raster.grid[i] === FLOOR) floorTiles++;
- else if (raster.grid[i] === WALL) wallTiles++;
- }
- const criticalLength = edges.filter((e) => e.isCritical).length;
- const dungeon = {
- floorIndex,
- theme: null,
- // stamped by the campaign
- name: dungeonName(rng.fork("name")),
- W: raster.W,
- H: raster.H,
- grid: raster.grid,
- bfs: raster.bfs,
- rooms,
- edges,
- doorways: raster.doorways,
- corridorCells: raster.corridorCells,
- props,
- spawns,
- portals: [],
- entranceId: semantics.entrance,
- bossId: semantics.boss,
- stats: {
- floorIndex,
- seed: attemptSeed,
- rooms: rooms.length,
- edges: edges.length,
- loops,
- criticalLength,
- floorTiles,
- wallTiles,
- props: props.length,
- spawns: spawns.length,
- entranceFallback: semantics.entranceFallback,
- attempts: attempt + 1,
- genMs: 0
- }
- };
- failures = validateDungeon(dungeon);
- if (failures.length === 0) {
- dungeon.stats.genMs = now() - started;
- return dungeon;
- }
- }
- throw new Error("dungeon generation failed after 5 attempts (seed " + seed + "): " + failures.join("; "));
-}
-
-// modules/dungeon-realms/src/gen/bestiary.js
-var BESTIARY = [
- { id: "spider", floors: [1, 2], weight: 10 },
- { id: "bat", floors: [1, 3], weight: 8 },
- { id: "slime", floors: [2, 3], weight: 8 },
- { id: "skeleton", floors: [2, 4], weight: 9 },
- { id: "goblin", floors: [3, 4], weight: 8 },
- { id: "wraith", floors: [3, 5], weight: 6 },
- { id: "golem", floors: [4, 5], weight: 4 }
-];
-
-// modules/dungeon-realms/src/gen/campaign.js
-var DEFAULT_CAMPAIGN_PARAMS = { ...DEFAULT_PARAMS, levelCount: 5, theme: "auto", roomCount: 0 };
-var THEMES = [
- { id: "crypt", floorTints: [6122328, 5596245, 5135442, 6385500], corridorTint: 4475199, wallTint: 4015160, fogColor: 395274, torchColor: 16747578, gemColor: 3858350 },
- { id: "sewer", floorTints: [4610122, 4084550, 5267532, 3755330], corridorTint: 3292467, wallTint: 3029809, fogColor: 266252, torchColor: 14201402, gemColor: 5365972 },
- { id: "forge", floorTints: [5588804, 6113857, 5194302, 6508618], corridorTint: 3945263, wallTint: 3616298, fogColor: 1181445, torchColor: 16734758, gemColor: 16757575 },
- { id: "frost", floorTints: [5003884, 5398386, 4674662, 5858936], corridorTint: 3752783, wallTint: 3358025, fogColor: 395796, torchColor: 7000831, gemColor: 10476799 },
- { id: "roost", floorTints: [5260894, 5721190, 4800598, 6181485], corridorTint: 3748421, wallTint: 3419711, fogColor: 656658, torchColor: 13212415, gemColor: 14715647 }
-];
-function themeFor(floorIndex, theme = "auto") {
- if (theme !== "auto") return THEMES.find((t) => t.id === theme) ?? THEMES[0];
- return THEMES[(floorIndex - 1) % THEMES.length];
-}
-function roomCountFor(floorIndex, params) {
- if (params.roomCount) return params.roomCount;
- return Math.min(34 + 6 * (floorIndex - 1), 60);
-}
-function nearestFreeCell(dungeon, tx, ty, blocked) {
- const { W, H, grid } = dungeon;
- let best = null, bestD = Infinity;
- for (let y = 1; y < H - 1; y++)
- for (let x = 1; x < W - 1; x++) {
- if (grid[y * W + x] !== FLOOR || blocked.has(y * W + x)) continue;
- const d = (x - tx) * (x - tx) + (y - ty) * (y - ty);
- if (d < bestD) {
- bestD = d;
- best = { x, y };
- }
- }
- return best;
-}
-function placePortals(dungeon, floorIndex, levelCount) {
- const boss = dungeon.rooms[dungeon.bossId];
- const entrance = dungeon.rooms[dungeon.entranceId];
- const { W } = dungeon;
- const blocked = /* @__PURE__ */ new Set();
- dungeon.props.forEach((p) => {
- if (p.kind === "torch" || p.kind === "debris") return;
- blocked.add(p.y * W + p.x);
- });
- dungeon.spawns.forEach((s) => blocked.add(s.y * W + s.x));
- const addPortal = (kind, room, gated) => {
- const cell = nearestFreeCell(dungeon, room.cx, room.cy, blocked);
- if (!cell) return null;
- dungeon.props = dungeon.props.filter(
- (p) => !(p.kind === "debris" && Math.abs(p.x - cell.x) <= 1 && Math.abs(p.y - cell.y) <= 1)
- );
- for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]])
- blocked.add((cell.y + dy) * W + (cell.x + dx));
- const portal = { kind, x: cell.x, y: cell.y, roomId: room.id, gated };
- dungeon.portals.push(portal);
- return portal;
- };
- if (floorIndex < levelCount) addPortal("up", boss, true);
- else {
- boss.type = "dragon";
- }
- if (floorIndex >= 2) addPortal("down", entrance, false);
-}
-function typeSpawns(dungeon, floorIndex, bestiaryTable, rng) {
- const legal = bestiaryTable.filter((b) => floorIndex >= b.floors[0] && floorIndex <= b.floors[1]);
- if (!legal.length) return;
- const total = legal.reduce((s, b) => s + b.weight, 0);
- dungeon.spawns.forEach((spawn) => {
- let roll = rng.float(0, total);
- spawn.type = legal[legal.length - 1].id;
- for (const b of legal) {
- roll -= b.weight;
- if (roll <= 0) {
- spawn.type = b.id;
- break;
- }
- }
- });
- if (dungeon.spawns.length >= 12) {
- const present = new Set(dungeon.spawns.map((s) => s.type));
- const missing = legal.filter((b) => !present.has(b.id));
- missing.forEach((b) => {
- const slot = dungeon.spawns[rng.int(0, dungeon.spawns.length - 1)];
- slot.type = b.id;
- });
- }
-}
-function generateCampaign(seed, params = {}, bestiaryTable = BESTIARY) {
- const merged = { ...DEFAULT_CAMPAIGN_PARAMS, ...params };
- merged.levelCount = Math.min(Math.max(merged.levelCount ?? 5, 1), 9);
- const floors = [];
- for (let i = 1; i <= merged.levelCount; i++) {
- const floorSeed = hash32(seed, i, "floor");
- const floorParams = {
- ...merged,
- roomCount: roomCountFor(i, merged),
- monsterDensity: Math.min(merged.monsterDensity * (1 + 0.1 * (i - 1)), merged.monsterDensity * 1.5)
- };
- const dungeon = generateDungeon(floorSeed, floorParams, i);
- dungeon.theme = themeFor(i, merged.theme);
- dungeon.rooms.forEach((room) => {
- if (room.type !== "boss" && room.type !== "dragon")
- room.difficulty = Math.min(1, room.difficulty + 0.1 * (i - 1));
- });
- placePortals(dungeon, i, merged.levelCount);
- typeSpawns(dungeon, i, bestiaryTable, makeRng(hash32(floorSeed, "spawnTypes")));
- dungeon.checksum = dungeonChecksum(dungeon);
- floors.push(dungeon);
- }
- floors[0].ox = -(floors[0].W >> 1);
- floors[0].oy = -(floors[0].H >> 1);
- for (let k = 1; k < floors.length; k++) {
- const up = floors[k - 1].portals.find((p) => p.kind === "up");
- const down = floors[k].portals.find((p) => p.kind === "down");
- if (up && down) {
- floors[k].ox = floors[k - 1].ox + up.x - down.x;
- floors[k].oy = floors[k - 1].oy + up.y - down.y;
- } else {
- floors[k].ox = -(floors[k].W >> 1);
- floors[k].oy = -(floors[k].H >> 1);
- }
- }
- let h = 2166136261;
- floors.forEach((f) => {
- h = checksum32(f.grid, h);
- f.portals.forEach((p) => {
- h = checksum32(new Uint8Array([p.x & 255, p.x >> 8, p.y & 255, p.y >> 8, p.kind === "up" ? 1 : 2]), h);
- });
- });
- const campaign = { seed: seed >>> 0, params: merged, floors, checksum: h >>> 0 };
- const failures = validateCampaign(campaign);
- if (failures.length) throw new Error("campaign validation failed: " + failures.join("; "));
- return campaign;
-}
-function validateCampaign(campaign) {
- const failures = [];
- const { floors, params } = campaign;
- if (floors.length !== params.levelCount) failures.push("floor count " + floors.length + " != " + params.levelCount);
- for (let k = 0; k < floors.length; k++) {
- const floor = floors[k];
- const i = k + 1;
- const up = floor.portals.find((p) => p.kind === "up");
- const down = floor.portals.find((p) => p.kind === "down");
- if (i < floors.length && (!up || !up.gated)) failures.push("floor " + i + " missing gated UP portal");
- if (i === floors.length && up) failures.push("top floor has an UP portal");
- if (i >= 2 && !down) failures.push("floor " + i + " missing DOWN portal");
- if (i === 1 && down) failures.push("floor 1 has a DOWN portal");
- for (const portal of floor.portals) {
- const cell = portal.y * floor.W + portal.x;
- if (floor.grid[cell] !== FLOOR) failures.push("floor " + i + " portal off floor");
- if (floor.bfs[cell] < 0) failures.push("floor " + i + " portal unreachable from entrance");
- }
- }
- return failures;
-}
-// modules/dungeon-realms/src/render.js
-function cellNoise(x, y, seed, amp = 0.05) {
- let h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ seed) >>> 0;
- h = Math.imul(h ^ h >>> 13, 1540483477) >>> 0;
- return 1 - amp + (h & 65535) / 65535 * amp * 2;
-}
-function buildFloorGroup(THREE, dungeon, collected) {
- const { W, H, grid, rooms, props, theme, ox, oy } = dungeon;
+// modules/dungeon-realms/src/overlay.js
+function buildOverlay(THREE, play, collected) {
const group = new THREE.Group();
- const matrix = new THREE.Matrix4();
- const position = new THREE.Vector3();
- const quaternion = new THREE.Quaternion();
- const scale = new THREE.Vector3(1, 1, 1);
- const color = new THREE.Color();
- const worldX = (x) => x + ox + 0.5;
- const worldZ = (y) => y + oy + 0.5;
- const roomOf = new Int16Array(W * H).fill(-1);
- rooms.forEach((room) => {
- for (let y = room.y; y < room.y + room.h; y++)
- for (let x = room.x; x < room.x + room.w; x++)
- if (grid[y * W + x] === FLOOR) roomOf[y * W + x] = room.id;
- });
- let floorCount = 0, wallCount = 0;
- for (let i = 0; i < grid.length; i++) {
- if (grid[i] === FLOOR) floorCount++;
- else if (grid[i] === WALL) wallCount++;
- }
- const floorMesh = new THREE.InstancedMesh(
- new THREE.BoxGeometry(1, 0.2, 1),
- new THREE.MeshStandardMaterial({ color: 16777215, roughness: 0.95 }),
- floorCount
- );
- floorMesh.name = "dr-floors";
- const wallMesh = new THREE.InstancedMesh(
- new THREE.BoxGeometry(1, 1, 1),
- new THREE.MeshStandardMaterial({ color: 16777215, roughness: 0.9 }),
- wallCount
- );
- wallMesh.name = "dr-walls";
- const wallsAround = (x, y) => {
- let count = 0;
- for (let dy = -1; dy <= 1; dy++)
- for (let dx = -1; dx <= 1; dx++) {
- const nx = x + dx, ny = y + dy;
- if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
- if (grid[ny * W + nx] === WALL) count++;
- }
- return count;
- };
- let floorIndex = 0, wallIndex = 0;
- for (let y = 0; y < H; y++)
- for (let x = 0; x < W; x++) {
- const cell = grid[y * W + x];
- if (cell === FLOOR) {
- matrix.makeTranslation(worldX(x), -0.1, worldZ(y));
- floorMesh.setMatrixAt(floorIndex, matrix);
- const roomId = roomOf[y * W + x];
- const tint = roomId >= 0 ? theme.floorTints[roomId % theme.floorTints.length] : theme.corridorTint;
- const ao = 1 - 0.09 * Math.min(wallsAround(x, y), 4);
- color.setHex(tint).multiplyScalar(ao * cellNoise(x, y, dungeon.stats.seed) * (roomId >= 0 ? 1 : 0.9));
- floorMesh.setColorAt(floorIndex, color);
- floorIndex++;
- } else if (cell === WALL) {
- const height = 2 + (cellNoise(x, y, dungeon.stats.seed ^ 24301, 0.125) - 1) * 2;
- position.set(worldX(x), height / 2, worldZ(y));
- scale.set(1, height, 1);
- matrix.compose(position, quaternion, scale);
- wallMesh.setMatrixAt(wallIndex, matrix);
- color.setHex(theme.wallTint).multiplyScalar(cellNoise(x, y, dungeon.stats.seed ^ 2577));
- wallMesh.setColorAt(wallIndex, color);
- wallIndex++;
- }
- }
- scale.set(1, 1, 1);
- group.add(floorMesh, wallMesh);
- const byKind = {};
- props.forEach((p) => (byKind[p.kind] ??= []).push(p));
- const instanced = (name, geometry, material, list, pose) => {
- if (!list?.length) return null;
- const mesh = new THREE.InstancedMesh(geometry, material, list.length);
- mesh.name = name;
- list.forEach((p, i) => {
- pose(p, i);
- matrix.compose(position, quaternion, scale);
- mesh.setMatrixAt(i, matrix);
- });
- position.set(0, 0, 0);
- quaternion.identity();
- scale.set(1, 1, 1);
- group.add(mesh);
- return mesh;
- };
- instanced(
- "dr-pillars",
- new THREE.CylinderGeometry(0.3, 0.38, 2.4, 8),
- new THREE.MeshStandardMaterial({ color: theme.wallTint, roughness: 0.85 }),
- byKind.pillar,
- (p) => position.set(worldX(p.x), 1.2, worldZ(p.y))
- );
- instanced(
- "dr-debris",
- new THREE.BoxGeometry(0.32, 0.22, 0.32),
- new THREE.MeshStandardMaterial({ color: theme.corridorTint, roughness: 1 }),
- byKind.debris,
- (p) => {
- position.set(worldX(p.x), 0.1, worldZ(p.y));
- quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), p.rot ?? 0);
- scale.setScalar(p.scale ?? 1);
- }
- );
- quaternion.identity();
- scale.set(1, 1, 1);
- instanced(
- "dr-crates",
- new THREE.BoxGeometry(0.72, 0.72, 0.72),
- new THREE.MeshStandardMaterial({ color: 9071165, roughness: 0.9 }),
- byKind.crate,
- (p) => position.set(worldX(p.x), 0.36, worldZ(p.y))
- );
- instanced(
- "dr-braziers",
- new THREE.CylinderGeometry(0.3, 0.2, 0.55, 8),
- new THREE.MeshStandardMaterial({ color: 2894900, roughness: 0.6, metalness: 0.4 }),
- byKind.brazier,
- (p) => position.set(worldX(p.x), 0.28, worldZ(p.y))
- );
- instanced(
- "dr-chests",
- new THREE.BoxGeometry(0.85, 0.55, 0.6),
- new THREE.MeshStandardMaterial({ color: 11569710, roughness: 0.5, metalness: 0.3 }),
- byKind.chest,
- (p) => position.set(worldX(p.x), 0.28, worldZ(p.y))
- );
- instanced(
- "dr-crystals",
- new THREE.OctahedronGeometry(0.42, 0),
- new THREE.MeshBasicMaterial({ color: theme.gemColor }),
- byKind.crystal,
- (p) => {
- position.set(worldX(p.x), 1, worldZ(p.y));
- scale.set(1, 1.7, 1);
- }
- );
- scale.set(1, 1, 1);
- instanced(
- "dr-rings",
- new THREE.TorusGeometry(0.9, 0.07, 8, 28),
- new THREE.MeshBasicMaterial({ color: 3854532 }),
- byKind.ring,
- (p) => {
- position.set(worldX(p.x), 0.06, worldZ(p.y));
- quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
- }
- );
- quaternion.identity();
- const torches = byKind.torch ?? [];
- instanced(
- "dr-torch-brackets",
- new THREE.BoxGeometry(0.12, 0.34, 0.12),
- new THREE.MeshStandardMaterial({ color: 3813670, roughness: 0.8 }),
- torches,
- (p) => position.set(worldX(p.x) + (p.fx ?? 0) * 0.42, 1.45, worldZ(p.y) + (p.fy ?? 0) * 0.42)
- );
- const flameSpots = torches.map((p) => ({
- x: worldX(p.x) + (p.fx ?? 0) * 0.42,
- y: 1.78,
- z: worldZ(p.y) + (p.fy ?? 0) * 0.42
- })).concat((byKind.brazier ?? []).map((p) => ({ x: worldX(p.x), y: 0.75, z: worldZ(p.y) })));
- instanced(
- "dr-flames",
- new THREE.ConeGeometry(0.11, 0.3, 6),
- new THREE.MeshBasicMaterial({ color: theme.torchColor }),
- flameSpots,
- (p) => position.set(p.x, p.y, p.z)
- );
- const budget = 8;
- const step = Math.max(1, Math.ceil(flameSpots.length / budget));
- for (let i = 0; i < flameSpots.length && i / step < budget; i += step) {
- const spot = flameSpots[i];
- const light = new THREE.PointLight(theme.torchColor, 5, 11, 2);
- light.name = "dr-light";
- light.position.set(spot.x, spot.y + 0.25, spot.z);
- group.add(light);
- }
- const gems = (byKind.gem ?? []).slice().sort((a, b) => a.index - b.index);
- const gemWorld = gems.map((p) => ({ x: worldX(p.x), y: 0.55, z: worldZ(p.y), index: p.index }));
+ const theme = play.theme ?? {};
+ const gemColor = theme.gemColor ?? 3793088;
+ const gems = (play.props ?? []).filter((p) => p.kind === "gem").slice().sort((a, b) => a.index - b.index);
+ const gemWorld = gems.map((p) => ({ x: p.wx, y: 0.55, z: p.wz, index: p.index }));
const gemMesh = new THREE.InstancedMesh(
new THREE.OctahedronGeometry(0.17, 0),
- new THREE.MeshBasicMaterial({ color: theme.gemColor }),
+ new THREE.MeshBasicMaterial({ color: gemColor }),
Math.max(1, gemWorld.length)
);
gemMesh.name = "dr-gems";
gemMesh.count = gemWorld.length;
group.add(gemMesh);
- dungeon.portals.forEach((portal) => {
+ for (const portal of play.portals ?? []) {
const portalGroup = new THREE.Group();
portalGroup.name = "dr-portal-" + portal.kind;
- portalGroup.position.set(worldX(portal.x), 0, worldZ(portal.y));
- const ring = new THREE.Mesh(
- new THREE.TorusGeometry(1.05, 0.1, 10, 36),
- new THREE.MeshBasicMaterial({ color: 5593702 })
- );
+ portalGroup.position.set(portal.wx, 0, portal.wz);
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(1.05, 0.1, 10, 36), new THREE.MeshBasicMaterial({ color: 5593702 }));
ring.name = "dr-portal-ring";
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.1;
@@ -1125,7 +87,7 @@ function buildFloorGroup(THREE, dungeon, collected) {
portalGroup.add(ring, disc);
portalGroup.userData.portal = { kind: portal.kind, gated: portal.gated };
group.add(portalGroup);
- });
+ }
group.userData._dr = { gemWorld, theme };
applyGems(group, collected);
return group;
@@ -1144,22 +106,23 @@ function applyGems(group, collected) {
}
function setPortalSealed(group, sealed, theme) {
const portal = group.getObjectByName("dr-portal-up");
- if (!portal) return;
- const ring = portal.getObjectByName("dr-portal-ring");
- const disc = portal.getObjectByName("dr-portal-disc");
- if (ring) ring.material.color.setHex(sealed ? 5593702 : 3793151);
- if (disc) {
- disc.material.color.setHex(sealed ? 3752016 : theme?.gemColor ?? 3793088);
- disc.material.opacity = sealed ? 0.35 : 0.75;
+ if (portal) {
+ const ring = portal.getObjectByName("dr-portal-ring");
+ const disc = portal.getObjectByName("dr-portal-disc");
+ if (ring) ring.material.color.setHex(sealed ? 5593702 : 3793151);
+ if (disc) {
+ disc.material.color.setHex(sealed ? 3752016 : theme?.gemColor ?? 3793088);
+ disc.material.opacity = sealed ? 0.35 : 0.75;
+ }
+ portal.userData.portal.sealed = sealed;
}
- portal.userData.portal.sealed = sealed;
const down = group.getObjectByName("dr-portal-down");
if (down) {
down.getObjectByName("dr-portal-ring")?.material.color.setHex(3776767);
down.userData.portal.sealed = false;
}
}
-function animateFloor(THREE, group, collected, time) {
+function animateOverlay(THREE, group, collected, time) {
const data = group.userData._dr;
if (!data) return;
const gemMesh = group.getObjectByName("dr-gems");
@@ -1182,18 +145,14 @@ function animateFloor(THREE, group, collected, time) {
if (child.name === "dr-portal-up" || child.name === "dr-portal-down") {
const ring = child.getObjectByName("dr-portal-ring");
if (ring && child.userData.portal?.sealed === false) ring.rotation.z = time * 0.8;
- } else if (child.name === "dr-light") {
- child.intensity = 5 + Math.sin(time * 9 + child.position.x * 3.7) * 0.9 + Math.sin(time * 23 + child.position.z * 5.1) * 0.5;
}
});
}
// modules/dungeon-realms/src/gui.js
var Z = 900;
-var GEM_SVG = '';
var root = null;
var menuCard = null;
-var hudBox = null;
var selectedIndex = 0;
var menuButtons = [];
var menuAction = null;
@@ -1278,42 +237,6 @@ function hideMenu() {
keyHandler = null;
}
}
-var CORNERS = {
- "top-left": "left:16px;top:64px;",
- "top-right": "right:16px;top:64px;text-align:right;",
- "bottom-left": "left:16px;bottom:70px;",
- "bottom-right": "right:16px;bottom:70px;text-align:right;"
-};
-function showHud(model) {
- ensureRoot();
- if (!hudBox) {
- hudBox = document.createElement("div");
- hudBox.id = "dr-hud";
- root.appendChild(hudBox);
- }
- const corner = CORNERS[model.corner ?? "top-left"] ?? CORNERS["top-left"];
- hudBox.style.cssText = "position:absolute;" + corner + "pointer-events:none;color:#e5e7eb;text-shadow:0 1px 3px rgba(0,0,0,.9);font-size:14px;line-height:1.7;";
- const show = model.show ?? { gems: true, level: true, players: true, objective: true };
- let html = "";
- if (show.gems && model.gems)
- html += '
' + GEM_SVG + ' ' + model.gems.have + ' / ' + model.gems.need + " needed · " + model.gems.total + " hidden
";
- if (show.level && model.level)
- html += 'LEVEL ' + model.level.k + " / " + model.level.n + " · " + model.level.name + "
";
- if (show.players && model.players?.length)
- html += '' + model.players.map(
- (p) => '' + p.slot.toUpperCase() + " " + p.name + (p.me ? " (you)" : "") + ""
- ).join("") + "
";
- (model.extraProps ?? []).forEach((prop) => {
- html += '' + prop.name + ": " + prop.value + "
";
- });
- if (show.objective && model.objective)
- html += '' + model.objective + "
";
- hudBox.innerHTML = html;
-}
-function hideHud() {
- hudBox?.remove();
- hudBox = null;
-}
// modules/dungeon-realms/src/audio.js
var ctx = null;
@@ -1381,37 +304,20 @@ function winFanfare() {
}
// modules/dungeon-realms/src/game.js
-var GROUP_NAME = "dungeon-module";
-var DEFAULT_RULES = {
- gemShare: 0.7,
- pickupRadius: 0.9,
- allPlayersPortal: true,
- disableFlight: true
-};
-var DEFAULT_MENU = {
- show: "auto",
- button1: "join-p1",
- button2: "join-p2",
- button3: "start",
- button4: "new-dungeon"
-};
-var DEFAULT_HUD = { showGems: true, showLevel: true, showPlayers: true, showObjective: true, corner: "top-left" };
+var GROUP_NAME = "dungeon-realms";
+var KIT_GROUP = "dungeon-module";
function createGame(api) {
const THREE = api.THREE;
const state = {
+ /** the Kit world this rule state belongs to (observed) */
seed: (
/** @type {number | null} */
null
),
- params: (
- /** @type {any} */
- {}
- ),
- campaign: (
- /** @type {any} */
- null
- ),
+ campaignChecksum: 0,
+ checksum: 0,
floorIndex: 1,
+ levelCount: 0,
/** @type {Record>} floor -> collected gem indices */
collected: {},
/** @type {Record} */
@@ -1425,23 +331,53 @@ function createGame(api) {
combo: 0,
lastGemAt: 0,
/** @type {Record} custom prop counters (drprop nodes) */
- propValues: {}
+ propValues: {},
+ /** a late-join state waiting for the Kit to show its seed */
+ wanted: (
+ /** @type {any} */
+ null
+ ),
+ _wasSealed: (
+ /** @type {boolean | undefined} */
+ void 0
+ ),
+ _menuSuppressed: false
};
const config = {
rules: { ...DEFAULT_RULES },
menu: { ...DEFAULT_MENU },
- hud: { ...DEFAULT_HUD },
/** @type {Record} */
props: {}
};
let guiDirty = true;
let playingNow = false;
+ let groundedSent = (
+ /** @type {boolean | null} */
+ null
+ );
+ let eventSink = null;
const me = () => api.peerId() ?? "me";
const shortName = (peerId) => peerId === me() ? "you" : String(peerId).slice(0, 6);
- const currentFloor = () => state.campaign?.floors[state.floorIndex - 1] ?? null;
const collectedSet = (floor = state.floorIndex) => state.collected[floor] ??= /* @__PURE__ */ new Set();
+ const kitGroup = () => api.scene()?.getObjectByName(KIT_GROUP) ?? null;
+ const kit = () => kitGroup()?.userData?.kit ?? null;
+ const play = () => kitGroup()?.userData?.play ?? null;
const group = () => api.scene()?.getObjectByName(GROUP_NAME) ?? null;
- function disposeGroup() {
+ const gemCount = (floor = state.floorIndex) => {
+ if (floor === state.floorIndex) return (play()?.props ?? []).filter((p) => p.kind === "gem").length;
+ const dungeon = kit()?.campaign?.()?.floors[floor - 1];
+ return dungeon ? dungeon.props.filter((p) => p.kind === "gem").length : 0;
+ };
+ function gemTotals2(floor = state.floorIndex) {
+ return gemTotals(gemCount(floor), collectedSet(floor).size, config.rules.gemShare);
+ }
+ const sealed = () => {
+ const { need, have } = gemTotals2();
+ return have < need;
+ };
+ const topFloor = () => state.floorIndex >= (state.levelCount || 1);
+ const players = () => ["p1", "p2"].filter((slot) => state.slots[slot]).length;
+ function disposeOverlay() {
const existing = group();
if (!existing) return;
existing.traverse((child) => {
@@ -1450,50 +386,16 @@ function createGame(api) {
});
existing.parent?.remove(existing);
}
- function gemTotals(floor = state.floorIndex) {
- const dungeon = state.campaign?.floors[floor - 1];
- if (!dungeon) return { total: 0, need: 0, have: 0 };
- const total = dungeon.props.filter((p) => p.kind === "gem").length;
- const need = Math.max(1, Math.ceil(total * config.rules.gemShare));
- return { total, need, have: Math.min(collectedSet(floor).size, total) };
+ function publishMarkers() {
+ kit()?.setMarkers?.(GROUP_NAME, minimapMarkers(play(), collectedSet()));
}
- const sealed = () => {
- const { need, have } = gemTotals();
- return have < need;
- };
- const topFloor = () => state.floorIndex >= (state.campaign?.floors.length ?? 1);
function rebuild() {
- disposeGroup();
+ disposeOverlay();
const scene = api.scene();
- const dungeon = currentFloor();
- if (!scene || !dungeon) return;
- const built = buildFloorGroup(THREE, dungeon, collectedSet());
+ const p = play();
+ if (!scene || !p) return;
+ const built = buildOverlay(THREE, p, collectedSet());
built.name = GROUP_NAME;
- const ordered = [...dungeon.rooms].sort((a, b) => {
- const ka = a.type === "entrance" ? -1 : a.depth;
- const kb = b.type === "entrance" ? -1 : b.depth;
- return ka - kb || a.id - b.id;
- }).map((room) => ({ x: room.x + dungeon.ox, y: room.y + dungeon.oy, w: room.w, h: room.h }));
- built.userData = {
- seed: state.seed,
- params: state.params,
- floorIndex: state.floorIndex,
- levelCount: state.campaign.floors.length,
- checksum: dungeon.checksum,
- campaignChecksum: state.campaign.checksum,
- name: dungeon.name,
- stats: dungeon.stats,
- _dr: built.userData._dr,
- play: {
- grid: dungeon.grid,
- width: dungeon.W,
- height: dungeon.H,
- minX: dungeon.ox,
- minY: dungeon.oy,
- rooms: ordered,
- floorValue: FLOOR
- }
- };
built.userData._dr.game = {
state,
config,
@@ -1502,24 +404,16 @@ function createGame(api) {
start: () => start(),
claimSlot: (slot) => claimSlot(slot),
menuAction: menuAction2,
- gemTotals
+ gemTotals: gemTotals2,
+ sealed,
+ objective
};
scene.add(built);
- setPortalSealed(built, sealed(), dungeon.theme);
+ setPortalSealed(built, sealed(), p.theme);
+ publishMarkers();
guiDirty = true;
}
- function generate(seed, params = {}, broadcast = true) {
- let campaign;
- try {
- campaign = generateCampaign(seed, params);
- } catch (error) {
- api.toast("Dungeon generation failed: " + error.message);
- return false;
- }
- state.seed = seed >>> 0;
- state.params = params;
- state.campaign = campaign;
- state.floorIndex = 1;
+ function resetForWorld() {
state.collected = {};
state.started = false;
state.wonAt = 0;
@@ -1528,15 +422,68 @@ function createGame(api) {
state.combo = 0;
state._wasSealed = void 0;
state._menuSuppressed = false;
+ }
+ function applyWanted(remote) {
+ state.collected = {};
+ Object.entries(remote.collected ?? {}).forEach(([floor, indices]) => {
+ state.collected[floor] = new Set(indices);
+ });
+ state.slots = { p1: null, p2: null, ...remote.slots ?? {} };
+ state.started = !!remote.started;
+ state.startedAt = remote.startedAt ?? 0;
+ state.wonAt = remote.wonAt ?? 0;
+ state.propValues = remote.propValues ?? {};
+ state._wasSealed = void 0;
+ }
+ function observe() {
+ const p = play();
+ if (!p) {
+ if (state.seed != null || group()) {
+ disposeOverlay();
+ state.seed = null;
+ state.levelCount = 0;
+ resetForWorld();
+ hideMenu();
+ guiDirty = true;
+ }
+ return;
+ }
+ const newWorld = p.seed !== state.seed || p.campaignChecksum !== state.campaignChecksum;
+ const newFloor = p.floorIndex !== state.floorIndex || p.checksum !== state.checksum;
+ if (!newWorld && !newFloor) {
+ if (!group()) rebuild();
+ return;
+ }
+ if (newWorld) {
+ state.seed = p.seed;
+ state.campaignChecksum = p.campaignChecksum;
+ state.levelCount = p.levelCount;
+ if (state.wanted && state.wanted.seed === p.seed) {
+ applyWanted(state.wanted);
+ if (state.wanted.floorIndex && state.wanted.floorIndex !== p.floorIndex) kit()?.showFloor(state.wanted.floorIndex, { broadcast: false });
+ state.wanted = null;
+ } else resetForWorld();
+ } else {
+ state.onPortal = {};
+ state.myOnPortal = false;
+ state._wasSealed = void 0;
+ portalWhoosh();
+ api.toast("LEVEL " + p.floorIndex + " / " + p.levelCount + " \u2014 " + p.name);
+ }
+ state.floorIndex = p.floorIndex;
+ state.checksum = p.checksum;
rebuild();
- if (broadcast) api.send({ op: "generate", seed: state.seed, params, checksum: campaign.checksum });
- return true;
+ guiDirty = true;
}
- function applyRemoteGenerate(data) {
- if (data.seed === state.seed && JSON.stringify(data.params ?? {}) === JSON.stringify(state.params) && state.campaign) return;
- if (!generate(data.seed, data.params ?? {}, false)) return;
- if (data.checksum && data.checksum !== state.campaign.checksum)
- api.toast("Dungeon checksum differs from the sender \u2014 versions may not match");
+ function newDungeon(seed) {
+ const k = kit();
+ if (!k) {
+ api.toast('Dungeon Realms needs the Dungeon Kit module \u2014 install "dungeon" first');
+ return false;
+ }
+ const ok = k.generate(seed, k.state().params);
+ if (ok) eventSink?.("reset");
+ return ok;
}
function collectGem(floor, index, broadcast = true) {
const set = collectedSet(floor);
@@ -1550,36 +497,34 @@ function createGame(api) {
if (wasSealed && !nowSealed && !topFloor()) {
sealBreak();
api.toast("The portal unseals!");
- setPortalSealed(g, false, currentFloor()?.theme);
+ setPortalSealed(g, false, play()?.theme);
+ if (broadcast) eventSink?.("unseal");
}
state._wasSealed = nowSealed;
+ publishMarkers();
}
if (broadcast) {
- const now2 = api.now();
- state.combo = now2 - state.lastGemAt < 4 ? state.combo + 1 : 0;
- state.lastGemAt = now2;
+ const now = api.now();
+ state.combo = now - state.lastGemAt < 4 ? state.combo + 1 : 0;
+ state.lastGemAt = now;
gemChime(state.combo);
api.send({ op: "gem", floor, index });
+ eventSink?.("gem");
}
guiDirty = true;
if (floor === state.floorIndex && topFloor() && state.started && !state.wonAt && !sealed()) {
state.wonAt = api.now();
winFanfare();
+ if (broadcast) eventSink?.("victory");
guiDirty = true;
}
}
- function travel(target, broadcast = true) {
- const levels = state.campaign?.floors.length ?? 0;
- if (!levels || target < 1 || target > levels || target === state.floorIndex) return;
- state.floorIndex = target;
- state.onPortal = {};
- state.myOnPortal = false;
- state._wasSealed = void 0;
- rebuild();
- portalWhoosh();
- const dungeon = currentFloor();
- api.toast("LEVEL " + target + " / " + levels + " \u2014 " + dungeon.name);
- if (broadcast) api.send({ op: "floor", floorIndex: target });
+ function travel(target) {
+ const k = kit();
+ if (!k || target === state.floorIndex) return false;
+ const ok = k.showFloor(target);
+ if (ok) eventSink?.("travel");
+ return ok;
}
function claimSlot(slot, broadcast = true, peerId = me()) {
const mineAlready = state.slots[slot]?.peerId === peerId;
@@ -1598,13 +543,16 @@ function createGame(api) {
guiDirty = true;
}
function start(broadcast = true) {
- if (!state.campaign || state.started) return;
+ if (state.seed == null || state.started) return;
state.started = true;
state.startedAt = api.now();
state.wonAt = 0;
startThump();
guiDirty = true;
- if (broadcast) api.send({ op: "start" });
+ if (broadcast) {
+ api.send({ op: "start" });
+ eventSink?.("start");
+ }
}
function reset(broadcast = true) {
state.started = false;
@@ -1618,16 +566,14 @@ function createGame(api) {
if (broadcast) api.send({ op: "prop", name, value: state.propValues[name] });
}
function clear() {
- disposeGroup();
- state.campaign = null;
+ disposeOverlay();
state.seed = null;
- state.started = false;
- state.wonAt = 0;
- state.collected = {};
- state.onPortal = {};
+ state.levelCount = 0;
+ resetForWorld();
state.propValues = {};
+ state.wanted = null;
hideMenu();
- hideHud();
+ guiDirty = true;
}
function menuAction2(id) {
if (id === "join-p1") claimSlot("p1");
@@ -1636,13 +582,9 @@ function createGame(api) {
else if (id === "resume") {
state._menuSuppressed = true;
guiDirty = true;
- } else if (id === "new-dungeon" || id === "play-again") {
+ } else if (id === "new-dungeon" || id === "play-again" || id === "generate") {
const seed = hash32(api.now() * 1e3 | 0, "dice") % 1e5;
- generate(seed, state.params ?? {});
- api.toast("New dungeon \u2014 seed " + seed);
- } else if (id === "generate") {
- const seed = hash32(api.now() * 1e3 | 0, "dice") % 1e5;
- generate(seed, {});
+ if (newDungeon(seed)) api.toast("New dungeon \u2014 seed " + seed);
}
guiDirty = true;
}
@@ -1658,7 +600,7 @@ function createGame(api) {
case "join-p2":
return { id: action, label: slotLabel("p2", "Join as Player 2") };
case "start":
- return { id: action, label: "Start adventure", disabled: !state.campaign };
+ return { id: action, label: "Start adventure", disabled: state.seed == null };
case "resume":
return { id: action, label: "Resume" };
case "new-dungeon":
@@ -1669,10 +611,23 @@ function createGame(api) {
return null;
}
}
+ function objective() {
+ const { need, have } = gemTotals2();
+ return objectiveText({
+ won: !!state.wonAt,
+ sealed: sealed(),
+ need,
+ have,
+ topFloor: topFloor(),
+ allPlayersPortal: config.rules.allPlayersPortal,
+ players: players()
+ });
+ }
function refreshGui() {
guiDirty = false;
const showMode = config.menu.show ?? "auto";
const menuWanted = playingNow && showMode !== "never" && (showMode === "always" || !state.started && !state._menuSuppressed || state.wonAt > 0);
+ const p = play();
if (menuWanted && state.wonAt) {
const seconds = Math.max(0, Math.round(state.wonAt - state.startedAt));
const totalGems = Object.values(state.collected).reduce((sum, set) => sum + set.size, 0);
@@ -1682,93 +637,81 @@ function createGame(api) {
lines: [
"Gems collected: " + totalGems,
"Time: " + Math.floor(seconds / 60) + "m " + seconds % 60 + "s",
- "Floors conquered: " + state.campaign.floors.length
+ "Floors conquered: " + state.levelCount
],
buttons: [buttonFor("play-again"), buttonFor("resume")].filter(Boolean),
onAction: menuAction2
});
} else if (menuWanted) {
- const dungeon = currentFloor();
- const { total } = gemTotals();
- const buttons = state.campaign ? [config.menu.button1, config.menu.button2, config.menu.button3, config.menu.button4].map(buttonFor).filter(Boolean) : [{ id: "generate", label: "Generate a dungeon \u{1F3B2}" }];
+ const { total } = gemTotals2();
+ const buttons = p ? [config.menu.button1, config.menu.button2, config.menu.button3, config.menu.button4].map(buttonFor).filter(Boolean) : [{ id: "generate", label: "Generate a dungeon \u{1F3B2}" }];
if (state.started && !buttons.some((b) => b.id === "resume")) buttons.push(buttonFor("resume"));
showMenu({
- title: dungeon ? dungeon.name : "Dungeon Realms",
- subtitle: dungeon ? "LEVEL " + state.floorIndex + " / " + state.campaign.floors.length + " \xB7 " + dungeon.rooms.length + " rooms \xB7 " + total + " gems hidden" : "Co-op gem hunt \xB7 collect gems, unseal portals, reach the top",
+ title: p ? p.name : "Dungeon Realms",
+ subtitle: p ? "LEVEL " + state.floorIndex + " / " + state.levelCount + " \xB7 " + p.rooms.length + " rooms \xB7 " + total + " gems hidden" : kit() ? "Co-op gem hunt \xB7 collect gems, unseal portals, reach the top" : 'Install the Dungeon Kit module (id "dungeon") to generate a world',
buttons,
onAction: menuAction2
});
} else hideMenu();
- const hudWanted = playingNow && state.started && state.campaign;
- if (hudWanted) {
- const dungeon = currentFloor();
- const { total, need, have } = gemTotals();
- const players = ["p1", "p2"].filter((slot) => state.slots[slot]).map((slot) => ({ slot, name: state.slots[slot].name, me: state.slots[slot].peerId === me() }));
- const extraProps = Object.entries(config.props).filter(([, def]) => def.showInHud).map(([name, def]) => ({ name, value: state.propValues[name] ?? def.initial ?? 0 }));
- const objective = state.wonAt ? "Victory! Press Esc to leave play mode." : sealed() ? "Collect " + (need - have) + " more gem" + (need - have === 1 ? "" : "s") + (topFloor() ? " to claim the dragon\u2019s hoard" : " to unseal the portal") : topFloor() ? "The hoard is yours!" : config.rules.allPlayersPortal && players.length > 1 ? "Portal unsealed \u2014 stand on it together!" : "Portal unsealed \u2014 step through!";
- showHud({
- gems: { have, need, total },
- level: { k: state.floorIndex, n: state.campaign.floors.length, name: dungeon.name },
- players,
- objective,
- extraProps,
- corner: config.hud.corner,
- show: {
- gems: config.hud.showGems,
- level: config.hud.showLevel,
- players: config.hud.showPlayers,
- objective: config.hud.showObjective
- }
- });
- } else hideHud();
}
- function tick(time) {
+ function isPlaying() {
+ if (typeof api.isPlaying === "function") return !!api.isPlaying();
const minimap = typeof document !== "undefined" ? document.getElementById("dungeon-minimap") : null;
- const playing = !!minimap && !minimap.classList.contains("hidden");
+ return !!minimap && !minimap.classList.contains("hidden");
+ }
+ function playerXZ() {
+ if (typeof api.playerPosition === "function") {
+ const p = api.playerPosition();
+ if (p) return { x: p[0], y: p[1], z: p[2] };
+ }
+ const origin = api.pointerRay()?.ray?.origin;
+ return origin ? { x: origin.x, y: origin.y, z: origin.z } : null;
+ }
+ function tick(time) {
+ observe();
+ const playing = isPlaying();
if (playing !== playingNow) {
playingNow = playing;
if (!playing) state._menuSuppressed = false;
guiDirty = true;
}
+ const grounded = !!config.rules.disableFlight;
+ if (grounded !== groundedSent && kit()) {
+ kit().setGrounded?.(grounded);
+ groundedSent = grounded;
+ }
const g = group();
- if (g) animateFloor(THREE, g, collectedSet(), time);
+ if (g) animateOverlay(THREE, g, collectedSet(), time);
if (playingNow && state.started && !state.wonAt && g) {
- const ray = api.pointerRay();
- const origin = ray?.ray?.origin;
- if (origin) {
+ const pos = playerXZ();
+ if (pos) {
const gems = g.userData._dr?.gemWorld ?? [];
const set = collectedSet();
+ const r2 = config.rules.pickupRadius * config.rules.pickupRadius;
for (const gem of gems) {
if (set.has(gem.index)) continue;
- const dx = origin.x - gem.x;
- const dz = origin.z - gem.z;
- if (dx * dx + dz * dz < config.rules.pickupRadius * config.rules.pickupRadius && Math.abs(origin.y - gem.y) < 2.6)
- collectGem(state.floorIndex, gem.index);
+ const dx = pos.x - gem.x;
+ const dz = pos.z - gem.z;
+ if (dx * dx + dz * dz < r2 && Math.abs(pos.y - gem.y) < 2.6) collectGem(state.floorIndex, gem.index);
}
const portal = g.getObjectByName("dr-portal-up");
if (portal && !sealed()) {
- const dx = origin.x - portal.position.x;
- const dz = origin.z - portal.position.z;
+ const dx = pos.x - portal.position.x;
+ const dz = pos.z - portal.position.z;
const on = dx * dx + dz * dz < 1.4 * 1.4;
if (on !== state.myOnPortal) {
state.myOnPortal = on;
state.onPortal[me()] = on;
api.send({ op: "onportal", peerId: me(), on });
}
- if (on) {
- const others = ["p1", "p2"].map((slot) => state.slots[slot]?.peerId).filter((peerId) => peerId && peerId !== me());
- const together = !config.rules.allPlayersPortal || others.every((peerId) => state.onPortal[peerId]);
- if (together) travel(state.floorIndex + 1);
- }
+ if (on && canTravelTogether(state.slots, state.onPortal, me(), config.rules.allPlayersPortal)) travel(state.floorIndex + 1);
}
}
}
if (guiDirty) refreshGui();
}
function handleMessage(data) {
- if (data.op === "generate") applyRemoteGenerate(data);
- else if (data.op === "floor") travel(data.floorIndex, false);
- else if (data.op === "gem") collectGem(data.floor, data.index, false);
+ if (data.op === "gem") collectGem(data.floor, data.index, false);
else if (data.op === "slot") applyRemoteSlot(data);
else if (data.op === "start") {
state.started = true;
@@ -1783,13 +726,12 @@ function createGame(api) {
} else if (data.op === "prop") {
state.propValues[data.name] = data.value;
guiDirty = true;
- } else if (data.op === "clear") clear();
+ }
}
function getState() {
if (state.seed == null) return null;
return {
seed: state.seed,
- params: state.params,
floorIndex: state.floorIndex,
collected: Object.fromEntries(Object.entries(state.collected).map(([floor, set]) => [floor, [...set]])),
slots: state.slots,
@@ -1801,24 +743,29 @@ function createGame(api) {
}
function applyState(remote) {
if (!remote || remote.seed == null) return;
- if (!generate(remote.seed, remote.params ?? {}, false)) return;
- Object.entries(remote.collected ?? {}).forEach(([floor, indices]) => {
- state.collected[floor] = new Set(indices);
- });
- state.slots = { p1: null, p2: null, ...remote.slots ?? {} };
- state.started = !!remote.started;
- state.startedAt = remote.startedAt ?? 0;
- state.wonAt = remote.wonAt ?? 0;
- state.propValues = remote.propValues ?? {};
- if (remote.floorIndex && remote.floorIndex !== state.floorIndex) travel(remote.floorIndex, false);
- else rebuild();
+ const p = play();
+ if (p && p.seed === remote.seed) {
+ state.seed = p.seed;
+ state.campaignChecksum = p.campaignChecksum;
+ state.levelCount = p.levelCount;
+ applyWanted(remote);
+ if (remote.floorIndex && remote.floorIndex !== p.floorIndex) kit()?.showFloor(remote.floorIndex, { broadcast: false });
+ else {
+ state.floorIndex = p.floorIndex;
+ state.checksum = p.checksum;
+ rebuild();
+ }
+ state.wanted = null;
+ } else state.wanted = remote;
guiDirty = true;
}
- const suppressFlight = () => playingNow && state.started && !state.wonAt && config.rules.disableFlight;
return {
state,
config,
- generate,
+ kit,
+ play,
+ group,
+ newDungeon,
collectGem,
travel,
claimSlot,
@@ -1831,34 +778,31 @@ function createGame(api) {
handleMessage,
getState,
applyState,
- gemTotals,
- suppressFlight,
- group,
- markGuiDirty: () => guiDirty = true
+ gemTotals: gemTotals2,
+ sealed,
+ topFloor,
+ players,
+ objective,
+ isPlaying,
+ markGuiDirty: () => guiDirty = true,
+ /** @param {(event: string) => void} fn */
+ onEvent: (fn) => eventSink = fn
};
}
// modules/dungeon-realms/src/nodes.js
var EXPIRE_FRAMES = 40;
+var EVENTS = ["start", "gem", "unseal", "travel", "victory", "reset"];
+var READS = ["gems", "need", "total", "level", "levels", "players", "started", "won", "sealed", "score"];
+var ROWS = ["objective", "players", "level", "gems", "all"];
function registerNodes(api, game) {
let frame = 0;
- const seen = { rules: -1, menu: -1, hud: -1 };
+ const seen = { rules: -1, menu: -1 };
const propSeen = {};
+ const MENU_OPTIONS = ["none", "join-p1", "join-p2", "start", "resume", "new-dungeon"];
api.registerNodeGroup({
group: "Dungeon Realms",
items: [
- {
- type: "drdungeon",
- label: "Dungeon",
- defaults: { seed: 7, roomCount: 0, levelCount: 5, gemDensity: 1, apply: false },
- params: [
- { key: "seed", kind: "range", min: 1, max: 9999, step: 1 },
- { key: "roomCount", kind: "range", min: 0, max: 60, step: 2 },
- { key: "levelCount", kind: "range", min: 1, max: 9, step: 1 },
- { key: "gemDensity", kind: "range", min: 0.25, max: 2, step: 0.05 },
- { key: "apply", kind: "toggle" }
- ]
- },
{
type: "drrules",
label: "Game Rules",
@@ -1876,22 +820,10 @@ function registerNodes(api, game) {
defaults: { ...DEFAULT_MENU },
params: [
{ key: "show", kind: "select", options: ["auto", "always", "never"] },
- { key: "button1", kind: "select", options: ["none", "join-p1", "join-p2", "start", "resume", "new-dungeon"] },
- { key: "button2", kind: "select", options: ["none", "join-p1", "join-p2", "start", "resume", "new-dungeon"] },
- { key: "button3", kind: "select", options: ["none", "join-p1", "join-p2", "start", "resume", "new-dungeon"] },
- { key: "button4", kind: "select", options: ["none", "join-p1", "join-p2", "start", "resume", "new-dungeon"] }
- ]
- },
- {
- type: "drhud",
- label: "Game HUD",
- defaults: { ...DEFAULT_HUD },
- params: [
- { key: "showGems", kind: "toggle" },
- { key: "showLevel", kind: "toggle" },
- { key: "showPlayers", kind: "toggle" },
- { key: "showObjective", kind: "toggle" },
- { key: "corner", kind: "select", options: ["top-left", "top-right", "bottom-left", "bottom-right"] }
+ { key: "button1", kind: "select", options: MENU_OPTIONS },
+ { key: "button2", kind: "select", options: MENU_OPTIONS },
+ { key: "button3", kind: "select", options: MENU_OPTIONS },
+ { key: "button4", kind: "select", options: MENU_OPTIONS }
]
},
{
@@ -1903,6 +835,27 @@ function registerNodes(api, game) {
{ key: "initial", kind: "range", min: 0, max: 100, step: 1 },
{ key: "showInHud", kind: "toggle" }
]
+ },
+ {
+ type: "drvalue",
+ label: "Realms Value",
+ defaults: { read: "gems" },
+ params: [{ key: "read", kind: "select", options: READS }]
+ },
+ {
+ type: "drrows",
+ label: "Realms HUD Rows",
+ defaults: { element: "", show: "objective" },
+ params: [
+ { key: "element", kind: "text", placeholder: "HUD list id", maxLength: 40 },
+ { key: "show", kind: "select", options: ROWS }
+ ]
+ },
+ {
+ type: "drevent",
+ label: "Realms Event",
+ defaults: { event: "start" },
+ params: [{ key: "event", kind: "select", options: EVENTS }]
}
]
});
@@ -1916,17 +869,6 @@ function registerNodes(api, game) {
}
if (changed) game.markGuiDirty();
};
- api.registerEffect("drdungeon", (object, base, data) => {
- if (!data.apply) return;
- const seed = Math.round(data.seed ?? 7);
- const params = {
- roomCount: Math.round(data.roomCount ?? 0) || 0,
- levelCount: Math.round(data.levelCount ?? 5),
- gemDensity: data.gemDensity ?? 1
- };
- if (game.state.seed !== seed || JSON.stringify(game.state.params) !== JSON.stringify(params))
- game.generate(seed, params, false);
- });
api.registerEffect("drrules", (object, base, data) => {
seen.rules = frame;
assign(game.config.rules, {
@@ -1946,16 +888,6 @@ function registerNodes(api, game) {
button4: data.button4 ?? DEFAULT_MENU.button4
});
});
- api.registerEffect("drhud", (object, base, data) => {
- seen.hud = frame;
- assign(game.config.hud, {
- showGems: !!(data.showGems ?? true),
- showLevel: !!(data.showLevel ?? true),
- showPlayers: !!(data.showPlayers ?? true),
- showObjective: !!(data.showObjective ?? true),
- corner: data.corner ?? "top-left"
- });
- });
api.registerEffect("drprop", (object, base, data) => {
const name = data.prop ?? "score";
propSeen[name] = frame;
@@ -1966,6 +898,67 @@ function registerNodes(api, game) {
game.markGuiDirty();
}
});
+ const lastRows = {};
+ api.registerEffect("drrows", (object, base, data, time, ctx2) => {
+ if (!api.hud?.rows) return;
+ const element = String(data.element ?? "").trim();
+ if (!element) return;
+ const rows = rowsFor(data.show ?? "objective");
+ const key = JSON.stringify(rows);
+ const id = ctx2?.id ?? element;
+ if (lastRows[id] === key) return;
+ lastRows[id] = key;
+ api.hud.rows(element, rows);
+ });
+ function rowsFor(show) {
+ const s = game.state;
+ const p = game.play();
+ const { total, need, have } = game.gemTotals();
+ const level = p ? "LEVEL " + s.floorIndex + " / " + s.levelCount + " \xB7 " + p.name : "no dungeon";
+ const gems = have + " / " + need + " needed \xB7 " + total + " hidden";
+ const players = ["p1", "p2"].filter((slot) => s.slots[slot]).map((slot) => slot.toUpperCase() + " " + s.slots[slot].name + (s.slots[slot].peerId === (api.peerId() ?? "me") ? " (you)" : ""));
+ const props = Object.entries(game.config.props).filter(([, def]) => def.showInHud).map(([name, def]) => name + ": " + (s.propValues[name] ?? def.initial ?? 0));
+ if (show === "objective") return p ? [game.objective()] : [];
+ if (show === "players") return players;
+ if (show === "level") return p ? [level] : [];
+ if (show === "gems") return p ? [gems, ...props] : [];
+ return p ? [gems, level, ...players, ...props, game.objective()] : [];
+ }
+ api.registerValueNode(
+ "drvalue",
+ (data) => {
+ const s = game.state;
+ const { total, need, have } = game.gemTotals();
+ switch (data?.read) {
+ case "need":
+ return need;
+ case "total":
+ return total;
+ case "level":
+ return s.seed == null ? 0 : s.floorIndex;
+ case "levels":
+ return s.levelCount;
+ case "players":
+ return game.players();
+ case "started":
+ return s.started ? 1 : 0;
+ case "won":
+ return s.wonAt ? 1 : 0;
+ case "sealed":
+ return s.seed != null && game.sealed() ? 1 : 0;
+ case "score":
+ return s.propValues.score ?? game.config.props.score?.initial ?? 0;
+ default:
+ return have;
+ }
+ },
+ { vtype: "number" }
+ );
+ api.registerValueNode("drevent", () => 0, { vtype: "event" });
+ game.onEvent((event) => {
+ if (typeof api.fireNodeTrigger !== "function") return;
+ api.fireNodeTrigger("drevent", (data) => (data?.event ?? "start") === event);
+ });
function tick() {
frame++;
if (seen.rules >= 0 && frame - seen.rules > EXPIRE_FRAMES) {
@@ -1976,10 +969,6 @@ function registerNodes(api, game) {
seen.menu = -1;
assign(game.config.menu, { ...DEFAULT_MENU });
}
- if (seen.hud >= 0 && frame - seen.hud > EXPIRE_FRAMES) {
- seen.hud = -1;
- assign(game.config.hud, { ...DEFAULT_HUD });
- }
for (const [name, at] of Object.entries(propSeen)) {
if (frame - at > EXPIRE_FRAMES) {
delete propSeen[name];
@@ -1988,15 +977,15 @@ function registerNodes(api, game) {
}
}
}
- return { tick };
+ return { tick, rowsFor };
}
// modules/dungeon-realms/src/index.js
var index_default = {
id: "dungeon-realms",
name: "Dungeon Realms",
- version: "1.0.0",
- description: "Co-op dungeon crawl: seeded multi-floor generator, gem-gated portals, P1/P2 play \u2014 every rule editable as flow nodes.",
+ version: "2.0.0",
+ description: 'Co-op dungeon crawl on the Dungeon Kit: gem-gated portals, P1/P2 play, travel-together floors \u2014 every rule and readout a flow node. Requires the "dungeon" (Dungeon Kit) module.',
/** @param {any} api the module SDK surface */
register(api) {
const game = createGame(api);
@@ -2005,12 +994,11 @@ var index_default = {
api.registerInteractiveGroup(GROUP_NAME);
api.registerMenu("Generate dungeon", () => {
const seed = hash32(api.now() * 1e3 | 0, "menu") % 1e5;
- if (game.generate(seed, game.state.params ?? {}))
- api.toast("Dungeon Realms seed " + seed + " \u2014 press the red Play button to start");
+ if (game.newDungeon(seed)) api.toast("Dungeon Realms seed " + seed + " \u2014 press the red Play button to start");
});
api.registerMenu("Clear dungeon", () => {
+ game.kit()?.clear();
game.clear();
- api.send({ op: "clear" });
});
api.registerClickHandler((mesh) => {
let cursor = mesh;
@@ -2050,22 +1038,20 @@ var index_default = {
nodes.tick();
game.tick(time);
});
- if (typeof window !== "undefined") {
- window.addEventListener(
- "keydown",
- (event) => {
- if ((event.code === "KeyQ" || event.code === "KeyE") && game.suppressFlight()) {
- event.preventDefault();
- event.stopImmediatePropagation();
- }
- },
- true
- );
- }
+ if (api.hud?.registerDebugLine)
+ api.hud.registerDebugLine(() => {
+ const s = game.state;
+ if (s.seed == null) return null;
+ const { have, need } = game.gemTotals();
+ return "realms seed " + s.seed + " floor " + s.floorIndex + "/" + s.levelCount + " gems " + have + "/" + need + (s.started ? " playing" : "");
+ });
api.registerBindings([
{ label: "Menu \u2014 select option", keys: "ArrowUp / ArrowDown" },
{ label: "Menu \u2014 confirm", keys: "Enter" }
]);
+ if (typeof window !== "undefined") {
+ window.__dungeonRealms = { game, nodes };
+ }
}
};
export {
diff --git a/modules/dungeon-realms/src/def.js b/modules/dungeon-realms/src/def.js
new file mode 100644
index 0000000..9e5f396
--- /dev/null
+++ b/modules/dungeon-realms/src/def.js
@@ -0,0 +1,219 @@
+// dungeon-realms — THE DEF (21-C C6-b), the shape core's scripts/author-templates.cjs
+// builds a game template from (kind, slug, modules, installModules, env, physics, post,
+// graphs, hud, objects, view, thumb). ONE source: the graph is the README's reference
+// graph made REAL, the HUD is the core HUD document the module publishes into, the
+// objects are the entrance arch — placed at the entrance room the SEED produces, which
+// the emitter (emit-def.mjs) computes with the Kit's own generator so the arch stands
+// where players spawn. Core reads `dungeon-realms.def.json` (emitted by
+// `npm run build:dungeon-realms`) the way it reads football's, and runs
+// `--only dungeon-realms` into a scratch folder the lane stages.
+//
+// TWO MODULES: `modules: [{id:'dungeon'}, {id:'dungeon-realms'}]` — the two-module case
+// the requirement list exists for. The Kit's Dungeon node IS the recipe: its replicated
+// data regenerates the identical campaign on every peer that loads the scene, and the
+// template broadcasts nothing (the derived requirement list picks both modules up from
+// the node types in the graph, so the authored and the derived lists agree).
+
+export const SEED = 1337;
+export const RECIPE = { seed: SEED, roomCount: 0, levelCount: 5, loopChance: 0.15, gemDensity: 1, apply: true };
+export const NAMES = { plinth: 'Entrance plinth', left: 'Arch left', right: 'Arch right', lintel: 'Arch lintel', lantern: 'Arch lantern', light: 'Arch light' };
+
+const PANEL = { bg: 'rgba(12, 16, 24, 0.9)', radius: 14, border: '1px solid rgba(103, 232, 249, 0.25)' };
+const BUTTON = (bg) => ({ size: 15, weight: '600', bg, color: '#ffffff', radius: 10 });
+
+/**
+ * The entrance arch + plinth in the replicated objectsGroup, centred on the entrance
+ * room the seed produces (players spawn there). @param {{x: number, z: number}} entrance
+ */
+export function archObjects(entrance) {
+ const { x, z } = entrance;
+ const stone = 0x2d3340;
+ const glow = 0x67e8f9;
+ return [
+ // the plinth: a flat slab the Object Selector targets (module effect nodes run wired)
+ { type: 'box', name: NAMES.plinth, color: 0x3a4150, roughness: 0.9, size: [2.8, 0.12, 2.8], pos: [x, 0.06, z] },
+ { type: 'box', name: NAMES.left, color: stone, roughness: 0.85, size: [0.36, 3, 0.36], pos: [x - 1.1, 1.5, z] },
+ { type: 'box', name: NAMES.right, color: stone, roughness: 0.85, size: [0.36, 3, 0.36], pos: [x + 1.1, 1.5, z] },
+ { type: 'box', name: NAMES.lintel, color: stone, roughness: 0.85, size: [2.6, 0.36, 0.5], pos: [x, 3.18, z] },
+ { type: 'sphere', name: NAMES.lantern, color: glow, emissive: glow, emissiveIntensity: 1.6, r: 0.16, pos: [x, 2.75, z], shadow: false },
+ { type: 'light', name: NAMES.light, color: glow, intensity: 6, distance: 9, pos: [x, 2.6, z] }
+ ];
+}
+
+/** the scene graph: recipe + rules + HUD readouts + game-shell events */
+export function realmsGraph() {
+ /** @type {any[]} */ const nodes = [];
+ /** @type {any[]} */ const edges = [];
+ const N = (id, type, label, x, y, data = {}) => nodes.push({ id, type, position: { x, y }, data: { label, ...data }, class: 'w-[150px]' });
+ const E = (source, target, handle) =>
+ edges.push({ id: 'e-' + source + '-' + target + (handle ? '.' + handle : ''), source, target, ...(handle ? { targetHandle: handle } : {}) });
+
+ let y = 40;
+ // ---- the recipe: a Number feeds the Dungeon node's seed; the node targets the plinth ----
+ N('seed', 'number', 'Seed', 40, y, { value: SEED, step: 1 });
+ N('dungeon', 'dkdungeon', 'Dungeon Kit', 280, y, { ...RECIPE });
+ N('selplinth', 'objectselector', 'Entrance plinth', 520, y, { selected: NAMES.plinth });
+ E('seed', 'dungeon', 'seed');
+ E('dungeon', 'selplinth');
+ y += 150;
+ // ---- the rules (rule ownership: the node data replicates, defaults return when removed) ----
+ N('rules', 'drrules', 'Game Rules', 280, y, { gemShare: 0.7, pickupRadius: 0.9, allPlayersPortal: true, disableFlight: true });
+ E('rules', 'selplinth');
+ y += 150;
+ N('menu', 'drmenu', 'Start Menu', 280, y, { show: 'auto', button1: 'join-p1', button2: 'join-p2', button3: 'start', button4: 'new-dungeon' });
+ E('menu', 'selplinth');
+ y += 150;
+ N('score', 'drprop', 'Prop Counter', 280, y, { prop: 'score', initial: 0, showInHud: true });
+ E('score', 'selplinth');
+ y += 150;
+ // ---- the HUD readouts: values into HUD Text, rows into HUD lists (bound by element id) ----
+ const readout = (key, read, element, format) => {
+ N('v' + key, 'drvalue', 'Realms: ' + read, 40, y, { read });
+ N('t' + key, 'hudtext', 'HUD ' + element, 280, y, { element, format, decimals: 0 });
+ E('v' + key, 't' + key, 'value');
+ y += 110;
+ };
+ readout('gems', 'gems', 'dr-gems', '{v}');
+ readout('need', 'need', 'dr-need', '/ {v} needed');
+ readout('level', 'level', 'dr-level', 'LEVEL {v}');
+ readout('levels', 'levels', 'dr-levels', '/ {v}');
+ readout('players', 'players', 'dr-player-count', '{v} in the party');
+ N('robjective', 'drrows', 'Objective line', 280, y, { element: 'dr-objective', show: 'objective' });
+ E('robjective', 'selplinth');
+ N('rplayers', 'drrows', 'Player pills', 520, y, { element: 'dr-players', show: 'players' });
+ E('rplayers', 'selplinth');
+ y += 150;
+ // ---- gems taken across the run: the gem EVENT into a Counter into a HUD Text ------------
+ N('evgem', 'drevent', 'On gem', 40, y, { event: 'gem' });
+ N('cgems', 'counter', 'Gems taken', 280, y, { op: 'up', step: 1 });
+ N('ttaken', 'hudtext', 'HUD dr-taken', 520, y, { element: 'dr-taken', format: '{v} gems taken', decimals: 0 });
+ E('evgem', 'cgems', 'trigger');
+ E('cgems', 'ttaken', 'value');
+ y += 150;
+ // ---- the game shell follows the module's events (Set Game State drives the screens) -------
+ N('evstart', 'drevent', 'On start', 40, y, { event: 'start' });
+ N('gostart', 'setgamestate', 'Realms: playing', 280, y, { state: 'playing', outcome: '', reset: false });
+ E('evstart', 'gostart', 'trigger');
+ y += 110;
+ N('evwin', 'drevent', 'On victory', 40, y, { event: 'victory' });
+ N('goover', 'setgamestate', 'Realms: over', 280, y, { state: 'over', outcome: 'The hoard is yours', reset: false });
+ E('evwin', 'goover', 'trigger');
+ y += 110;
+ N('evreset', 'drevent', 'On new dungeon', 40, y, { event: 'reset' });
+ N('gomenu', 'setgamestate', 'Realms: menu', 280, y, { state: 'menu', outcome: '', reset: true });
+ E('evreset', 'gomenu', 'trigger');
+ y += 150;
+ // ---- pause (P) — the Towers / Football shape ---------------------------------------------
+ N('pkey', 'keypress', 'Press P', 40, y, { code: 'KeyP', edge: 'down', pulse: 0.3 });
+ N('pausetoggle', 'hudscreen', 'Toggle pause menu', 280, y, { screen: 'pause', action: 'toggle' });
+ E('pkey', 'pausetoggle', 'trigger');
+ y += 110;
+ N('bresume', 'hudbutton', 'Resume button', 40, y, { element: 'resume-btn' });
+ N('resumehide', 'hudscreen', 'Close pause menu', 280, y, { screen: 'pause', action: 'hide' });
+ E('bresume', 'resumehide', 'trigger');
+ y += 110;
+ N('bquit', 'hudbutton', 'Quit to menu button', 40, y, { element: 'quit-btn' });
+ N('doquit', 'setgamestate', 'Quit to menu', 280, y, { state: 'menu', outcome: '', reset: true });
+ N('quithide', 'hudscreen', 'Close pause on quit', 520, y, { screen: 'pause', action: 'hide' });
+ E('bquit', 'doquit', 'trigger');
+ E('bquit', 'quithide', 'trigger');
+ return { nodes, edges };
+}
+
+/** the def's `hud` field — the DOM HUD the desktop peer and the editor see */
+export function realmsHud() {
+ return {
+ scene: {
+ active: '',
+ changedAt: 0,
+ screens: [
+ {
+ id: 'menu',
+ name: 'Menu',
+ showWhile: 'menu',
+ input: 'game',
+ elements: [
+ { id: 'menu-panel', kind: 'panel', anchor: 'top-center', x: 0, y: 16, w: 520, h: 96, z: 0, label: '', style: PANEL },
+ { id: 'title', kind: 'text', anchor: 'top-center', x: 0, y: 26, w: 480, h: 40, z: 1, label: 'DUNGEON REALMS', style: { size: 28, weight: '700', color: '#67e8f9', align: 'center' } },
+ { id: 'subtitle', kind: 'text', anchor: 'top-center', x: 0, y: 66, w: 500, h: 40, z: 1, label: 'Press ▶ Play. In the start menu: ↑↓ + Enter — join as Player 1 / 2, then Start. Collect gems, unseal the portal, stand on it together.', style: { size: 12, color: '#d8dee9', align: 'center' }, wrap: true }
+ ]
+ },
+ {
+ id: 'hud',
+ name: 'HUD',
+ showWhile: 'playing',
+ input: 'game',
+ elements: [
+ { id: 'hud-panel', kind: 'panel', anchor: 'top-left', x: 16, y: 64, w: 300, h: 150, z: 0, label: '', style: PANEL },
+ { id: 'dr-gems', kind: 'text', anchor: 'top-left', x: 28, y: 72, w: 60, h: 34, z: 1, label: '0', style: { size: 26, weight: '700', color: '#39e0c0', align: 'left' } },
+ { id: 'dr-need', kind: 'text', anchor: 'top-left', x: 88, y: 82, w: 220, h: 22, z: 1, label: '/ 0 needed', style: { size: 13, color: '#94a3b8', align: 'left' } },
+ { id: 'dr-level', kind: 'text', anchor: 'top-left', x: 28, y: 110, w: 110, h: 20, z: 1, label: 'LEVEL 1', style: { size: 12, weight: '600', color: '#a5b4fc', align: 'left' } },
+ { id: 'dr-levels', kind: 'text', anchor: 'top-left', x: 118, y: 110, w: 60, h: 20, z: 1, label: '/ 5', style: { size: 12, color: '#a5b4fc', align: 'left' } },
+ { id: 'dr-player-count', kind: 'text', anchor: 'top-left', x: 180, y: 110, w: 130, h: 20, z: 1, label: '0 in the party', style: { size: 12, color: '#94a3b8', align: 'left' } },
+ { id: 'dr-players', kind: 'list', anchor: 'top-left', x: 28, y: 134, w: 280, h: 34, z: 1, label: '', rows: [], style: { size: 11, color: '#c8d0dc', align: 'left' } },
+ { id: 'dr-objective', kind: 'list', anchor: 'top-left', x: 28, y: 170, w: 280, h: 36, z: 1, label: '', rows: [], style: { size: 12, color: '#86efac', align: 'left' } },
+ { id: 'dr-taken', kind: 'text', anchor: 'top-right', x: 16, y: 64, w: 200, h: 24, z: 1, label: '0 gems taken', style: { size: 12, color: '#c8d0dc', align: 'right' } },
+ { id: 'play-hint', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 560, h: 20, z: 1, label: 'WASD walk · walk over gems · stand on the unsealed portal together · P pauses', style: { size: 11, color: '#8b97a8', align: 'center' } }
+ ]
+ },
+ {
+ id: 'pause',
+ name: 'Pause',
+ input: 'menu',
+ elements: [
+ { id: 'pause-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 380, h: 250, z: 0, label: '', style: PANEL },
+ { id: 'pause-title', kind: 'text', anchor: 'center', x: 0, y: -75, w: 340, h: 36, z: 1, label: 'PAUSED', style: { size: 26, weight: '700', color: '#e5e9f0', align: 'center' } },
+ { id: 'resume-btn', kind: 'button', anchor: 'center', x: 0, y: -10, w: 240, h: 42, z: 1, label: 'Resume', enabled: true, style: BUTTON('#0ea5e9') },
+ { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 42, w: 240, h: 42, z: 1, label: 'Quit to menu', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } }
+ ]
+ },
+ {
+ id: 'over',
+ name: 'Victory',
+ showWhile: 'over',
+ input: 'game',
+ elements: [
+ { id: 'over-panel', kind: 'panel', anchor: 'top-center', x: 0, y: 16, w: 480, h: 96, z: 0, label: '', style: PANEL },
+ { id: 'over-title', kind: 'text', anchor: 'top-center', x: 0, y: 26, w: 440, h: 40, z: 1, label: 'THE HOARD IS YOURS', style: { size: 26, weight: '700', color: '#ffd45e', align: 'center' } },
+ { id: 'dr-taken-over', kind: 'text', anchor: 'top-center', x: 0, y: 70, w: 440, h: 24, z: 1, label: 'Play again from the menu — a new dungeon every time', style: { size: 13, color: '#c8d0dc', align: 'center' } }
+ ]
+ }
+ ]
+ }
+ };
+}
+
+/** @param {{x: number, z: number}} entrance the entrance room centre (world) for SEED */
+export function realmsDef(entrance) {
+ return {
+ kind: 'game',
+ slug: 'dungeon-realms',
+ title: 'Dungeon Realms',
+ description:
+ 'Co-op dungeon crawl: a seeded five-floor dungeon, gems to collect, portals that unseal when enough are found, and a dragon’s hoard at the top. Join as Player 1 / 2, walk it together.',
+ license: 'CC0-1.0',
+ author: 'theprototype',
+ tags: ['co-op', 'procedural', 'vr', 'dungeon'],
+ modules: [{ id: 'dungeon', version: '2.0.0' }, { id: 'dungeon-realms', version: '2.0.0' }],
+ installModules: ['dungeon', 'dungeon-realms'],
+ // night + fog: torches and gems glow, the corridors read as depth
+ env: { preset: 'night', exposure: 1.15 },
+ // the dungeon is WALKED on its own raster: click interaction, grounded, no sim
+ physics: { play: { interaction: 'click', grounded: true, simOnPlay: false } },
+ post: {
+ enabled: true,
+ effects: [
+ { id: 'tone', kind: 'tonemapping', enabled: true, params: { mode: 'AGX' } },
+ { id: 'bloom', kind: 'bloom', enabled: true, params: { intensity: 0.8, luminanceThreshold: 0.7 } },
+ { id: 'aa', kind: 'smaa', enabled: true, params: {} }
+ ],
+ changedAt: 0
+ },
+ graphs: { scene: realmsGraph() },
+ hud: realmsHud(),
+ objects: archObjects(entrance),
+ // the editor camera opens on the arch; the card renders the module's world too
+ view: { pos: [entrance.x + 9, 7, entrance.z + 11], target: [entrance.x, 1, entrance.z] },
+ thumb: { sceneGroups: ['dungeon-module'] }
+ };
+}
diff --git a/modules/dungeon-realms/src/game.js b/modules/dungeon-realms/src/game.js
index d7aad51..2d09ffc 100644
--- a/modules/dungeon-realms/src/game.js
+++ b/modules/dungeon-realms/src/game.js
@@ -1,45 +1,37 @@
-// The game layer: replication (deterministic model — only {seed, params,
-// floorIndex} + discrete events travel), the co-op rules (P1/P2 slots,
-// gem-gated portals, travel-together), GUI state machine and the frame tick.
+// The game layer: the co-op rules (P1/P2 slots, gem-gated portals, travel-together),
+// the menu state machine and the frame tick — as an OVERLAY on the Dungeon Kit.
//
-// Movement is the app's own play mode (red Play button): WASD walking, wall
-// collision and per-peer spawn all come FREE from publishing userData.play in
-// the exact dungeonPlay.js shape. The group name MUST be 'dungeon-module' —
-// core resolves the play contract by that name (DEVX-REQUESTS #13).
-
-import { generateCampaign } from './gen/campaign.js';
-import { FLOOR } from './gen/dungeon.js';
-import { hash32 } from './gen/rng.js';
-import { buildFloorGroup, applyGems, setPortalSealed, animateFloor } from './render.js';
+// 21-C C6: the world is the Kit's. This module never generates or renders a level;
+// it reads the Kit's scene-root group ('dungeon-module') through api.scene() —
+// `userData.play` is the published contract (raster, rooms, props, portals, world
+// offsets), `userData.kit` the function seam (generate / showFloor / setMarkers /
+// setGrounded). Realms OBSERVES {seed, floorIndex} every frame and rebuilds its
+// overlay when the Kit's world changes, whoever changed it (this module's portal,
+// the Kit's toolbox, a Dungeon node, a late-join sync); it DRIVES travel by calling
+// kit.showFloor, which the Kit replicates itself. So what replicates here is only
+// the rule state: gem pickups, slots, start/reset, portal presence, prop counters.
+
+import { DEFAULT_RULES, DEFAULT_MENU, gemTotals as totals, objectiveText, minimapMarkers, canTravelTogether } from './rules.js';
+import { hash32 } from './hash.js';
+import { buildOverlay, applyGems, setPortalSealed, animateOverlay } from './overlay.js';
import * as gui from './gui.js';
import * as audio from './audio.js';
-export const GROUP_NAME = 'dungeon-module';
-
-export const DEFAULT_RULES = {
- gemShare: 0.7,
- pickupRadius: 0.9,
- allPlayersPortal: true,
- disableFlight: true
-};
-export const DEFAULT_MENU = {
- show: 'auto',
- button1: 'join-p1',
- button2: 'join-p2',
- button3: 'start',
- button4: 'new-dungeon'
-};
-export const DEFAULT_HUD = { showGems: true, showLevel: true, showPlayers: true, showObjective: true, corner: 'top-left' };
+export const GROUP_NAME = 'dungeon-realms';
+export const KIT_GROUP = 'dungeon-module';
+export { DEFAULT_RULES, DEFAULT_MENU };
/** @param {any} api the module SDK surface */
export function createGame(api) {
const THREE = api.THREE;
const state = {
+ /** the Kit world this rule state belongs to (observed) */
seed: /** @type {number | null} */ (null),
- params: /** @type {any} */ ({}),
- campaign: /** @type {any} */ (null),
+ campaignChecksum: 0,
+ checksum: 0,
floorIndex: 1,
+ levelCount: 0,
/** @type {Record>} floor -> collected gem indices */
collected: {},
/** @type {Record} */
@@ -53,29 +45,55 @@ export function createGame(api) {
combo: 0,
lastGemAt: 0,
/** @type {Record} custom prop counters (drprop nodes) */
- propValues: {}
+ propValues: {},
+ /** a late-join state waiting for the Kit to show its seed */
+ wanted: /** @type {any} */ (null),
+ _wasSealed: /** @type {boolean | undefined} */ (undefined),
+ _menuSuppressed: false
};
// live node-config overrides (nodes.js writes these; absent nodes = defaults)
const config = {
rules: { ...DEFAULT_RULES },
menu: { ...DEFAULT_MENU },
- hud: { ...DEFAULT_HUD },
/** @type {Record} */
props: {}
};
let guiDirty = true;
let playingNow = false;
+ let groundedSent = /** @type {boolean | null} */ (null);
+ /** @type {((event: string) => void) | null} nodes.js hooks the event node here */
+ let eventSink = null;
const me = () => api.peerId() ?? 'me';
const shortName = (peerId) => (peerId === me() ? 'you' : String(peerId).slice(0, 6));
- const currentFloor = () => state.campaign?.floors[state.floorIndex - 1] ?? null;
const collectedSet = (floor = state.floorIndex) => (state.collected[floor] ??= new Set());
+ // ---- the Kit, through the scene -------------------------------------------------
+ const kitGroup = () => api.scene()?.getObjectByName(KIT_GROUP) ?? null;
+ /** the Kit's function seam, or null when the Kit is not installed */
+ const kit = () => kitGroup()?.userData?.kit ?? null;
+ /** the Kit's published contract, or null when no dungeon exists */
+ const play = () => kitGroup()?.userData?.play ?? null;
const group = () => api.scene()?.getObjectByName(GROUP_NAME) ?? null;
- function disposeGroup() {
+ const gemCount = (floor = state.floorIndex) => {
+ if (floor === state.floorIndex) return (play()?.props ?? []).filter((p) => p.kind === 'gem').length;
+ const dungeon = kit()?.campaign?.()?.floors[floor - 1];
+ return dungeon ? dungeon.props.filter((p) => p.kind === 'gem').length : 0;
+ };
+ function gemTotals(floor = state.floorIndex) {
+ return totals(gemCount(floor), collectedSet(floor).size, config.rules.gemShare);
+ }
+ const sealed = () => {
+ const { need, have } = gemTotals();
+ return have < need;
+ };
+ const topFloor = () => state.floorIndex >= (state.levelCount || 1);
+ const players = () => ['p1', 'p2'].filter((slot) => state.slots[slot]).length;
+
+ function disposeOverlay() {
const existing = group();
if (!existing) return;
existing.traverse((child) => {
@@ -85,60 +103,21 @@ export function createGame(api) {
existing.parent?.remove(existing);
}
- function gemTotals(floor = state.floorIndex) {
- const dungeon = state.campaign?.floors[floor - 1];
- if (!dungeon) return { total: 0, need: 0, have: 0 };
- const total = dungeon.props.filter((p) => p.kind === 'gem').length;
- const need = Math.max(1, Math.ceil(total * config.rules.gemShare));
- return { total, need, have: Math.min(collectedSet(floor).size, total) };
+ /** the minimap half of the seam: uncollected gems + portals as markers */
+ function publishMarkers() {
+ kit()?.setMarkers?.(GROUP_NAME, minimapMarkers(play(), collectedSet()));
}
- const sealed = () => {
- const { need, have } = gemTotals();
- return have < need;
- };
- const topFloor = () => state.floorIndex >= (state.campaign?.floors.length ?? 1);
-
- /** (Re)build the current floor's meshes + play contract. */
+ /** (Re)build the overlay for the Kit's CURRENT floor. */
function rebuild() {
- disposeGroup();
+ disposeOverlay();
const scene = api.scene();
- const dungeon = currentFloor();
- if (!scene || !dungeon) return;
- const built = buildFloorGroup(THREE, dungeon, collectedSet());
+ const p = play();
+ if (!scene || !p) return;
+ const built = buildOverlay(THREE, p, collectedSet());
built.name = GROUP_NAME;
-
- // play contract — EXACT dungeonPlay.js shape; rooms ordered entrance-first
- // then by depth so both players spawn at the dungeon start together
- const ordered = [...dungeon.rooms]
- .sort((a, b) => {
- const ka = a.type === 'entrance' ? -1 : a.depth;
- const kb = b.type === 'entrance' ? -1 : b.depth;
- return ka - kb || a.id - b.id;
- })
- .map((room) => ({ x: room.x + dungeon.ox, y: room.y + dungeon.oy, w: room.w, h: room.h }));
- built.userData = {
- seed: state.seed,
- params: state.params,
- floorIndex: state.floorIndex,
- levelCount: state.campaign.floors.length,
- checksum: dungeon.checksum,
- campaignChecksum: state.campaign.checksum,
- name: dungeon.name,
- stats: dungeon.stats,
- _dr: built.userData._dr,
- play: {
- grid: dungeon.grid,
- width: dungeon.W,
- height: dungeon.H,
- minX: dungeon.ox,
- minY: dungeon.oy,
- rooms: ordered,
- floorValue: FLOOR
- }
- };
- // debug/test hook (scene-root local, never serialized) — the flight drives
- // the game through this instead of reaching into module scope
+ // debug/test hook (scene-root local, never serialized) — the flight drives the
+ // game through this instead of reaching into module scope
built.userData._dr.game = {
state,
config,
@@ -147,28 +126,18 @@ export function createGame(api) {
start: () => start(),
claimSlot: (slot) => claimSlot(slot),
menuAction,
- gemTotals
+ gemTotals,
+ sealed,
+ objective
};
scene.add(built);
- setPortalSealed(built, sealed(), dungeon.theme);
+ setPortalSealed(built, sealed(), p.theme);
+ publishMarkers();
guiDirty = true;
}
- // ---- actions (each: apply locally, optionally broadcast; receivers never
- // re-send — the applier is shared by both paths) -----------------------------
-
- function generate(seed, params = {}, broadcast = true) {
- let campaign;
- try {
- campaign = generateCampaign(seed, params);
- } catch (error) {
- api.toast('Dungeon generation failed: ' + error.message);
- return false;
- }
- state.seed = seed >>> 0;
- state.params = params;
- state.campaign = campaign;
- state.floorIndex = 1;
+ /** reset the rule state for a NEW world (keeps the slots — the players are still here) */
+ function resetForWorld() {
state.collected = {};
state.started = false;
state.wonAt = 0;
@@ -177,16 +146,80 @@ export function createGame(api) {
state.combo = 0;
state._wasSealed = undefined;
state._menuSuppressed = false;
+ }
+
+ /** apply a late-join state now that the Kit shows its seed @param {any} remote */
+ function applyWanted(remote) {
+ state.collected = {};
+ Object.entries(remote.collected ?? {}).forEach(([floor, indices]) => {
+ state.collected[floor] = new Set(indices);
+ });
+ state.slots = { p1: null, p2: null, ...(remote.slots ?? {}) };
+ state.started = !!remote.started;
+ state.startedAt = remote.startedAt ?? 0;
+ state.wonAt = remote.wonAt ?? 0;
+ state.propValues = remote.propValues ?? {};
+ state._wasSealed = undefined;
+ }
+
+ /**
+ * Every frame: follow the Kit. A new seed = a new game (unless a late-join state
+ * for exactly that seed is waiting); a new floor = rebuild the overlay there.
+ */
+ function observe() {
+ const p = play();
+ if (!p) {
+ if (state.seed != null || group()) {
+ disposeOverlay();
+ state.seed = null;
+ state.levelCount = 0;
+ resetForWorld();
+ gui.hideMenu();
+ guiDirty = true;
+ }
+ return;
+ }
+ const newWorld = p.seed !== state.seed || p.campaignChecksum !== state.campaignChecksum;
+ const newFloor = p.floorIndex !== state.floorIndex || p.checksum !== state.checksum;
+ if (!newWorld && !newFloor) {
+ if (!group()) rebuild(); // the overlay was cleared under us (scene clear ordering)
+ return;
+ }
+ if (newWorld) {
+ state.seed = p.seed;
+ state.campaignChecksum = p.campaignChecksum;
+ state.levelCount = p.levelCount;
+ if (state.wanted && state.wanted.seed === p.seed) {
+ applyWanted(state.wanted);
+ if (state.wanted.floorIndex && state.wanted.floorIndex !== p.floorIndex) kit()?.showFloor(state.wanted.floorIndex, { broadcast: false });
+ state.wanted = null;
+ } else resetForWorld();
+ } else {
+ state.onPortal = {};
+ state.myOnPortal = false;
+ state._wasSealed = undefined;
+ audio.portalWhoosh();
+ api.toast('LEVEL ' + p.floorIndex + ' / ' + p.levelCount + ' — ' + p.name);
+ }
+ state.floorIndex = p.floorIndex;
+ state.checksum = p.checksum;
rebuild();
- if (broadcast) api.send({ op: 'generate', seed: state.seed, params, checksum: campaign.checksum });
- return true;
+ guiDirty = true;
}
- function applyRemoteGenerate(data) {
- if (data.seed === state.seed && JSON.stringify(data.params ?? {}) === JSON.stringify(state.params) && state.campaign) return;
- if (!generate(data.seed, data.params ?? {}, false)) return;
- if (data.checksum && data.checksum !== state.campaign.checksum)
- api.toast('Dungeon checksum differs from the sender — versions may not match');
+ // ---- actions (each: apply locally, optionally broadcast; receivers never
+ // re-send — the applier is shared by both paths) -----------------------------
+
+ /** ask the Kit for a new world (the Kit replicates it) @param {number} seed */
+ function newDungeon(seed) {
+ const k = kit();
+ if (!k) {
+ api.toast('Dungeon Realms needs the Dungeon Kit module — install "dungeon" first');
+ return false;
+ }
+ const ok = k.generate(seed, k.state().params);
+ if (ok) eventSink?.('reset');
+ return ok;
}
function collectGem(floor, index, broadcast = true) {
@@ -201,9 +234,11 @@ export function createGame(api) {
if (wasSealed && !nowSealed && !topFloor()) {
audio.sealBreak();
api.toast('The portal unseals!');
- setPortalSealed(g, false, currentFloor()?.theme);
+ setPortalSealed(g, false, play()?.theme);
+ if (broadcast) eventSink?.('unseal');
}
state._wasSealed = nowSealed;
+ publishMarkers();
}
if (broadcast) {
const now = api.now();
@@ -211,28 +246,25 @@ export function createGame(api) {
state.lastGemAt = now;
audio.gemChime(state.combo);
api.send({ op: 'gem', floor, index });
+ eventSink?.('gem');
}
guiDirty = true;
// victory: enough gems on the top floor
if (floor === state.floorIndex && topFloor() && state.started && !state.wonAt && !sealed()) {
state.wonAt = api.now();
audio.winFanfare();
+ if (broadcast) eventSink?.('victory');
guiDirty = true;
}
}
- function travel(target, broadcast = true) {
- const levels = state.campaign?.floors.length ?? 0;
- if (!levels || target < 1 || target > levels || target === state.floorIndex) return;
- state.floorIndex = target;
- state.onPortal = {};
- state.myOnPortal = false;
- state._wasSealed = undefined;
- rebuild();
- audio.portalWhoosh();
- const dungeon = currentFloor();
- api.toast('LEVEL ' + target + ' / ' + levels + ' — ' + dungeon.name);
- if (broadcast) api.send({ op: 'floor', floorIndex: target });
+ /** travel to floor `target` — the Kit shows it and replicates; observe() follows */
+ function travel(target) {
+ const k = kit();
+ if (!k || target === state.floorIndex) return false;
+ const ok = k.showFloor(target);
+ if (ok) eventSink?.('travel');
+ return ok;
}
function claimSlot(slot, broadcast = true, peerId = me()) {
@@ -255,13 +287,16 @@ export function createGame(api) {
}
function start(broadcast = true) {
- if (!state.campaign || state.started) return;
+ if (state.seed == null || state.started) return;
state.started = true;
state.startedAt = api.now();
state.wonAt = 0;
audio.startThump();
guiDirty = true;
- if (broadcast) api.send({ op: 'start' });
+ if (broadcast) {
+ api.send({ op: 'start' });
+ eventSink?.('start');
+ }
}
function reset(broadcast = true) {
@@ -277,17 +312,16 @@ export function createGame(api) {
if (broadcast) api.send({ op: 'prop', name, value: state.propValues[name] });
}
+ /** drop the overlay and the rule state (the Kit clears its own world) */
function clear() {
- disposeGroup();
- state.campaign = null;
+ disposeOverlay();
state.seed = null;
- state.started = false;
- state.wonAt = 0;
- state.collected = {};
- state.onPortal = {};
+ state.levelCount = 0;
+ resetForWorld();
state.propValues = {};
+ state.wanted = null;
gui.hideMenu();
- gui.hideHud();
+ guiDirty = true;
}
// ---- menu ------------------------------------------------------------------
@@ -299,13 +333,9 @@ export function createGame(api) {
else if (id === 'resume') {
state._menuSuppressed = true;
guiDirty = true;
- } else if (id === 'new-dungeon' || id === 'play-again') {
- const seed = hash32((api.now() * 1000) | 0, 'dice') % 100000;
- generate(seed, state.params ?? {});
- api.toast('New dungeon — seed ' + seed);
- } else if (id === 'generate') {
+ } else if (id === 'new-dungeon' || id === 'play-again' || id === 'generate') {
const seed = hash32((api.now() * 1000) | 0, 'dice') % 100000;
- generate(seed, {});
+ if (newDungeon(seed)) api.toast('New dungeon — seed ' + seed);
}
guiDirty = true;
}
@@ -320,7 +350,7 @@ export function createGame(api) {
switch (action) {
case 'join-p1': return { id: action, label: slotLabel('p1', 'Join as Player 1') };
case 'join-p2': return { id: action, label: slotLabel('p2', 'Join as Player 2') };
- case 'start': return { id: action, label: 'Start adventure', disabled: !state.campaign };
+ case 'start': return { id: action, label: 'Start adventure', disabled: state.seed == null };
case 'resume': return { id: action, label: 'Resume' };
case 'new-dungeon': return { id: action, label: 'New dungeon \u{1F3B2}' };
case 'play-again': return { id: action, label: 'Play again \u{1F3B2}' };
@@ -328,6 +358,20 @@ export function createGame(api) {
}
}
+ /** the objective line (also what the HUD rows node publishes) */
+ function objective() {
+ const { need, have } = gemTotals();
+ return objectiveText({
+ won: !!state.wonAt,
+ sealed: sealed(),
+ need,
+ have,
+ topFloor: topFloor(),
+ allPlayersPortal: config.rules.allPlayersPortal,
+ players: players()
+ });
+ }
+
function refreshGui() {
guiDirty = false;
const showMode = config.menu.show ?? 'auto';
@@ -335,6 +379,7 @@ export function createGame(api) {
playingNow &&
showMode !== 'never' &&
(showMode === 'always' || (!state.started && !state._menuSuppressed) || state.wonAt > 0);
+ const p = play();
if (menuWanted && state.wonAt) {
const seconds = Math.max(0, Math.round(state.wonAt - state.startedAt));
@@ -345,116 +390,95 @@ export function createGame(api) {
lines: [
'Gems collected: ' + totalGems,
'Time: ' + Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's',
- 'Floors conquered: ' + state.campaign.floors.length
+ 'Floors conquered: ' + state.levelCount
],
buttons: [buttonFor('play-again'), buttonFor('resume')].filter(Boolean),
onAction: menuAction
});
} else if (menuWanted) {
- const dungeon = currentFloor();
const { total } = gemTotals();
- const buttons = state.campaign
- ? [config.menu.button1, config.menu.button2, config.menu.button3, config.menu.button4]
- .map(buttonFor)
- .filter(Boolean)
+ const buttons = p
+ ? [config.menu.button1, config.menu.button2, config.menu.button3, config.menu.button4].map(buttonFor).filter(Boolean)
: [{ id: 'generate', label: 'Generate a dungeon \u{1F3B2}' }];
if (state.started && !buttons.some((b) => b.id === 'resume')) buttons.push(buttonFor('resume'));
gui.showMenu({
- title: dungeon ? dungeon.name : 'Dungeon Realms',
- subtitle: dungeon
- ? 'LEVEL ' + state.floorIndex + ' / ' + state.campaign.floors.length + ' · ' +
- dungeon.rooms.length + ' rooms · ' + total + ' gems hidden'
- : 'Co-op gem hunt · collect gems, unseal portals, reach the top',
+ title: p ? p.name : 'Dungeon Realms',
+ subtitle: p
+ ? 'LEVEL ' + state.floorIndex + ' / ' + state.levelCount + ' · ' + p.rooms.length + ' rooms · ' + total + ' gems hidden'
+ : kit()
+ ? 'Co-op gem hunt · collect gems, unseal portals, reach the top'
+ : 'Install the Dungeon Kit module (id "dungeon") to generate a world',
buttons,
onAction: menuAction
});
} else gui.hideMenu();
-
- const hudWanted = playingNow && state.started && state.campaign;
- if (hudWanted) {
- const dungeon = currentFloor();
- const { total, need, have } = gemTotals();
- const players = ['p1', 'p2']
- .filter((slot) => state.slots[slot])
- .map((slot) => ({ slot, name: state.slots[slot].name, me: state.slots[slot].peerId === me() }));
- const extraProps = Object.entries(config.props)
- .filter(([, def]) => def.showInHud)
- .map(([name, def]) => ({ name, value: state.propValues[name] ?? def.initial ?? 0 }));
- const objective = state.wonAt
- ? 'Victory! Press Esc to leave play mode.'
- : sealed()
- ? 'Collect ' + (need - have) + ' more gem' + (need - have === 1 ? '' : 's') +
- (topFloor() ? ' to claim the dragon’s hoard' : ' to unseal the portal')
- : topFloor()
- ? 'The hoard is yours!'
- : config.rules.allPlayersPortal && players.length > 1
- ? 'Portal unsealed — stand on it together!'
- : 'Portal unsealed — step through!';
- gui.showHud({
- gems: { have, need, total },
- level: { k: state.floorIndex, n: state.campaign.floors.length, name: dungeon.name },
- players,
- objective,
- extraProps,
- corner: config.hud.corner,
- show: {
- gems: config.hud.showGems,
- level: config.hud.showLevel,
- players: config.hud.showPlayers,
- objective: config.hud.showObjective
- }
- });
- } else gui.hideHud();
}
// ---- frame tick --------------------------------------------------------------
- function tick(time) {
- // play-mode signal: the core minimap is visible exactly while play mode is
- // engaged AND our play contract exists (no api.isPlaying() yet — DEVX #11)
+ /** play-mode signal: api.isPlaying() (DEVX #11), the minimap DOM on an older app */
+ function isPlaying() {
+ if (typeof api.isPlaying === 'function') return !!api.isPlaying();
const minimap = typeof document !== 'undefined' ? document.getElementById('dungeon-minimap') : null;
- const playing = !!minimap && !minimap.classList.contains('hidden');
+ return !!minimap && !minimap.classList.contains('hidden');
+ }
+
+ /** where the player stands: api.playerPosition() (R3a), the pointer ray origin before it */
+ function playerXZ() {
+ if (typeof api.playerPosition === 'function') {
+ const p = api.playerPosition();
+ if (p) return { x: p[0], y: p[1], z: p[2] };
+ }
+ const origin = api.pointerRay()?.ray?.origin;
+ return origin ? { x: origin.x, y: origin.y, z: origin.z } : null;
+ }
+
+ function tick(time) {
+ observe();
+
+ const playing = isPlaying();
if (playing !== playingNow) {
playingNow = playing;
if (!playing) state._menuSuppressed = false;
guiDirty = true;
}
+ // Game Rules ▸ disableFlight rides the CONTRACT (userData.play.grounded, DEVX
+ // #14) instead of swallowing Q/E at window capture; sent on change only
+ const grounded = !!config.rules.disableFlight;
+ if (grounded !== groundedSent && kit()) {
+ kit().setGrounded?.(grounded);
+ groundedSent = grounded;
+ }
+
const g = group();
- if (g) animateFloor(THREE, g, collectedSet(), time);
+ if (g) animateOverlay(THREE, g, collectedSet(), time);
if (playingNow && state.started && !state.wonAt && g) {
- const ray = api.pointerRay();
- const origin = ray?.ray?.origin;
- if (origin) {
+ const pos = playerXZ();
+ if (pos) {
// gem pickup by proximity (walk over it)
const gems = g.userData._dr?.gemWorld ?? [];
const set = collectedSet();
+ const r2 = config.rules.pickupRadius * config.rules.pickupRadius;
for (const gem of gems) {
if (set.has(gem.index)) continue;
- const dx = origin.x - gem.x;
- const dz = origin.z - gem.z;
- if (dx * dx + dz * dz < config.rules.pickupRadius * config.rules.pickupRadius && Math.abs(origin.y - gem.y) < 2.6)
- collectGem(state.floorIndex, gem.index);
+ const dx = pos.x - gem.x;
+ const dz = pos.z - gem.z;
+ if (dx * dx + dz * dz < r2 && Math.abs(pos.y - gem.y) < 2.6) collectGem(state.floorIndex, gem.index);
}
// portal travel: stand on the unsealed UP portal (together, by default)
const portal = g.getObjectByName('dr-portal-up');
if (portal && !sealed()) {
- const dx = origin.x - portal.position.x;
- const dz = origin.z - portal.position.z;
+ const dx = pos.x - portal.position.x;
+ const dz = pos.z - portal.position.z;
const on = dx * dx + dz * dz < 1.4 * 1.4;
if (on !== state.myOnPortal) {
state.myOnPortal = on;
state.onPortal[me()] = on;
api.send({ op: 'onportal', peerId: me(), on });
}
- if (on) {
- const others = ['p1', 'p2']
- .map((slot) => state.slots[slot]?.peerId)
- .filter((peerId) => peerId && peerId !== me());
- const together = !config.rules.allPlayersPortal || others.every((peerId) => state.onPortal[peerId]);
- if (together) travel(state.floorIndex + 1);
- }
+ if (on && canTravelTogether(state.slots, state.onPortal, me(), config.rules.allPlayersPortal)) travel(state.floorIndex + 1);
}
}
}
@@ -465,9 +489,7 @@ export function createGame(api) {
// ---- netcode -----------------------------------------------------------------
function handleMessage(data) {
- if (data.op === 'generate') applyRemoteGenerate(data);
- else if (data.op === 'floor') travel(data.floorIndex, false);
- else if (data.op === 'gem') collectGem(data.floor, data.index, false);
+ if (data.op === 'gem') collectGem(data.floor, data.index, false);
else if (data.op === 'slot') applyRemoteSlot(data);
else if (data.op === 'start') {
state.started = true;
@@ -482,14 +504,13 @@ export function createGame(api) {
} else if (data.op === 'prop') {
state.propValues[data.name] = data.value;
guiDirty = true;
- } else if (data.op === 'clear') clear();
+ }
}
function getState() {
if (state.seed == null) return null;
return {
seed: state.seed,
- params: state.params,
floorIndex: state.floorIndex,
collected: Object.fromEntries(Object.entries(state.collected).map(([floor, set]) => [floor, [...set]])),
slots: state.slots,
@@ -500,29 +521,33 @@ export function createGame(api) {
};
}
+ /** the Kit syncs its own world; this waits for it when it has not arrived yet */
function applyState(remote) {
if (!remote || remote.seed == null) return;
- if (!generate(remote.seed, remote.params ?? {}, false)) return;
- Object.entries(remote.collected ?? {}).forEach(([floor, indices]) => {
- state.collected[floor] = new Set(indices);
- });
- state.slots = { p1: null, p2: null, ...(remote.slots ?? {}) };
- state.started = !!remote.started;
- state.startedAt = remote.startedAt ?? 0;
- state.wonAt = remote.wonAt ?? 0;
- state.propValues = remote.propValues ?? {};
- if (remote.floorIndex && remote.floorIndex !== state.floorIndex) travel(remote.floorIndex, false);
- else rebuild();
+ const p = play();
+ if (p && p.seed === remote.seed) {
+ state.seed = p.seed;
+ state.campaignChecksum = p.campaignChecksum;
+ state.levelCount = p.levelCount;
+ applyWanted(remote);
+ if (remote.floorIndex && remote.floorIndex !== p.floorIndex) kit()?.showFloor(remote.floorIndex, { broadcast: false });
+ else {
+ state.floorIndex = p.floorIndex;
+ state.checksum = p.checksum;
+ rebuild();
+ }
+ state.wanted = null;
+ } else state.wanted = remote; // observe() applies it when the Kit shows this seed
guiDirty = true;
}
- const suppressFlight = () =>
- playingNow && state.started && !state.wonAt && config.rules.disableFlight;
-
return {
state,
config,
- generate,
+ kit,
+ play,
+ group,
+ newDungeon,
collectGem,
travel,
claimSlot,
@@ -536,8 +561,13 @@ export function createGame(api) {
getState,
applyState,
gemTotals,
- suppressFlight,
- group,
- markGuiDirty: () => (guiDirty = true)
+ sealed,
+ topFloor,
+ players,
+ objective,
+ isPlaying,
+ markGuiDirty: () => (guiDirty = true),
+ /** @param {(event: string) => void} fn */
+ onEvent: (fn) => (eventSink = fn)
};
}
diff --git a/modules/dungeon-realms/src/gui.js b/modules/dungeon-realms/src/gui.js
index d83f3ee..ed827a0 100644
--- a/modules/dungeon-realms/src/gui.js
+++ b/modules/dungeon-realms/src/gui.js
@@ -1,19 +1,18 @@
-// The game GUI — a DOM overlay owned by the module (the SDK has no UI-panel
-// surface for user modules yet; filed in DEVX-REQUESTS.md). Everything is
-// inline-styled and self-contained. The start/win menus are keyboard-driven
-// (Arrow keys + Enter) because play mode holds pointer lock on desktop —
-// they are also mouse-clickable whenever the pointer is free (mobile, tests).
+// The MENU — start / victory, a modal, keyboard-driven, focus-owning dialog
+// inside a pointer-locked viewport (Arrow keys + Enter, because play mode holds
+// pointer lock on desktop; also mouse-clickable whenever the pointer is free).
+//
+// 21-C C6.2: this is deliberately NOT a HUD and NOT a toolbox. The in-game HUD
+// (gems, level, players, objective) moved to core HUD elements the template
+// authors, driven by this module's value/rows nodes bound by element id; the old
+// `#dr-hud` DOM and the `drhud` node are gone. The menu stays module DOM until
+// core grows a play-mode menu surface ("play-mode menus", filed in
+// DEVX-REQUESTS.md), restyled onto the app's card conventions.
const Z = 900; // above viewport/HUD chrome, below the app's modal/toast tiers
-const GEM_SVG =
- '';
-
/** @type {HTMLElement | null} */ let root = null;
/** @type {HTMLElement | null} */ let menuCard = null;
-/** @type {HTMLElement | null} */ let hudBox = null;
let selectedIndex = 0;
/** @type {{id: string, label: string, disabled?: boolean}[]} */ let menuButtons = [];
/** @type {((id: string) => void) | null} */ let menuAction = null;
@@ -33,7 +32,6 @@ function ensureRoot() {
export function destroyGui() {
hideMenu();
- hideHud();
root?.remove();
root = null;
}
@@ -141,66 +139,3 @@ export function hideMenu() {
export function menuVisible() {
return !!menuCard;
}
-
-// ---- HUD ---------------------------------------------------------------------
-
-const CORNERS = {
- 'top-left': 'left:16px;top:64px;',
- 'top-right': 'right:16px;top:64px;text-align:right;',
- 'bottom-left': 'left:16px;bottom:70px;',
- 'bottom-right': 'right:16px;bottom:70px;text-align:right;'
-};
-
-/**
- * Show/refresh the in-game HUD.
- * @param {{gems?: {have: number, need: number, total: number}, level?: {k: number, n: number, name: string},
- * players?: {slot: string, name: string, me: boolean}[], objective?: string,
- * extraProps?: {name: string, value: number}[], corner?: string,
- * show?: {gems: boolean, level: boolean, players: boolean, objective: boolean}}} model
- */
-export function showHud(model) {
- ensureRoot();
- if (!hudBox) {
- hudBox = document.createElement('div');
- hudBox.id = 'dr-hud';
- root.appendChild(hudBox);
- }
- const corner = CORNERS[model.corner ?? 'top-left'] ?? CORNERS['top-left'];
- hudBox.style.cssText =
- 'position:absolute;' + corner + 'pointer-events:none;color:#e5e7eb;' +
- 'text-shadow:0 1px 3px rgba(0,0,0,.9);font-size:14px;line-height:1.7;';
- const show = model.show ?? { gems: true, level: true, players: true, objective: true };
- let html = '';
- if (show.gems && model.gems)
- html +=
- '' + GEM_SVG + ' ' +
- '' + model.gems.have + '' +
- ' / ' + model.gems.need + ' needed · ' + model.gems.total + ' hidden
';
- if (show.level && model.level)
- html +=
- 'LEVEL ' +
- model.level.k + ' / ' + model.level.n + ' · ' + model.level.name + '
';
- if (show.players && model.players?.length)
- html +=
- '' +
- model.players
- .map(
- (p) =>
- '' +
- p.slot.toUpperCase() + ' ' + p.name + (p.me ? ' (you)' : '') + ''
- )
- .join('') +
- '
';
- (model.extraProps ?? []).forEach((prop) => {
- html += '' + prop.name + ': ' + prop.value + '
';
- });
- if (show.objective && model.objective)
- html += '' + model.objective + '
';
- hudBox.innerHTML = html;
-}
-
-export function hideHud() {
- hudBox?.remove();
- hudBox = null;
-}
diff --git a/modules/dungeon-realms/src/hash.js b/modules/dungeon-realms/src/hash.js
new file mode 100644
index 0000000..5fda74f
--- /dev/null
+++ b/modules/dungeon-realms/src/hash.js
@@ -0,0 +1,16 @@
+// FNV-1a folded with a seed — the menu's seed picker (moved with the generator; the
+// Kit's rng.js is the same function, and two modules cannot share code).
+/** @param {number} seed @param {...(string | number)} parts */
+export function hash32(seed, ...parts) {
+ let h = 0x811c9dc5 ^ (seed >>> 0);
+ for (const part of parts) {
+ const s = String(part);
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ h ^= 0x9e3779b9;
+ h = Math.imul(h, 0x01000193);
+ }
+ return h >>> 0;
+}
diff --git a/modules/dungeon-realms/src/index.js b/modules/dungeon-realms/src/index.js
index c7ade95..e199595 100644
--- a/modules/dungeon-realms/src/index.js
+++ b/modules/dungeon-realms/src/index.js
@@ -2,25 +2,26 @@
// module.js by esbuild (npm run build:dungeon-realms); everything outside
// comes through `api`.
//
-// The game in one breath: a 9-stage pure-data generator (src/gen, node-tested
-// without the app) builds a multi-floor campaign from ONE seed; the renderer
-// instances it into the scene-root 'dungeon-module' group and publishes
-// userData.play, so the app's own play mode (red Play button) provides WASD
-// walking, wall collision, spawns and the minimap; the game layer adds gems,
-// gem-gated portals, P1/P2 slots, travel-together co-op and the GUI; the node
-// family exposes every rule in the flow editor. Replication is deterministic:
-// {seed, params, floorIndex} + tiny discrete events.
+// The game in one breath (21-C C6): the DUNGEON KIT module (id 'dungeon') generates
+// and renders a multi-floor campaign from one seed and publishes userData.play on
+// its scene-root group, so the app's own play mode provides WASD walking, wall
+// collision, spawns and the minimap; THIS module reads that contract through
+// api.scene() and adds the game on top — gems, gem-gated portals, P1/P2 slots,
+// travel-together co-op, the start/victory menu — and exposes every rule and every
+// readout as flow nodes so a template's core HUD shows the game. Replication is
+// deterministic: the Kit replicates {seed, params, floorIndex}; this module
+// replicates only tiny discrete rule events.
import { createGame, GROUP_NAME } from './game.js';
import { registerNodes } from './nodes.js';
-import { hash32 } from './gen/rng.js';
+import { hash32 } from './hash.js';
export default {
id: 'dungeon-realms',
name: 'Dungeon Realms',
- version: '1.0.0',
+ version: '2.0.0',
description:
- 'Co-op dungeon crawl: seeded multi-floor generator, gem-gated portals, P1/P2 play — every rule editable as flow nodes.',
+ 'Co-op dungeon crawl on the Dungeon Kit: gem-gated portals, P1/P2 play, travel-together floors — every rule and readout a flow node. Requires the "dungeon" (Dungeon Kit) module.',
/** @param {any} api the module SDK surface */
register(api) {
@@ -33,12 +34,11 @@ export default {
// ---- module card buttons -------------------------------------------------
api.registerMenu('Generate dungeon', () => {
const seed = hash32((api.now() * 1000) | 0, 'menu') % 100000;
- if (game.generate(seed, game.state.params ?? {}))
- api.toast('Dungeon Realms seed ' + seed + ' — press the red Play button to start');
+ if (game.newDungeon(seed)) api.toast('Dungeon Realms seed ' + seed + ' — press the red Play button to start');
});
api.registerMenu('Clear dungeon', () => {
+ game.kit()?.clear();
game.clear();
- api.send({ op: 'clear' });
});
// ---- clicks: portals travel, gems collect (desktop editor + VR trigger) ---
@@ -85,27 +85,23 @@ export default {
game.tick(time);
});
- // ---- flight suppression (Game Rules ▸ disableFlight) ---------------------------
- // Play mode's Q/E fly keys are swallowed at window CAPTURE while the game
- // runs, so players walk the dungeon instead of flying out of it. keyup
- // passes through so a held key can never wedge the move state. A proper
- // "grounded" play-mode option is filed in DEVX-REQUESTS.md.
- if (typeof window !== 'undefined') {
- window.addEventListener(
- 'keydown',
- (event) => {
- if ((event.code === 'KeyQ' || event.code === 'KeyE') && game.suppressFlight()) {
- event.preventDefault();
- event.stopImmediatePropagation();
- }
- },
- true
- );
- }
+ if (api.hud?.registerDebugLine)
+ api.hud.registerDebugLine(() => {
+ const s = game.state;
+ if (s.seed == null) return null;
+ const { have, need } = game.gemTotals();
+ return 'realms seed ' + s.seed + ' floor ' + s.floorIndex + '/' + s.levelCount + ' gems ' + have + '/' + need + (s.started ? ' playing' : '');
+ });
api.registerBindings([
{ label: 'Menu — select option', keys: 'ArrowUp / ArrowDown' },
{ label: 'Menu — confirm', keys: 'Enter' }
]);
+
+ // test/debug hook (never serialized): the flight reaches the game here too, so
+ // it can assert before any dungeon exists
+ if (typeof window !== 'undefined') {
+ /** @type {any} */ (window).__dungeonRealms = { game, nodes };
+ }
}
};
diff --git a/modules/dungeon-realms/src/nodes.js b/modules/dungeon-realms/src/nodes.js
index bd61bee..9cef973 100644
--- a/modules/dungeon-realms/src/nodes.js
+++ b/modules/dungeon-realms/src/nodes.js
@@ -1,40 +1,35 @@
-// The node family — the ENTIRE game surface as flow nodes. Every node OWNS a
-// rule group while it is alive in a running graph: its (replicated) node data
-// overrides the module defaults on every peer identically, so no extra
+// The node family (group "Dungeon Realms") — the RULE surface, not the engine. A
+// node OWNS a rule group while it is alive in a running graph: its (replicated)
+// node data overrides the module defaults on every peer identically, so no extra
// netcode is needed. Remove the node and the defaults return within a second.
//
-// Runtime reality (SDK v1, see DEVX-REQUESTS.md): module nodes are EFFECT
-// nodes — they run when wired to an Object Selector, or with no wiring at all
-// inside any object's own graph (the implicit-owner rule). Range params get
-// wireable input sockets for free; module nodes cannot yet OUTPUT values or
-// fire triggers, and a module cannot pre-seed a wired example graph.
+// 21-C C6: the Dungeon (recipe) node moved to the Kit (`dkdungeon`); the Game HUD
+// node is DELETED, not ported — the HUD is core HUD elements the template authors,
+// and this module publishes VALUES into them: `drvalue` (a number for a HUD Text /
+// Compare / Gate), `drrows` (lines into a HUD list element by id, the football
+// Records shape) and `drevent` (an event a Counter / Set Game State / Sound wires
+// to, pulsed on the peer where the event happened — fireNodeTrigger replicates).
-import { DEFAULT_RULES, DEFAULT_MENU, DEFAULT_HUD } from './game.js';
+import { DEFAULT_RULES, DEFAULT_MENU } from './rules.js';
const EXPIRE_FRAMES = 40; // node gone from the graph -> defaults return
+export const EVENTS = ['start', 'gem', 'unseal', 'travel', 'victory', 'reset'];
+export const READS = ['gems', 'need', 'total', 'level', 'levels', 'players', 'started', 'won', 'sealed', 'score'];
+export const ROWS = ['objective', 'players', 'level', 'gems', 'all'];
+
/** @param {any} api @param {ReturnType} game */
export function registerNodes(api, game) {
let frame = 0;
- const seen = { rules: -1, menu: -1, hud: -1 };
+ const seen = { rules: -1, menu: -1 };
/** @type {Record} prop name -> last seen frame */
const propSeen = {};
+ const MENU_OPTIONS = ['none', 'join-p1', 'join-p2', 'start', 'resume', 'new-dungeon'];
+
api.registerNodeGroup({
group: 'Dungeon Realms',
items: [
- {
- type: 'drdungeon',
- label: 'Dungeon',
- defaults: { seed: 7, roomCount: 0, levelCount: 5, gemDensity: 1, apply: false },
- params: [
- { key: 'seed', kind: 'range', min: 1, max: 9999, step: 1 },
- { key: 'roomCount', kind: 'range', min: 0, max: 60, step: 2 },
- { key: 'levelCount', kind: 'range', min: 1, max: 9, step: 1 },
- { key: 'gemDensity', kind: 'range', min: 0.25, max: 2, step: 0.05 },
- { key: 'apply', kind: 'toggle' }
- ]
- },
{
type: 'drrules',
label: 'Game Rules',
@@ -52,22 +47,10 @@ export function registerNodes(api, game) {
defaults: { ...DEFAULT_MENU },
params: [
{ key: 'show', kind: 'select', options: ['auto', 'always', 'never'] },
- { key: 'button1', kind: 'select', options: ['none', 'join-p1', 'join-p2', 'start', 'resume', 'new-dungeon'] },
- { key: 'button2', kind: 'select', options: ['none', 'join-p1', 'join-p2', 'start', 'resume', 'new-dungeon'] },
- { key: 'button3', kind: 'select', options: ['none', 'join-p1', 'join-p2', 'start', 'resume', 'new-dungeon'] },
- { key: 'button4', kind: 'select', options: ['none', 'join-p1', 'join-p2', 'start', 'resume', 'new-dungeon'] }
- ]
- },
- {
- type: 'drhud',
- label: 'Game HUD',
- defaults: { ...DEFAULT_HUD },
- params: [
- { key: 'showGems', kind: 'toggle' },
- { key: 'showLevel', kind: 'toggle' },
- { key: 'showPlayers', kind: 'toggle' },
- { key: 'showObjective', kind: 'toggle' },
- { key: 'corner', kind: 'select', options: ['top-left', 'top-right', 'bottom-left', 'bottom-right'] }
+ { key: 'button1', kind: 'select', options: MENU_OPTIONS },
+ { key: 'button2', kind: 'select', options: MENU_OPTIONS },
+ { key: 'button3', kind: 'select', options: MENU_OPTIONS },
+ { key: 'button4', kind: 'select', options: MENU_OPTIONS }
]
},
{
@@ -79,6 +62,27 @@ export function registerNodes(api, game) {
{ key: 'initial', kind: 'range', min: 0, max: 100, step: 1 },
{ key: 'showInHud', kind: 'toggle' }
]
+ },
+ {
+ type: 'drvalue',
+ label: 'Realms Value',
+ defaults: { read: 'gems' },
+ params: [{ key: 'read', kind: 'select', options: READS }]
+ },
+ {
+ type: 'drrows',
+ label: 'Realms HUD Rows',
+ defaults: { element: '', show: 'objective' },
+ params: [
+ { key: 'element', kind: 'text', placeholder: 'HUD list id', maxLength: 40 },
+ { key: 'show', kind: 'select', options: ROWS }
+ ]
+ },
+ {
+ type: 'drevent',
+ label: 'Realms Event',
+ defaults: { event: 'start' },
+ params: [{ key: 'event', kind: 'select', options: EVENTS }]
}
]
});
@@ -95,20 +99,6 @@ export function registerNodes(api, game) {
if (changed) game.markGuiDirty();
};
- api.registerEffect('drdungeon', (object, base, data) => {
- if (!data.apply) return;
- const seed = Math.round(data.seed ?? 7);
- const params = {
- roomCount: Math.round(data.roomCount ?? 0) || 0,
- levelCount: Math.round(data.levelCount ?? 5),
- gemDensity: data.gemDensity ?? 1
- };
- // the node data replicates with the graph, so every peer regenerates the
- // SAME dungeon locally — deterministic model, no op needed
- if (game.state.seed !== seed || JSON.stringify(game.state.params) !== JSON.stringify(params))
- game.generate(seed, params, false);
- });
-
api.registerEffect('drrules', (object, base, data) => {
seen.rules = frame;
assign(game.config.rules, {
@@ -130,17 +120,6 @@ export function registerNodes(api, game) {
});
});
- api.registerEffect('drhud', (object, base, data) => {
- seen.hud = frame;
- assign(game.config.hud, {
- showGems: !!(data.showGems ?? true),
- showLevel: !!(data.showLevel ?? true),
- showPlayers: !!(data.showPlayers ?? true),
- showObjective: !!(data.showObjective ?? true),
- corner: data.corner ?? 'top-left'
- });
- });
-
api.registerEffect('drprop', (object, base, data) => {
const name = data.prop ?? 'score';
propSeen[name] = frame;
@@ -152,6 +131,71 @@ export function registerNodes(api, game) {
}
});
+ // ---- the HUD half: rows into a core HUD list element, by id ------------------------
+ /** @type {Record} node id -> last pushed rows (push on change only) */
+ const lastRows = {};
+ api.registerEffect('drrows', (object, base, data, time, ctx) => {
+ if (!api.hud?.rows) return;
+ const element = String(data.element ?? '').trim();
+ if (!element) return;
+ const rows = rowsFor(data.show ?? 'objective');
+ const key = JSON.stringify(rows);
+ const id = ctx?.id ?? element;
+ if (lastRows[id] === key) return;
+ lastRows[id] = key;
+ api.hud.rows(element, rows);
+ });
+
+ /** @param {string} show */
+ function rowsFor(show) {
+ const s = game.state;
+ const p = game.play();
+ const { total, need, have } = game.gemTotals();
+ const level = p ? 'LEVEL ' + s.floorIndex + ' / ' + s.levelCount + ' · ' + p.name : 'no dungeon';
+ const gems = have + ' / ' + need + ' needed · ' + total + ' hidden';
+ const players = ['p1', 'p2']
+ .filter((slot) => s.slots[slot])
+ .map((slot) => slot.toUpperCase() + ' ' + s.slots[slot].name + (s.slots[slot].peerId === (api.peerId() ?? 'me') ? ' (you)' : ''));
+ const props = Object.entries(game.config.props)
+ .filter(([, def]) => def.showInHud)
+ .map(([name, def]) => name + ': ' + (s.propValues[name] ?? def.initial ?? 0));
+ if (show === 'objective') return p ? [game.objective()] : [];
+ if (show === 'players') return players;
+ if (show === 'level') return p ? [level] : [];
+ if (show === 'gems') return p ? [gems, ...props] : [];
+ return p ? [gems, level, ...players, ...props, game.objective()] : [];
+ }
+
+ // ---- the readable half: a number a core HUD Text / Compare / Gate consumes --------------
+ // PURE of (data) over replicated state: collected/slots/started ride this module's
+ // ops + state sync, the floor rides the Kit's (the value-node contract)
+ api.registerValueNode(
+ 'drvalue',
+ (data) => {
+ const s = game.state;
+ const { total, need, have } = game.gemTotals();
+ switch (data?.read) {
+ case 'need': return need;
+ case 'total': return total;
+ case 'level': return s.seed == null ? 0 : s.floorIndex;
+ case 'levels': return s.levelCount;
+ case 'players': return game.players();
+ case 'started': return s.started ? 1 : 0;
+ case 'won': return s.wonAt ? 1 : 0;
+ case 'sealed': return s.seed != null && game.sealed() ? 1 : 0;
+ case 'score': return s.propValues.score ?? game.config.props.score?.initial ?? 0;
+ default: return have;
+ }
+ },
+ { vtype: 'number' }
+ );
+ // the event half is pulsed by game.js on the ORIGINATING peer (fireNodeTrigger replicates)
+ api.registerValueNode('drevent', () => 0, { vtype: 'event' });
+ game.onEvent((event) => {
+ if (typeof api.fireNodeTrigger !== 'function') return;
+ api.fireNodeTrigger('drevent', (data) => (data?.event ?? 'start') === event);
+ });
+
/** called from the module frame task: expire configs whose node vanished */
function tick() {
frame++;
@@ -163,10 +207,6 @@ export function registerNodes(api, game) {
seen.menu = -1;
assign(game.config.menu, { ...DEFAULT_MENU });
}
- if (seen.hud >= 0 && frame - seen.hud > EXPIRE_FRAMES) {
- seen.hud = -1;
- assign(game.config.hud, { ...DEFAULT_HUD });
- }
for (const [name, at] of Object.entries(propSeen)) {
if (frame - at > EXPIRE_FRAMES) {
delete propSeen[name];
@@ -176,5 +216,5 @@ export function registerNodes(api, game) {
}
}
- return { tick };
+ return { tick, rowsFor };
}
diff --git a/modules/dungeon-realms/src/overlay.js b/modules/dungeon-realms/src/overlay.js
new file mode 100644
index 0000000..d5d5ab9
--- /dev/null
+++ b/modules/dungeon-realms/src/overlay.js
@@ -0,0 +1,114 @@
+// The OVERLAY — what Realms adds to the Kit's world: gems (the objective) and the two
+// portals, built from the Kit's play contract (userData.play.props / .portals, world
+// coordinates) into Realms' OWN scene-root group. The Kit never draws these: a rule
+// module owns what it plays with (21-C C6).
+
+/**
+ * @param {any} THREE @param {any} play the Kit's userData.play record
+ * @param {Set} collected collected gem indices on this floor
+ */
+export function buildOverlay(THREE, play, collected) {
+ const group = new THREE.Group();
+ const theme = play.theme ?? {};
+ const gemColor = theme.gemColor ?? 0x39e0c0;
+
+ // ---- gems (one InstancedMesh, zero-scaled when collected) --------------------
+ const gems = (play.props ?? []).filter((p) => p.kind === 'gem').slice().sort((a, b) => a.index - b.index);
+ const gemWorld = gems.map((p) => ({ x: p.wx, y: 0.55, z: p.wz, index: p.index }));
+ const gemMesh = new THREE.InstancedMesh(
+ new THREE.OctahedronGeometry(0.17, 0),
+ new THREE.MeshBasicMaterial({ color: gemColor }),
+ Math.max(1, gemWorld.length)
+ );
+ gemMesh.name = 'dr-gems';
+ gemMesh.count = gemWorld.length;
+ group.add(gemMesh);
+
+ // ---- portals -------------------------------------------------------------------
+ for (const portal of play.portals ?? []) {
+ const portalGroup = new THREE.Group();
+ portalGroup.name = 'dr-portal-' + portal.kind;
+ portalGroup.position.set(portal.wx, 0, portal.wz);
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(1.05, 0.1, 10, 36), new THREE.MeshBasicMaterial({ color: 0x555a66 }));
+ ring.name = 'dr-portal-ring';
+ ring.rotation.x = -Math.PI / 2;
+ ring.position.y = 0.1;
+ const disc = new THREE.Mesh(
+ new THREE.CircleGeometry(0.92, 28),
+ new THREE.MeshBasicMaterial({ color: 0x394050, transparent: true, opacity: 0.55 })
+ );
+ disc.name = 'dr-portal-disc';
+ disc.rotation.x = -Math.PI / 2;
+ disc.position.y = 0.08;
+ portalGroup.add(ring, disc);
+ portalGroup.userData.portal = { kind: portal.kind, gated: portal.gated };
+ group.add(portalGroup);
+ }
+
+ // runtime bookkeeping the game loop reads (scene-root only — never serialized)
+ group.userData._dr = { gemWorld, theme };
+ applyGems(group, collected);
+ return group;
+}
+
+/** Zero-scale collected gems; bob+spin comes from animateOverlay. */
+export function applyGems(group, collected) {
+ const gemMesh = group.getObjectByName('dr-gems');
+ const { gemWorld } = group.userData._dr;
+ if (!gemMesh || !gemWorld.length) return;
+ const matrix = new (gemMesh.matrixWorld.constructor)();
+ gemWorld.forEach((gem, i) => {
+ if (collected.has(gem.index)) matrix.makeScale(0, 0, 0);
+ else matrix.makeTranslation(gem.x, gem.y, gem.z);
+ gemMesh.setMatrixAt(i, matrix);
+ });
+ gemMesh.instanceMatrix.needsUpdate = true;
+}
+
+/** Seal/unseal the UP portal visuals. @param {boolean} sealed */
+export function setPortalSealed(group, sealed, theme) {
+ const portal = group.getObjectByName('dr-portal-up');
+ if (portal) {
+ const ring = portal.getObjectByName('dr-portal-ring');
+ const disc = portal.getObjectByName('dr-portal-disc');
+ if (ring) ring.material.color.setHex(sealed ? 0x555a66 : 0x39e0ff);
+ if (disc) {
+ disc.material.color.setHex(sealed ? 0x394050 : theme?.gemColor ?? 0x39e0c0);
+ disc.material.opacity = sealed ? 0.35 : 0.75;
+ }
+ portal.userData.portal.sealed = sealed;
+ }
+ const down = group.getObjectByName('dr-portal-down');
+ if (down) {
+ down.getObjectByName('dr-portal-ring')?.material.color.setHex(0x39a0ff);
+ down.userData.portal.sealed = false;
+ }
+}
+
+/** Per-frame juice: gem spin/bob and unsealed portal ring spin. */
+export function animateOverlay(THREE, group, collected, time) {
+ const data = group.userData._dr;
+ if (!data) return;
+ const gemMesh = group.getObjectByName('dr-gems');
+ if (gemMesh && data.gemWorld.length) {
+ const matrix = new THREE.Matrix4();
+ const position = new THREE.Vector3();
+ const quaternion = new THREE.Quaternion();
+ const scale = new THREE.Vector3(1, 1, 1);
+ const axis = new THREE.Vector3(0, 1, 0);
+ data.gemWorld.forEach((gem, i) => {
+ if (collected.has(gem.index)) return; // stays zero-scaled
+ position.set(gem.x, gem.y + Math.sin(time * 2 + gem.x) * 0.08, gem.z);
+ quaternion.setFromAxisAngle(axis, time * 1.6 + gem.index);
+ matrix.compose(position, quaternion, scale);
+ gemMesh.setMatrixAt(i, matrix);
+ });
+ gemMesh.instanceMatrix.needsUpdate = true;
+ }
+ group.children.forEach((child) => {
+ if (child.name === 'dr-portal-up' || child.name === 'dr-portal-down') {
+ const ring = child.getObjectByName('dr-portal-ring');
+ if (ring && child.userData.portal?.sealed === false) ring.rotation.z = time * 0.8;
+ }
+ });
+}
diff --git a/modules/dungeon-realms/src/rules.js b/modules/dungeon-realms/src/rules.js
new file mode 100644
index 0000000..b49912c
--- /dev/null
+++ b/modules/dungeon-realms/src/rules.js
@@ -0,0 +1,61 @@
+// The PURE rules — no THREE, no DOM, no api (node-tested in test/rules.test.mjs).
+// game.js applies them to replicated state; the value node reads through them.
+
+export const DEFAULT_RULES = {
+ gemShare: 0.7,
+ pickupRadius: 0.9,
+ allPlayersPortal: true,
+ disableFlight: true
+};
+
+export const DEFAULT_MENU = {
+ show: 'auto',
+ button1: 'join-p1',
+ button2: 'join-p2',
+ button3: 'start',
+ button4: 'new-dungeon'
+};
+
+/** gems needed to unseal, from the floor's total and the share rule
+ * @param {number} total @param {number} collected @param {number} gemShare */
+export function gemTotals(total, collected, gemShare) {
+ if (!total) return { total: 0, need: 0, have: 0 };
+ const share = Math.min(1, Math.max(0.05, Number(gemShare) || 0));
+ const need = Math.max(1, Math.ceil(total * share));
+ return { total, need, have: Math.min(collected, total) };
+}
+
+/** @param {{won: boolean, sealed: boolean, need: number, have: number, topFloor: boolean, allPlayersPortal: boolean, players: number}} s */
+export function objectiveText(s) {
+ if (s.won) return 'Victory! Press Esc to leave play mode.';
+ const missing = Math.max(0, s.need - s.have);
+ if (s.sealed)
+ return 'Collect ' + missing + ' more gem' + (missing === 1 ? '' : 's') + (s.topFloor ? ' to claim the dragon’s hoard' : ' to unseal the portal');
+ if (s.topFloor) return 'The hoard is yours!';
+ return s.allPlayersPortal && s.players > 1 ? 'Portal unsealed — stand on it together!' : 'Portal unsealed — step through!';
+}
+
+/**
+ * The world-space MARKERS Realms puts on the play minimap: uncollected gems and the
+ * floor's portals. @param {any} play the Kit's userData.play @param {Set} collected
+ */
+export function minimapMarkers(play, collected) {
+ if (!play) return [];
+ const out = [];
+ for (const p of play.props ?? []) if (p.kind === 'gem' && !collected.has(p.index)) out.push({ x: p.wx, z: p.wz, kind: 'gem' });
+ for (const p of play.portals ?? []) out.push({ x: p.wx, z: p.wz, kind: 'door' });
+ return out;
+}
+
+/**
+ * Travel-together: every OTHER slotted peer stands on the portal (or the rule is off).
+ * @param {Record} slots @param {Record} onPortal
+ * @param {string} me @param {boolean} allPlayersPortal
+ */
+export function canTravelTogether(slots, onPortal, me, allPlayersPortal) {
+ if (!allPlayersPortal) return true;
+ const others = Object.values(slots)
+ .map((slot) => slot?.peerId)
+ .filter((peerId) => peerId && peerId !== me);
+ return others.every((peerId) => !!onPortal[peerId]);
+}
diff --git a/modules/dungeon-realms/test/rules.test.mjs b/modules/dungeon-realms/test/rules.test.mjs
new file mode 100644
index 0000000..1ed20ad
--- /dev/null
+++ b/modules/dungeon-realms/test/rules.test.mjs
@@ -0,0 +1,36 @@
+// The pure rules — run WITHOUT the app.
+import { gemTotals, objectiveText, minimapMarkers, canTravelTogether, DEFAULT_RULES } from '../src/rules.js';
+
+export function run(check) {
+ // gem share -> need
+ check(JSON.stringify(gemTotals(10, 3, 0.7)) === JSON.stringify({ total: 10, need: 7, have: 3 }), 'gemTotals: 10 gems at .7 share need 7');
+ check(gemTotals(3, 0, 0.05).need === 1, 'need is never below 1');
+ check(gemTotals(0, 5, 0.7).need === 0 && gemTotals(0, 5, 0.7).have === 0, 'no gems -> nothing needed, nothing had');
+ check(gemTotals(4, 9, 1).have === 4, 'have is capped at the total');
+ check(gemTotals(10, 0, 5).need === 10, 'a share above 1 clamps to 1');
+ check(gemTotals(10, 0, 0).need === 1 && gemTotals(10, 0, -3).need === 1, 'a zero or negative share still needs ONE gem (the portal can never be free)');
+ // objective text
+ const base = { won: false, sealed: true, need: 7, have: 5, topFloor: false, allPlayersPortal: true, players: 2 };
+ check(objectiveText(base) === 'Collect 2 more gems to unseal the portal', 'objective: sealed, mid floor');
+ check(objectiveText({ ...base, have: 6 }).startsWith('Collect 1 more gem to'), 'objective: singular gem');
+ check(objectiveText({ ...base, topFloor: true }).endsWith('dragon’s hoard'), 'objective: sealed on the top floor names the hoard');
+ check(objectiveText({ ...base, sealed: false }) === 'Portal unsealed — stand on it together!', 'objective: unsealed with two players says together');
+ check(objectiveText({ ...base, sealed: false, players: 1 }) === 'Portal unsealed — step through!', 'objective: unsealed solo says step through');
+ check(objectiveText({ ...base, sealed: false, allPlayersPortal: false }) === 'Portal unsealed — step through!', 'objective: rule off says step through');
+ check(objectiveText({ ...base, won: true }).startsWith('Victory'), 'objective: won');
+ // minimap markers from the Kit contract: uncollected gems + portals
+ const play = {
+ props: [{ kind: 'gem', index: 0, wx: 1, wz: 2 }, { kind: 'gem', index: 1, wx: 3, wz: 4 }, { kind: 'crate', wx: 9, wz: 9 }],
+ portals: [{ kind: 'up', wx: 5, wz: 6 }]
+ };
+ const markers = minimapMarkers(play, new Set([0]));
+ check(markers.length === 2 && markers[0].kind === 'gem' && markers[0].x === 3 && markers[1].kind === 'door' && markers[1].x === 5, 'markers: one uncollected gem + the portal, crates ignored');
+ check(minimapMarkers(null, new Set()).length === 0, 'markers: no contract -> none');
+ // travel together
+ const slots = { p1: { peerId: 'a' }, p2: { peerId: 'b' } };
+ check(canTravelTogether(slots, { a: true }, 'a', true) === false, 'together: the other player is not on the portal yet');
+ check(canTravelTogether(slots, { a: true, b: true }, 'a', true) === true, 'together: both on the portal');
+ check(canTravelTogether(slots, {}, 'a', false) === true, 'together: rule off travels alone');
+ check(canTravelTogether({ p1: { peerId: 'a' }, p2: null }, {}, 'a', true) === true, 'together: a solo player travels');
+ check(DEFAULT_RULES.disableFlight === true && DEFAULT_RULES.gemShare === 0.7, 'defaults: grounded by default, .7 share');
+}
diff --git a/modules/dungeon-realms/test/run.mjs b/modules/dungeon-realms/test/run.mjs
index 61915b2..2cb2aa4 100644
--- a/modules/dungeon-realms/test/run.mjs
+++ b/modules/dungeon-realms/test/run.mjs
@@ -1,8 +1,7 @@
-// Pure-data generator tests — run WITHOUT the app:
-// node modules/dungeon-realms/test/run.mjs
-// (also wired as `npm run test:dungeon-realms` at the repo root)
-import { run as genTests } from './gen.test.mjs';
-import { run as campaignTests } from './campaign.test.mjs';
+// Pure rule tests — run WITHOUT the app:
+// node modules/dungeon-realms/test/run.mjs (npm run test:dungeon-realms)
+// The generator suites live with the generator in modules/dungeon/test (21-C C6).
+import { run as rulesTests } from './rules.test.mjs';
let failures = 0;
function check(ok, label) {
@@ -10,10 +9,8 @@ function check(ok, label) {
if (!ok) failures++;
}
-console.log('=== gen.test.mjs ===');
-genTests(check);
-console.log('\n=== campaign.test.mjs ===');
-campaignTests(check);
+console.log('=== rules.test.mjs ===');
+rulesTests(check);
console.log('\n' + (failures === 0 ? 'ALL PASS' : failures + ' FAILURES'));
process.exit(failures === 0 ? 0 : 1);
diff --git a/modules/dungeon/README.md b/modules/dungeon/README.md
new file mode 100644
index 0000000..e194e04
--- /dev/null
+++ b/modules/dungeon/README.md
@@ -0,0 +1,80 @@
+# dungeon — the Dungeon Kit
+
+A level-generation **toolbox**, not a game: one seed → a multi-floor dungeon campaign
+(rooms, corridors, decor, torches, typed spawn slots), rendered as instanced meshes
+at the scene root and published as a **play contract** the app walks in play mode.
+The playable co-op game built on it is [`dungeon-realms`](../dungeon-realms/).
+
+```
+modules/dungeon/
+ src/gen/ the 9-stage PURE-DATA generator (zero THREE/DOM — node-runnable)
+ src/contract.js the userData.play record (pure, node-tested)
+ src/render.js the instanced level renderer (floors, walls, decor, torches, lights)
+ src/kit.js world state {seed, params, floorIndex}, replication, the seam
+ src/toolbox.js api.registerToolbox (+ the #dungeon-panel fallback)
+ src/nodes.js the "Dungeon" node — the recipe as a node
+ test/ gen.test / campaign.test / contract.test (no app needed)
+ module.js the bundled, self-contained entry (committed)
+```
+
+```bash
+npm run build:dungeon # src/ -> module.js (esbuild)
+npm run test:dungeon # generator + contract acceptance (100-seed sweep, <50ms)
+npm run pack -- dungeon # -> dungeon.zip
+APP_URL=https://localhost:5216/ npm test -- dungeon.test # the test-flight
+```
+
+## The toolbox (Modules ▸ Dungeon Kit)
+
+Seed + 🎲, Rooms (0 = the per-floor auto ramp), Extra loops, Floors, **Generate** /
+**Clear**, a floor stepper and a stats footer. It is `api.registerToolbox` over the
+app's shared ToolboxWindow (draggable, resizable, themed, in its own z-band); on an
+app without that call it falls back to the old fixed `#dungeon-panel` DOM, mounting
+the same controls (the AUTHORING.md feature-detect rule). A toolbox is LOCAL: what it
+changes goes through the replicated paths — the Kit's own `generate` / `floor` /
+`clear` ops, or the Dungeon **node** when one owns the recipe.
+
+## The node (group "Dungeon Kit")
+
+**Dungeon** — `seed · roomCount (0 = auto) · levelCount · loopChance · gemDensity ·
+apply`. A node alive in a running graph with `apply` on regenerates the identical
+campaign on every peer from its replicated data and broadcasts nothing: the node IS the
+recipe. A template carries it wired to an Object Selector (module effect nodes run
+wired, or unwired inside an object's own graph). The toolbox edits this node when it
+exists (`api.flow.setNodeData`) so the two never fight.
+
+## The contract (`userData.play` on the scene-root group `dungeon-module`)
+
+Core reads `{grid, width, height, minX, minY, rooms, floorValue}` (walking, collision,
+spawns, the minimap) and `grounded` / `markers` (play settings). The rest is the
+**inter-module seam** — an installed module's entry file has no imports, so a rule
+module reads the Kit's record through `api.scene()` instead:
+
+| field | what |
+|---|---|
+| `contract` | `2` — bump when a field changes shape |
+| `seed`, `params`, `floorIndex`, `levelCount`, `name`, `theme`, `checksum`, `campaignChecksum` | the world |
+| `props[]` | every prop with `wx`/`wz` world coords (gems carry `index`) |
+| `portals[]` | `kind: 'up' \| 'down'`, `gated`, world coords |
+| `spawns[]` | typed enemy slots, world coords |
+| `grounded` | `true` by default — a dungeon is walked |
+| `markers[]` | `{x, z, kind}` for the minimap; a bare Kit publishes **none** |
+
+`userData.kit` is the function half: `generate(seed, params)`, `showFloor(k)`,
+`clear()` (each replicates), `setMarkers(owner, list)`, `setGrounded(bool|null)`,
+`state()`, `campaign()`, `stats()`, `onChange(fn)`. World coordinates: cell `(x, y)`
+of floor k is at `(x + ox + 0.5, y + oy + 0.5)`; floor k+1's DOWN portal stands exactly
+where floor k's UP portal stands, so travel rebuilds the world around the players.
+
+## The generator
+
+See the stage list in [`dungeon-realms/README.md`](../dungeon-realms/README.md#the-generator);
+it moved here unchanged with its tests (21-C C6). Deterministic from one integer seed
+(per-stage forked mulberry32, no transcendentals), 100% flood-fill connectivity with
+internal re-roll, ≤ 50 ms per 60-room floor.
+
+## Replication
+
+Deterministic (golden rule 8): `{op:'generate', seed, params, checksum}`, `{op:'floor',
+floorIndex}`, `{op:'clear'}`; every peer regenerates locally and the checksum only
+DETECTS divergence. Late joiners get `{seed, params, floorIndex}` via `registerStateSync`.
diff --git a/modules/dungeon/manifest.json b/modules/dungeon/manifest.json
index 7523f8c..f50ef9d 100644
--- a/modules/dungeon/manifest.json
+++ b/modules/dungeon/manifest.json
@@ -1,9 +1,9 @@
{
"id": "dungeon",
- "name": "Dungeon Generator",
- "version": "1.1.0",
+ "name": "Dungeon Kit",
+ "version": "2.0.0",
"format": 1,
- "description": "Seed-replicated procedural dungeon (rooms, corridors, torches) with a key-and-door objective; publishes userData.play so the app walks it in play mode.",
+ "description": "Level generation toolbox: a seeded multi-floor dungeon generator (rooms, corridors, decor, torches) with a toolbox, a Dungeon node and the userData.play contract the app walks in play mode. The playable game is Dungeon Realms.",
"entry": "module.js",
"files": ["module.js"]
}
diff --git a/modules/dungeon/module.js b/modules/dungeon/module.js
index c55e5bc..c9ec628 100644
--- a/modules/dungeon/module.js
+++ b/modules/dungeon/module.js
@@ -1,601 +1,1610 @@
-// Dungeon generator — moved out of core in 17-A and rewritten as a
-// SELF-CONTAINED user module: the generator pipeline and the two room helpers
-// are inlined here, THREE arrives as api.THREE, and the Svelte control panel
-// became plain DOM (user modules cannot ship .svelte components).
-//
-// Only {seed, params} replicates — every peer regenerates the identical dungeon
-// locally (determinism IS the netcode). The meshes live in a module-owned group
-// at the SCENE root, never in objectsGroup: the dungeon regenerates wholesale
-// and must not enter the object list or GLTF sync.
-//
-// THE CONTRACT WITH CORE: the group's userData.play publishes the raster, and
-// the app's own dungeonPlay.js consumes it for play-mode collision, spawn points
-// and the minimap. Keep {grid, width, height, minX, minY, cell, floorValue,
-// rooms} stable — core reads it, and any module may publish it.
-
-// ---------------------------------------------------------------------------
-// generator (inlined from the core module's generator.js)
-// ---------------------------------------------------------------------------
-
-const VOID = 0;
-const FLOOR = 1;
-const WALL = 2;
-
-/** @param {number} seed */
-function mulberry32(seed) {
- let a = seed >>> 0;
- return function () {
- a += 0x6d2b79f5;
- let t = a;
- t = Math.imul(t ^ (t >>> 15), t | 1);
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
+// modules/dungeon/src/gen/rng.js
+function hash32(seed, ...parts) {
+ let h = 2166136261 ^ seed >>> 0;
+ for (const part of parts) {
+ const s = String(part);
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ h ^= 2654435769;
+ h = Math.imul(h, 16777619);
+ }
+ return h >>> 0;
}
-
-/** @param {ReturnType} rand @param {number} count */
-function scatterRooms(rand, count) {
- const rooms = [];
- for (let i = 0; i < count; i++) {
- const angle = 2 * Math.PI * rand();
- const radius = Math.sqrt(rand());
- const w = 4 + Math.floor(rand() * 8);
- const h = 4 + Math.floor(rand() * 8);
- const cx = Math.round(Math.cos(angle) * radius * 30);
- const cy = Math.round(Math.sin(angle) * radius * 20);
- rooms.push({ x: cx - (w >> 1), y: cy - (h >> 1), w, h });
- }
- return rooms;
+function makeRng(seed) {
+ let a = seed >>> 0;
+ const next = () => {
+ a += 1831565813;
+ let t = a;
+ t = Math.imul(t ^ t >>> 15, t | 1);
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
+ };
+ const rng = {
+ seed: seed >>> 0,
+ /** uniform [0,1) */
+ next,
+ /** uniform [a,b) @param {number} a @param {number} b */
+ float: (a2, b2) => a2 + next() * (b2 - a2),
+ /** integer [a,b] inclusive @param {number} a @param {number} b */
+ int: (a2, b2) => a2 + Math.floor(next() * (b2 - a2 + 1)),
+ /** @template T @param {T[]} arr @returns {T} */
+ pick: (arr) => arr[Math.floor(next() * arr.length)],
+ /** @param {number} p */
+ chance: (p) => next() < p,
+ /** independent child stream — same label, same stream, always @param {string} label */
+ fork: (label) => makeRng(hash32(seed, label))
+ };
+ return rng;
+}
+function checksum32(arr, h = 2166136261) {
+ for (let i = 0; i < arr.length; i++) {
+ h ^= arr[i] & 255;
+ h = Math.imul(h, 16777619);
+ h ^= arr[i] >> 8 & 255;
+ h = Math.imul(h, 16777619);
+ }
+ return h >>> 0;
}
-/** Push overlapping rooms apart (1-cell gap), integer steps @param {any[]} rooms */
-function separateRooms(rooms) {
- for (let iteration = 0; iteration < 100; iteration++) {
- let moved = false;
- for (let i = 0; i < rooms.length; i++) {
- for (let j = i + 1; j < rooms.length; j++) {
- const a = rooms[i];
- const b = rooms[j];
- const overlapX = Math.min(a.x + a.w + 1, b.x + b.w + 1) - Math.max(a.x - 1, b.x - 1);
- const overlapY = Math.min(a.y + a.h + 1, b.y + b.h + 1) - Math.max(a.y - 1, b.y - 1);
- if (overlapX <= 0 || overlapY <= 0) continue;
- moved = true;
- if (overlapX < overlapY) {
- const push = Math.ceil(overlapX / 2);
- if (a.x + a.w / 2 < b.x + b.w / 2) {
- a.x -= push;
- b.x += push;
- } else {
- a.x += push;
- b.x -= push;
- }
- } else {
- const push = Math.ceil(overlapY / 2);
- if (a.y + a.h / 2 < b.y + b.h / 2) {
- a.y -= push;
- b.y += push;
- } else {
- a.y += push;
- b.y -= push;
- }
- }
- }
- }
- if (!moved) break;
- }
- return rooms;
+// modules/dungeon/src/gen/names.js
+var ADJECTIVES = [
+ "Ashen",
+ "Sunken",
+ "Howling",
+ "Gilded",
+ "Mossy",
+ "Broken",
+ "Silent",
+ "Ember",
+ "Frozen",
+ "Obsidian",
+ "Weeping",
+ "Forgotten",
+ "Crimson",
+ "Hollow",
+ "Shattered",
+ "Verdant",
+ "Rusted",
+ "Pale",
+ "Umbral",
+ "Thorned"
+];
+var PLACES = [
+ "Vaults",
+ "Halls",
+ "Depths",
+ "Catacombs",
+ "Warrens",
+ "Chambers",
+ "Galleries",
+ "Crypts",
+ "Cisterns",
+ "Foundry",
+ "Reliquary",
+ "Barrows",
+ "Passages",
+ "Sanctum",
+ "Undercroft",
+ "Mines",
+ "Roost",
+ "Maze"
+];
+var SYL_A = ["Vor", "Mal", "Kar", "Thu", "Gor", "Zan", "Bel", "Dra", "Ny", "Ul", "Sha", "Mor"];
+var SYL_B = ["gul", "thak", "ric", "mash", "dun", "zir", "goth", "ral", "ssk", "bar", "nox", "vek"];
+function dungeonName(rng) {
+ const adjective = rng.pick(ADJECTIVES);
+ const place = rng.pick(PLACES);
+ if (rng.chance(0.6)) {
+ const owner = rng.pick(SYL_A) + (rng.chance(0.5) ? "'" : "") + rng.pick(SYL_B);
+ return "The " + adjective + " " + place + " of " + owner;
+ }
+ return "The " + adjective + " " + place;
}
-/**
- * Bowyer-Watson Delaunay triangulation.
- * @param {{x: number, y: number}[]} points
- * @returns {[number, number][]} unique edges as point-index pairs
- */
+// modules/dungeon/src/gen/dungeon.js
+var VOID = 0;
+var FLOOR = 1;
+var WALL = 2;
+var DEFAULT_PARAMS = {
+ roomCount: 34,
+ loopChance: 0.15,
+ decorDensity: 0.6,
+ monsterDensity: 1,
+ gemDensity: 1
+};
+var now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
+function scatterRooms(rng, roomCount) {
+ const candidates = Math.ceil(roomCount * 1.4);
+ const rx = 2.4 * Math.sqrt(roomCount) + 4;
+ const ry = rx * 0.72;
+ const rooms = [];
+ for (let i = 0; i < candidates; i++) {
+ let ux = 0, uy = 0;
+ do {
+ ux = rng.float(-1, 1);
+ uy = rng.float(-1, 1);
+ } while (ux * ux + uy * uy > 1);
+ const roll = rng.next();
+ const archetype = roll < 0.45 ? "small" : roll < 0.85 ? "medium" : "large";
+ const [lo, hi] = archetype === "small" ? [5, 7] : archetype === "medium" ? [8, 12] : [13, 18];
+ const shapeRoll = rng.next();
+ const shape = shapeRoll < 0.6 ? "rect" : shapeRoll < 0.82 ? "ellipse" : "octagon";
+ rooms.push({
+ id: i,
+ archetype,
+ shape,
+ w: rng.int(lo, hi),
+ h: rng.int(lo, hi),
+ fx: ux * rx,
+ // float centers until separation snaps them
+ fy: uy * ry
+ });
+ }
+ let larges = rooms.filter((r) => r.archetype === "large").length;
+ for (let i = rooms.length - 1; i >= 0 && larges < 2; i--) {
+ if (rooms[i].archetype === "large") continue;
+ rooms[i].archetype = "large";
+ rooms[i].w = rng.int(13, 18);
+ rooms[i].h = rng.int(13, 18);
+ larges++;
+ }
+ return rooms;
+}
+function separateRooms(rooms, roomCount) {
+ const PAD = 2;
+ for (let iteration = 0; iteration < 300; iteration++) {
+ let moved = false;
+ for (let i = 0; i < rooms.length; i++) {
+ for (let j = i + 1; j < rooms.length; j++) {
+ const a = rooms[i], b = rooms[j];
+ const overlapX = (a.w + b.w) / 2 + PAD - Math.abs(a.fx - b.fx);
+ const overlapY = (a.h + b.h) / 2 + PAD - Math.abs(a.fy - b.fy);
+ if (overlapX <= 0 || overlapY <= 0) continue;
+ moved = true;
+ if (overlapX < overlapY) {
+ const push = overlapX / 2 + 0.05;
+ const dir = a.fx < b.fx || a.fx === b.fx && i < j ? -1 : 1;
+ a.fx += dir * push;
+ b.fx -= dir * push;
+ } else {
+ const push = overlapY / 2 + 0.05;
+ const dir = a.fy < b.fy || a.fy === b.fy && i < j ? -1 : 1;
+ a.fy += dir * push;
+ b.fy -= dir * push;
+ }
+ }
+ }
+ if (!moved) break;
+ }
+ rooms.forEach((r) => {
+ r.x = Math.round(r.fx - r.w / 2);
+ r.y = Math.round(r.fy - r.h / 2);
+ delete r.fx;
+ delete r.fy;
+ });
+ rooms.sort((a, b) => b.w * b.h - a.w * a.h || a.id - b.id);
+ rooms.length = Math.min(rooms.length, roomCount);
+ rooms.sort((a, b) => a.y - b.y || a.x - b.x || a.id - b.id);
+ rooms.forEach((r, i) => r.id = i);
+ return rooms;
+}
function delaunayEdges(points) {
- if (points.length < 2) return [];
- if (points.length === 2) return [[0, 1]];
-
- // super-triangle enclosing everything
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- points.forEach((p) => {
- minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
- maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
- });
- const span = Math.max(maxX - minX, maxY - minY, 1) * 10;
- const midX = (minX + maxX) / 2;
- const midY = (minY + maxY) / 2;
- const verts = [
- ...points,
- { x: midX - span, y: midY - span },
- { x: midX + span, y: midY - span },
- { x: midX, y: midY + span }
- ];
- const superA = points.length, superB = points.length + 1, superC = points.length + 2;
-
- /** circumcircle test @param {number[]} tri @param {{x,y}} p */
- const inCircumcircle = (tri, p) => {
- const a = verts[tri[0]], b = verts[tri[1]], c = verts[tri[2]];
- const ax = a.x - p.x, ay = a.y - p.y;
- const bx = b.x - p.x, by = b.y - p.y;
- const cx = c.x - p.x, cy = c.y - p.y;
- const det =
- (ax * ax + ay * ay) * (bx * cy - cx * by) -
- (bx * bx + by * by) * (ax * cy - cx * ay) +
- (cx * cx + cy * cy) * (ax * by - bx * ay);
- // orientation-aware: normalize by triangle winding
- const area = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
- return area > 0 ? det > 0 : det < 0;
- };
-
- let triangles = [[superA, superB, superC]];
- for (let pi = 0; pi < points.length; pi++) {
- const bad = triangles.filter((t) => inCircumcircle(t, points[pi]));
- const edgeCount = new Map();
- bad.forEach((t) => {
- [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]].forEach(([u, v]) => {
- const key = u < v ? u + ':' + v : v + ':' + u;
- edgeCount.set(key, (edgeCount.get(key) ?? 0) + 1);
- });
- });
- triangles = triangles.filter((t) => !bad.includes(t));
- edgeCount.forEach((count, key) => {
- if (count !== 1) return; // shared edges disappear
- const [u, v] = key.split(':').map(Number);
- triangles.push([u, v, pi]);
- });
- }
-
- const edges = new Set();
- triangles.forEach((t) => {
- [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]].forEach(([u, v]) => {
- if (u >= points.length || v >= points.length) return; // super-triangle
- edges.add(u < v ? u + ':' + v : v + ':' + u);
- });
- });
- return [...edges].sort().map((key) => key.split(':').map(Number));
+ if (points.length < 2) return [];
+ if (points.length === 2) return [[0, 1]];
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ points.forEach((p) => {
+ minX = Math.min(minX, p.x);
+ minY = Math.min(minY, p.y);
+ maxX = Math.max(maxX, p.x);
+ maxY = Math.max(maxY, p.y);
+ });
+ const span = Math.max(maxX - minX, maxY - minY, 1) * 3;
+ const midX = (minX + maxX) / 2, midY = (minY + maxY) / 2;
+ const verts = [
+ ...points,
+ { x: midX - span * 3, y: midY - span },
+ { x: midX + span * 3, y: midY - span },
+ { x: midX, y: midY + span * 3 }
+ ];
+ const superA = points.length;
+ const inCircumcircle = (tri, p) => {
+ const a = verts[tri[0]], b = verts[tri[1]], c = verts[tri[2]];
+ const ax = a.x - p.x, ay = a.y - p.y;
+ const bx = b.x - p.x, by = b.y - p.y;
+ const cx = c.x - p.x, cy = c.y - p.y;
+ const det = (ax * ax + ay * ay) * (bx * cy - cx * by) - (bx * bx + by * by) * (ax * cy - cx * ay) + (cx * cx + cy * cy) * (ax * by - bx * ay);
+ const area = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y);
+ if (Math.abs(area) < 1e-9) return false;
+ return area > 0 ? det > 1e-9 : det < -1e-9;
+ };
+ let triangles = [[superA, superA + 1, superA + 2]];
+ for (let pi = 0; pi < points.length; pi++) {
+ const bad = triangles.filter((t) => inCircumcircle(t, points[pi]));
+ const edgeCount = /* @__PURE__ */ new Map();
+ bad.forEach((t) => {
+ [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]].forEach(([u, v]) => {
+ const key = u < v ? u + ":" + v : v + ":" + u;
+ edgeCount.set(key, (edgeCount.get(key) ?? 0) + 1);
+ });
+ });
+ triangles = triangles.filter((t) => !bad.includes(t));
+ edgeCount.forEach((count, key) => {
+ if (count !== 1) return;
+ const [u, v] = key.split(":").map(Number);
+ triangles.push([u, v, pi]);
+ });
+ }
+ const edges = /* @__PURE__ */ new Set();
+ triangles.forEach((t) => {
+ [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]].forEach(([u, v]) => {
+ if (u >= points.length || v >= points.length) return;
+ edges.add(u < v ? u + ":" + v : v + ":" + u);
+ });
+ });
+ const list = [...edges].sort().map((key) => (
+ /** @type {[number, number]} */
+ key.split(":").map(Number)
+ ));
+ if (list.length === 0) {
+ const order = points.map((p, i) => i).sort((a, b) => points[a].x - points[b].x || points[a].y - points[b].y);
+ for (let i = 1; i < order.length; i++) list.push([Math.min(order[i - 1], order[i]), Math.max(order[i - 1], order[i])]);
+ }
+ return list;
}
-
-/**
- * Prim MST over the Delaunay edges, then re-add skipped edges with loopChance.
- * @param {{x,y}[]} centers @param {[number, number][]} edges @param {ReturnType} rand @param {number} loopChance
- */
-function chooseCorridors(centers, edges, rand, loopChance) {
- const dist2 = ([u, v]) =>
- (centers[u].x - centers[v].x) ** 2 + (centers[u].y - centers[v].y) ** 2;
- const inTree = new Set([0]);
- const chosen = [];
- const remaining = [...edges];
- while (inTree.size < centers.length && remaining.length > 0) {
- let best = -1;
- let bestD = Infinity;
- for (let i = 0; i < remaining.length; i++) {
- const [u, v] = remaining[i];
- if (inTree.has(u) === inTree.has(v)) continue;
- const d = dist2(remaining[i]);
- if (d < bestD) {
- bestD = d;
- best = i;
- }
- }
- if (best < 0) break; // disconnected (shouldn't happen with delaunay)
- const edge = remaining.splice(best, 1)[0];
- chosen.push(edge);
- inTree.add(edge[0]);
- inTree.add(edge[1]);
- }
- let loops = 0;
- remaining.forEach((edge) => {
- if (rand() < loopChance) {
- chosen.push(edge);
- loops++;
- }
- });
- return { chosen, loops };
+function buildGraph(centers, delaunay, rng, loopChance) {
+ const length = ([u, v]) => Math.hypot(centers[u].x - centers[v].x, centers[u].y - centers[v].y);
+ const inTree = /* @__PURE__ */ new Set([0]);
+ const mst = [];
+ const remaining = [...delaunay];
+ while (inTree.size < centers.length && remaining.length) {
+ let best = -1, bestD = Infinity;
+ for (let i = 0; i < remaining.length; i++) {
+ const [u, v] = remaining[i];
+ if (inTree.has(u) === inTree.has(v)) continue;
+ const d = length(remaining[i]);
+ if (d < bestD) {
+ bestD = d;
+ best = i;
+ }
+ }
+ if (best < 0) break;
+ const edge = remaining.splice(best, 1)[0];
+ mst.push(edge);
+ inTree.add(edge[0]);
+ inTree.add(edge[1]);
+ }
+ const meanMst = mst.reduce((s, e) => s + length(e), 0) / Math.max(1, mst.length);
+ const edges = mst.map(([a, b]) => ({ a, b, isLoop: false, isCritical: false }));
+ let loops = 0;
+ const rejected = [];
+ remaining.forEach((edge) => {
+ if (length(edge) > 2.2 * meanMst) return;
+ if (rng.chance(loopChance)) {
+ edges.push({ a: edge[0], b: edge[1], isLoop: true, isCritical: false });
+ loops++;
+ } else rejected.push(edge);
+ });
+ if (loops === 0 && rejected.length) {
+ rejected.sort((e1, e2) => length(e1) - length(e2));
+ edges.push({ a: rejected[0][0], b: rejected[0][1], isLoop: true, isCritical: false });
+ loops = 1;
+ }
+ return { edges, loops };
}
-
-/**
- * Generate the full dungeon.
- * @param {number} seed @param {{roomCount?: number, loopChance?: number}} params
- */
-function generateDungeon(seed, params = {}) {
- const started = performance.now();
- const roomCount = Math.min(Math.max(params.roomCount ?? 24, 4), 80);
- const loopChance = Math.min(Math.max(params.loopChance ?? 0.15, 0), 1);
- const rand = mulberry32(seed);
-
- const rooms = separateRooms(scatterRooms(rand, roomCount));
- const centers = rooms.map((r) => ({ x: r.x + (r.w >> 1), y: r.y + (r.h >> 1) }));
- const { chosen, loops } = chooseCorridors(centers, delaunayEdges(centers), rand, loopChance);
-
- // bounds incl. corridor + wall margin
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- rooms.forEach((r) => {
- minX = Math.min(minX, r.x); minY = Math.min(minY, r.y);
- maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h);
- });
- minX -= 2; minY -= 2; maxX += 2; maxY += 2;
- const width = maxX - minX + 1;
- const height = maxY - minY + 1;
- const grid = new Uint8Array(width * height);
- const carve = (x, y) => {
- if (x < minX || y < minY || x > maxX || y > maxY) return;
- grid[(y - minY) * width + (x - minX)] = FLOOR;
- };
-
- rooms.forEach((r) => {
- for (let y = r.y; y < r.y + r.h; y++) for (let x = r.x; x < r.x + r.w; x++) carve(x, y);
- });
- // L corridors (2 wide), horizontal leg first — fixed order keeps peers identical
- chosen.forEach(([u, v]) => {
- const a = centers[u], b = centers[v];
- const stepX = a.x < b.x ? 1 : -1;
- for (let x = a.x; x !== b.x + stepX; x += stepX) { carve(x, a.y); carve(x, a.y + 1); }
- const stepY = a.y < b.y ? 1 : -1;
- for (let y = a.y; y !== b.y + stepY; y += stepY) { carve(b.x, y); carve(b.x + 1, y); }
- });
- // walls: VOID cells touching FLOOR (8-neighborhood)
- for (let y = 0; y < height; y++) {
- for (let x = 0; x < width; x++) {
- if (grid[y * width + x] !== VOID) continue;
- let nearFloor = false;
- for (let dy = -1; dy <= 1 && !nearFloor; dy++)
- for (let dx = -1; dx <= 1 && !nearFloor; dx++) {
- const nx = x + dx, ny = y + dy;
- if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
- if (grid[ny * width + nx] === FLOOR) nearFloor = true;
- }
- if (nearFloor) grid[y * width + x] = WALL;
- }
- }
-
- return {
- grid, width, height, minX, minY,
- rooms, edges: chosen, loops,
- ms: performance.now() - started
- };
+function graphDistances(roomCount, edges, from) {
+ const adjacency = Array.from({ length: roomCount }, () => []);
+ edges.forEach((e) => {
+ adjacency[e.a].push(e.b);
+ adjacency[e.b].push(e.a);
+ });
+ const dist = new Int32Array(roomCount).fill(-1);
+ dist[from] = 0;
+ const queue = [from];
+ for (let qi = 0; qi < queue.length; qi++) {
+ const u = queue[qi];
+ for (const v of adjacency[u]) if (dist[v] < 0) {
+ dist[v] = dist[u] + 1;
+ queue.push(v);
+ }
+ }
+ return dist;
}
-
-/** djb2 over the raster — equal checksums = identical dungeons @param {Uint8Array} grid */
-function gridChecksum(grid) {
- let hash = 5381;
- for (let i = 0; i < grid.length; i++) hash = ((hash * 33) ^ grid[i]) >>> 0;
- return hash;
+function assignSemantics(rooms, edges, rng) {
+ const degree = new Int32Array(rooms.length);
+ edges.forEach((e) => {
+ degree[e.a]++;
+ degree[e.b]++;
+ });
+ rooms.forEach((r, i) => r.degree = degree[i]);
+ let boss = 0;
+ rooms.forEach((r, i) => {
+ if (r.w * r.h > rooms[boss].w * rooms[boss].h) boss = i;
+ });
+ const fromBoss = graphDistances(rooms.length, edges, boss);
+ let entrance = -1;
+ for (let i = 0; i < rooms.length; i++) {
+ if (i === boss || degree[i] !== 1) continue;
+ if (entrance < 0 || fromBoss[i] > fromBoss[entrance]) entrance = i;
+ }
+ let entranceFallback = false;
+ if (entrance < 0) {
+ entranceFallback = true;
+ for (let i = 0; i < rooms.length; i++) {
+ if (i === boss) continue;
+ if (entrance < 0 || degree[i] < degree[entrance] || degree[i] === degree[entrance] && fromBoss[i] > fromBoss[entrance])
+ entrance = i;
+ }
+ }
+ const fromEntrance = graphDistances(rooms.length, edges, entrance);
+ const maxDepth = Math.max(1, ...rooms.map((r, i) => fromEntrance[i]));
+ rooms.forEach((r, i) => {
+ r.depth = Math.max(0, fromEntrance[i]);
+ r.difficulty = Math.min(1, 0.15 + 0.85 * (r.depth / maxDepth));
+ });
+ const adjacency = Array.from({ length: rooms.length }, () => []);
+ edges.forEach((e, ei) => {
+ adjacency[e.a].push({ to: e.b, edge: ei });
+ adjacency[e.b].push({ to: e.a, edge: ei });
+ });
+ const critical = /* @__PURE__ */ new Set([boss]);
+ let cursor = boss;
+ let guard = rooms.length + 2;
+ while (cursor !== entrance && guard-- > 0) {
+ let step = null;
+ for (const { to, edge } of adjacency[cursor]) {
+ if (fromEntrance[to] === fromEntrance[cursor] - 1 && (step === null || to < step.to)) step = { to, edge };
+ }
+ if (!step) break;
+ edges[step.edge].isCritical = true;
+ critical.add(step.to);
+ cursor = step.to;
+ }
+ rooms.forEach((r) => r.type = "combat");
+ rooms[boss].type = "boss";
+ rooms[entrance].type = "entrance";
+ const leaves = rooms.map((r, i) => i).filter((i) => i !== boss && i !== entrance && degree[i] === 1).sort((a, b) => fromEntrance[b] - fromEntrance[a] || a - b);
+ leaves.slice(0, 4).forEach((i) => rooms[i].type = "treasure");
+ const shrineCandidates = rooms.map((r, i) => i).filter((i) => rooms[i].type === "combat" && !critical.has(i) && fromEntrance[i] >= 0.4 * maxDepth && fromEntrance[i] <= 0.7 * maxDepth);
+ const shrineCount = Math.min(shrineCandidates.length, rng.int(1, 2));
+ for (let k = 0; k < shrineCount; k++) {
+ const pickIndex = rng.int(0, shrineCandidates.length - 1);
+ rooms[shrineCandidates[pickIndex]].type = "shrine";
+ shrineCandidates.splice(pickIndex, 1);
+ }
+ const eliteCandidates = rooms.map((r, i) => i).filter((i) => rooms[i].type === "combat" && critical.has(i) && rooms[i].archetype !== "small" && fromEntrance[i] >= 0.55 * maxDepth && fromEntrance[i] <= 0.85 * maxDepth);
+ const eliteCount = Math.min(eliteCandidates.length, rng.int(1, 2));
+ for (let k = 0; k < eliteCount; k++) {
+ const pickIndex = rng.int(0, eliteCandidates.length - 1);
+ rooms[eliteCandidates[pickIndex]].type = "elite";
+ eliteCandidates.splice(pickIndex, 1);
+ }
+ rooms[boss].difficulty = 1;
+ return { boss, entrance, entranceFallback, maxDepth, criticalRooms: critical };
}
-
-// --- room helpers (inlined from the app's dungeonPlay.js) ------------------
-function roomCenter(room) {
- return { x: room.x + room.w / 2, z: room.y + room.h / 2 };
-}
-function farthestRoom(rooms) {
- if (!rooms?.length) return null;
- const start = roomCenter(rooms[0]);
- let best = rooms[0];
- let bestDist = -1;
- for (const room of rooms) {
- const c = roomCenter(room);
- const d = (c.x - start.x) ** 2 + (c.z - start.z) ** 2;
- if (d > bestDist) {
- bestDist = d;
- best = room;
- }
- }
- return best;
+function inRoomShape(room, x, y) {
+ if (x < room.x || y < room.y || x >= room.x + room.w || y >= room.y + room.h) return false;
+ if (room.shape === "rect") return true;
+ const dx = x + 0.5 - (room.x + room.w / 2);
+ const dy = y + 0.5 - (room.y + room.h / 2);
+ if (room.shape === "ellipse") {
+ const nx = dx / (room.w / 2);
+ const ny = dy / (room.h / 2);
+ return nx * nx + ny * ny <= 1;
+ }
+ const chamfer = Math.max(1, Math.floor(Math.min(room.w, room.h) / 3.5));
+ const ex = Math.min(x - room.x, room.x + room.w - 1 - x);
+ const ey = Math.min(y - room.y, room.y + room.h - 1 - y);
+ return ex + ey >= chamfer;
}
-
-// --- control panel (plain DOM; the core version was a .svelte component) ----
-/** @type {any} */ let panelEl = null;
-/** @type {any} */ let statsEl = null;
-let panelStats = null;
-
-function setPanelStats(stats) {
- panelStats = stats;
- if (!statsEl) return;
- statsEl.textContent = stats
- ? stats.rooms + ' rooms, ' + stats.loops + ' loops, ' + stats.ms + ' ms — checksum ' + stats.checksum
- : '';
+function rasterize(rooms, edges, semantics, rng) {
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ rooms.forEach((r) => {
+ minX = Math.min(minX, r.x);
+ minY = Math.min(minY, r.y);
+ maxX = Math.max(maxX, r.x + r.w);
+ maxY = Math.max(maxY, r.y + r.h);
+ });
+ const MARGIN = 3;
+ const shiftX = MARGIN - minX;
+ const shiftY = MARGIN - minY;
+ rooms.forEach((r) => {
+ r.x += shiftX;
+ r.y += shiftY;
+ });
+ const W = maxX - minX + MARGIN * 2;
+ const H = maxY - minY + MARGIN * 2;
+ const grid = new Uint8Array(W * H);
+ const roomOf = new Int16Array(W * H).fill(-1);
+ rooms.forEach((r) => {
+ r.cx = r.x + (r.w >> 1);
+ r.cy = r.y + (r.h >> 1);
+ for (let y = r.y; y < r.y + r.h; y++)
+ for (let x = r.x; x < r.x + r.w; x++)
+ if (inRoomShape(r, x, y)) {
+ grid[y * W + x] = FLOOR;
+ roomOf[y * W + x] = r.id;
+ }
+ });
+ const corridorCells = [];
+ const stampCorridor = (x, y, width, horizontal) => {
+ for (let o = 0; o < width; o++) {
+ const off = o - (width >> 1);
+ const cx2 = horizontal ? x : x + off;
+ const cy2 = horizontal ? y + off : y;
+ if (cx2 < 0 || cy2 < 0 || cx2 >= W || cy2 >= H) continue;
+ const i = cy2 * W + cx2;
+ if (grid[i] !== FLOOR) {
+ grid[i] = FLOOR;
+ corridorCells.push({ x: cx2, y: cy2 });
+ }
+ }
+ };
+ edges.forEach((edge) => {
+ const a = rooms[edge.a], b = rooms[edge.b];
+ const spur = a.type === "treasure" || b.type === "treasure";
+ const width = edge.isCritical ? 3 : spur ? 1 : 2;
+ const overlapX0 = Math.max(a.x, b.x), overlapX1 = Math.min(a.x + a.w, b.x + b.w);
+ const overlapY0 = Math.max(a.y, b.y), overlapY1 = Math.min(a.y + a.h, b.y + b.h);
+ if (overlapX1 - overlapX0 >= 3) {
+ const x = overlapX0 + overlapX1 >> 1;
+ const y0 = Math.min(a.cy, b.cy), y1 = Math.max(a.cy, b.cy);
+ for (let y = y0; y <= y1; y++) stampCorridor(x, y, width, false);
+ } else if (overlapY1 - overlapY0 >= 3) {
+ const y = overlapY0 + overlapY1 >> 1;
+ const x0 = Math.min(a.cx, b.cx), x1 = Math.max(a.cx, b.cx);
+ for (let x = x0; x <= x1; x++) stampCorridor(x, y, width, true);
+ } else {
+ const horizontalFirst = rng.chance(0.5);
+ const stepX = a.cx <= b.cx ? 1 : -1;
+ const stepY = a.cy <= b.cy ? 1 : -1;
+ if (horizontalFirst) {
+ for (let x = a.cx; x !== b.cx + stepX; x += stepX) stampCorridor(x, a.cy, width, true);
+ for (let y = a.cy; y !== b.cy + stepY; y += stepY) stampCorridor(b.cx, y, width, false);
+ } else {
+ for (let y = a.cy; y !== b.cy + stepY; y += stepY) stampCorridor(a.cx, y, width, false);
+ for (let x = a.cx; x !== b.cx + stepX; x += stepX) stampCorridor(x, b.cy, width, true);
+ }
+ }
+ });
+ for (let y = 0; y < H; y++)
+ for (let x = 0; x < W; x++) {
+ if (grid[y * W + x] !== VOID) continue;
+ let nearFloor = false;
+ for (let dy = -1; dy <= 1 && !nearFloor; dy++)
+ for (let dx = -1; dx <= 1 && !nearFloor; dx++) {
+ const nx = x + dx, ny = y + dy;
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
+ if (grid[ny * W + nx] === FLOOR) nearFloor = true;
+ }
+ if (nearFloor) grid[y * W + x] = WALL;
+ }
+ const doorways = [];
+ const corridorSet = new Set(corridorCells.map((c) => c.y * W + c.x));
+ corridorCells.forEach(({ x, y }) => {
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const ni = (y + dy) * W + (x + dx);
+ if (grid[ni] === FLOOR && roomOf[ni] >= 0) {
+ doorways.push({ x, y });
+ return;
+ }
+ }
+ });
+ const entranceRoom = rooms[semantics.entrance];
+ const bfs = new Int16Array(W * H).fill(-1);
+ const start = entranceRoom.cy * W + entranceRoom.cx;
+ bfs[start] = 0;
+ const queue = new Int32Array(W * H);
+ queue[0] = start;
+ let head = 0, tail = 1;
+ while (head < tail) {
+ const i = queue[head++];
+ const x = i % W, y = i / W | 0;
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
+ const nx = x + dx, ny = y + dy;
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
+ const ni = ny * W + nx;
+ if (grid[ni] === FLOOR && bfs[ni] < 0) {
+ bfs[ni] = bfs[i] + 1;
+ queue[tail++] = ni;
+ }
+ }
+ }
+ return { W, H, grid, roomOf, corridorCells, corridorSet, doorways, bfs };
}
-
-function closePanel() {
- panelEl?.remove();
- panelEl = null;
- statsEl = null;
+function decorate(rooms, raster, semantics, params, rng) {
+ const { W, H, grid, roomOf, doorways, corridorSet } = raster;
+ const props = [];
+ const spawns = [];
+ const taken = new Uint8Array(W * H);
+ doorways.forEach(({ x, y }) => taken[y * W + x] = 1);
+ const doorwayNear = (x, y, radius) => doorways.some((d) => Math.max(Math.abs(d.x - x), Math.abs(d.y - y)) < radius);
+ const allFloor8 = (x, y) => {
+ for (let dy = -1; dy <= 1; dy++)
+ for (let dx = -1; dx <= 1; dx++)
+ if (grid[(y + dy) * W + (x + dx)] !== FLOOR) return false;
+ return true;
+ };
+ const place = (kind, x, y, roomId, extra = {}) => {
+ taken[y * W + x] = 1;
+ props.push({ kind, x, y, rot: 0, scale: 1, roomId, ...extra });
+ };
+ const freeCells = (room, filter) => {
+ const cells = [];
+ for (let y = room.y; y < room.y + room.h; y++)
+ for (let x = room.x; x < room.x + room.w; x++) {
+ const i = y * W + x;
+ if (grid[i] !== FLOOR || roomOf[i] !== room.id || taken[i]) continue;
+ if (filter && !filter(x, y)) continue;
+ cells.push({ x, y });
+ }
+ return cells;
+ };
+ const wallAdjacent = (x, y) => [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => grid[(y + dy) * W + (x + dx)] === WALL);
+ rooms.forEach((room) => {
+ if (room.archetype !== "large") return;
+ for (let y = room.y + 1; y < room.y + room.h - 1; y++)
+ for (let x = room.x + 1; x < room.x + room.w - 1; x++) {
+ if ((x - room.cx) % 3 !== 0 || (y - room.cy) % 3 !== 0) continue;
+ if (x === room.cx && y === room.cy) continue;
+ if (!allFloor8(x, y) || taken[y * W + x] || doorwayNear(x, y, 2)) continue;
+ place("pillar", x, y, room.id);
+ }
+ });
+ const torches = [];
+ for (let y = 1; y < H - 1; y++)
+ for (let x = 1; x < W - 1; x++) {
+ if (grid[y * W + x] !== WALL) continue;
+ const facing = [[1, 0], [-1, 0], [0, 1], [0, -1]].find(([dx, dy]) => grid[(y + dy) * W + (x + dx)] === FLOOR);
+ if (!facing) continue;
+ if (torches.some((t) => Math.max(Math.abs(t.x - x), Math.abs(t.y - y)) < 4)) continue;
+ torches.push({ x, y, fx: facing[0], fy: facing[1] });
+ }
+ torches.forEach((t) => props.push({ kind: "torch", x: t.x, y: t.y, rot: 0, scale: 1, roomId: roomOf[t.y * W + t.x], fx: t.fx, fy: t.fy }));
+ rooms.forEach((room) => {
+ const cells = freeCells(room);
+ const count = Math.round(cells.length * 0.05 * params.decorDensity * (1.4 - room.difficulty));
+ for (let k = 0; k < count && cells.length; k++) {
+ const cell = cells.splice(rng.int(0, cells.length - 1), 1)[0];
+ place("debris", cell.x, cell.y, room.id, { rot: rng.float(0, 6.283), scale: rng.float(0.5, 1.1) });
+ }
+ });
+ rooms.forEach((room) => {
+ if (room.type !== "combat") return;
+ const cells = freeCells(room, (x, y) => wallAdjacent(x, y) && !doorwayNear(x, y, 2));
+ const count = Math.min(cells.length, Math.max(0, Math.round(rng.int(2, 4) * params.decorDensity)));
+ for (let k = 0; k < count && cells.length; k++) {
+ const cell = cells.splice(rng.int(0, cells.length - 1), 1)[0];
+ place("crate", cell.x, cell.y, room.id);
+ }
+ });
+ rooms.forEach((room) => {
+ if (room.type === "boss") {
+ const ring = freeCells(room, (x, y) => {
+ const edgeX = Math.min(x - room.x, room.x + room.w - 1 - x);
+ const edgeY = Math.min(y - room.y, room.y + room.h - 1 - y);
+ return Math.min(edgeX, edgeY) === 1 && (x + y) % 3 === 0 && !doorwayNear(x, y, 2);
+ });
+ ring.forEach((cell) => place("brazier", cell.x, cell.y, room.id));
+ } else if (room.type === "treasure") {
+ const cells = freeCells(room, (x, y) => Math.abs(x - room.cx) <= 1 && Math.abs(y - room.cy) <= 1);
+ if (cells.length) {
+ const cell = cells[rng.int(0, cells.length - 1)];
+ place("chest", cell.x, cell.y, room.id);
+ }
+ } else if (room.type === "shrine") {
+ if (grid[room.cy * W + room.cx] === FLOOR && !taken[room.cy * W + room.cx])
+ place("crystal", room.cx, room.cy, room.id);
+ } else if (room.type === "entrance") {
+ if (!taken[room.cy * W + room.cx]) place("ring", room.cx, room.cy, room.id);
+ }
+ });
+ const gemCount = Math.max(4, Math.min(40, Math.round(rooms.length * 0.4 * (params.gemDensity ?? 1))));
+ const gemRooms = rooms.filter((r) => r.type !== "entrance");
+ const gemRng = rng.fork("gems");
+ let gemsPlaced = 0;
+ let attempts = gemCount * 8;
+ while (gemsPlaced < gemCount && attempts-- > 0) {
+ const weights = gemRooms.map((r) => 1 + r.depth);
+ const total = weights.reduce((s, w2) => s + w2, 0);
+ let roll = gemRng.float(0, total);
+ let room = gemRooms[gemRooms.length - 1];
+ for (let i = 0; i < gemRooms.length; i++) {
+ roll -= weights[i];
+ if (roll <= 0) {
+ room = gemRooms[i];
+ break;
+ }
+ }
+ const cells = freeCells(room);
+ if (!cells.length) continue;
+ const cell = cells[gemRng.int(0, cells.length - 1)];
+ place("gem", cell.x, cell.y, room.id, { index: gemsPlaced });
+ gemsPlaced++;
+ }
+ const spawnRng = rng.fork("spawns");
+ rooms.forEach((room) => {
+ if (room.type !== "combat" && room.type !== "elite") return;
+ const area = room.w * room.h;
+ const count = Math.round(area / 18 * (0.5 + room.difficulty) * params.monsterDensity);
+ const cells = freeCells(room, (x, y) => !corridorSet.has(y * W + x));
+ for (let k = 0; k < count && cells.length; k++) {
+ const cell = cells.splice(spawnRng.int(0, cells.length - 1), 1)[0];
+ taken[cell.y * W + cell.x] = 1;
+ spawns.push({ x: cell.x, y: cell.y, tier: room.type === "elite" ? "elite" : "normal", roomId: room.id });
+ }
+ });
+ return { props, spawns, taken };
}
-
-function openPanel() {
- if (panelEl || typeof document === 'undefined') return;
- const el = document.createElement('div');
- el.id = 'dungeon-panel';
- el.style.cssText =
- 'position:fixed;right:0.5rem;top:5rem;z-index:40;display:flex;width:18rem;flex-direction:column;' +
- 'gap:0.5rem;border-radius:0.5rem;background:#1f2937;padding:0.75rem;font-size:0.875rem;color:#fff;' +
- 'box-shadow:0 10px 25px rgba(0,0,0,0.4);';
- el.innerHTML =
- '' +
- 'Dungeon generator' +
- '
' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '
' +
- '' +
- 'Peers regenerate from the seed — the same checksum means the exact same dungeon.
';
- document.body.appendChild(el);
- panelEl = el;
- statsEl = el.querySelector('#dungeon-stats');
-
- const seedInput = el.querySelector('#dungeon-seed');
- const roomsInput = el.querySelector('#dungeon-rooms');
- const loopsInput = el.querySelector('#dungeon-loops');
- el.querySelector('#dungeon-close').onclick = () => closePanel();
- el.querySelector('#dungeon-dice').onclick = () => {
- // the seed CHOICE is local; the seed itself replicates
- seedInput.value = String(Math.floor(Math.random() * 1000000));
- };
- roomsInput.oninput = () => (el.querySelector('#dungeon-rooms-out').textContent = roomsInput.value);
- loopsInput.oninput = () => (el.querySelector('#dungeon-loops-out').textContent = loopsInput.value);
- el.querySelector('#dungeon-generate').onclick = () =>
- generateAndBroadcast(+seedInput.value >>> 0, {
- roomCount: +roomsInput.value,
- loopChance: +loopsInput.value
- });
- el.querySelector('#dungeon-clear').onclick = () => clearAndBroadcast();
- setPanelStats(panelStats);
+function validateDungeon(d) {
+ const failures = [];
+ const { W, grid, bfs } = d;
+ let floorTiles = 0, reached = 0;
+ for (let i = 0; i < grid.length; i++) {
+ if (grid[i] !== FLOOR) continue;
+ floorTiles++;
+ if (bfs[i] >= 0) reached++;
+ }
+ if (reached !== floorTiles) failures.push("connectivity: " + reached + "/" + floorTiles + " floor cells reachable");
+ const boss = d.rooms.find((r) => r.type === "boss" || r.type === "dragon");
+ const entrance = d.rooms.find((r) => r.type === "entrance");
+ if (!boss) failures.push("no boss room");
+ if (!entrance) failures.push("no entrance room");
+ if (entrance && entrance.degree !== 1 && !d.stats.entranceFallback) failures.push("entrance degree " + entrance.degree);
+ if (boss && entrance) {
+ let maxBfs = 0;
+ for (let i = 0; i < bfs.length; i++) if (bfs[i] > maxBfs) maxBfs = bfs[i];
+ const bossDepth = bfs[boss.cy * W + boss.cx];
+ if (bossDepth < 0.6 * maxBfs) failures.push("boss depth " + bossDepth + " < 60% of " + maxBfs);
+ }
+ const loops = d.edges.filter((e) => e.isLoop).length;
+ if (loops < 1) failures.push("no loops (cyclomatic 0)");
+ const doorwaySet = new Set(d.doorways.map((c) => c.y * W + c.x));
+ for (const p of d.props) {
+ const i = p.y * W + p.x;
+ if (grid[i] === FLOOR && doorwaySet.has(i)) failures.push("prop " + p.kind + " on doorway");
+ else if (p.kind !== "torch" && grid[i] !== FLOOR) failures.push("prop " + p.kind + " off floor");
+ else if (p.kind === "torch" && grid[i] !== WALL) failures.push("torch off wall");
+ }
+ for (const s of d.spawns) {
+ const i = s.y * W + s.x;
+ if (grid[i] !== FLOOR || doorwaySet.has(i)) failures.push("spawn off floor / on doorway");
+ }
+ for (const portal of d.portals ?? []) {
+ const i = portal.y * W + portal.x;
+ if (grid[i] !== FLOOR) failures.push("portal off floor");
+ else if (bfs[i] < 0) failures.push("portal unreachable");
+ }
+ return failures;
}
-
-// ---------------------------------------------------------------------------
-// module
-// ---------------------------------------------------------------------------
-
-// Dungeon generator: only {seed, params} replicates — every peer regenerates
-// the identical dungeon locally (determinism is the netcode). The meshes live
-// in a module-owned group at the SCENE root, not in objectsGroup: the dungeon
-// regenerates wholesale and must never enter the object list / GLTF sync.
-// Playable layer (58): the group's userData.play publishes the raster for
-// collision/spawns/minimap, and a key→door objective replicates through
-// module messages ({op:'key'|'door'}) + the state sync.
-
-
-/** @type {any} */ let apiRef = null;
-/** @type {any} */ let THREE = null; // api.THREE
-/** @type {any} */ let current = null; // {seed, params} of the built dungeon
-let play = { keyHolder: null, doorOpen: false };
-
-const GROUP_NAME = 'dungeon-module';
-
-/** @param {{keyHolder: string | null, doorOpen: boolean}} next */
-function setPlay(next) {
- play = next;
- const scene = apiRef?.scene();
- const group = scene?.getObjectByName(GROUP_NAME);
- const key = group?.getObjectByName('dungeon-key');
- if (key) key.visible = !next.keyHolder;
- const bar = group?.getObjectByName('dungeon-door-bar');
- if (bar) bar.visible = !next.doorOpen;
+function dungeonChecksum(d) {
+ let h = checksum32(d.grid);
+ for (const portal of d.portals ?? []) h = checksum32(new Uint8Array([portal.x & 255, portal.x >> 8, portal.y & 255, portal.y >> 8]), h);
+ return h >>> 0;
}
-
-function clearGroup() {
- const scene = apiRef?.scene();
- const group = scene?.getObjectByName(GROUP_NAME);
- if (!group) return;
- group.traverse((child) => {
- child.geometry?.dispose?.();
- if (child.material && !Array.isArray(child.material)) child.material.dispose?.();
- });
- scene.remove(group);
+function generateDungeon(seed, params = {}, floorIndex = 1) {
+ const started = now();
+ const merged = { ...DEFAULT_PARAMS, ...params };
+ merged.roomCount = Math.min(Math.max(merged.roomCount, 4), 60);
+ merged.loopChance = Math.min(Math.max(merged.loopChance, 0), 1);
+ let failures = [];
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const attemptSeed = attempt === 0 ? seed >>> 0 : hash32(seed, "attempt", attempt);
+ const rng = makeRng(attemptSeed);
+ const rooms = separateRooms(scatterRooms(rng.fork("rooms"), merged.roomCount), merged.roomCount);
+ const centers = rooms.map((r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }));
+ const { edges, loops } = buildGraph(centers, delaunayEdges(centers), rng.fork("graph"), merged.loopChance);
+ const semantics = assignSemantics(rooms, edges, rng.fork("semantics"));
+ const raster = rasterize(rooms, edges, semantics, rng.fork("carve"));
+ const { props, spawns } = decorate(rooms, raster, semantics, merged, rng.fork("decor"));
+ let floorTiles = 0, wallTiles = 0;
+ for (let i = 0; i < raster.grid.length; i++) {
+ if (raster.grid[i] === FLOOR) floorTiles++;
+ else if (raster.grid[i] === WALL) wallTiles++;
+ }
+ const criticalLength = edges.filter((e) => e.isCritical).length;
+ const dungeon = {
+ floorIndex,
+ theme: null,
+ // stamped by the campaign
+ name: dungeonName(rng.fork("name")),
+ W: raster.W,
+ H: raster.H,
+ grid: raster.grid,
+ bfs: raster.bfs,
+ rooms,
+ edges,
+ doorways: raster.doorways,
+ corridorCells: raster.corridorCells,
+ props,
+ spawns,
+ portals: [],
+ entranceId: semantics.entrance,
+ bossId: semantics.boss,
+ stats: {
+ floorIndex,
+ seed: attemptSeed,
+ rooms: rooms.length,
+ edges: edges.length,
+ loops,
+ criticalLength,
+ floorTiles,
+ wallTiles,
+ props: props.length,
+ spawns: spawns.length,
+ entranceFallback: semantics.entranceFallback,
+ attempts: attempt + 1,
+ genMs: 0
+ }
+ };
+ failures = validateDungeon(dungeon);
+ if (failures.length === 0) {
+ dungeon.stats.genMs = now() - started;
+ return dungeon;
+ }
+ }
+ throw new Error("dungeon generation failed after 5 attempts (seed " + seed + "): " + failures.join("; "));
}
-/** Build (or rebuild) the dungeon locally @param {number} seed @param {any} params */
-function build(seed, params) {
- const scene = apiRef?.scene();
- if (!scene) return;
- clearGroup();
- const result = generateDungeon(seed, params);
- const { grid, width, height, minX, minY } = result;
-
- const group = new THREE.Group();
- group.name = GROUP_NAME;
-
- let floors = 0;
- let walls = 0;
- for (let i = 0; i < grid.length; i++) {
- if (grid[i] === FLOOR) floors++;
- else if (grid[i] === WALL) walls++;
- }
- const floorMesh = new THREE.InstancedMesh(
- new THREE.BoxGeometry(1, 0.2, 1),
- new THREE.MeshStandardMaterial({ color: 0x8a7f70 }),
- floors
- );
- const wallMesh = new THREE.InstancedMesh(
- new THREE.BoxGeometry(1, 2.4, 1),
- new THREE.MeshStandardMaterial({ color: 0x4a4550 }),
- walls
- );
- const matrix = new THREE.Matrix4();
- let floorIndex = 0;
- let wallIndex = 0;
- for (let y = 0; y < height; y++) {
- for (let x = 0; x < width; x++) {
- const cell = grid[y * width + x];
- if (cell === FLOOR) {
- matrix.setPosition(minX + x + 0.5, -0.1, minY + y + 0.5);
- floorMesh.setMatrixAt(floorIndex++, matrix);
- } else if (cell === WALL) {
- matrix.setPosition(minX + x + 0.5, 1.2, minY + y + 0.5);
- wallMesh.setMatrixAt(wallIndex++, matrix);
- }
- }
- }
- group.add(floorMesh, wallMesh);
-
- // a few torches in random rooms (own rand stream, still seed-deterministic)
- const torchRand = mulberry32(seed ^ 0xbeef);
- const torchRooms = [...result.rooms].slice(0, 64);
- for (let i = 0; i < Math.min(8, torchRooms.length); i++) {
- const room = torchRooms[Math.floor(torchRand() * torchRooms.length)];
- const light = new THREE.PointLight(0xffaa55, 6, 14);
- light.position.set(room.x + (room.w >> 1) + 0.5, 1.8, room.y + (room.h >> 1) + 0.5);
- group.add(light);
- }
+// modules/dungeon/src/gen/bestiary.js
+var BESTIARY = [
+ { id: "spider", floors: [1, 2], weight: 10 },
+ { id: "bat", floors: [1, 3], weight: 8 },
+ { id: "slime", floors: [2, 3], weight: 8 },
+ { id: "skeleton", floors: [2, 4], weight: 9 },
+ { id: "goblin", floors: [3, 4], weight: 8 },
+ { id: "wraith", floors: [3, 5], weight: 6 },
+ { id: "golem", floors: [4, 5], weight: 4 }
+];
- // objective props (58.4): the key waits in the farthest room, the exit
- // door frame stands in the FIRST room — both seed-deterministic
- const keyRoom = farthestRoom(result.rooms);
- if (keyRoom) {
- const c = roomCenter(keyRoom);
- const key = new THREE.Mesh(
- new THREE.OctahedronGeometry(0.22, 0),
- new THREE.MeshStandardMaterial({ color: 0xffc93d, emissive: 0x8a6a00, emissiveIntensity: 0.8 })
- );
- key.name = 'dungeon-key';
- key.position.set(c.x, 0.9, c.z);
- group.add(key);
- }
- if (result.rooms.length) {
- const c = roomCenter(result.rooms[0]);
- const door = new THREE.Group();
- door.name = 'dungeon-door';
- door.position.set(c.x, 0, c.z);
- const frameMat = new THREE.MeshStandardMaterial({ color: 0x2d3340 });
- const left = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2.2, 0.2), frameMat);
- left.position.set(-0.7, 1.1, 0);
- const right = left.clone();
- right.position.x = 0.7;
- const top = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.2, 0.2), frameMat);
- top.position.set(0, 2.2, 0);
- const bar = new THREE.Mesh(
- new THREE.BoxGeometry(1.2, 2, 0.08),
- new THREE.MeshStandardMaterial({
- color: 0x39d0ff,
- emissive: 0x1a6f8f,
- emissiveIntensity: 0.9,
- transparent: true,
- opacity: 0.55
- })
- );
- bar.name = 'dungeon-door-bar';
- bar.position.set(0, 1.1, 0);
- door.add(left, right, top, bar);
- group.add(door);
- }
-
- const stats = {
- rooms: result.rooms.length,
- loops: result.loops,
- ms: Math.round(result.ms * 10) / 10,
- checksum: gridChecksum(grid),
- floors,
- walls
- };
- group.userData = {
- seed,
- params,
- ...stats,
- // the play contract (58): collision/spawn/minimap raster for dungeonPlay.js
- play: { grid, width, height, minX, minY, rooms: result.rooms, floorValue: FLOOR }
- };
- scene.add(group);
- current = { seed, params };
- setPlay({ keyHolder: null, doorOpen: false });
- setPanelStats(stats);
+// modules/dungeon/src/gen/campaign.js
+var DEFAULT_CAMPAIGN_PARAMS = { ...DEFAULT_PARAMS, levelCount: 5, theme: "auto", roomCount: 0 };
+var THEMES = [
+ { id: "crypt", floorTints: [6122328, 5596245, 5135442, 6385500], corridorTint: 4475199, wallTint: 4015160, fogColor: 395274, torchColor: 16747578, gemColor: 3858350 },
+ { id: "sewer", floorTints: [4610122, 4084550, 5267532, 3755330], corridorTint: 3292467, wallTint: 3029809, fogColor: 266252, torchColor: 14201402, gemColor: 5365972 },
+ { id: "forge", floorTints: [5588804, 6113857, 5194302, 6508618], corridorTint: 3945263, wallTint: 3616298, fogColor: 1181445, torchColor: 16734758, gemColor: 16757575 },
+ { id: "frost", floorTints: [5003884, 5398386, 4674662, 5858936], corridorTint: 3752783, wallTint: 3358025, fogColor: 395796, torchColor: 7000831, gemColor: 10476799 },
+ { id: "roost", floorTints: [5260894, 5721190, 4800598, 6181485], corridorTint: 3748421, wallTint: 3419711, fogColor: 656658, torchColor: 13212415, gemColor: 14715647 }
+];
+function themeFor(floorIndex, theme = "auto") {
+ if (theme !== "auto") return THEMES.find((t) => t.id === theme) ?? THEMES[0];
+ return THEMES[(floorIndex - 1) % THEMES.length];
}
-
-function clear() {
- clearGroup();
- current = null;
- setPlay({ keyHolder: null, doorOpen: false });
- setPanelStats(null);
+function roomCountFor(floorIndex, params) {
+ if (params.roomCount) return params.roomCount;
+ return Math.min(34 + 6 * (floorIndex - 1), 60);
}
-
-/** Local pickup/open attempt (click handler + tests) @param {string} what */
-function tryObjective(what) {
- if (!apiRef || !current) return false;
- const me = apiRef.peerId() ?? 'me';
- if (what === 'key' && !play.keyHolder) {
- setPlay({ ...play, keyHolder: me });
- apiRef.send({ op: 'key', holder: me });
- apiRef.toast('You picked up the key — find the glowing door!');
- return true;
- }
- if (what === 'door' && !play.doorOpen) {
- if (play.keyHolder !== me) {
- apiRef.toast(play.keyHolder ? 'The key holder must open the door' : 'The door needs a key');
- return false;
- }
- setPlay({ ...play, doorOpen: true });
- apiRef.send({ op: 'door' });
- apiRef.toast('The door opens — dungeon escaped! 🎉');
- return true;
- }
- return false;
+function nearestFreeCell(dungeon, tx, ty, blocked) {
+ const { W, H, grid } = dungeon;
+ let best = null, bestD = Infinity;
+ for (let y = 1; y < H - 1; y++)
+ for (let x = 1; x < W - 1; x++) {
+ if (grid[y * W + x] !== FLOOR || blocked.has(y * W + x)) continue;
+ const d = (x - tx) * (x - tx) + (y - ty) * (y - ty);
+ if (d < bestD) {
+ bestD = d;
+ best = { x, y };
+ }
+ }
+ return best;
}
-
-/** Panel action: generate locally and tell every peer @param {number} seed @param {any} params */
-function generateAndBroadcast(seed, params) {
- build(seed, params);
- apiRef?.send({ op: 'generate', seed: seed, params: params });
+function placePortals(dungeon, floorIndex, levelCount) {
+ const boss = dungeon.rooms[dungeon.bossId];
+ const entrance = dungeon.rooms[dungeon.entranceId];
+ const { W } = dungeon;
+ const blocked = /* @__PURE__ */ new Set();
+ dungeon.props.forEach((p) => {
+ if (p.kind === "torch" || p.kind === "debris") return;
+ blocked.add(p.y * W + p.x);
+ });
+ dungeon.spawns.forEach((s) => blocked.add(s.y * W + s.x));
+ const addPortal = (kind, room, gated) => {
+ const cell = nearestFreeCell(dungeon, room.cx, room.cy, blocked);
+ if (!cell) return null;
+ dungeon.props = dungeon.props.filter(
+ (p) => !(p.kind === "debris" && Math.abs(p.x - cell.x) <= 1 && Math.abs(p.y - cell.y) <= 1)
+ );
+ for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]])
+ blocked.add((cell.y + dy) * W + (cell.x + dx));
+ const portal = { kind, x: cell.x, y: cell.y, roomId: room.id, gated };
+ dungeon.portals.push(portal);
+ return portal;
+ };
+ if (floorIndex < levelCount) addPortal("up", boss, true);
+ else {
+ boss.type = "dragon";
+ }
+ if (floorIndex >= 2) addPortal("down", entrance, false);
}
-
-function clearAndBroadcast() {
- clear();
- apiRef?.send({ op: 'clear' });
+function typeSpawns(dungeon, floorIndex, bestiaryTable, rng) {
+ const legal = bestiaryTable.filter((b) => floorIndex >= b.floors[0] && floorIndex <= b.floors[1]);
+ if (!legal.length) return;
+ const total = legal.reduce((s, b) => s + b.weight, 0);
+ dungeon.spawns.forEach((spawn) => {
+ let roll = rng.float(0, total);
+ spawn.type = legal[legal.length - 1].id;
+ for (const b of legal) {
+ roll -= b.weight;
+ if (roll <= 0) {
+ spawn.type = b.id;
+ break;
+ }
+ }
+ });
+ if (dungeon.spawns.length >= 12) {
+ const present = new Set(dungeon.spawns.map((s) => s.type));
+ const missing = legal.filter((b) => !present.has(b.id));
+ missing.forEach((b) => {
+ const slot = dungeon.spawns[rng.int(0, dungeon.spawns.length - 1)];
+ slot.type = b.id;
+ });
+ }
+}
+function generateCampaign(seed, params = {}, bestiaryTable = BESTIARY) {
+ const merged = { ...DEFAULT_CAMPAIGN_PARAMS, ...params };
+ merged.levelCount = Math.min(Math.max(merged.levelCount ?? 5, 1), 9);
+ const floors = [];
+ for (let i = 1; i <= merged.levelCount; i++) {
+ const floorSeed = hash32(seed, i, "floor");
+ const floorParams = {
+ ...merged,
+ roomCount: roomCountFor(i, merged),
+ monsterDensity: Math.min(merged.monsterDensity * (1 + 0.1 * (i - 1)), merged.monsterDensity * 1.5)
+ };
+ const dungeon = generateDungeon(floorSeed, floorParams, i);
+ dungeon.theme = themeFor(i, merged.theme);
+ dungeon.rooms.forEach((room) => {
+ if (room.type !== "boss" && room.type !== "dragon")
+ room.difficulty = Math.min(1, room.difficulty + 0.1 * (i - 1));
+ });
+ placePortals(dungeon, i, merged.levelCount);
+ typeSpawns(dungeon, i, bestiaryTable, makeRng(hash32(floorSeed, "spawnTypes")));
+ dungeon.checksum = dungeonChecksum(dungeon);
+ floors.push(dungeon);
+ }
+ floors[0].ox = -(floors[0].W >> 1);
+ floors[0].oy = -(floors[0].H >> 1);
+ for (let k = 1; k < floors.length; k++) {
+ const up = floors[k - 1].portals.find((p) => p.kind === "up");
+ const down = floors[k].portals.find((p) => p.kind === "down");
+ if (up && down) {
+ floors[k].ox = floors[k - 1].ox + up.x - down.x;
+ floors[k].oy = floors[k - 1].oy + up.y - down.y;
+ } else {
+ floors[k].ox = -(floors[k].W >> 1);
+ floors[k].oy = -(floors[k].H >> 1);
+ }
+ }
+ let h = 2166136261;
+ floors.forEach((f) => {
+ h = checksum32(f.grid, h);
+ f.portals.forEach((p) => {
+ h = checksum32(new Uint8Array([p.x & 255, p.x >> 8, p.y & 255, p.y >> 8, p.kind === "up" ? 1 : 2]), h);
+ });
+ });
+ const campaign = { seed: seed >>> 0, params: merged, floors, checksum: h >>> 0 };
+ const failures = validateCampaign(campaign);
+ if (failures.length) throw new Error("campaign validation failed: " + failures.join("; "));
+ return campaign;
+}
+function validateCampaign(campaign) {
+ const failures = [];
+ const { floors, params } = campaign;
+ if (floors.length !== params.levelCount) failures.push("floor count " + floors.length + " != " + params.levelCount);
+ for (let k = 0; k < floors.length; k++) {
+ const floor = floors[k];
+ const i = k + 1;
+ const up = floor.portals.find((p) => p.kind === "up");
+ const down = floor.portals.find((p) => p.kind === "down");
+ if (i < floors.length && (!up || !up.gated)) failures.push("floor " + i + " missing gated UP portal");
+ if (i === floors.length && up) failures.push("top floor has an UP portal");
+ if (i >= 2 && !down) failures.push("floor " + i + " missing DOWN portal");
+ if (i === 1 && down) failures.push("floor 1 has a DOWN portal");
+ for (const portal of floor.portals) {
+ const cell = portal.y * floor.W + portal.x;
+ if (floor.grid[cell] !== FLOOR) failures.push("floor " + i + " portal off floor");
+ if (floor.bfs[cell] < 0) failures.push("floor " + i + " portal unreachable from entrance");
+ }
+ }
+ return failures;
}
-export default {
- id: 'dungeon',
- name: 'Dungeon Generator',
- version: '1.1.0',
- description: 'Seed-replicated procedural dungeon (rooms, corridors, torches) with a key-and-door objective; publishes userData.play so the app walks it in play mode.',
- /** @param {any} api */
- register(api) {
- apiRef = api;
- THREE = api.THREE;
-
- api.registerSystemGroup(GROUP_NAME); // visible under the System filter
- api.registerInteractiveGroup(GROUP_NAME); // key/door are clickable (58.4)
+// modules/dungeon/src/render.js
+function cellNoise(x, y, seed, amp = 0.05) {
+ let h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ seed) >>> 0;
+ h = Math.imul(h ^ h >>> 13, 1540483477) >>> 0;
+ return 1 - amp + (h & 65535) / 65535 * amp * 2;
+}
+function buildFloorGroup(THREE, dungeon) {
+ const { W, H, grid, rooms, props, theme, ox, oy } = dungeon;
+ const group = new THREE.Group();
+ const matrix = new THREE.Matrix4();
+ const position = new THREE.Vector3();
+ const quaternion = new THREE.Quaternion();
+ const scale = new THREE.Vector3(1, 1, 1);
+ const color = new THREE.Color();
+ const worldX = (x) => x + ox + 0.5;
+ const worldZ = (y) => y + oy + 0.5;
+ const roomOf = new Int16Array(W * H).fill(-1);
+ rooms.forEach((room) => {
+ for (let y = room.y; y < room.y + room.h; y++)
+ for (let x = room.x; x < room.x + room.w; x++)
+ if (grid[y * W + x] === FLOOR) roomOf[y * W + x] = room.id;
+ });
+ let floorCount = 0, wallCount = 0;
+ for (let i = 0; i < grid.length; i++) {
+ if (grid[i] === FLOOR) floorCount++;
+ else if (grid[i] === WALL) wallCount++;
+ }
+ const floorMesh = new THREE.InstancedMesh(
+ new THREE.BoxGeometry(1, 0.2, 1),
+ new THREE.MeshStandardMaterial({ color: 16777215, roughness: 0.95 }),
+ floorCount
+ );
+ floorMesh.name = "dk-floors";
+ const wallMesh = new THREE.InstancedMesh(
+ new THREE.BoxGeometry(1, 1, 1),
+ new THREE.MeshStandardMaterial({ color: 16777215, roughness: 0.9 }),
+ wallCount
+ );
+ wallMesh.name = "dk-walls";
+ const wallsAround = (x, y) => {
+ let count = 0;
+ for (let dy = -1; dy <= 1; dy++)
+ for (let dx = -1; dx <= 1; dx++) {
+ const nx = x + dx, ny = y + dy;
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
+ if (grid[ny * W + nx] === WALL) count++;
+ }
+ return count;
+ };
+ let floorIndex = 0, wallIndex = 0;
+ for (let y = 0; y < H; y++)
+ for (let x = 0; x < W; x++) {
+ const cell = grid[y * W + x];
+ if (cell === FLOOR) {
+ matrix.makeTranslation(worldX(x), -0.1, worldZ(y));
+ floorMesh.setMatrixAt(floorIndex, matrix);
+ const roomId = roomOf[y * W + x];
+ const tint = roomId >= 0 ? theme.floorTints[roomId % theme.floorTints.length] : theme.corridorTint;
+ const ao = 1 - 0.09 * Math.min(wallsAround(x, y), 4);
+ color.setHex(tint).multiplyScalar(ao * cellNoise(x, y, dungeon.stats.seed) * (roomId >= 0 ? 1 : 0.9));
+ floorMesh.setColorAt(floorIndex, color);
+ floorIndex++;
+ } else if (cell === WALL) {
+ const height = 2 + (cellNoise(x, y, dungeon.stats.seed ^ 24301, 0.125) - 1) * 2;
+ position.set(worldX(x), height / 2, worldZ(y));
+ scale.set(1, height, 1);
+ matrix.compose(position, quaternion, scale);
+ wallMesh.setMatrixAt(wallIndex, matrix);
+ color.setHex(theme.wallTint).multiplyScalar(cellNoise(x, y, dungeon.stats.seed ^ 2577));
+ wallMesh.setColorAt(wallIndex, color);
+ wallIndex++;
+ }
+ }
+ scale.set(1, 1, 1);
+ group.add(floorMesh, wallMesh);
+ const byKind = {};
+ props.forEach((p) => (byKind[p.kind] ??= []).push(p));
+ const instanced = (name, geometry, material, list, pose) => {
+ if (!list?.length) return null;
+ const mesh = new THREE.InstancedMesh(geometry, material, list.length);
+ mesh.name = name;
+ list.forEach((p, i) => {
+ pose(p, i);
+ matrix.compose(position, quaternion, scale);
+ mesh.setMatrixAt(i, matrix);
+ });
+ position.set(0, 0, 0);
+ quaternion.identity();
+ scale.set(1, 1, 1);
+ group.add(mesh);
+ return mesh;
+ };
+ instanced(
+ "dk-pillars",
+ new THREE.CylinderGeometry(0.3, 0.38, 2.4, 8),
+ new THREE.MeshStandardMaterial({ color: theme.wallTint, roughness: 0.85 }),
+ byKind.pillar,
+ (p) => position.set(worldX(p.x), 1.2, worldZ(p.y))
+ );
+ instanced(
+ "dk-debris",
+ new THREE.BoxGeometry(0.32, 0.22, 0.32),
+ new THREE.MeshStandardMaterial({ color: theme.corridorTint, roughness: 1 }),
+ byKind.debris,
+ (p) => {
+ position.set(worldX(p.x), 0.1, worldZ(p.y));
+ quaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), p.rot ?? 0);
+ scale.setScalar(p.scale ?? 1);
+ }
+ );
+ quaternion.identity();
+ scale.set(1, 1, 1);
+ instanced(
+ "dk-crates",
+ new THREE.BoxGeometry(0.72, 0.72, 0.72),
+ new THREE.MeshStandardMaterial({ color: 9071165, roughness: 0.9 }),
+ byKind.crate,
+ (p) => position.set(worldX(p.x), 0.36, worldZ(p.y))
+ );
+ instanced(
+ "dk-braziers",
+ new THREE.CylinderGeometry(0.3, 0.2, 0.55, 8),
+ new THREE.MeshStandardMaterial({ color: 2894900, roughness: 0.6, metalness: 0.4 }),
+ byKind.brazier,
+ (p) => position.set(worldX(p.x), 0.28, worldZ(p.y))
+ );
+ instanced(
+ "dk-chests",
+ new THREE.BoxGeometry(0.85, 0.55, 0.6),
+ new THREE.MeshStandardMaterial({ color: 11569710, roughness: 0.5, metalness: 0.3 }),
+ byKind.chest,
+ (p) => position.set(worldX(p.x), 0.28, worldZ(p.y))
+ );
+ instanced(
+ "dk-crystals",
+ new THREE.OctahedronGeometry(0.42, 0),
+ new THREE.MeshBasicMaterial({ color: theme.gemColor }),
+ byKind.crystal,
+ (p) => {
+ position.set(worldX(p.x), 1, worldZ(p.y));
+ scale.set(1, 1.7, 1);
+ }
+ );
+ scale.set(1, 1, 1);
+ instanced(
+ "dk-rings",
+ new THREE.TorusGeometry(0.9, 0.07, 8, 28),
+ new THREE.MeshBasicMaterial({ color: 3854532 }),
+ byKind.ring,
+ (p) => {
+ position.set(worldX(p.x), 0.06, worldZ(p.y));
+ quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
+ }
+ );
+ quaternion.identity();
+ const torches = byKind.torch ?? [];
+ instanced(
+ "dk-torch-brackets",
+ new THREE.BoxGeometry(0.12, 0.34, 0.12),
+ new THREE.MeshStandardMaterial({ color: 3813670, roughness: 0.8 }),
+ torches,
+ (p) => position.set(worldX(p.x) + (p.fx ?? 0) * 0.42, 1.45, worldZ(p.y) + (p.fy ?? 0) * 0.42)
+ );
+ const flameSpots = torches.map((p) => ({
+ x: worldX(p.x) + (p.fx ?? 0) * 0.42,
+ y: 1.78,
+ z: worldZ(p.y) + (p.fy ?? 0) * 0.42
+ })).concat((byKind.brazier ?? []).map((p) => ({ x: worldX(p.x), y: 0.75, z: worldZ(p.y) })));
+ instanced(
+ "dk-flames",
+ new THREE.ConeGeometry(0.11, 0.3, 6),
+ new THREE.MeshBasicMaterial({ color: theme.torchColor }),
+ flameSpots,
+ (p) => position.set(p.x, p.y, p.z)
+ );
+ const budget = 8;
+ const step = Math.max(1, Math.ceil(flameSpots.length / budget));
+ for (let i = 0; i < flameSpots.length && i / step < budget; i += step) {
+ const spot = flameSpots[i];
+ const light = new THREE.PointLight(theme.torchColor, 5, 11, 2);
+ light.name = "dk-light";
+ light.position.set(spot.x, spot.y + 0.25, spot.z);
+ group.add(light);
+ }
+ group.userData._dk = { theme };
+ return group;
+}
+function animateFloor(group, time) {
+ group.children.forEach((child) => {
+ if (child.name === "dk-light")
+ child.intensity = 5 + Math.sin(time * 9 + child.position.x * 3.7) * 0.9 + Math.sin(time * 23 + child.position.z * 5.1) * 0.5;
+ });
+}
- api.registerMenu('Dungeon generator', () => {
- openPanel();
- });
+// modules/dungeon/src/contract.js
+var GROUP_NAME = "dungeon-module";
+var CONTRACT_VERSION = 2;
+function worldRoom(room, ox, oy) {
+ return {
+ x: room.x + ox,
+ y: room.y + oy,
+ w: room.w,
+ h: room.h,
+ id: room.id,
+ type: room.type,
+ depth: room.depth,
+ cx: room.cx + ox + 0.5,
+ cz: room.cy + oy + 0.5
+ };
+}
+function spawnOrderedRooms(dungeon) {
+ return [...dungeon.rooms].sort((a, b) => {
+ const ka = a.type === "entrance" ? -1 : a.depth;
+ const kb = b.type === "entrance" ? -1 : b.depth;
+ return ka - kb || a.id - b.id;
+ }).map((room) => worldRoom(room, dungeon.ox, dungeon.oy));
+}
+function playPayload(campaign, floorIndex, extras = {}) {
+ const dungeon = campaign.floors[floorIndex - 1];
+ if (!dungeon) return null;
+ const { ox, oy } = dungeon;
+ const wx = (x) => x + ox + 0.5;
+ const wz = (y) => y + oy + 0.5;
+ const play = {
+ // ---- the core half (dungeonPlay.js shape — DO NOT CHANGE) ----------------------
+ grid: dungeon.grid,
+ width: dungeon.W,
+ height: dungeon.H,
+ minX: ox,
+ minY: oy,
+ rooms: spawnOrderedRooms(dungeon),
+ floorValue: FLOOR,
+ // ---- play settings (playSettings.js) --------------------------------------------
+ // a dungeon is WALKED: the fly keys are off unless a rule module says otherwise
+ grounded: extras.grounded == null ? true : !!extras.grounded,
+ // ---- the inter-module seam (a rule module reads these) --------------------------
+ contract: CONTRACT_VERSION,
+ seed: campaign.seed,
+ params: campaign.params,
+ floorIndex,
+ levelCount: campaign.floors.length,
+ name: dungeon.name,
+ theme: dungeon.theme,
+ checksum: dungeon.checksum,
+ campaignChecksum: campaign.checksum,
+ /** every prop in WORLD coordinates (gems carry `index`) */
+ props: dungeon.props.map((p) => ({ ...p, wx: wx(p.x), wz: wz(p.y) })),
+ /** portals in world coordinates: kind 'up' | 'down', gated */
+ portals: dungeon.portals.map((p) => ({ kind: p.kind, gated: !!p.gated, roomId: p.roomId, x: p.x, y: p.y, wx: wx(p.x), wz: wz(p.y) })),
+ /** typed enemy spawn slots in world coordinates */
+ spawns: dungeon.spawns.map((s) => ({ ...s, wx: wx(s.x), wz: wz(s.y) })),
+ /** minimap markers: a BARE Kit publishes none — rule modules add theirs */
+ markers: Array.isArray(extras.markers) ? extras.markers : []
+ };
+ return play;
+}
+function mergeMarkers(byOwner) {
+ const out = [];
+ for (const owner of Object.keys(byOwner).sort()) {
+ for (const marker of byOwner[owner] ?? []) {
+ if (typeof marker?.x === "number" && typeof marker?.z === "number")
+ out.push({ x: marker.x, z: marker.z, kind: String(marker.kind ?? "marker") });
+ }
+ }
+ return out;
+}
+function normalizeParams(params = {}) {
+ const out = {};
+ if (params.roomCount != null) out.roomCount = Math.max(0, Math.min(60, Math.round(Number(params.roomCount) || 0)));
+ if (params.loopChance != null) out.loopChance = Math.max(0, Math.min(1, Number(params.loopChance) || 0));
+ if (params.levelCount != null) out.levelCount = Math.max(1, Math.min(9, Math.round(Number(params.levelCount) || 1)));
+ if (params.gemDensity != null) out.gemDensity = Math.max(0.25, Math.min(2, Number(params.gemDensity) || 1));
+ if (params.decorDensity != null) out.decorDensity = Math.max(0, Math.min(2, Number(params.decorDensity) || 0));
+ if (params.theme != null) out.theme = String(params.theme);
+ return out;
+}
- // desktop click + VR trigger on the key/door (58.4)
- api.registerClickHandler((/** @type {any} */ mesh) => {
- if (mesh?.name === 'dungeon-key') return tryObjective('key');
- if (mesh?.name === 'dungeon-door-bar' || mesh?.parent?.name === 'dungeon-door') return tryObjective('door');
- return false;
- });
+// modules/dungeon/src/kit.js
+function createKit(api) {
+ const THREE = api.THREE;
+ const state = {
+ seed: (
+ /** @type {number | null} */
+ null
+ ),
+ params: (
+ /** @type {any} */
+ {}
+ ),
+ campaign: (
+ /** @type {any} */
+ null
+ ),
+ floorIndex: 1
+ };
+ const markersByOwner = {};
+ let grounded = null;
+ let floorGroup = null;
+ const listeners = [];
+ const currentFloor = () => state.campaign?.floors[state.floorIndex - 1] ?? null;
+ function group() {
+ const scene = api.scene();
+ if (!scene) return null;
+ let existing = scene.getObjectByName(GROUP_NAME);
+ if (!existing) {
+ existing = new THREE.Group();
+ existing.name = GROUP_NAME;
+ scene.add(existing);
+ }
+ if (existing.userData.kit !== kit) existing.userData.kit = kit;
+ return existing;
+ }
+ function disposeFloor() {
+ if (!floorGroup) return;
+ floorGroup.traverse((child) => {
+ child.geometry?.dispose?.();
+ if (child.material && !Array.isArray(child.material)) child.material.dispose?.();
+ });
+ floorGroup.parent?.remove(floorGroup);
+ floorGroup = null;
+ }
+ function notify() {
+ listeners.forEach((fn) => {
+ try {
+ fn();
+ } catch {
+ }
+ });
+ }
+ function publish() {
+ const g = group();
+ if (!g) return;
+ const play = state.campaign ? playPayload(state.campaign, state.floorIndex, { grounded, markers: mergeMarkers(markersByOwner) }) : null;
+ if (play) {
+ const dungeon = currentFloor();
+ g.userData.play = play;
+ g.userData.seed = state.seed;
+ g.userData.params = state.params;
+ g.userData.floorIndex = state.floorIndex;
+ g.userData.levelCount = state.campaign.floors.length;
+ g.userData.checksum = dungeon.checksum;
+ g.userData.campaignChecksum = state.campaign.checksum;
+ g.userData.name = dungeon.name;
+ g.userData.stats = dungeon.stats;
+ } else {
+ delete g.userData.play;
+ delete g.userData.seed;
+ delete g.userData.params;
+ delete g.userData.floorIndex;
+ delete g.userData.levelCount;
+ delete g.userData.checksum;
+ delete g.userData.campaignChecksum;
+ delete g.userData.name;
+ delete g.userData.stats;
+ }
+ notify();
+ }
+ function rebuild() {
+ disposeFloor();
+ const g = group();
+ const dungeon = currentFloor();
+ if (g && dungeon) {
+ floorGroup = buildFloorGroup(THREE, dungeon);
+ floorGroup.name = "dk-floor";
+ g.add(floorGroup);
+ }
+ publish();
+ }
+ function generate(seed, params = {}, opts = {}) {
+ const clean = normalizeParams(params);
+ let campaign;
+ try {
+ campaign = generateCampaign(seed >>> 0, clean);
+ } catch (error) {
+ api.toast("Dungeon generation failed: " + error.message);
+ return false;
+ }
+ state.seed = seed >>> 0;
+ state.params = clean;
+ state.campaign = campaign;
+ state.floorIndex = 1;
+ for (const owner of Object.keys(markersByOwner)) delete markersByOwner[owner];
+ rebuild();
+ if (opts.broadcast !== false) api.send({ op: "generate", seed: state.seed, params: clean, checksum: campaign.checksum });
+ return true;
+ }
+ function applyRemoteGenerate(data) {
+ const same = data.seed === state.seed && JSON.stringify(normalizeParams(data.params ?? {})) === JSON.stringify(state.params);
+ if (same && state.campaign) return;
+ if (!generate(data.seed, data.params ?? {}, { broadcast: false })) return;
+ if (data.checksum && data.checksum !== state.campaign.checksum)
+ api.toast("Dungeon checksum differs from the sender \u2014 module versions may not match");
+ }
+ function showFloor(target, opts = {}) {
+ const levels = state.campaign?.floors.length ?? 0;
+ target = Math.round(Number(target));
+ if (!levels || !(target >= 1) || target > levels) return false;
+ if (target === state.floorIndex) return true;
+ state.floorIndex = target;
+ for (const owner of Object.keys(markersByOwner)) delete markersByOwner[owner];
+ rebuild();
+ if (opts.broadcast !== false) api.send({ op: "floor", floorIndex: target });
+ return true;
+ }
+ function clear(opts = {}) {
+ disposeFloor();
+ state.campaign = null;
+ state.seed = null;
+ state.params = {};
+ state.floorIndex = 1;
+ for (const owner of Object.keys(markersByOwner)) delete markersByOwner[owner];
+ publish();
+ if (opts.broadcast !== false) api.send({ op: "clear" });
+ }
+ function setMarkers(owner, list) {
+ if (!owner) return;
+ if (list && list.length) markersByOwner[owner] = list;
+ else delete markersByOwner[owner];
+ const g = group();
+ if (g?.userData.play) g.userData.play.markers = mergeMarkers(markersByOwner);
+ }
+ function setGrounded(value) {
+ grounded = value == null ? null : !!value;
+ const g = group();
+ if (g?.userData.play) g.userData.play.grounded = grounded == null ? true : grounded;
+ }
+ function stats() {
+ const dungeon = currentFloor();
+ if (!dungeon || !state.campaign) return null;
+ return {
+ rooms: dungeon.rooms.length,
+ gems: dungeon.props.filter((p) => p.kind === "gem").length,
+ loops: dungeon.stats.loops,
+ ms: Math.round(dungeon.stats.genMs * 10) / 10,
+ checksum: dungeon.checksum,
+ campaignChecksum: state.campaign.checksum,
+ name: dungeon.name,
+ floorIndex: state.floorIndex,
+ levelCount: state.campaign.floors.length
+ };
+ }
+ const kit = {
+ /** bump when a field below changes shape */
+ version: 2,
+ generate,
+ showFloor,
+ clear,
+ setMarkers,
+ setGrounded,
+ /** the replicated world state, copied */
+ state: () => ({ seed: state.seed, params: { ...state.params }, floorIndex: state.floorIndex, levelCount: state.campaign?.floors.length ?? 0 }),
+ /** the whole campaign (pure data) — for a rule that needs another floor's props */
+ campaign: () => state.campaign,
+ stats,
+ /** @param {() => void} fn refresh hook (toolbox) */
+ onChange: (fn) => {
+ listeners.push(fn);
+ return () => {
+ const at = listeners.indexOf(fn);
+ if (at >= 0) listeners.splice(at, 1);
+ };
+ }
+ };
+ function handleMessage(data) {
+ if (data.op === "generate") applyRemoteGenerate(data);
+ else if (data.op === "floor") showFloor(data.floorIndex, { broadcast: false });
+ else if (data.op === "clear") clear({ broadcast: false });
+ }
+ function getState() {
+ if (state.seed == null) return null;
+ return { seed: state.seed, params: state.params, floorIndex: state.floorIndex };
+ }
+ function applyState(remote) {
+ if (!remote || remote.seed == null) return;
+ if (!generate(remote.seed, remote.params ?? {}, { broadcast: false })) return;
+ if (remote.floorIndex && remote.floorIndex !== state.floorIndex) showFloor(remote.floorIndex, { broadcast: false });
+ }
+ function tick(time) {
+ if (floorGroup) animateFloor(floorGroup, time);
+ }
+ return { kit, state, group, handleMessage, getState, applyState, tick, ensureGroup: group };
+}
- api.onMessage((/** @type {any} */ data) => {
- if (data.op === 'generate') build(data.seed, data.params);
- else if (data.op === 'clear') clear();
- else if (data.op === 'key') {
- setPlay({ ...play, keyHolder: data.holder });
- apiRef.toast('The key was picked up!');
- } else if (data.op === 'door') {
- setPlay({ ...play, doorOpen: true });
- apiRef.toast('The door opens — dungeon escaped! 🎉');
- }
- });
+// modules/dungeon/src/nodes.js
+var NODE_TYPE = "dkdungeon";
+function registerNodes(api, core) {
+ const { kit } = core;
+ api.registerNodeGroup({
+ group: "Dungeon Kit",
+ items: [
+ {
+ type: NODE_TYPE,
+ label: "Dungeon",
+ defaults: { seed: 1337, roomCount: 0, levelCount: 5, loopChance: 0.15, gemDensity: 1, apply: false },
+ params: [
+ { key: "seed", kind: "range", min: 0, max: 999999, step: 1 },
+ { key: "roomCount", kind: "range", min: 0, max: 60, step: 2 },
+ { key: "levelCount", kind: "range", min: 1, max: 9, step: 1 },
+ { key: "loopChance", kind: "range", min: 0, max: 0.5, step: 0.05 },
+ { key: "gemDensity", kind: "range", min: 0.25, max: 2, step: 0.05 },
+ { key: "apply", kind: "toggle" }
+ ]
+ }
+ ]
+ });
+ api.registerEffect(NODE_TYPE, (object, base, data) => {
+ if (!data.apply) return;
+ const seed = Math.max(0, Math.round(Number(data.seed) || 0));
+ const params = {
+ roomCount: Math.round(Number(data.roomCount) || 0),
+ levelCount: Math.round(Number(data.levelCount) || 5),
+ loopChance: Number(data.loopChance ?? 0.15),
+ gemDensity: Number(data.gemDensity ?? 1)
+ };
+ const current = kit.state();
+ const same = current.seed === seed && current.params.roomCount === params.roomCount && current.params.levelCount === params.levelCount && Math.abs((current.params.loopChance ?? 0.15) - params.loopChance) < 1e-9 && Math.abs((current.params.gemDensity ?? 1) - params.gemDensity) < 1e-9;
+ if (same) return;
+ kit.generate(seed, params, { broadcast: false });
+ });
+}
- api.onSceneClear(() => clear());
+// modules/dungeon/src/toolbox.js
+var SELECT_CSS = "background:rgba(0,0,0,0.25);color:inherit;border:1px solid rgba(255,255,255,0.15);border-radius:4px;font-size:11px;padding:1px 4px;";
+function registerToolbox(api, core) {
+ const { kit } = core;
+ function elem(tag, props, css) {
+ const node = document.createElement(tag);
+ Object.assign(node, props ?? {});
+ if (css) node.setAttribute("style", css);
+ return node;
+ }
+ const recipeNode = () => {
+ if (!api.flow?.nodes) return null;
+ return api.flow.nodes(NODE_TYPE).find((n) => n.data?.apply) ?? null;
+ };
+ function applyRecipe(seed, params) {
+ const node = recipeNode();
+ if (node && api.flow.setNodeData(node.id, { seed, ...params })) {
+ api.toast("Dungeon node updated \u2014 every peer regenerates from the graph");
+ return;
+ }
+ kit.generate(seed, params);
+ }
+ function mount(el) {
+ el.innerHTML = "";
+ const current = kit.state();
+ const values = {
+ seed: current.seed ?? 1337,
+ roomCount: current.params.roomCount ?? 0,
+ loopChance: current.params.loopChance ?? 0.15,
+ levelCount: current.params.levelCount ?? 5
+ };
+ const row = (label, control, out) => {
+ const line = elem("div", { className: "tbx-row" }, "display:flex;align-items:center;gap:6px;margin:3px 0;");
+ line.appendChild(elem("span", { className: "tbx-label", textContent: label }, "flex:1;font-size:11px;"));
+ if (out) line.appendChild(out);
+ line.appendChild(control);
+ el.appendChild(line);
+ return line;
+ };
+ const seedInput = (
+ /** @type {HTMLInputElement} */
+ elem("input", { id: "dk-seed", type: "number", value: String(values.seed), min: "0", step: "1" }, SELECT_CSS + "width:84px;")
+ );
+ seedInput.onchange = () => values.seed = Math.max(0, Math.round(Number(seedInput.value) || 0));
+ const dice = elem("button", { id: "dk-dice", className: "tbx-btn", textContent: "\u{1F3B2}", title: "Random seed" });
+ dice.onclick = () => {
+ values.seed = Math.floor(Math.random() * 1e6);
+ seedInput.value = String(values.seed);
+ };
+ const seedLine = elem("div", {}, "display:flex;align-items:center;gap:6px;margin:3px 0;");
+ seedLine.appendChild(elem("span", { className: "tbx-label", textContent: "Seed" }, "flex:1;font-size:11px;"));
+ seedLine.appendChild(seedInput);
+ seedLine.appendChild(dice);
+ el.appendChild(seedLine);
+ const slider = (key, min, max, step, fmt) => {
+ const out = elem("span", { textContent: fmt(values[key]) }, "font-size:11px;opacity:0.8;min-width:34px;text-align:right;");
+ const input = (
+ /** @type {HTMLInputElement} */
+ elem("input", { id: "dk-" + key, type: "range", min: String(min), max: String(max), step: String(step), value: String(values[key]) }, "width:96px;")
+ );
+ input.oninput = () => {
+ values[key] = Number(input.value);
+ out.textContent = fmt(values[key]);
+ };
+ return { input, out };
+ };
+ const rooms = slider("roomCount", 0, 60, 2, (v) => v ? String(v) : "auto");
+ row("Rooms", rooms.input, rooms.out);
+ const loops = slider("loopChance", 0, 0.5, 0.05, (v) => v.toFixed(2));
+ row("Extra loops", loops.input, loops.out);
+ const levels = slider("levelCount", 1, 9, 1, (v) => String(v));
+ row("Floors", levels.input, levels.out);
+ const buttons = elem("div", {}, "display:flex;flex-wrap:wrap;gap:4px;margin:6px 0;");
+ const generate = elem("button", { id: "dk-generate", className: "tbx-btn tbx-primary", textContent: "Generate" });
+ generate.onclick = () => applyRecipe(values.seed, { roomCount: values.roomCount, loopChance: values.loopChance, levelCount: values.levelCount });
+ const clear = elem("button", { id: "dk-clear", className: "tbx-btn", textContent: "Clear" });
+ clear.onclick = () => kit.clear();
+ buttons.appendChild(generate);
+ buttons.appendChild(clear);
+ el.appendChild(buttons);
+ const floorLine = elem("div", {}, "display:flex;align-items:center;gap:6px;margin:3px 0;");
+ const down = elem("button", { id: "dk-floor-down", className: "tbx-btn", textContent: "\u25C2", title: "Floor below" });
+ const floorOut = elem("span", { id: "dk-floor", textContent: "\u2014" }, "flex:1;text-align:center;font-size:11px;");
+ const up = elem("button", { id: "dk-floor-up", className: "tbx-btn", textContent: "\u25B8", title: "Floor above" });
+ down.onclick = () => kit.showFloor(kit.state().floorIndex - 1);
+ up.onclick = () => kit.showFloor(kit.state().floorIndex + 1);
+ floorLine.appendChild(down);
+ floorLine.appendChild(floorOut);
+ floorLine.appendChild(up);
+ el.appendChild(floorLine);
+ const status = elem("div", { id: "dk-stats", className: "tbx-label" }, "white-space:pre-wrap;font-size:11px;opacity:0.8;margin-top:6px;");
+ el.appendChild(status);
+ el.appendChild(
+ elem(
+ "div",
+ { className: "tbx-label", textContent: "Peers regenerate from the seed \u2014 the same checksum means the exact same dungeon. Dungeon Realms plays it." },
+ "font-size:10px;opacity:0.6;margin-top:4px;"
+ )
+ );
+ const refresh = () => {
+ const s = kit.stats();
+ const st = kit.state();
+ floorOut.textContent = s ? "Floor " + s.floorIndex + " / " + s.levelCount + " \xB7 " + s.name : "no dungeon";
+ down.disabled = !s || s.floorIndex <= 1;
+ up.disabled = !s || s.floorIndex >= s.levelCount;
+ status.textContent = s ? s.rooms + " rooms \xB7 " + s.gems + " gems \xB7 " + s.loops + " loops \xB7 " + s.ms + " ms\nchecksum " + s.checksum + " \xB7 campaign " + s.campaignChecksum + (recipeNode() ? "\nrecipe owned by the Dungeon node" : "") : "Generate a dungeon, then press the red Play button to walk it.";
+ if (st.seed != null && Number(seedInput.value) !== st.seed && document.activeElement !== seedInput) {
+ values.seed = st.seed;
+ seedInput.value = String(st.seed);
+ }
+ };
+ refresh();
+ const off = kit.onChange(refresh);
+ return () => off();
+ }
+ if (typeof api.registerToolbox === "function") {
+ const id = api.registerToolbox({ id: "kit", title: "Dungeon Kit", width: 260, minW: 220, mount });
+ api.registerMenu("Dungeon Kit", () => typeof api.openToolbox === "function" ? api.openToolbox(id) : null);
+ return { id, mount, host: "toolbox" };
+ }
+ let panel = null;
+ let cleanup = null;
+ function openPanel() {
+ if (panel || typeof document === "undefined") return;
+ panel = elem(
+ "div",
+ { id: "dungeon-panel" },
+ "position:fixed;right:0.5rem;top:5rem;z-index:40;display:flex;width:18rem;flex-direction:column;gap:0.5rem;border-radius:0.5rem;background:#1f2937;padding:0.75rem;font-size:0.875rem;color:#fff;box-shadow:0 10px 25px rgba(0,0,0,0.4);"
+ );
+ const head = elem("div", {}, "display:flex;align-items:center;justify-content:space-between");
+ head.appendChild(elem("span", { textContent: "Dungeon Kit" }, "font-weight:600"));
+ const close = elem("button", { id: "dungeon-close", textContent: "\u2715" }, "border-radius:2px;background:#4b5563;padding:0 0.5rem");
+ close.onclick = () => {
+ cleanup?.();
+ cleanup = null;
+ panel?.remove();
+ panel = null;
+ };
+ head.appendChild(close);
+ panel.appendChild(head);
+ const body = elem("div");
+ panel.appendChild(body);
+ document.body.appendChild(panel);
+ cleanup = mount(body) ?? null;
+ }
+ api.registerMenu("Dungeon Kit", openPanel);
+ return { id: null, mount, host: "panel", openPanel };
+}
- // late joiners rebuild from the current seed + objective state
- api.registerStateSync({
- getState: () => (current ? { ...current, play } : null),
- applyState: (/** @type {any} */ state) => {
- if (state?.seed != null) {
- build(state.seed, state.params);
- if (state.play) setPlay(state.play);
- }
- }
- });
- }
+// modules/dungeon/src/index.js
+var index_default = {
+ id: "dungeon",
+ name: "Dungeon Kit",
+ version: "2.0.0",
+ description: "Level generation toolbox: a seeded multi-floor dungeon generator (rooms, corridors, decor, torches) with a toolbox, a Dungeon node and the userData.play contract the app walks in play mode. The playable game is Dungeon Realms.",
+ /** @param {any} api the module SDK surface */
+ register(api) {
+ const core = createKit(api);
+ registerNodes(api, core);
+ const toolbox = registerToolbox(api, core);
+ api.registerSystemGroup(GROUP_NAME);
+ core.ensureGroup();
+ api.onMessage((data) => core.handleMessage(data));
+ api.onSceneClear(() => core.kit.clear({ broadcast: false }));
+ api.registerStateSync({
+ getState: () => core.getState(),
+ applyState: (remote) => core.applyState(remote)
+ });
+ api.registerFrameTask((time) => core.tick(time));
+ if (typeof window !== "undefined") {
+ window.__dungeonKit = { kit: core.kit, toolbox, groupName: GROUP_NAME };
+ }
+ }
+};
+export {
+ index_default as default
};
diff --git a/modules/dungeon/src/contract.js b/modules/dungeon/src/contract.js
new file mode 100644
index 0000000..33c8505
--- /dev/null
+++ b/modules/dungeon/src/contract.js
@@ -0,0 +1,124 @@
+// THE PLAY CONTRACT — the pure half of what the Kit publishes on its scene-root
+// group's `userData.play`. Zero THREE/DOM (node-tested in test/contract.test.mjs).
+//
+// Core reads {grid, width, height, minX, minY, rooms, floorValue} for play-mode
+// collision, spawns and the minimap (dungeonPlay.js), and `grounded` / `markers`
+// through playSettings.js. Everything else is the INTER-MODULE seam (21-C C6):
+// an installed module's entry file may have no imports, so Dungeon Realms cannot
+// share code with the Kit — it shares the SCENE, reading this record through
+// api.scene().getObjectByName('dungeon-module').userData.play. Keep the fields
+// below stable; add, never rename.
+//
+// World coordinates: a floor cell (x, y) of floor k sits at world
+// (x + ox + 0.5, y + oy + 0.5) — `ox`/`oy` are the campaign's world offsets, which
+// place floor k+1's DOWN portal exactly where floor k's UP portal stands.
+
+import { FLOOR } from './gen/dungeon.js';
+
+export const GROUP_NAME = 'dungeon-module';
+export const CONTRACT_VERSION = 2;
+
+/** @param {any} room @param {number} ox @param {number} oy */
+function worldRoom(room, ox, oy) {
+ return {
+ x: room.x + ox,
+ y: room.y + oy,
+ w: room.w,
+ h: room.h,
+ id: room.id,
+ type: room.type,
+ depth: room.depth,
+ cx: room.cx + ox + 0.5,
+ cz: room.cy + oy + 0.5
+ };
+}
+
+/**
+ * Rooms in SPAWN order: the entrance first, then by depth, then by id — so every peer
+ * (dungeonPlay.spawnPointFor takes consecutive rooms by sorted peer id) starts near
+ * the dungeon's start together.
+ * @param {any} dungeon
+ */
+export function spawnOrderedRooms(dungeon) {
+ return [...dungeon.rooms]
+ .sort((a, b) => {
+ const ka = a.type === 'entrance' ? -1 : a.depth;
+ const kb = b.type === 'entrance' ? -1 : b.depth;
+ return ka - kb || a.id - b.id;
+ })
+ .map((room) => worldRoom(room, dungeon.ox, dungeon.oy));
+}
+
+/**
+ * The full `userData.play` record for one floor of a campaign.
+ * @param {any} campaign generateCampaign()'s result
+ * @param {number} floorIndex 1-based
+ * @param {{grounded?: boolean | null, markers?: {x: number, z: number, kind: string}[]}=} extras
+ */
+export function playPayload(campaign, floorIndex, extras = {}) {
+ const dungeon = campaign.floors[floorIndex - 1];
+ if (!dungeon) return null;
+ const { ox, oy } = dungeon;
+ const wx = (/** @type {number} */ x) => x + ox + 0.5;
+ const wz = (/** @type {number} */ y) => y + oy + 0.5;
+ const play = {
+ // ---- the core half (dungeonPlay.js shape — DO NOT CHANGE) ----------------------
+ grid: dungeon.grid,
+ width: dungeon.W,
+ height: dungeon.H,
+ minX: ox,
+ minY: oy,
+ rooms: spawnOrderedRooms(dungeon),
+ floorValue: FLOOR,
+ // ---- play settings (playSettings.js) --------------------------------------------
+ // a dungeon is WALKED: the fly keys are off unless a rule module says otherwise
+ grounded: extras.grounded == null ? true : !!extras.grounded,
+ // ---- the inter-module seam (a rule module reads these) --------------------------
+ contract: CONTRACT_VERSION,
+ seed: campaign.seed,
+ params: campaign.params,
+ floorIndex,
+ levelCount: campaign.floors.length,
+ name: dungeon.name,
+ theme: dungeon.theme,
+ checksum: dungeon.checksum,
+ campaignChecksum: campaign.checksum,
+ /** every prop in WORLD coordinates (gems carry `index`) */
+ props: dungeon.props.map((p) => ({ ...p, wx: wx(p.x), wz: wz(p.y) })),
+ /** portals in world coordinates: kind 'up' | 'down', gated */
+ portals: dungeon.portals.map((p) => ({ kind: p.kind, gated: !!p.gated, roomId: p.roomId, x: p.x, y: p.y, wx: wx(p.x), wz: wz(p.y) })),
+ /** typed enemy spawn slots in world coordinates */
+ spawns: dungeon.spawns.map((s) => ({ ...s, wx: wx(s.x), wz: wz(s.y) })),
+ /** minimap markers: a BARE Kit publishes none — rule modules add theirs */
+ markers: Array.isArray(extras.markers) ? extras.markers : []
+ };
+ return play;
+}
+
+/**
+ * Merge per-owner marker lists into one array, owner name order, so two rule
+ * modules never fight over the minimap.
+ * @param {Record} byOwner
+ */
+export function mergeMarkers(byOwner) {
+ const out = [];
+ for (const owner of Object.keys(byOwner).sort()) {
+ for (const marker of byOwner[owner] ?? []) {
+ if (typeof marker?.x === 'number' && typeof marker?.z === 'number')
+ out.push({ x: marker.x, z: marker.z, kind: String(marker.kind ?? 'marker') });
+ }
+ }
+ return out;
+}
+
+/** Clamp toolbox/node params into the generator's legal ranges. @param {any} params */
+export function normalizeParams(params = {}) {
+ const out = {};
+ if (params.roomCount != null) out.roomCount = Math.max(0, Math.min(60, Math.round(Number(params.roomCount) || 0)));
+ if (params.loopChance != null) out.loopChance = Math.max(0, Math.min(1, Number(params.loopChance) || 0));
+ if (params.levelCount != null) out.levelCount = Math.max(1, Math.min(9, Math.round(Number(params.levelCount) || 1)));
+ if (params.gemDensity != null) out.gemDensity = Math.max(0.25, Math.min(2, Number(params.gemDensity) || 1));
+ if (params.decorDensity != null) out.decorDensity = Math.max(0, Math.min(2, Number(params.decorDensity) || 0));
+ if (params.theme != null) out.theme = String(params.theme);
+ return out;
+}
diff --git a/modules/dungeon-realms/src/gen/bestiary.js b/modules/dungeon/src/gen/bestiary.js
similarity index 100%
rename from modules/dungeon-realms/src/gen/bestiary.js
rename to modules/dungeon/src/gen/bestiary.js
diff --git a/modules/dungeon-realms/src/gen/campaign.js b/modules/dungeon/src/gen/campaign.js
similarity index 100%
rename from modules/dungeon-realms/src/gen/campaign.js
rename to modules/dungeon/src/gen/campaign.js
diff --git a/modules/dungeon-realms/src/gen/dungeon.js b/modules/dungeon/src/gen/dungeon.js
similarity index 100%
rename from modules/dungeon-realms/src/gen/dungeon.js
rename to modules/dungeon/src/gen/dungeon.js
diff --git a/modules/dungeon-realms/src/gen/names.js b/modules/dungeon/src/gen/names.js
similarity index 100%
rename from modules/dungeon-realms/src/gen/names.js
rename to modules/dungeon/src/gen/names.js
diff --git a/modules/dungeon-realms/src/gen/rng.js b/modules/dungeon/src/gen/rng.js
similarity index 100%
rename from modules/dungeon-realms/src/gen/rng.js
rename to modules/dungeon/src/gen/rng.js
diff --git a/modules/dungeon/src/index.js b/modules/dungeon/src/index.js
new file mode 100644
index 0000000..1e0fbf0
--- /dev/null
+++ b/modules/dungeon/src/index.js
@@ -0,0 +1,52 @@
+// Dungeon Kit — the module entry. Bundled to one self-contained module.js by esbuild
+// (npm run build:dungeon); everything outside comes through `api`.
+//
+// 21-C C6: `dungeon` is the KIT — the 9-stage pure-data generator (src/gen, node-
+// tested), the instanced renderer, the toolbox and the published play contract. The
+// playable game is DUNGEON REALMS (modules/dungeon-realms), which reads this module's
+// scene-root group through api.scene() and adds gems, portals, rules and a menu on
+// top. Two modules cannot share code (an entry file has no imports), so they share
+// the SCENE: userData.play is the public contract, userData.kit the function seam.
+//
+// Only {seed, params, floorIndex} replicates — every peer regenerates the identical
+// dungeon locally (determinism IS the netcode). The meshes live in a module-owned
+// group at the SCENE root, never in objectsGroup.
+
+import { createKit } from './kit.js';
+import { registerNodes } from './nodes.js';
+import { registerToolbox } from './toolbox.js';
+import { GROUP_NAME } from './contract.js';
+
+export default {
+ id: 'dungeon',
+ name: 'Dungeon Kit',
+ version: '2.0.0',
+ description:
+ 'Level generation toolbox: a seeded multi-floor dungeon generator (rooms, corridors, decor, torches) with a toolbox, a Dungeon node and the userData.play contract the app walks in play mode. The playable game is Dungeon Realms.',
+
+ /** @param {any} api the module SDK surface */
+ register(api) {
+ const core = createKit(api);
+ registerNodes(api, core);
+ const toolbox = registerToolbox(api, core);
+
+ api.registerSystemGroup(GROUP_NAME); // visible under the System filter
+ core.ensureGroup(); // the persistent group exists from the first frame
+
+ api.onMessage((data) => core.handleMessage(data));
+ api.onSceneClear(() => core.kit.clear({ broadcast: false }));
+
+ // late joiners rebuild from {seed, params, floorIndex}
+ api.registerStateSync({
+ getState: () => core.getState(),
+ applyState: (remote) => core.applyState(remote)
+ });
+
+ api.registerFrameTask((time) => core.tick(time));
+
+ // test/debug hook (never serialized): the flights read the toolbox host
+ if (typeof window !== 'undefined') {
+ /** @type {any} */ (window).__dungeonKit = { kit: core.kit, toolbox, groupName: GROUP_NAME };
+ }
+ }
+};
diff --git a/modules/dungeon/src/kit.js b/modules/dungeon/src/kit.js
new file mode 100644
index 0000000..2e23574
--- /dev/null
+++ b/modules/dungeon/src/kit.js
@@ -0,0 +1,264 @@
+// The Kit's core: world state {seed, params, floorIndex}, its replication, the ONE
+// persistent scene-root group and the published contract.
+//
+// Replication is deterministic (golden rule 8): only {seed, params} and the floor
+// index travel — every peer regenerates the identical campaign locally, and the
+// checksum only DETECTS divergence (a toast, never a fight). Late joiners get
+// {seed, params, floorIndex} through registerStateSync.
+//
+// THE GROUP IS PERSISTENT. 'dungeon-module' is created once and its floor child is
+// swapped on regenerate / travel, so `userData.kit` (the function seam a rule module
+// calls) never goes stale and there is no frame in which the group is missing.
+
+import { generateCampaign } from './gen/campaign.js';
+import { buildFloorGroup, animateFloor } from './render.js';
+import { GROUP_NAME, playPayload, mergeMarkers, normalizeParams } from './contract.js';
+
+/** @param {any} api the module SDK surface */
+export function createKit(api) {
+ const THREE = api.THREE;
+
+ const state = {
+ seed: /** @type {number | null} */ (null),
+ params: /** @type {any} */ ({}),
+ campaign: /** @type {any} */ (null),
+ floorIndex: 1
+ };
+ /** @type {Record} rule modules' minimap markers */
+ const markersByOwner = {};
+ /** @type {boolean | null} null = the contract default (grounded) */
+ let grounded = null;
+ /** @type {any} the current floor's meshes (a child of the persistent group) */
+ let floorGroup = null;
+ /** @type {(() => void)[]} toolbox/UI refresh hooks */
+ const listeners = [];
+
+ const currentFloor = () => state.campaign?.floors[state.floorIndex - 1] ?? null;
+
+ /** the persistent scene-root group, created on first use */
+ function group() {
+ const scene = api.scene();
+ if (!scene) return null;
+ let existing = scene.getObjectByName(GROUP_NAME);
+ if (!existing) {
+ existing = new THREE.Group();
+ existing.name = GROUP_NAME;
+ scene.add(existing);
+ }
+ if (existing.userData.kit !== kit) existing.userData.kit = kit;
+ return existing;
+ }
+
+ function disposeFloor() {
+ if (!floorGroup) return;
+ floorGroup.traverse((child) => {
+ child.geometry?.dispose?.();
+ if (child.material && !Array.isArray(child.material)) child.material.dispose?.();
+ });
+ floorGroup.parent?.remove(floorGroup);
+ floorGroup = null;
+ }
+
+ function notify() {
+ listeners.forEach((fn) => {
+ try {
+ fn();
+ } catch {
+ /* a UI refresh must never break the world */
+ }
+ });
+ }
+
+ /** (re)stamp userData: the play contract when a dungeon exists, nothing otherwise */
+ function publish() {
+ const g = group();
+ if (!g) return;
+ const play = state.campaign
+ ? playPayload(state.campaign, state.floorIndex, { grounded, markers: mergeMarkers(markersByOwner) })
+ : null;
+ if (play) {
+ const dungeon = currentFloor();
+ g.userData.play = play;
+ g.userData.seed = state.seed;
+ g.userData.params = state.params;
+ g.userData.floorIndex = state.floorIndex;
+ g.userData.levelCount = state.campaign.floors.length;
+ g.userData.checksum = dungeon.checksum;
+ g.userData.campaignChecksum = state.campaign.checksum;
+ g.userData.name = dungeon.name;
+ g.userData.stats = dungeon.stats;
+ } else {
+ delete g.userData.play;
+ delete g.userData.seed;
+ delete g.userData.params;
+ delete g.userData.floorIndex;
+ delete g.userData.levelCount;
+ delete g.userData.checksum;
+ delete g.userData.campaignChecksum;
+ delete g.userData.name;
+ delete g.userData.stats;
+ }
+ notify();
+ }
+
+ function rebuild() {
+ disposeFloor();
+ const g = group();
+ const dungeon = currentFloor();
+ if (g && dungeon) {
+ floorGroup = buildFloorGroup(THREE, dungeon);
+ floorGroup.name = 'dk-floor';
+ g.add(floorGroup);
+ }
+ publish();
+ }
+
+ // ---- actions (apply locally, optionally broadcast; receivers never re-send) ----
+
+ /**
+ * Generate a campaign. @param {number} seed @param {any=} params
+ * @param {{broadcast?: boolean}=} opts
+ */
+ function generate(seed, params = {}, opts = {}) {
+ const clean = normalizeParams(params);
+ let campaign;
+ try {
+ campaign = generateCampaign(seed >>> 0, clean);
+ } catch (error) {
+ api.toast('Dungeon generation failed: ' + error.message);
+ return false;
+ }
+ state.seed = seed >>> 0;
+ state.params = clean;
+ state.campaign = campaign;
+ state.floorIndex = 1;
+ // a rule module's markers belong to the world that just went away
+ for (const owner of Object.keys(markersByOwner)) delete markersByOwner[owner];
+ rebuild();
+ if (opts.broadcast !== false) api.send({ op: 'generate', seed: state.seed, params: clean, checksum: campaign.checksum });
+ return true;
+ }
+
+ /** @param {any} data */
+ function applyRemoteGenerate(data) {
+ const same = data.seed === state.seed && JSON.stringify(normalizeParams(data.params ?? {})) === JSON.stringify(state.params);
+ if (same && state.campaign) return;
+ if (!generate(data.seed, data.params ?? {}, { broadcast: false })) return;
+ if (data.checksum && data.checksum !== state.campaign.checksum)
+ api.toast('Dungeon checksum differs from the sender — module versions may not match');
+ }
+
+ /** Show floor k (1-based). @param {number} target @param {{broadcast?: boolean}=} opts */
+ function showFloor(target, opts = {}) {
+ const levels = state.campaign?.floors.length ?? 0;
+ target = Math.round(Number(target));
+ if (!levels || !(target >= 1) || target > levels) return false;
+ if (target === state.floorIndex) return true;
+ state.floorIndex = target;
+ for (const owner of Object.keys(markersByOwner)) delete markersByOwner[owner];
+ rebuild();
+ if (opts.broadcast !== false) api.send({ op: 'floor', floorIndex: target });
+ return true;
+ }
+
+ /** @param {{broadcast?: boolean}=} opts */
+ function clear(opts = {}) {
+ disposeFloor();
+ state.campaign = null;
+ state.seed = null;
+ state.params = {};
+ state.floorIndex = 1;
+ for (const owner of Object.keys(markersByOwner)) delete markersByOwner[owner];
+ publish();
+ if (opts.broadcast !== false) api.send({ op: 'clear' });
+ }
+
+ // ---- the seam a rule module drives (userData.kit) ---------------------------------
+
+ /**
+ * Put markers on the play minimap under an owner key; replaces that owner's list.
+ * @param {string} owner @param {{x: number, z: number, kind: string}[] | null} list
+ */
+ function setMarkers(owner, list) {
+ if (!owner) return;
+ if (list && list.length) markersByOwner[owner] = list;
+ else delete markersByOwner[owner];
+ const g = group();
+ if (g?.userData.play) g.userData.play.markers = mergeMarkers(markersByOwner);
+ }
+
+ /** Override the contract's grounded flag (null = the Kit default, true). @param {boolean | null} value */
+ function setGrounded(value) {
+ grounded = value == null ? null : !!value;
+ const g = group();
+ if (g?.userData.play) g.userData.play.grounded = grounded == null ? true : grounded;
+ }
+
+ /** what the toolbox footer shows */
+ function stats() {
+ const dungeon = currentFloor();
+ if (!dungeon || !state.campaign) return null;
+ return {
+ rooms: dungeon.rooms.length,
+ gems: dungeon.props.filter((p) => p.kind === 'gem').length,
+ loops: dungeon.stats.loops,
+ ms: Math.round(dungeon.stats.genMs * 10) / 10,
+ checksum: dungeon.checksum,
+ campaignChecksum: state.campaign.checksum,
+ name: dungeon.name,
+ floorIndex: state.floorIndex,
+ levelCount: state.campaign.floors.length
+ };
+ }
+
+ const kit = {
+ /** bump when a field below changes shape */
+ version: 2,
+ generate,
+ showFloor,
+ clear,
+ setMarkers,
+ setGrounded,
+ /** the replicated world state, copied */
+ state: () => ({ seed: state.seed, params: { ...state.params }, floorIndex: state.floorIndex, levelCount: state.campaign?.floors.length ?? 0 }),
+ /** the whole campaign (pure data) — for a rule that needs another floor's props */
+ campaign: () => state.campaign,
+ stats,
+ /** @param {() => void} fn refresh hook (toolbox) */
+ onChange: (fn) => {
+ listeners.push(fn);
+ return () => {
+ const at = listeners.indexOf(fn);
+ if (at >= 0) listeners.splice(at, 1);
+ };
+ }
+ };
+
+ // ---- netcode -----------------------------------------------------------------
+
+ /** @param {any} data */
+ function handleMessage(data) {
+ if (data.op === 'generate') applyRemoteGenerate(data);
+ else if (data.op === 'floor') showFloor(data.floorIndex, { broadcast: false });
+ else if (data.op === 'clear') clear({ broadcast: false });
+ }
+
+ function getState() {
+ if (state.seed == null) return null;
+ return { seed: state.seed, params: state.params, floorIndex: state.floorIndex };
+ }
+
+ /** @param {any} remote */
+ function applyState(remote) {
+ if (!remote || remote.seed == null) return;
+ if (!generate(remote.seed, remote.params ?? {}, { broadcast: false })) return;
+ if (remote.floorIndex && remote.floorIndex !== state.floorIndex) showFloor(remote.floorIndex, { broadcast: false });
+ }
+
+ /** @param {number} time */
+ function tick(time) {
+ if (floorGroup) animateFloor(floorGroup, time);
+ }
+
+ return { kit, state, group, handleMessage, getState, applyState, tick, ensureGroup: group };
+}
diff --git a/modules/dungeon/src/nodes.js b/modules/dungeon/src/nodes.js
new file mode 100644
index 0000000..7a9ee38
--- /dev/null
+++ b/modules/dungeon/src/nodes.js
@@ -0,0 +1,56 @@
+// THE NODE (group "Dungeon Kit"): the recipe as a node. Its (replicated) data
+// regenerates the identical campaign on every peer and broadcasts nothing at all —
+// the node IS the recipe (rule ownership, the dungeon-realms pattern). A template
+// carries it wired to an Object Selector, which answers DEVX #10 without an SDK call.
+//
+// Runtime reality (SDK v1): a module node is an EFFECT node — it runs when wired to
+// an Object Selector, or unwired inside any object's own graph (the implicit-owner
+// rule). Range params get wireable input sockets for free (a Number node feeds `seed`).
+
+export const NODE_TYPE = 'dkdungeon';
+
+/** @param {any} api @param {ReturnType} core */
+export function registerNodes(api, core) {
+ const { kit } = core;
+
+ api.registerNodeGroup({
+ group: 'Dungeon Kit',
+ items: [
+ {
+ type: NODE_TYPE,
+ label: 'Dungeon',
+ defaults: { seed: 1337, roomCount: 0, levelCount: 5, loopChance: 0.15, gemDensity: 1, apply: false },
+ params: [
+ { key: 'seed', kind: 'range', min: 0, max: 999999, step: 1 },
+ { key: 'roomCount', kind: 'range', min: 0, max: 60, step: 2 },
+ { key: 'levelCount', kind: 'range', min: 1, max: 9, step: 1 },
+ { key: 'loopChance', kind: 'range', min: 0, max: 0.5, step: 0.05 },
+ { key: 'gemDensity', kind: 'range', min: 0.25, max: 2, step: 0.05 },
+ { key: 'apply', kind: 'toggle' }
+ ]
+ }
+ ]
+ });
+
+ api.registerEffect(NODE_TYPE, (object, base, data) => {
+ if (!data.apply) return;
+ const seed = Math.max(0, Math.round(Number(data.seed) || 0));
+ const params = {
+ roomCount: Math.round(Number(data.roomCount) || 0),
+ levelCount: Math.round(Number(data.levelCount) || 5),
+ loopChance: Number(data.loopChance ?? 0.15),
+ gemDensity: Number(data.gemDensity ?? 1)
+ };
+ const current = kit.state();
+ const same =
+ current.seed === seed &&
+ current.params.roomCount === params.roomCount &&
+ current.params.levelCount === params.levelCount &&
+ Math.abs((current.params.loopChance ?? 0.15) - params.loopChance) < 1e-9 &&
+ Math.abs((current.params.gemDensity ?? 1) - params.gemDensity) < 1e-9;
+ if (same) return;
+ // the node data replicates with the graph: every peer regenerates the SAME
+ // dungeon locally — deterministic model, no op needed
+ kit.generate(seed, params, { broadcast: false });
+ });
+}
diff --git a/modules/dungeon-realms/src/render.js b/modules/dungeon/src/render.js
similarity index 56%
rename from modules/dungeon-realms/src/render.js
rename to modules/dungeon/src/render.js
index cae7552..c02900d 100644
--- a/modules/dungeon-realms/src/render.js
+++ b/modules/dungeon/src/render.js
@@ -1,7 +1,12 @@
-// C2 renderer — data in, InstancedMesh out. LEVEL geometry (floor/wall/pillar)
-// is 3 draw calls; each prop kind is one more (spec target: level geometry in
-// ≤ 10). Per-instance colors bake AO (adjacent-wall darkening) + value noise +
-// room tint. Everything mounts under ONE scene-root group the caller owns.
+// The Kit's renderer — data in, InstancedMesh out. LEVEL geometry (floor/wall/
+// pillar) is 3 draw calls; each decor prop kind is one more (spec target: level
+// geometry in <= 10). Per-instance colors bake AO (adjacent-wall darkening) +
+// value noise + room tint. Everything mounts under ONE group the caller owns.
+//
+// 21-C C6: moved here from dungeon-realms with the generator. GEMS and PORTALS
+// are NOT drawn here any more — they are Dungeon Realms' objective and its
+// overlay renders them from the play contract (userData.play.props/portals), so
+// the Kit stays a level generator and a rule module owns what it plays with.
import { FLOOR, WALL } from './gen/dungeon.js';
@@ -16,9 +21,8 @@ function cellNoise(x, y, seed, amp = 0.05) {
* Build one floor's meshes. Returns the group — the caller names it, stamps
* userData and adds it to the scene root.
* @param {any} THREE @param {any} dungeon (with ox/oy world offsets)
- * @param {Set} collected collected gem indices on this floor
*/
-export function buildFloorGroup(THREE, dungeon, collected) {
+export function buildFloorGroup(THREE, dungeon) {
const { W, H, grid, rooms, props, theme, ox, oy } = dungeon;
const group = new THREE.Group();
const matrix = new THREE.Matrix4();
@@ -48,13 +52,13 @@ export function buildFloorGroup(THREE, dungeon, collected) {
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.95 }),
floorCount
);
- floorMesh.name = 'dr-floors';
+ floorMesh.name = 'dk-floors';
const wallMesh = new THREE.InstancedMesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.9 }),
wallCount
);
- wallMesh.name = 'dr-walls';
+ wallMesh.name = 'dk-walls';
const wallsAround = (x, y) => {
let count = 0;
for (let dy = -1; dy <= 1; dy++)
@@ -114,11 +118,11 @@ export function buildFloorGroup(THREE, dungeon, collected) {
return mesh;
};
- instanced('dr-pillars', new THREE.CylinderGeometry(0.3, 0.38, 2.4, 8),
+ instanced('dk-pillars', new THREE.CylinderGeometry(0.3, 0.38, 2.4, 8),
new THREE.MeshStandardMaterial({ color: theme.wallTint, roughness: 0.85 }),
byKind.pillar, (p) => position.set(worldX(p.x), 1.2, worldZ(p.y)));
- instanced('dr-debris', new THREE.BoxGeometry(0.32, 0.22, 0.32),
+ instanced('dk-debris', new THREE.BoxGeometry(0.32, 0.22, 0.32),
new THREE.MeshStandardMaterial({ color: theme.corridorTint, roughness: 1 }),
byKind.debris, (p) => {
position.set(worldX(p.x), 0.1, worldZ(p.y));
@@ -128,19 +132,19 @@ export function buildFloorGroup(THREE, dungeon, collected) {
quaternion.identity();
scale.set(1, 1, 1);
- instanced('dr-crates', new THREE.BoxGeometry(0.72, 0.72, 0.72),
+ instanced('dk-crates', new THREE.BoxGeometry(0.72, 0.72, 0.72),
new THREE.MeshStandardMaterial({ color: 0x8a6a3d, roughness: 0.9 }),
byKind.crate, (p) => position.set(worldX(p.x), 0.36, worldZ(p.y)));
- instanced('dr-braziers', new THREE.CylinderGeometry(0.3, 0.2, 0.55, 8),
+ instanced('dk-braziers', new THREE.CylinderGeometry(0.3, 0.2, 0.55, 8),
new THREE.MeshStandardMaterial({ color: 0x2c2c34, roughness: 0.6, metalness: 0.4 }),
byKind.brazier, (p) => position.set(worldX(p.x), 0.28, worldZ(p.y)));
- instanced('dr-chests', new THREE.BoxGeometry(0.85, 0.55, 0.6),
+ instanced('dk-chests', new THREE.BoxGeometry(0.85, 0.55, 0.6),
new THREE.MeshStandardMaterial({ color: 0xb08a2e, roughness: 0.5, metalness: 0.3 }),
byKind.chest, (p) => position.set(worldX(p.x), 0.28, worldZ(p.y)));
- instanced('dr-crystals', new THREE.OctahedronGeometry(0.42, 0),
+ instanced('dk-crystals', new THREE.OctahedronGeometry(0.42, 0),
new THREE.MeshBasicMaterial({ color: theme.gemColor }),
byKind.crystal, (p) => {
position.set(worldX(p.x), 1, worldZ(p.y));
@@ -148,7 +152,7 @@ export function buildFloorGroup(THREE, dungeon, collected) {
});
scale.set(1, 1, 1);
- instanced('dr-rings', new THREE.TorusGeometry(0.9, 0.07, 8, 28),
+ instanced('dk-rings', new THREE.TorusGeometry(0.9, 0.07, 8, 28),
new THREE.MeshBasicMaterial({ color: 0x3ad0c4 }),
byKind.ring, (p) => {
position.set(worldX(p.x), 0.06, worldZ(p.y));
@@ -158,13 +162,13 @@ export function buildFloorGroup(THREE, dungeon, collected) {
// torches: bracket on the wall face + unlit flame above it
const torches = byKind.torch ?? [];
- instanced('dr-torch-brackets', new THREE.BoxGeometry(0.12, 0.34, 0.12),
+ instanced('dk-torch-brackets', new THREE.BoxGeometry(0.12, 0.34, 0.12),
new THREE.MeshStandardMaterial({ color: 0x3a3126, roughness: 0.8 }),
torches, (p) => position.set(worldX(p.x) + (p.fx ?? 0) * 0.42, 1.45, worldZ(p.y) + (p.fy ?? 0) * 0.42));
const flameSpots = torches.map((p) => ({
x: worldX(p.x) + (p.fx ?? 0) * 0.42, y: 1.78, z: worldZ(p.y) + (p.fy ?? 0) * 0.42
})).concat((byKind.brazier ?? []).map((p) => ({ x: worldX(p.x), y: 0.75, z: worldZ(p.y) })));
- instanced('dr-flames', new THREE.ConeGeometry(0.11, 0.3, 6),
+ instanced('dk-flames', new THREE.ConeGeometry(0.11, 0.3, 6),
new THREE.MeshBasicMaterial({ color: theme.torchColor }),
flameSpots, (p) => position.set(p.x, p.y, p.z));
@@ -174,112 +178,20 @@ export function buildFloorGroup(THREE, dungeon, collected) {
for (let i = 0; i < flameSpots.length && i / step < budget; i += step) {
const spot = flameSpots[i];
const light = new THREE.PointLight(theme.torchColor, 5, 11, 2);
- light.name = 'dr-light';
+ light.name = 'dk-light';
light.position.set(spot.x, spot.y + 0.25, spot.z);
group.add(light);
}
- // ---- gems (the collectible objective) -------------------------------------
- const gems = (byKind.gem ?? []).slice().sort((a, b) => a.index - b.index);
- const gemWorld = gems.map((p) => ({ x: worldX(p.x), y: 0.55, z: worldZ(p.y), index: p.index }));
- const gemMesh = new THREE.InstancedMesh(
- new THREE.OctahedronGeometry(0.17, 0),
- new THREE.MeshBasicMaterial({ color: theme.gemColor }),
- Math.max(1, gemWorld.length)
- );
- gemMesh.name = 'dr-gems';
- gemMesh.count = gemWorld.length;
- group.add(gemMesh);
-
- // ---- portals ---------------------------------------------------------------
- dungeon.portals.forEach((portal) => {
- const portalGroup = new THREE.Group();
- portalGroup.name = 'dr-portal-' + portal.kind;
- portalGroup.position.set(worldX(portal.x), 0, worldZ(portal.y));
- const ring = new THREE.Mesh(
- new THREE.TorusGeometry(1.05, 0.1, 10, 36),
- new THREE.MeshBasicMaterial({ color: 0x555a66 })
- );
- ring.name = 'dr-portal-ring';
- ring.rotation.x = -Math.PI / 2;
- ring.position.y = 0.1;
- const disc = new THREE.Mesh(
- new THREE.CircleGeometry(0.92, 28),
- new THREE.MeshBasicMaterial({ color: 0x394050, transparent: true, opacity: 0.55 })
- );
- disc.name = 'dr-portal-disc';
- disc.rotation.x = -Math.PI / 2;
- disc.position.y = 0.08;
- portalGroup.add(ring, disc);
- portalGroup.userData.portal = { kind: portal.kind, gated: portal.gated };
- group.add(portalGroup);
- });
-
- // runtime bookkeeping the game loop reads (scene-root only — never serialized)
- group.userData._dr = { gemWorld, theme };
- applyGems(group, collected);
+ // runtime bookkeeping (scene-root only — never serialized)
+ group.userData._dk = { theme };
return group;
}
-/** Zero-scale collected gems; bob+spin comes from animateFloor. */
-export function applyGems(group, collected) {
- const gemMesh = group.getObjectByName('dr-gems');
- const { gemWorld } = group.userData._dr;
- if (!gemMesh || !gemWorld.length) return;
- const matrix = new (gemMesh.matrixWorld.constructor)();
- gemWorld.forEach((gem, i) => {
- if (collected.has(gem.index)) matrix.makeScale(0, 0, 0);
- else matrix.makeTranslation(gem.x, gem.y, gem.z);
- gemMesh.setMatrixAt(i, matrix);
- });
- gemMesh.instanceMatrix.needsUpdate = true;
-}
-
-/** Seal/unseal the UP portal visuals. @param {boolean} sealed */
-export function setPortalSealed(group, sealed, theme) {
- const portal = group.getObjectByName('dr-portal-up');
- if (!portal) return;
- const ring = portal.getObjectByName('dr-portal-ring');
- const disc = portal.getObjectByName('dr-portal-disc');
- if (ring) ring.material.color.setHex(sealed ? 0x555a66 : 0x39e0ff);
- if (disc) {
- disc.material.color.setHex(sealed ? 0x394050 : theme?.gemColor ?? 0x39e0c0);
- disc.material.opacity = sealed ? 0.35 : 0.75;
- }
- portal.userData.portal.sealed = sealed;
- const down = group.getObjectByName('dr-portal-down');
- if (down) {
- down.getObjectByName('dr-portal-ring')?.material.color.setHex(0x39a0ff);
- down.userData.portal.sealed = false;
- }
-}
-
-/** Per-frame juice: gem spin/bob, portal ring spin, torch light flicker. */
-export function animateFloor(THREE, group, collected, time) {
- const data = group.userData._dr;
- if (!data) return;
- const gemMesh = group.getObjectByName('dr-gems');
- if (gemMesh && data.gemWorld.length) {
- const matrix = new THREE.Matrix4();
- const position = new THREE.Vector3();
- const quaternion = new THREE.Quaternion();
- const scale = new THREE.Vector3(1, 1, 1);
- const axis = new THREE.Vector3(0, 1, 0);
- data.gemWorld.forEach((gem, i) => {
- if (collected.has(gem.index)) return; // stays zero-scaled
- position.set(gem.x, gem.y + Math.sin(time * 2 + gem.x) * 0.08, gem.z);
- quaternion.setFromAxisAngle(axis, time * 1.6 + gem.index);
- matrix.compose(position, quaternion, scale);
- gemMesh.setMatrixAt(i, matrix);
- });
- gemMesh.instanceMatrix.needsUpdate = true;
- }
+/** Per-frame juice the Kit owns: torch light flicker. @param {any} group @param {number} time */
+export function animateFloor(group, time) {
group.children.forEach((child) => {
- if (child.name === 'dr-portal-up' || child.name === 'dr-portal-down') {
- const ring = child.getObjectByName('dr-portal-ring');
- if (ring && child.userData.portal?.sealed === false) ring.rotation.z = time * 0.8;
- } else if (child.name === 'dr-light') {
+ if (child.name === 'dk-light')
child.intensity = 5 + Math.sin(time * 9 + child.position.x * 3.7) * 0.9 + Math.sin(time * 23 + child.position.z * 5.1) * 0.5;
- }
});
}
diff --git a/modules/dungeon/src/toolbox.js b/modules/dungeon/src/toolbox.js
new file mode 100644
index 0000000..5353520
--- /dev/null
+++ b/modules/dungeon/src/toolbox.js
@@ -0,0 +1,180 @@
+// THE TOOLBOX (21-C C6.2): seed + dice, rooms / loops / levels, Generate / Clear, a
+// floor stepper and a stats footer — `api.registerToolbox` over core's shared
+// ToolboxWindow, so the palette is draggable, resizable, themed and sits in the
+// z-band it owns. This is the SDK's worked toolbox example (AUTHORING.md).
+//
+// FALLBACK: `#dungeon-panel`, the plain fixed DOM the module shipped before, kept
+// behind `typeof api.registerToolbox === 'function'` (the AUTHORING.md feature-detect
+// rule) so the Kit still installs on an older app. Both hosts mount the SAME `mount(el)`.
+//
+// Writes go where the rule lives: when a Dungeon node with `apply` is alive in a
+// running graph, the toolbox edits THAT node (api.flow.setNodeData — the replicated
+// graph; otherwise the node would put its own recipe back a frame later); with no
+// node, the Kit's own replicated `generate` op.
+
+import { NODE_TYPE } from './nodes.js';
+
+const SELECT_CSS =
+ 'background:rgba(0,0,0,0.25);color:inherit;border:1px solid rgba(255,255,255,0.15);border-radius:4px;font-size:11px;padding:1px 4px;';
+
+/** @param {any} api @param {ReturnType} core */
+export function registerToolbox(api, core) {
+ const { kit } = core;
+
+ /** @param {string} tag @param {Record=} props @param {string=} css */
+ function elem(tag, props, css) {
+ const node = document.createElement(tag);
+ Object.assign(node, props ?? {});
+ if (css) node.setAttribute('style', css);
+ return node;
+ }
+
+ /** the live Dungeon node that owns the recipe, if any */
+ const recipeNode = () => {
+ if (!api.flow?.nodes) return null;
+ return api.flow.nodes(NODE_TYPE).find((n) => n.data?.apply) ?? null;
+ };
+
+ /** @param {number} seed @param {any} params */
+ function applyRecipe(seed, params) {
+ const node = recipeNode();
+ if (node && api.flow.setNodeData(node.id, { seed, ...params })) {
+ api.toast('Dungeon node updated — every peer regenerates from the graph');
+ return;
+ }
+ kit.generate(seed, params);
+ }
+
+ /** @param {HTMLElement} el */
+ function mount(el) {
+ el.innerHTML = '';
+ const current = kit.state();
+ const values = {
+ seed: current.seed ?? 1337,
+ roomCount: current.params.roomCount ?? 0,
+ loopChance: current.params.loopChance ?? 0.15,
+ levelCount: current.params.levelCount ?? 5
+ };
+
+ /** @param {string} label @param {HTMLElement} control @param {HTMLElement=} out */
+ const row = (label, control, out) => {
+ const line = elem('div', { className: 'tbx-row' }, 'display:flex;align-items:center;gap:6px;margin:3px 0;');
+ line.appendChild(elem('span', { className: 'tbx-label', textContent: label }, 'flex:1;font-size:11px;'));
+ if (out) line.appendChild(out);
+ line.appendChild(control);
+ el.appendChild(line);
+ return line;
+ };
+
+ // seed + dice: the seed CHOICE is local, the seed itself replicates
+ const seedInput = /** @type {HTMLInputElement} */ (elem('input', { id: 'dk-seed', type: 'number', value: String(values.seed), min: '0', step: '1' }, SELECT_CSS + 'width:84px;'));
+ seedInput.onchange = () => (values.seed = Math.max(0, Math.round(Number(seedInput.value) || 0)));
+ const dice = elem('button', { id: 'dk-dice', className: 'tbx-btn', textContent: '\u{1F3B2}', title: 'Random seed' });
+ dice.onclick = () => {
+ values.seed = Math.floor(Math.random() * 1000000);
+ seedInput.value = String(values.seed);
+ };
+ const seedLine = elem('div', {}, 'display:flex;align-items:center;gap:6px;margin:3px 0;');
+ seedLine.appendChild(elem('span', { className: 'tbx-label', textContent: 'Seed' }, 'flex:1;font-size:11px;'));
+ seedLine.appendChild(seedInput);
+ seedLine.appendChild(dice);
+ el.appendChild(seedLine);
+
+ /** @param {keyof typeof values} key @param {number} min @param {number} max @param {number} step @param {(v: number) => string} fmt */
+ const slider = (key, min, max, step, fmt) => {
+ const out = elem('span', { textContent: fmt(values[key]) }, 'font-size:11px;opacity:0.8;min-width:34px;text-align:right;');
+ const input = /** @type {HTMLInputElement} */ (elem('input', { id: 'dk-' + key, type: 'range', min: String(min), max: String(max), step: String(step), value: String(values[key]) }, 'width:96px;'));
+ input.oninput = () => {
+ values[key] = Number(input.value);
+ out.textContent = fmt(values[key]);
+ };
+ return { input, out };
+ };
+ const rooms = slider('roomCount', 0, 60, 2, (v) => (v ? String(v) : 'auto'));
+ row('Rooms', rooms.input, rooms.out);
+ const loops = slider('loopChance', 0, 0.5, 0.05, (v) => v.toFixed(2));
+ row('Extra loops', loops.input, loops.out);
+ const levels = slider('levelCount', 1, 9, 1, (v) => String(v));
+ row('Floors', levels.input, levels.out);
+
+ const buttons = elem('div', {}, 'display:flex;flex-wrap:wrap;gap:4px;margin:6px 0;');
+ const generate = elem('button', { id: 'dk-generate', className: 'tbx-btn tbx-primary', textContent: 'Generate' });
+ generate.onclick = () => applyRecipe(values.seed, { roomCount: values.roomCount, loopChance: values.loopChance, levelCount: values.levelCount });
+ const clear = elem('button', { id: 'dk-clear', className: 'tbx-btn', textContent: 'Clear' });
+ clear.onclick = () => kit.clear();
+ buttons.appendChild(generate);
+ buttons.appendChild(clear);
+ el.appendChild(buttons);
+
+ // the floor stepper: replicated travel, the rule module's portals do the same
+ const floorLine = elem('div', {}, 'display:flex;align-items:center;gap:6px;margin:3px 0;');
+ const down = elem('button', { id: 'dk-floor-down', className: 'tbx-btn', textContent: '◂', title: 'Floor below' });
+ const floorOut = elem('span', { id: 'dk-floor', textContent: '—' }, 'flex:1;text-align:center;font-size:11px;');
+ const up = elem('button', { id: 'dk-floor-up', className: 'tbx-btn', textContent: '▸', title: 'Floor above' });
+ down.onclick = () => kit.showFloor(kit.state().floorIndex - 1);
+ up.onclick = () => kit.showFloor(kit.state().floorIndex + 1);
+ floorLine.appendChild(down);
+ floorLine.appendChild(floorOut);
+ floorLine.appendChild(up);
+ el.appendChild(floorLine);
+
+ const status = elem('div', { id: 'dk-stats', className: 'tbx-label' }, 'white-space:pre-wrap;font-size:11px;opacity:0.8;margin-top:6px;');
+ el.appendChild(status);
+ el.appendChild(
+ elem('div', { className: 'tbx-label', textContent: 'Peers regenerate from the seed — the same checksum means the exact same dungeon. Dungeon Realms plays it.' },
+ 'font-size:10px;opacity:0.6;margin-top:4px;')
+ );
+
+ const refresh = () => {
+ const s = kit.stats();
+ const st = kit.state();
+ floorOut.textContent = s ? 'Floor ' + s.floorIndex + ' / ' + s.levelCount + ' · ' + s.name : 'no dungeon';
+ down.disabled = !s || s.floorIndex <= 1;
+ up.disabled = !s || s.floorIndex >= s.levelCount;
+ status.textContent = s
+ ? s.rooms + ' rooms · ' + s.gems + ' gems · ' + s.loops + ' loops · ' + s.ms + ' ms\nchecksum ' + s.checksum + ' · campaign ' + s.campaignChecksum +
+ (recipeNode() ? '\nrecipe owned by the Dungeon node' : '')
+ : 'Generate a dungeon, then press the red Play button to walk it.';
+ if (st.seed != null && Number(seedInput.value) !== st.seed && document.activeElement !== seedInput) {
+ values.seed = st.seed;
+ seedInput.value = String(st.seed);
+ }
+ };
+ refresh();
+ const off = kit.onChange(refresh);
+ return () => off();
+ }
+
+ // ---- host: the shared ToolboxWindow, or the fixed panel on an older app ------
+ if (typeof api.registerToolbox === 'function') {
+ const id = api.registerToolbox({ id: 'kit', title: 'Dungeon Kit', width: 260, minW: 220, mount });
+ api.registerMenu('Dungeon Kit', () => (typeof api.openToolbox === 'function' ? api.openToolbox(id) : null));
+ return { id, mount, host: 'toolbox' };
+ }
+
+ /** @type {HTMLElement | null} */ let panel = null;
+ /** @type {(() => void) | null} */ let cleanup = null;
+ function openPanel() {
+ if (panel || typeof document === 'undefined') return;
+ panel = elem('div', { id: 'dungeon-panel' },
+ 'position:fixed;right:0.5rem;top:5rem;z-index:40;display:flex;width:18rem;flex-direction:column;gap:0.5rem;' +
+ 'border-radius:0.5rem;background:#1f2937;padding:0.75rem;font-size:0.875rem;color:#fff;box-shadow:0 10px 25px rgba(0,0,0,0.4);');
+ const head = elem('div', {}, 'display:flex;align-items:center;justify-content:space-between');
+ head.appendChild(elem('span', { textContent: 'Dungeon Kit' }, 'font-weight:600'));
+ const close = elem('button', { id: 'dungeon-close', textContent: '✕' }, 'border-radius:2px;background:#4b5563;padding:0 0.5rem');
+ close.onclick = () => {
+ cleanup?.();
+ cleanup = null;
+ panel?.remove();
+ panel = null;
+ };
+ head.appendChild(close);
+ panel.appendChild(head);
+ const body = elem('div');
+ panel.appendChild(body);
+ document.body.appendChild(panel);
+ cleanup = mount(body) ?? null;
+ }
+ api.registerMenu('Dungeon Kit', openPanel);
+ return { id: null, mount, host: 'panel', openPanel };
+}
diff --git a/modules/dungeon-realms/test/campaign.test.mjs b/modules/dungeon/test/campaign.test.mjs
similarity index 100%
rename from modules/dungeon-realms/test/campaign.test.mjs
rename to modules/dungeon/test/campaign.test.mjs
diff --git a/modules/dungeon/test/contract.test.mjs b/modules/dungeon/test/contract.test.mjs
new file mode 100644
index 0000000..469263c
--- /dev/null
+++ b/modules/dungeon/test/contract.test.mjs
@@ -0,0 +1,40 @@
+// The play contract — pure, node-run. What core and Dungeon Realms both read.
+import { generateCampaign } from '../src/gen/campaign.js';
+import { FLOOR } from '../src/gen/dungeon.js';
+import { playPayload, mergeMarkers, normalizeParams, CONTRACT_VERSION } from '../src/contract.js';
+
+export function run(check) {
+ const campaign = generateCampaign(1234);
+ const play = playPayload(campaign, 1);
+ // the dungeonPlay.js half is byte-shaped: grid + dims + offsets + rooms + floorValue
+ check(play.grid === campaign.floors[0].grid && play.width === campaign.floors[0].W && play.height === campaign.floors[0].H, 'raster, width and height are the floor\'s own');
+ check(play.minX === campaign.floors[0].ox && play.minY === campaign.floors[0].oy, 'minX/minY are the campaign world offsets');
+ check(play.floorValue === FLOOR, 'floorValue is FLOOR');
+ check(play.rooms[0].type === 'entrance', 'rooms are spawn-ordered: the entrance comes first');
+ const depths = play.rooms.slice(1).map((r) => r.depth);
+ check(depths.every((d, i) => i === 0 || depths[i - 1] <= d), 'then by depth (both players spawn near the start)');
+ // the seam half
+ check(play.contract === CONTRACT_VERSION && play.seed === 1234 && play.floorIndex === 1 && play.levelCount === 5, 'seam: contract version, seed, floorIndex, levelCount');
+ const gems = play.props.filter((p) => p.kind === 'gem');
+ check(gems.length >= 4 && gems.every((g) => typeof g.index === 'number' && typeof g.wx === 'number' && typeof g.wz === 'number'), 'gem props carry index + world coords (' + gems.length + ')');
+ const up = play.portals.find((p) => p.kind === 'up');
+ check(!!up && up.gated === true && up.wx === up.x + play.minX + 0.5, 'the gated UP portal is in world coordinates');
+ // floor 2's DOWN portal stands exactly where floor 1's UP portal stands (world space)
+ const play2 = playPayload(campaign, 2);
+ const down = play2.portals.find((p) => p.kind === 'down');
+ check(!!down && down.wx === up.wx && down.wz === up.wz, 'floor 2 DOWN portal lands on floor 1 UP portal in world space');
+ // world coords sit on FLOOR cells of the raster the same record publishes
+ const onFloor = gems.every((g) => play.grid[(Math.floor(g.wz - play.minY)) * play.width + Math.floor(g.wx - play.minX)] === FLOOR);
+ check(onFloor, 'every gem world coordinate resolves to a FLOOR cell of the published raster');
+ // defaults: grounded, no markers
+ check(play.grounded === true && Array.isArray(play.markers) && play.markers.length === 0, 'a bare Kit publishes grounded:true and NO markers');
+ check(playPayload(campaign, 1, { grounded: false }).grounded === false, 'grounded can be overridden by a rule module');
+ check(playPayload(campaign, 9) === null, 'a floor past the campaign is null, never a throw');
+ // markers merge by owner name order, garbage dropped
+ const merged = mergeMarkers({ zeta: [{ x: 1, z: 2, kind: 'gem' }], alpha: [{ x: 3, z: 4 }, { x: 'no' }] });
+ check(merged.length === 2 && merged[0].x === 3 && merged[0].kind === 'marker' && merged[1].kind === 'gem', 'mergeMarkers: owner order, kind default, garbage dropped');
+ // params clamp
+ const p = normalizeParams({ roomCount: 999, loopChance: -1, levelCount: 0, gemDensity: 5 });
+ check(p.roomCount === 60 && p.loopChance === 0 && p.levelCount === 1 && p.gemDensity === 2, 'normalizeParams clamps into the generator ranges');
+ check(Object.keys(normalizeParams({})).length === 0, 'normalizeParams leaves absent params absent');
+}
diff --git a/modules/dungeon-realms/test/gen.test.mjs b/modules/dungeon/test/gen.test.mjs
similarity index 100%
rename from modules/dungeon-realms/test/gen.test.mjs
rename to modules/dungeon/test/gen.test.mjs
diff --git a/modules/dungeon/test/run.mjs b/modules/dungeon/test/run.mjs
new file mode 100644
index 0000000..822b545
--- /dev/null
+++ b/modules/dungeon/test/run.mjs
@@ -0,0 +1,23 @@
+// Pure-data tests for the Dungeon Kit — run WITHOUT the app:
+// node modules/dungeon/test/run.mjs (npm run test:dungeon)
+// The generator suites moved here from dungeon-realms with the generator (21-C C6);
+// contract.test.mjs covers the userData.play record both core and Realms read.
+import { run as genTests } from './gen.test.mjs';
+import { run as campaignTests } from './campaign.test.mjs';
+import { run as contractTests } from './contract.test.mjs';
+
+let failures = 0;
+function check(ok, label) {
+ console.log((ok ? 'PASS ' : 'FAIL ') + label);
+ if (!ok) failures++;
+}
+
+console.log('=== gen.test.mjs ===');
+genTests(check);
+console.log('\n=== campaign.test.mjs ===');
+campaignTests(check);
+console.log('\n=== contract.test.mjs ===');
+contractTests(check);
+
+console.log('\n' + (failures === 0 ? 'ALL PASS' : failures + ' FAILURES'));
+process.exit(failures === 0 ? 0 : 1);
diff --git a/modules/football/README.md b/modules/football/README.md
new file mode 100644
index 0000000..01e3442
--- /dev/null
+++ b/modules/football/README.md
@@ -0,0 +1,88 @@
+# football — VR football on the knock, every rule a flow node
+
+Two gates float at chest height at the ends of a small pitch; one ball floats between
+them (zero-g scene physics, fork 2 of roadmap #24). You hit the ball with your hands —
+core's **knock** (24-A A1): it flies at the speed you hit it, bounces off the invisible
+pitch walls and slows a little. A ball through a gate is a goal for the team whose
+player touched it last; a defender's last touch is an own goal on the sheet. Modes decide
+when the match ends. Every player's goals, touches and own goals are theirs alone
+(peerVars, one writer per row); a match log survives in the saved scene
+(`gameState.vars.football.matches`, capped at 50). Colocated play rides the existing
+CO0–CO7 ritual; a desktop peer joins the same match with the head probe and grab/throw.
+
+```
+modules/football/
+ src/rules.js PURE rules: slots, attribution, modes, outcome, serve, the sheet (node-tested)
+ src/pitch.js PURE pitch layout + graph builder — the def's objects/graph/physics source
+ src/game.js replicated match state, ops, authority, the hit feed, goal detection
+ src/nodes.js the "Football" node family (rule ownership, the dungeon-realms pattern)
+ src/toolbox.js host settings + the "Build pitch" / "Fit pitch" recipes
+ test/ node tests (no app): every rule with its counterfactual
+ module.js the bundled entry (committed)
+```
+
+```bash
+npm run build:football # src/ -> module.js (esbuild)
+npm run test:football # rules + pitch, no app
+npm run pack -- football # -> football.zip
+flock -w 5400 /tmp/tp-e2e.lock env APP_URL=https://theprototype.app:5208/ npm test -- football
+```
+
+## Nodes (group "Football")
+
+A node alive in a running graph OWNS its rule group on every peer (its data replicates
+with the graph); absent, the defaults — or the toolbox's host settings — return within a
+second. **No football node may target the ball**: the flow runtime re-seats a targeted
+object's base pose every frame, which would fight the physics body. Match Rules targets
+the pitch and names the ball through its `ball` object input.
+
+| Node | Target | Params / inputs |
+|---|---|---|
+| **Match Rules** `fbrules` | a static object (the pitch) | `ball` (wire an Object Selector) · `mode: duel\|teams\|freeforall\|practice` · `winBy: goals\|time\|either` · `goalsToWin` · `matchSeconds` · `serve: auto\|button` · `serveDelay` · `serveSpeed` · `ownGoals: count\|ignore` |
+| **Team Gate** `fbgate` | the gate's SENSOR box | `team` — a ball entering THIS gate scores for the OTHER team |
+| **Match Button** `fbbutton` | the button object | `action: join-red\|join-blue\|spectate\|start\|new-match\|swap-sides\|serve` · `press` (event in: a perPlayer HUD Button or On Click) — a click on the object (desktop or VR trigger, play mode included) runs the action on the clicker's peer |
+| **Serve** `fbserve` | any static object | `trigger` (event in) → serve now (an On Rest, a Delay, a button) |
+| **Score Lamp** `fblamp` | one lamp object | `team` · `index` — lit while that team's score ≥ index |
+| **Records** `fbrecords` | any static object | `show` · `element` (HUD list: the sheet) · `scoreElement` (score / last touch / clock) · `logElement` (the saved matches) |
+| **Football Value** `fbvalue` | — (value out) | `read: red\|blue\|goals\|myteam\|lastteam\|started\|left\|players\|serves\|mygoals` |
+| **Football Event** `fbevent` | — (event out) | `event: goal\|redgoal\|bluegoal\|serve\|start\|over\|reset\|touch` — wire `start`→Set Game State (playing), `over`→(over) so the DOM HUD screens follow the match |
+
+## Replication
+
+| Feature | Model |
+|---|---|
+| slots, start, new match, swap, rules override | events from the presser; every peer applies the same pure rule |
+| last touch | derived from the knock hit feed every peer receives (`api.onHit`, A2; the debug hook on an A1 build) |
+| goal, serve, match over | the physics initiator decides and broadcasts (with no sim anywhere, the lowest peer id) |
+| per-player sheet | `api.peerVars.setMine` by the ONE peer the goal names |
+| late joiners | `registerStateSync` carries the whole match state |
+| the saved log | `api.game.setVar('football', {matches})` on the game singleton, written by the authority at match end |
+
+## Records (fork 3: session records + a saved match log)
+
+- **Per player, per session**: `goals`, `touches`, `owngoals` on `peerVars` — the one peer
+ a goal names writes its own row; a `leaderboard` node renders them; a row survives its
+ owner's Esc and drops with a disconnect (the peerVars lifetime). A new match does NOT
+ reset them: a session record is a session record (author a reset with `On Game State
+ (playing)` → `Set Variable scope:player` if you want one).
+- **Match history**: at match end the authority appends `{at, red, blue, winner,
+ scorers: [{name, goals}]}` to `gameState.vars.football.matches` (capped 50, names not
+ peer ids). `gameState` is in the session payload, so a saved `.tpscene` carries the
+ sheet and reopening the scene shows past matches; the Records node's `logElement` lists
+ them and `Football Value read: matches` counts them.
+- **Cloud leaderboard** (persistent totals by account): not here — a PocketBase collection
+ + a plugin in the cloud repo, its own plan.
+
+## The template
+
+`pitchObjects(dims)`, `pitchGraph(names, {hudButtons: true})` and `PITCH_PHYSICS` in
+`src/pitch.js` are the def's objects, graph and physics block; the toolbox's **Build pitch**
+creates exactly them in an empty scene (through `api.create`, `api.physics.set` and
+`api.flow.addNodes`), which is how the test-flight plays without the scenes repo. The
+scene-physics block itself has no api write — set gravity 0, ground off and Knock on in
+Inspector ▸ Physics (DEVX #20), or load the template.
+
+## Owed on device
+
+The knock's feel on a Quest, ball legibility at 10 m/s, the physical buttons' reach at a
+real gate, haptics on a hit, the colocated fit of a 3 × 5 m pitch in a real room.
diff --git a/modules/football/emit-def.mjs b/modules/football/emit-def.mjs
new file mode 100644
index 0000000..1456a5a
--- /dev/null
+++ b/modules/football/emit-def.mjs
@@ -0,0 +1,10 @@
+// emit football.def.json beside the module — the orchestrator's input for the scenes
+// def (run by `npm run build:football`)
+import { writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { footballDef } from './src/def.js';
+
+const here = dirname(fileURLToPath(import.meta.url));
+writeFileSync(join(here, 'football.def.json'), JSON.stringify(footballDef(), null, '\t') + '\n');
+console.log('football.def.json written');
diff --git a/modules/football/football.def.json b/modules/football/football.def.json
new file mode 100644
index 0000000..9d10f08
--- /dev/null
+++ b/modules/football/football.def.json
@@ -0,0 +1,2714 @@
+{
+ "kind": "game",
+ "slug": "football",
+ "title": "Football",
+ "description": "VR football: hit the floating ball with your hands, a ball through the other gate is a goal. Pick a side, Start, five goals wins. Colocation-ready.",
+ "license": "CC0-1.0",
+ "author": "theprototype",
+ "tags": [
+ "vr",
+ "competitive",
+ "colocation",
+ "physics"
+ ],
+ "modules": [
+ {
+ "id": "football",
+ "version": "1.0.0"
+ }
+ ],
+ "installModules": [
+ "football"
+ ],
+ "env": {
+ "preset": "daylight",
+ "exposure": 0.9
+ },
+ "physics": {
+ "gravity": 0,
+ "ground": {
+ "enabled": false
+ },
+ "damping": {
+ "linear": 0.35,
+ "angular": 0.5
+ },
+ "ccd": true,
+ "knock": {
+ "enabled": true,
+ "gain": 1,
+ "maxSpeed": 10,
+ "spin": 0.8
+ },
+ "play": {
+ "interaction": "grab",
+ "grounded": false,
+ "simOnPlay": true
+ }
+ },
+ "post": {
+ "enabled": true,
+ "effects": [
+ {
+ "id": "tone",
+ "kind": "tonemapping",
+ "enabled": true,
+ "params": {
+ "mode": "AGX"
+ }
+ },
+ {
+ "id": "bloom",
+ "kind": "bloom",
+ "enabled": true,
+ "params": {
+ "intensity": 0.6,
+ "luminanceThreshold": 0.8
+ }
+ },
+ {
+ "id": "aa",
+ "kind": "smaa",
+ "enabled": true,
+ "params": {}
+ }
+ ],
+ "changedAt": 0
+ },
+ "graphs": {
+ "scene": {
+ "nodes": [
+ {
+ "id": "rules",
+ "type": "fbrules",
+ "position": {
+ "x": 280,
+ "y": 40
+ },
+ "data": {
+ "label": "Match Rules"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selpitch",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 40
+ },
+ "data": {
+ "label": "Pitch",
+ "selected": "Pitch"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selball",
+ "type": "objectselector",
+ "position": {
+ "x": 40,
+ "y": 40
+ },
+ "data": {
+ "label": "Football",
+ "selected": "Football"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "gater",
+ "type": "fbgate",
+ "position": {
+ "x": 280,
+ "y": 190
+ },
+ "data": {
+ "label": "Red gate",
+ "team": "red"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selgater",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 190
+ },
+ "data": {
+ "label": "Red gate sensor",
+ "selected": "Red gate"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "gateb",
+ "type": "fbgate",
+ "position": {
+ "x": 280,
+ "y": 340
+ },
+ "data": {
+ "label": "Blue gate",
+ "team": "blue"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selgateb",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 340
+ },
+ "data": {
+ "label": "Blue gate sensor",
+ "selected": "Blue gate"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "records",
+ "type": "fbrecords",
+ "position": {
+ "x": 280,
+ "y": 490
+ },
+ "data": {
+ "label": "Records",
+ "show": "all",
+ "element": "fb-sheet,fb-sheet-play",
+ "scoreElement": "fb-score,fb-score-over",
+ "logElement": "fb-log"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bjoinr",
+ "type": "fbbutton",
+ "position": {
+ "x": 280,
+ "y": 640
+ },
+ "data": {
+ "label": "Button: join-red",
+ "action": "join-red"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selbjoinr",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 640
+ },
+ "data": {
+ "label": "Join red",
+ "selected": "Join red"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "hbjoinr",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 640
+ },
+ "data": {
+ "label": "HUD join-red",
+ "element": "fb-join-red",
+ "perPlayer": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "dbjoinr",
+ "type": "delay",
+ "position": {
+ "x": 160,
+ "y": 640
+ },
+ "data": {
+ "label": "HUD join-red press",
+ "seconds": 0.05,
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bjoinb",
+ "type": "fbbutton",
+ "position": {
+ "x": 280,
+ "y": 790
+ },
+ "data": {
+ "label": "Button: join-blue",
+ "action": "join-blue"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selbjoinb",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 790
+ },
+ "data": {
+ "label": "Join blue",
+ "selected": "Join blue"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "hbjoinb",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 790
+ },
+ "data": {
+ "label": "HUD join-blue",
+ "element": "fb-join-blue",
+ "perPlayer": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "dbjoinb",
+ "type": "delay",
+ "position": {
+ "x": 160,
+ "y": 790
+ },
+ "data": {
+ "label": "HUD join-blue press",
+ "seconds": 0.05,
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bstart",
+ "type": "fbbutton",
+ "position": {
+ "x": 280,
+ "y": 940
+ },
+ "data": {
+ "label": "Button: start",
+ "action": "start"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selbstart",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 940
+ },
+ "data": {
+ "label": "Start match",
+ "selected": "Start match"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "hbstart",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 940
+ },
+ "data": {
+ "label": "HUD start",
+ "element": "fb-start",
+ "perPlayer": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "dbstart",
+ "type": "delay",
+ "position": {
+ "x": 160,
+ "y": 940
+ },
+ "data": {
+ "label": "HUD start press",
+ "seconds": 0.05,
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bnew",
+ "type": "fbbutton",
+ "position": {
+ "x": 280,
+ "y": 1090
+ },
+ "data": {
+ "label": "Button: new-match",
+ "action": "new-match"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selbnew",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 1090
+ },
+ "data": {
+ "label": "New match",
+ "selected": "New match"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "hbnew",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 1090
+ },
+ "data": {
+ "label": "HUD new-match",
+ "element": "fb-new-match",
+ "perPlayer": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "dbnew",
+ "type": "delay",
+ "position": {
+ "x": 160,
+ "y": 1090
+ },
+ "data": {
+ "label": "HUD new-match press",
+ "seconds": 0.05,
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred1",
+ "type": "fblamp",
+ "position": {
+ "x": 40,
+ "y": 1240
+ },
+ "data": {
+ "label": "Red lamp 1",
+ "team": "red",
+ "index": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred1",
+ "type": "objectselector",
+ "position": {
+ "x": 40,
+ "y": 1310
+ },
+ "data": {
+ "label": "Red lamp 1",
+ "selected": "Red lamp 1"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred2",
+ "type": "fblamp",
+ "position": {
+ "x": 280,
+ "y": 1240
+ },
+ "data": {
+ "label": "Red lamp 2",
+ "team": "red",
+ "index": 2
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred2",
+ "type": "objectselector",
+ "position": {
+ "x": 280,
+ "y": 1310
+ },
+ "data": {
+ "label": "Red lamp 2",
+ "selected": "Red lamp 2"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred3",
+ "type": "fblamp",
+ "position": {
+ "x": 520,
+ "y": 1240
+ },
+ "data": {
+ "label": "Red lamp 3",
+ "team": "red",
+ "index": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred3",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 1310
+ },
+ "data": {
+ "label": "Red lamp 3",
+ "selected": "Red lamp 3"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred4",
+ "type": "fblamp",
+ "position": {
+ "x": 760,
+ "y": 1240
+ },
+ "data": {
+ "label": "Red lamp 4",
+ "team": "red",
+ "index": 4
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred4",
+ "type": "objectselector",
+ "position": {
+ "x": 760,
+ "y": 1310
+ },
+ "data": {
+ "label": "Red lamp 4",
+ "selected": "Red lamp 4"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred5",
+ "type": "fblamp",
+ "position": {
+ "x": 1000,
+ "y": 1240
+ },
+ "data": {
+ "label": "Red lamp 5",
+ "team": "red",
+ "index": 5
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred5",
+ "type": "objectselector",
+ "position": {
+ "x": 1000,
+ "y": 1310
+ },
+ "data": {
+ "label": "Red lamp 5",
+ "selected": "Red lamp 5"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred6",
+ "type": "fblamp",
+ "position": {
+ "x": 40,
+ "y": 1390
+ },
+ "data": {
+ "label": "Red lamp 6",
+ "team": "red",
+ "index": 6
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred6",
+ "type": "objectselector",
+ "position": {
+ "x": 40,
+ "y": 1460
+ },
+ "data": {
+ "label": "Red lamp 6",
+ "selected": "Red lamp 6"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred7",
+ "type": "fblamp",
+ "position": {
+ "x": 280,
+ "y": 1390
+ },
+ "data": {
+ "label": "Red lamp 7",
+ "team": "red",
+ "index": 7
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred7",
+ "type": "objectselector",
+ "position": {
+ "x": 280,
+ "y": 1460
+ },
+ "data": {
+ "label": "Red lamp 7",
+ "selected": "Red lamp 7"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred8",
+ "type": "fblamp",
+ "position": {
+ "x": 520,
+ "y": 1390
+ },
+ "data": {
+ "label": "Red lamp 8",
+ "team": "red",
+ "index": 8
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred8",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 1460
+ },
+ "data": {
+ "label": "Red lamp 8",
+ "selected": "Red lamp 8"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred9",
+ "type": "fblamp",
+ "position": {
+ "x": 760,
+ "y": 1390
+ },
+ "data": {
+ "label": "Red lamp 9",
+ "team": "red",
+ "index": 9
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred9",
+ "type": "objectselector",
+ "position": {
+ "x": 760,
+ "y": 1460
+ },
+ "data": {
+ "label": "Red lamp 9",
+ "selected": "Red lamp 9"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampred10",
+ "type": "fblamp",
+ "position": {
+ "x": 1000,
+ "y": 1390
+ },
+ "data": {
+ "label": "Red lamp 10",
+ "team": "red",
+ "index": 10
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampred10",
+ "type": "objectselector",
+ "position": {
+ "x": 1000,
+ "y": 1460
+ },
+ "data": {
+ "label": "Red lamp 10",
+ "selected": "Red lamp 10"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue1",
+ "type": "fblamp",
+ "position": {
+ "x": 40,
+ "y": 1540
+ },
+ "data": {
+ "label": "Blue lamp 1",
+ "team": "blue",
+ "index": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue1",
+ "type": "objectselector",
+ "position": {
+ "x": 40,
+ "y": 1610
+ },
+ "data": {
+ "label": "Blue lamp 1",
+ "selected": "Blue lamp 1"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue2",
+ "type": "fblamp",
+ "position": {
+ "x": 280,
+ "y": 1540
+ },
+ "data": {
+ "label": "Blue lamp 2",
+ "team": "blue",
+ "index": 2
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue2",
+ "type": "objectselector",
+ "position": {
+ "x": 280,
+ "y": 1610
+ },
+ "data": {
+ "label": "Blue lamp 2",
+ "selected": "Blue lamp 2"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue3",
+ "type": "fblamp",
+ "position": {
+ "x": 520,
+ "y": 1540
+ },
+ "data": {
+ "label": "Blue lamp 3",
+ "team": "blue",
+ "index": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue3",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 1610
+ },
+ "data": {
+ "label": "Blue lamp 3",
+ "selected": "Blue lamp 3"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue4",
+ "type": "fblamp",
+ "position": {
+ "x": 760,
+ "y": 1540
+ },
+ "data": {
+ "label": "Blue lamp 4",
+ "team": "blue",
+ "index": 4
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue4",
+ "type": "objectselector",
+ "position": {
+ "x": 760,
+ "y": 1610
+ },
+ "data": {
+ "label": "Blue lamp 4",
+ "selected": "Blue lamp 4"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue5",
+ "type": "fblamp",
+ "position": {
+ "x": 1000,
+ "y": 1540
+ },
+ "data": {
+ "label": "Blue lamp 5",
+ "team": "blue",
+ "index": 5
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue5",
+ "type": "objectselector",
+ "position": {
+ "x": 1000,
+ "y": 1610
+ },
+ "data": {
+ "label": "Blue lamp 5",
+ "selected": "Blue lamp 5"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue6",
+ "type": "fblamp",
+ "position": {
+ "x": 40,
+ "y": 1690
+ },
+ "data": {
+ "label": "Blue lamp 6",
+ "team": "blue",
+ "index": 6
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue6",
+ "type": "objectselector",
+ "position": {
+ "x": 40,
+ "y": 1760
+ },
+ "data": {
+ "label": "Blue lamp 6",
+ "selected": "Blue lamp 6"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue7",
+ "type": "fblamp",
+ "position": {
+ "x": 280,
+ "y": 1690
+ },
+ "data": {
+ "label": "Blue lamp 7",
+ "team": "blue",
+ "index": 7
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue7",
+ "type": "objectselector",
+ "position": {
+ "x": 280,
+ "y": 1760
+ },
+ "data": {
+ "label": "Blue lamp 7",
+ "selected": "Blue lamp 7"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue8",
+ "type": "fblamp",
+ "position": {
+ "x": 520,
+ "y": 1690
+ },
+ "data": {
+ "label": "Blue lamp 8",
+ "team": "blue",
+ "index": 8
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue8",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 1760
+ },
+ "data": {
+ "label": "Blue lamp 8",
+ "selected": "Blue lamp 8"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue9",
+ "type": "fblamp",
+ "position": {
+ "x": 760,
+ "y": 1690
+ },
+ "data": {
+ "label": "Blue lamp 9",
+ "team": "blue",
+ "index": 9
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue9",
+ "type": "objectselector",
+ "position": {
+ "x": 760,
+ "y": 1760
+ },
+ "data": {
+ "label": "Blue lamp 9",
+ "selected": "Blue lamp 9"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "lampblue10",
+ "type": "fblamp",
+ "position": {
+ "x": 1000,
+ "y": 1690
+ },
+ "data": {
+ "label": "Blue lamp 10",
+ "team": "blue",
+ "index": 10
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "sellampblue10",
+ "type": "objectselector",
+ "position": {
+ "x": 1000,
+ "y": 1760
+ },
+ "data": {
+ "label": "Blue lamp 10",
+ "selected": "Blue lamp 10"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bnewover",
+ "type": "fbbutton",
+ "position": {
+ "x": 280,
+ "y": 1840
+ },
+ "data": {
+ "label": "Button: new-match (fb-new-match)",
+ "action": "new-match"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "hbnewover",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 1840
+ },
+ "data": {
+ "label": "HUD fb-new-match",
+ "element": "fb-new-match",
+ "perPlayer": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "dbnewover",
+ "type": "delay",
+ "position": {
+ "x": 160,
+ "y": 1840
+ },
+ "data": {
+ "label": "HUD fb-new-match press",
+ "seconds": 0.05,
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bnewpause",
+ "type": "fbbutton",
+ "position": {
+ "x": 280,
+ "y": 1990
+ },
+ "data": {
+ "label": "Button: new-match (fb-new-match-pause)",
+ "action": "new-match"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "hbnewpause",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 1990
+ },
+ "data": {
+ "label": "HUD fb-new-match-pause",
+ "element": "fb-new-match-pause",
+ "perPlayer": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "dbnewpause",
+ "type": "delay",
+ "position": {
+ "x": 160,
+ "y": 1990
+ },
+ "data": {
+ "label": "HUD fb-new-match-pause press",
+ "seconds": 0.05,
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "pkey",
+ "type": "keypress",
+ "position": {
+ "x": 40,
+ "y": 2140
+ },
+ "data": {
+ "label": "Press P",
+ "code": "KeyP",
+ "edge": "down",
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "pausetoggle",
+ "type": "hudscreen",
+ "position": {
+ "x": 280,
+ "y": 2140
+ },
+ "data": {
+ "label": "Toggle pause menu",
+ "screen": "pause",
+ "action": "toggle"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bresume",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 2290
+ },
+ "data": {
+ "label": "Resume button",
+ "element": "resume-btn"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "resumehide",
+ "type": "hudscreen",
+ "position": {
+ "x": 280,
+ "y": 2290
+ },
+ "data": {
+ "label": "Close pause menu",
+ "screen": "pause",
+ "action": "hide"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bquit",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 2440
+ },
+ "data": {
+ "label": "Quit to menu button",
+ "element": "quit-btn"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "doquit",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 2440
+ },
+ "data": {
+ "label": "Quit to menu",
+ "state": "menu",
+ "outcome": "",
+ "reset": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "quithide",
+ "type": "hudscreen",
+ "position": {
+ "x": 520,
+ "y": 2440
+ },
+ "data": {
+ "label": "Close pause on quit",
+ "screen": "pause",
+ "action": "hide"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "evstart",
+ "type": "fbevent",
+ "position": {
+ "x": 40,
+ "y": 2590
+ },
+ "data": {
+ "label": "On match start",
+ "event": "start"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "gostart",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 2590
+ },
+ "data": {
+ "label": "Match: playing",
+ "state": "playing",
+ "outcome": "",
+ "reset": false
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "evover",
+ "type": "fbevent",
+ "position": {
+ "x": 40,
+ "y": 2740
+ },
+ "data": {
+ "label": "On match over",
+ "event": "over"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "goover",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 2740
+ },
+ "data": {
+ "label": "Match: over",
+ "state": "over",
+ "outcome": "Match over",
+ "reset": false
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "evnew",
+ "type": "fbevent",
+ "position": {
+ "x": 40,
+ "y": 2890
+ },
+ "data": {
+ "label": "On new match",
+ "event": "reset"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "gomenu",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 2890
+ },
+ "data": {
+ "label": "Match: menu",
+ "state": "menu",
+ "outcome": "",
+ "reset": true
+ },
+ "class": "w-[150px]"
+ }
+ ],
+ "edges": [
+ {
+ "id": "e-rules-selpitch",
+ "source": "rules",
+ "target": "selpitch"
+ },
+ {
+ "id": "e-selball-rules.ball",
+ "source": "selball",
+ "target": "rules",
+ "targetHandle": "ball"
+ },
+ {
+ "id": "e-gater-selgater",
+ "source": "gater",
+ "target": "selgater"
+ },
+ {
+ "id": "e-gateb-selgateb",
+ "source": "gateb",
+ "target": "selgateb"
+ },
+ {
+ "id": "e-records-selpitch",
+ "source": "records",
+ "target": "selpitch"
+ },
+ {
+ "id": "e-bjoinr-selbjoinr",
+ "source": "bjoinr",
+ "target": "selbjoinr"
+ },
+ {
+ "id": "e-hbjoinr-dbjoinr.trigger",
+ "source": "hbjoinr",
+ "target": "dbjoinr",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-dbjoinr-bjoinr.press",
+ "source": "dbjoinr",
+ "target": "bjoinr",
+ "targetHandle": "press"
+ },
+ {
+ "id": "e-bjoinb-selbjoinb",
+ "source": "bjoinb",
+ "target": "selbjoinb"
+ },
+ {
+ "id": "e-hbjoinb-dbjoinb.trigger",
+ "source": "hbjoinb",
+ "target": "dbjoinb",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-dbjoinb-bjoinb.press",
+ "source": "dbjoinb",
+ "target": "bjoinb",
+ "targetHandle": "press"
+ },
+ {
+ "id": "e-bstart-selbstart",
+ "source": "bstart",
+ "target": "selbstart"
+ },
+ {
+ "id": "e-hbstart-dbstart.trigger",
+ "source": "hbstart",
+ "target": "dbstart",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-dbstart-bstart.press",
+ "source": "dbstart",
+ "target": "bstart",
+ "targetHandle": "press"
+ },
+ {
+ "id": "e-bnew-selbnew",
+ "source": "bnew",
+ "target": "selbnew"
+ },
+ {
+ "id": "e-hbnew-dbnew.trigger",
+ "source": "hbnew",
+ "target": "dbnew",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-dbnew-bnew.press",
+ "source": "dbnew",
+ "target": "bnew",
+ "targetHandle": "press"
+ },
+ {
+ "id": "e-lampred1-sellampred1",
+ "source": "lampred1",
+ "target": "sellampred1"
+ },
+ {
+ "id": "e-lampred2-sellampred2",
+ "source": "lampred2",
+ "target": "sellampred2"
+ },
+ {
+ "id": "e-lampred3-sellampred3",
+ "source": "lampred3",
+ "target": "sellampred3"
+ },
+ {
+ "id": "e-lampred4-sellampred4",
+ "source": "lampred4",
+ "target": "sellampred4"
+ },
+ {
+ "id": "e-lampred5-sellampred5",
+ "source": "lampred5",
+ "target": "sellampred5"
+ },
+ {
+ "id": "e-lampred6-sellampred6",
+ "source": "lampred6",
+ "target": "sellampred6"
+ },
+ {
+ "id": "e-lampred7-sellampred7",
+ "source": "lampred7",
+ "target": "sellampred7"
+ },
+ {
+ "id": "e-lampred8-sellampred8",
+ "source": "lampred8",
+ "target": "sellampred8"
+ },
+ {
+ "id": "e-lampred9-sellampred9",
+ "source": "lampred9",
+ "target": "sellampred9"
+ },
+ {
+ "id": "e-lampred10-sellampred10",
+ "source": "lampred10",
+ "target": "sellampred10"
+ },
+ {
+ "id": "e-lampblue1-sellampblue1",
+ "source": "lampblue1",
+ "target": "sellampblue1"
+ },
+ {
+ "id": "e-lampblue2-sellampblue2",
+ "source": "lampblue2",
+ "target": "sellampblue2"
+ },
+ {
+ "id": "e-lampblue3-sellampblue3",
+ "source": "lampblue3",
+ "target": "sellampblue3"
+ },
+ {
+ "id": "e-lampblue4-sellampblue4",
+ "source": "lampblue4",
+ "target": "sellampblue4"
+ },
+ {
+ "id": "e-lampblue5-sellampblue5",
+ "source": "lampblue5",
+ "target": "sellampblue5"
+ },
+ {
+ "id": "e-lampblue6-sellampblue6",
+ "source": "lampblue6",
+ "target": "sellampblue6"
+ },
+ {
+ "id": "e-lampblue7-sellampblue7",
+ "source": "lampblue7",
+ "target": "sellampblue7"
+ },
+ {
+ "id": "e-lampblue8-sellampblue8",
+ "source": "lampblue8",
+ "target": "sellampblue8"
+ },
+ {
+ "id": "e-lampblue9-sellampblue9",
+ "source": "lampblue9",
+ "target": "sellampblue9"
+ },
+ {
+ "id": "e-lampblue10-sellampblue10",
+ "source": "lampblue10",
+ "target": "sellampblue10"
+ },
+ {
+ "id": "e-bnewover-selbnew",
+ "source": "bnewover",
+ "target": "selbnew"
+ },
+ {
+ "id": "e-hbnewover-dbnewover.trigger",
+ "source": "hbnewover",
+ "target": "dbnewover",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-dbnewover-bnewover.press",
+ "source": "dbnewover",
+ "target": "bnewover",
+ "targetHandle": "press"
+ },
+ {
+ "id": "e-bnewpause-selbnew",
+ "source": "bnewpause",
+ "target": "selbnew"
+ },
+ {
+ "id": "e-hbnewpause-dbnewpause.trigger",
+ "source": "hbnewpause",
+ "target": "dbnewpause",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-dbnewpause-bnewpause.press",
+ "source": "dbnewpause",
+ "target": "bnewpause",
+ "targetHandle": "press"
+ },
+ {
+ "id": "e-pkey-pausetoggle.trigger",
+ "source": "pkey",
+ "target": "pausetoggle",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bresume-resumehide.trigger",
+ "source": "bresume",
+ "target": "resumehide",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bquit-doquit.trigger",
+ "source": "bquit",
+ "target": "doquit",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bquit-quithide.trigger",
+ "source": "bquit",
+ "target": "quithide",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-evstart-gostart.trigger",
+ "source": "evstart",
+ "target": "gostart",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-evover-goover.trigger",
+ "source": "evover",
+ "target": "goover",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-evnew-gomenu.trigger",
+ "source": "evnew",
+ "target": "gomenu",
+ "targetHandle": "trigger"
+ }
+ ]
+ }
+ },
+ "hud": {
+ "scene": {
+ "active": "",
+ "changedAt": 0,
+ "screens": [
+ {
+ "id": "menu",
+ "name": "Menu",
+ "showWhile": "menu",
+ "input": "menu",
+ "elements": [
+ {
+ "id": "menu-panel",
+ "kind": "panel",
+ "anchor": "center",
+ "x": 0,
+ "y": 0,
+ "w": 480,
+ "h": 400,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(20, 26, 36, 0.92)",
+ "radius": 16,
+ "border": "1px solid rgba(136, 192, 208, 0.25)"
+ }
+ },
+ {
+ "id": "title",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -150,
+ "w": 400,
+ "h": 54,
+ "z": 1,
+ "label": "FOOTBALL",
+ "style": {
+ "size": 40,
+ "weight": "700",
+ "color": "#ffd45e",
+ "align": "center"
+ }
+ },
+ {
+ "id": "subtitle",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -100,
+ "w": 440,
+ "h": 44,
+ "z": 1,
+ "label": "Pick a side, then Start. Hit the floating ball with your hands; a ball through the other gate is a goal.",
+ "style": {
+ "size": 14,
+ "color": "#d8dee9",
+ "align": "center"
+ },
+ "wrap": true
+ },
+ {
+ "id": "fb-join-red",
+ "kind": "button",
+ "anchor": "center",
+ "x": -110,
+ "y": -30,
+ "w": 190,
+ "h": 44,
+ "z": 1,
+ "label": "Join RED",
+ "enabled": true,
+ "style": {
+ "size": 16,
+ "weight": "600",
+ "bg": "#c94a4a",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "fb-join-blue",
+ "kind": "button",
+ "anchor": "center",
+ "x": 110,
+ "y": -30,
+ "w": 190,
+ "h": 44,
+ "z": 1,
+ "label": "Join BLUE",
+ "enabled": true,
+ "style": {
+ "size": 16,
+ "weight": "600",
+ "bg": "#3b7dd8",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "fb-start",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 30,
+ "w": 220,
+ "h": 48,
+ "z": 1,
+ "label": "Start match",
+ "enabled": true,
+ "style": {
+ "size": 16,
+ "weight": "600",
+ "bg": "#4c9e6a",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "fb-sheet",
+ "kind": "list",
+ "anchor": "center",
+ "x": 0,
+ "y": 105,
+ "w": 440,
+ "h": 70,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 12,
+ "color": "#c8d0dc",
+ "align": "center"
+ }
+ },
+ {
+ "id": "menu-hint",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": 165,
+ "w": 440,
+ "h": 30,
+ "z": 1,
+ "label": "Walk into the ball to knock it · Grab: hold click · Pause: P",
+ "style": {
+ "size": 12,
+ "color": "#8b97a8",
+ "align": "center"
+ },
+ "wrap": true
+ }
+ ]
+ },
+ {
+ "id": "hud",
+ "name": "HUD",
+ "showWhile": "playing",
+ "input": "game",
+ "elements": [
+ {
+ "id": "fb-score",
+ "kind": "list",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 14,
+ "w": 420,
+ "h": 70,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 18,
+ "weight": "600",
+ "color": "#e5e9f0",
+ "align": "center"
+ }
+ },
+ {
+ "id": "fb-sheet-play",
+ "kind": "list",
+ "anchor": "top-right",
+ "x": 16,
+ "y": 14,
+ "w": 260,
+ "h": 120,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 12,
+ "color": "#c8d0dc",
+ "align": "right"
+ }
+ },
+ {
+ "id": "play-hint",
+ "kind": "text",
+ "anchor": "bottom-center",
+ "x": 0,
+ "y": 12,
+ "w": 520,
+ "h": 20,
+ "z": 1,
+ "label": "Hit the ball toward the other gate. Press P to pause.",
+ "style": {
+ "size": 11,
+ "color": "#8b97a8",
+ "align": "center"
+ }
+ }
+ ]
+ },
+ {
+ "id": "pause",
+ "name": "Pause",
+ "input": "menu",
+ "elements": [
+ {
+ "id": "pause-panel",
+ "kind": "panel",
+ "anchor": "center",
+ "x": 0,
+ "y": 0,
+ "w": 380,
+ "h": 300,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(20, 26, 36, 0.92)",
+ "radius": 16,
+ "border": "1px solid rgba(136, 192, 208, 0.25)"
+ }
+ },
+ {
+ "id": "pause-title",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -95,
+ "w": 340,
+ "h": 36,
+ "z": 1,
+ "label": "PAUSED",
+ "style": {
+ "size": 26,
+ "weight": "700",
+ "color": "#e5e9f0",
+ "align": "center"
+ }
+ },
+ {
+ "id": "resume-btn",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": -30,
+ "w": 240,
+ "h": 42,
+ "z": 1,
+ "label": "Resume",
+ "enabled": true,
+ "style": {
+ "size": 16,
+ "weight": "600",
+ "bg": "#3b7dd8",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "fb-new-match-pause",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 22,
+ "w": 240,
+ "h": 42,
+ "z": 1,
+ "label": "New match",
+ "enabled": true,
+ "style": {
+ "size": 16,
+ "weight": "600",
+ "bg": "#4c9e6a",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "quit-btn",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 74,
+ "w": 240,
+ "h": 42,
+ "z": 1,
+ "label": "Quit to menu",
+ "enabled": true,
+ "style": {
+ "size": 15,
+ "weight": "500",
+ "bg": "#3a4150",
+ "color": "#e5e9f0",
+ "radius": 10
+ }
+ }
+ ]
+ },
+ {
+ "id": "over",
+ "name": "Match over",
+ "showWhile": "over",
+ "input": "menu",
+ "elements": [
+ {
+ "id": "over-panel",
+ "kind": "panel",
+ "anchor": "center",
+ "x": 0,
+ "y": 0,
+ "w": 460,
+ "h": 360,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(20, 26, 36, 0.92)",
+ "radius": 16,
+ "border": "1px solid rgba(136, 192, 208, 0.25)"
+ }
+ },
+ {
+ "id": "over-title",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -130,
+ "w": 420,
+ "h": 40,
+ "z": 1,
+ "label": "MATCH OVER",
+ "style": {
+ "size": 30,
+ "weight": "700",
+ "color": "#ffd45e",
+ "align": "center"
+ }
+ },
+ {
+ "id": "fb-score-over",
+ "kind": "list",
+ "anchor": "center",
+ "x": 0,
+ "y": -70,
+ "w": 420,
+ "h": 60,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 16,
+ "color": "#e5e9f0",
+ "align": "center"
+ }
+ },
+ {
+ "id": "fb-log",
+ "kind": "list",
+ "anchor": "center",
+ "x": 0,
+ "y": 20,
+ "w": 420,
+ "h": 100,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 12,
+ "color": "#c8d0dc",
+ "align": "center"
+ }
+ },
+ {
+ "id": "fb-new-match",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 120,
+ "w": 220,
+ "h": 44,
+ "z": 1,
+ "label": "New match",
+ "enabled": true,
+ "style": {
+ "size": 16,
+ "weight": "600",
+ "bg": "#3b7dd8",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "objects": [
+ {
+ "type": "box",
+ "name": "Pitch",
+ "color": 3820093,
+ "size": [
+ 3,
+ 0.05,
+ 5.5
+ ],
+ "pos": [
+ 0,
+ -0.025,
+ 0
+ ],
+ "roughness": 0.95,
+ "physics": {
+ "mode": "static",
+ "friction": 0.6
+ }
+ },
+ {
+ "type": "box",
+ "name": "Wall left",
+ "color": 10413823,
+ "opacity": 0.06,
+ "size": [
+ 0.05,
+ 2.4,
+ 5.5
+ ],
+ "pos": [
+ -1.525,
+ 1.2,
+ 0
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Wall right",
+ "color": 10413823,
+ "opacity": 0.06,
+ "size": [
+ 0.05,
+ 2.4,
+ 5.5
+ ],
+ "pos": [
+ 1.525,
+ 1.2,
+ 0
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Wall red end",
+ "color": 10413823,
+ "opacity": 0.06,
+ "size": [
+ 3,
+ 2.4,
+ 0.05
+ ],
+ "pos": [
+ 0,
+ 1.2,
+ -2.775
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Wall blue end",
+ "color": 10413823,
+ "opacity": 0.06,
+ "size": [
+ 3,
+ 2.4,
+ 0.05
+ ],
+ "pos": [
+ 0,
+ 1.2,
+ 2.775
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Ceiling",
+ "color": 10413823,
+ "opacity": 0.06,
+ "size": [
+ 3,
+ 0.05,
+ 5.5
+ ],
+ "pos": [
+ 0,
+ 2.425,
+ 0
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red post left",
+ "color": 14240330,
+ "emissive": 14240330,
+ "emissiveIntensity": 0.9,
+ "roughness": 0.4,
+ "size": [
+ 0.06,
+ 0.8600000000000001,
+ 0.06
+ ],
+ "pos": [
+ -0.6,
+ 1.35,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red post right",
+ "color": 14240330,
+ "emissive": 14240330,
+ "emissiveIntensity": 0.9,
+ "roughness": 0.4,
+ "size": [
+ 0.06,
+ 0.8600000000000001,
+ 0.06
+ ],
+ "pos": [
+ 0.6,
+ 1.35,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red bar top",
+ "color": 14240330,
+ "emissive": 14240330,
+ "emissiveIntensity": 0.9,
+ "roughness": 0.4,
+ "size": [
+ 1.26,
+ 0.06,
+ 0.06
+ ],
+ "pos": [
+ 0,
+ 1.75,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red bar bottom",
+ "color": 14240330,
+ "emissive": 14240330,
+ "emissiveIntensity": 0.9,
+ "roughness": 0.4,
+ "size": [
+ 1.26,
+ 0.06,
+ 0.06
+ ],
+ "pos": [
+ 0,
+ 0.9500000000000001,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red gate",
+ "color": 14240330,
+ "opacity": 0.12,
+ "size": [
+ 1.2,
+ 0.8,
+ 0.5
+ ],
+ "pos": [
+ 0,
+ 1.35,
+ -2.45
+ ],
+ "physics": {
+ "mode": "static",
+ "sensor": true,
+ "collider": "box"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 1",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ -0.5,
+ 2,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 2",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ -0.25,
+ 2,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 3",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0,
+ 2,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 4",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0.25,
+ 2,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 5",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0.5,
+ 2,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 6",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ -0.5,
+ 2.22,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 7",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ -0.25,
+ 2.22,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 8",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0,
+ 2.22,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 9",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0.25,
+ 2.22,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Red lamp 10",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0.5,
+ 2.22,
+ -2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Join red",
+ "color": 14240330,
+ "emissive": 14240330,
+ "emissiveIntensity": 0.5,
+ "size": [
+ 0.3,
+ 0.12,
+ 0.3
+ ],
+ "pos": [
+ 1.2,
+ 1.05,
+ -1.6
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue post left",
+ "color": 4881881,
+ "emissive": 4881881,
+ "emissiveIntensity": 0.9,
+ "roughness": 0.4,
+ "size": [
+ 0.06,
+ 0.8600000000000001,
+ 0.06
+ ],
+ "pos": [
+ -0.6,
+ 1.35,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue post right",
+ "color": 4881881,
+ "emissive": 4881881,
+ "emissiveIntensity": 0.9,
+ "roughness": 0.4,
+ "size": [
+ 0.06,
+ 0.8600000000000001,
+ 0.06
+ ],
+ "pos": [
+ 0.6,
+ 1.35,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue bar top",
+ "color": 4881881,
+ "emissive": 4881881,
+ "emissiveIntensity": 0.9,
+ "roughness": 0.4,
+ "size": [
+ 1.26,
+ 0.06,
+ 0.06
+ ],
+ "pos": [
+ 0,
+ 1.75,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue bar bottom",
+ "color": 4881881,
+ "emissive": 4881881,
+ "emissiveIntensity": 0.9,
+ "roughness": 0.4,
+ "size": [
+ 1.26,
+ 0.06,
+ 0.06
+ ],
+ "pos": [
+ 0,
+ 0.9500000000000001,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue gate",
+ "color": 4881881,
+ "opacity": 0.12,
+ "size": [
+ 1.2,
+ 0.8,
+ 0.5
+ ],
+ "pos": [
+ 0,
+ 1.35,
+ 2.45
+ ],
+ "physics": {
+ "mode": "static",
+ "sensor": true,
+ "collider": "box"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 1",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ -0.5,
+ 2,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 2",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ -0.25,
+ 2,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 3",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0,
+ 2,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 4",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0.25,
+ 2,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 5",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0.5,
+ 2,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 6",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ -0.5,
+ 2.22,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 7",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ -0.25,
+ 2.22,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 8",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0,
+ 2.22,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 9",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0.25,
+ 2.22,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Blue lamp 10",
+ "color": 2237998,
+ "emissive": 2237998,
+ "emissiveIntensity": 0.2,
+ "size": [
+ 0.16,
+ 0.16,
+ 0.06
+ ],
+ "pos": [
+ 0.5,
+ 2.22,
+ 2.2
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Join blue",
+ "color": 4881881,
+ "emissive": 4881881,
+ "emissiveIntensity": 0.5,
+ "size": [
+ 0.3,
+ 0.12,
+ 0.3
+ ],
+ "pos": [
+ -1.2,
+ 1.05,
+ 1.6
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Start match",
+ "color": 5021290,
+ "emissive": 5021290,
+ "emissiveIntensity": 0.5,
+ "size": [
+ 0.3,
+ 0.12,
+ 0.3
+ ],
+ "pos": [
+ 1.2,
+ 1.05,
+ -0.35
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "New match",
+ "color": 9080730,
+ "emissive": 9080730,
+ "emissiveIntensity": 0.4,
+ "size": [
+ 0.3,
+ 0.12,
+ 0.3
+ ],
+ "pos": [
+ 1.2,
+ 1.05,
+ 0.35
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "sphere",
+ "name": "Football",
+ "color": 15921906,
+ "r": 0.22,
+ "pos": [
+ 0,
+ 1.3,
+ 0
+ ],
+ "roughness": 0.6,
+ "physics": {
+ "mode": "dynamic",
+ "mass": 0.45,
+ "restitution": 0.7,
+ "friction": 0.2
+ }
+ }
+ ]
+}
diff --git a/modules/football/manifest.json b/modules/football/manifest.json
new file mode 100644
index 0000000..800d649
--- /dev/null
+++ b/modules/football/manifest.json
@@ -0,0 +1,9 @@
+{
+ "id": "football",
+ "name": "Football",
+ "version": "1.0.0",
+ "format": 1,
+ "description": "VR football on the knock: floating ball, two team gates, last-touch attribution, modes, per-player records and a saved match log - every rule a flow node.",
+ "entry": "module.js",
+ "files": ["module.js"]
+}
diff --git a/modules/football/module.js b/modules/football/module.js
new file mode 100644
index 0000000..9ed7f79
--- /dev/null
+++ b/modules/football/module.js
@@ -0,0 +1,1515 @@
+// modules/football/src/rules.js
+var TEAMS = ["red", "blue"];
+var MODES = ["duel", "teams", "freeforall", "practice"];
+var WIN_BY = ["goals", "time", "either"];
+var SERVE = ["auto", "button"];
+var OWN_GOALS = ["count", "ignore"];
+var ACTIONS = ["none", "join-red", "join-blue", "spectate", "start", "new-match", "swap-sides", "serve"];
+var DEFAULT_RULES = {
+ mode: "teams",
+ winBy: "goals",
+ goalsToWin: 5,
+ matchSeconds: 180,
+ serve: "auto",
+ serveDelay: 2,
+ ownGoals: "count",
+ serveSpeed: 3
+};
+var MATCH_LOG_CAP = 50;
+var REST_DISTANCE = 0.03;
+var REST_SECONDS = 3;
+function num(v, lo, hi, d) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return d;
+ return Math.min(hi, Math.max(lo, n));
+}
+function pick(v, options, d) {
+ return options.includes(v) ? v : d;
+}
+function normalizeRules(raw) {
+ const r = raw && typeof raw === "object" ? raw : {};
+ const d = DEFAULT_RULES;
+ return {
+ mode: pick(r.mode, MODES, d.mode),
+ winBy: pick(r.winBy, WIN_BY, d.winBy),
+ goalsToWin: Math.round(num(r.goalsToWin, 1, 20, d.goalsToWin)),
+ matchSeconds: Math.round(num(r.matchSeconds, 30, 1800, d.matchSeconds)),
+ serve: pick(r.serve, SERVE, d.serve),
+ serveDelay: num(r.serveDelay, 0.5, 5, d.serveDelay),
+ ownGoals: pick(r.ownGoals, OWN_GOALS, d.ownGoals),
+ serveSpeed: num(r.serveSpeed, 0.5, 10, d.serveSpeed)
+ };
+}
+function otherTeam(team) {
+ return team === "red" ? "blue" : team === "blue" ? "red" : null;
+}
+function hash32(seed, ...parts) {
+ let h = 2166136261 ^ seed >>> 0;
+ for (const part of parts) {
+ const s = String(part);
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ h ^= 2654435769;
+ h = Math.imul(h, 16777619);
+ }
+ return h >>> 0;
+}
+function emptySlots() {
+ return { red: [], blue: [] };
+}
+function teamOf(slots, peerId) {
+ if (!peerId) return null;
+ if (slots.red.includes(peerId)) return "red";
+ if (slots.blue.includes(peerId)) return "blue";
+ return null;
+}
+function canJoin(slots, team, peerId, mode) {
+ if (team === "none") return { ok: true, reason: "" };
+ if (!TEAMS.includes(team)) return { ok: false, reason: "no such team" };
+ if (teamOf(slots, peerId) === team) return { ok: true, reason: "already there" };
+ if (mode === "duel" && slots[team].length >= 1) return { ok: false, reason: team + " is taken (duel: one per side)" };
+ return { ok: true, reason: "" };
+}
+function applySlot(slots, team, peerId) {
+ const next = { red: slots.red.filter((id) => id !== peerId), blue: slots.blue.filter((id) => id !== peerId) };
+ if (team === "red" || team === "blue") next[team] = [...next[team], peerId];
+ return next;
+}
+function freeVanished(slots, liveIds) {
+ const live = new Set(liveIds);
+ const freed = [];
+ const keep = (id) => {
+ if (live.has(id)) return true;
+ freed.push(id);
+ return false;
+ };
+ return { slots: { red: slots.red.filter(keep), blue: slots.blue.filter(keep) }, freed };
+}
+function swapSlots(slots) {
+ return { red: [...slots.blue], blue: [...slots.red] };
+}
+function attributeGoal({ gateTeam, lastTouch, slots, mode, ownGoals }) {
+ if (mode === "practice") return { team: null, by: null, own: false, counts: false, credit: null, reason: "practice" };
+ const by = lastTouch?.by ?? null;
+ const toucherTeam = by ? teamOf(slots, by) : null;
+ if (mode === "freeforall") {
+ if (!by) return { team: null, by: null, own: false, counts: false, credit: null, reason: "nobody touched it" };
+ return { team: null, by, own: false, counts: true, credit: "goals", reason: "free for all" };
+ }
+ const scoring = otherTeam(gateTeam);
+ if (!by || !toucherTeam) return { team: scoring, by: null, own: false, counts: true, credit: null, reason: by ? "spectator touch" : "no touch" };
+ if (toucherTeam === scoring) return { team: scoring, by, own: false, counts: true, credit: "goals", reason: "goal" };
+ if (ownGoals === "ignore") return { team: scoring, by, own: true, counts: false, credit: null, reason: "own goal ignored" };
+ return { team: scoring, by, own: true, counts: true, credit: "owngoals", reason: "own goal" };
+}
+function applyGoal(score, a) {
+ if (!a.counts || !a.team) return { ...score };
+ return { ...score, [a.team]: (score[a.team] ?? 0) + 1 };
+}
+function matchOutcome({ score, rules, elapsed, playerGoals }) {
+ const r = normalizeRules(rules);
+ if (r.mode === "practice") return null;
+ const byGoals = r.winBy === "goals" || r.winBy === "either";
+ const byTime = r.winBy === "time" || r.winBy === "either";
+ if (byGoals) {
+ if (r.mode === "freeforall") {
+ for (const [id, n] of Object.entries(playerGoals ?? {})) if (n >= r.goalsToWin) return { winner: id, reason: "goals" };
+ } else {
+ if (score.red >= r.goalsToWin && score.red > score.blue) return { winner: "red", reason: "goals" };
+ if (score.blue >= r.goalsToWin && score.blue > score.red) return { winner: "blue", reason: "goals" };
+ }
+ }
+ if (byTime && elapsed >= r.matchSeconds) {
+ if (r.mode === "freeforall") {
+ let best = null;
+ let tie = false;
+ for (const [id, n] of Object.entries(playerGoals ?? {})) {
+ if (!best || n > best.n) {
+ best = { id, n };
+ tie = false;
+ } else if (n === best.n) tie = true;
+ }
+ return { winner: best && !tie ? best.id : "draw", reason: "time" };
+ }
+ if (score.red === score.blue) return { winner: "draw", reason: "time" };
+ return { winner: score.red > score.blue ? "red" : "blue", reason: "time" };
+ }
+ return null;
+}
+function secondsLeft(rules, elapsed) {
+ const r = normalizeRules(rules);
+ if (r.winBy === "goals" || r.mode === "practice") return null;
+ return Math.max(0, r.matchSeconds - elapsed);
+}
+function serveDirection(at) {
+ const h = hash32(Math.floor(at), "serve");
+ const sign = h & 1 ? 1 : -1;
+ const lean = ((h >>> 8 & 255) / 255 - 0.5) * 0.6;
+ const len = Math.hypot(lean, 1);
+ return [lean / len, 0, sign / len];
+}
+function serveImpulse(pos, centre, mass, speed, at) {
+ const dx = centre[0] - pos[0];
+ const dz = centre[2] - pos[2];
+ const dist = Math.hypot(dx, dz);
+ const dir = dist > 0.5 ? [dx / dist, 0, dz / dist] : serveDirection(at);
+ const m = Math.max(0.01, mass || 1) * Math.max(0, speed);
+ return [dir[0] * m, dir[1] * m, dir[2] * m];
+}
+function matchLogEntry({ at, score, winner, rows }) {
+ return {
+ at: Math.floor(at),
+ red: score.red,
+ blue: score.blue,
+ winner,
+ scorers: (rows ?? []).filter((r) => r.goals > 0).map((r) => ({ name: String(r.name), goals: r.goals }))
+ };
+}
+function appendMatchLog(log, entry, cap = MATCH_LOG_CAP) {
+ const list = Array.isArray(log) ? [...log, entry] : [entry];
+ return list.slice(-Math.max(1, cap));
+}
+function scoreLine(score) {
+ return "RED " + (score.red ?? 0) + " \u2014 " + (score.blue ?? 0) + " BLUE";
+}
+
+// modules/football/src/game.js
+var LOG_VAR = "football";
+function createGame(api) {
+ const THREE = api.THREE;
+ const state = {
+ /** rules from the toolbox when no Match Rules node is alive */
+ rulesOverride: (
+ /** @type {any} */
+ null
+ ),
+ slots: emptySlots(),
+ /** @type {Record} peerId -> name at join time (the sheet's names) */
+ names: {},
+ started: false,
+ startedAt: 0,
+ endedAt: 0,
+ score: { red: 0, blue: 0 },
+ /** @type {import('./rules.js').LastTouch} */
+ lastTouch: null,
+ /** when the next serve is due (synced seconds), 0 = none pending */
+ serveAt: 0,
+ serves: 0,
+ goals: 0,
+ /** free-for-all: goals per player, replicated with the goal op */
+ playerGoals: (
+ /** @type {Record} */
+ {}
+ ),
+ /** @type {{winner: string, reason: string} | null} */
+ outcome: null,
+ /** the last op stamp — a late joiner adopts the newer state */
+ at: 0
+ };
+ const config = {
+ rules: (
+ /** @type {any} */
+ null
+ ),
+ /** @type {Record} gate object uuid -> team */
+ gates: {},
+ ballUuid: (
+ /** @type {string | null} */
+ null
+ )
+ };
+ const listeners = /* @__PURE__ */ new Set();
+ const emit = (what, data) => {
+ for (const fn of listeners) {
+ try {
+ fn(what, data);
+ } catch (error) {
+ console.log("football listener failed", error);
+ }
+ }
+ };
+ const me = () => api.peerId() ?? "me";
+ const now = () => api.now();
+ const rules = () => normalizeRules(config.rules ?? state.rulesOverride ?? DEFAULT_RULES);
+ function roster() {
+ const rows = typeof api.peerNames === "function" ? api.peerNames() : [];
+ if (!rows.some((r) => r.me)) rows.push({ id: me(), name: "", label: "you", me: true });
+ return rows;
+ }
+ function nameOf(peerId) {
+ if (peerId === me()) {
+ const mine = roster().find((r) => r.me);
+ return mine?.name || state.names[peerId] || "you";
+ }
+ return roster().find((r) => r.id === peerId)?.label || state.names[peerId] || "peer " + String(peerId).slice(0, 4);
+ }
+ const liveIds = () => {
+ const ids = typeof api.peerIds === "function" ? api.peerIds() : [];
+ return ids.includes(me()) ? ids : [me(), ...ids];
+ };
+ function isAuthority() {
+ if (api.physics?.running?.()) return !!api.physics.isInitiator();
+ const ids = liveIds().slice().sort();
+ return ids[0] === me();
+ }
+ const ball = () => config.ballUuid ? api.objectsGroup()?.getObjectByProperty("uuid", config.ballUuid) ?? null : null;
+ function stamp(data) {
+ const at = Number(data?.at) || now();
+ if (at > state.at) state.at = at;
+ return at;
+ }
+ function applySlotOp(data) {
+ const team = data.team === "red" || data.team === "blue" ? data.team : "none";
+ const verdict = canJoin(state.slots, team, data.peerId, rules().mode);
+ if (!verdict.ok) return false;
+ state.slots = applySlot(state.slots, team, data.peerId);
+ if (data.name) state.names[data.peerId] = data.name;
+ stamp(data);
+ emit("slots");
+ return true;
+ }
+ function applyStart(data) {
+ if (state.started) return false;
+ const at = stamp(data);
+ state.started = true;
+ state.startedAt = at;
+ state.endedAt = 0;
+ state.score = { red: 0, blue: 0 };
+ state.playerGoals = {};
+ state.lastTouch = null;
+ state.outcome = null;
+ state.goals = 0;
+ state.serveAt = at + rules().serveDelay;
+ emit("start");
+ fireEvent("start");
+ api.game?.setState?.("playing", "");
+ return true;
+ }
+ function applyReset(data) {
+ const at = stamp(data);
+ state.started = false;
+ state.startedAt = 0;
+ state.endedAt = 0;
+ state.score = { red: 0, blue: 0 };
+ state.playerGoals = {};
+ state.lastTouch = null;
+ state.outcome = null;
+ state.serveAt = 0;
+ emit("reset");
+ fireEvent("reset");
+ api.game?.setState?.("menu", "");
+ void at;
+ return true;
+ }
+ function applySwap(data) {
+ stamp(data);
+ state.slots = swapSlots(state.slots);
+ emit("slots");
+ return true;
+ }
+ function applyGoalOp(data) {
+ if (!state.started) return false;
+ const at = stamp(data);
+ const a = { team: data.team ?? null, by: data.by ?? null, own: !!data.own, counts: !!data.counts, credit: data.credit ?? null, reason: "" };
+ state.score = applyGoal(state.score, a);
+ if (a.counts && a.by && rules().mode === "freeforall") state.playerGoals[a.by] = (state.playerGoals[a.by] ?? 0) + 1;
+ if (a.counts) state.goals++;
+ if (a.by === me() && a.credit && api.peerVars?.setMine) api.peerVars.setMine(a.credit, api.peerVars.mine(a.credit, 0) + 1);
+ state.lastTouch = null;
+ state.serveAt = rules().serve === "auto" ? at + rules().serveDelay : 0;
+ const gate = api.objectsGroup()?.getObjectByProperty("uuid", data.gate);
+ const where = gate ? gate.getWorldPosition(new THREE.Vector3()).toArray() : void 0;
+ if (a.counts) api.playSound?.("ding", where);
+ emit("goal", a);
+ fireEvent("goal");
+ if (a.counts && a.team) fireEvent(a.team + "goal");
+ return true;
+ }
+ function applyServeOp(data) {
+ stamp(data);
+ state.serves++;
+ state.serveAt = 0;
+ state.lastTouch = null;
+ emit("serve");
+ fireEvent("serve");
+ return true;
+ }
+ function applyOver(data) {
+ if (!state.started) return false;
+ const at = stamp(data);
+ state.started = false;
+ state.endedAt = at;
+ state.serveAt = 0;
+ state.outcome = { winner: String(data.winner ?? "draw"), reason: String(data.reason ?? "") };
+ emit("over", state.outcome);
+ fireEvent("over");
+ api.game?.setState?.("over", outcomeText());
+ return true;
+ }
+ function applyRulesOp(data) {
+ stamp(data);
+ state.rulesOverride = normalizeRules(data.rules);
+ emit("rules");
+ return true;
+ }
+ function act(action) {
+ const at = now();
+ switch (action) {
+ case "join-red":
+ case "join-blue":
+ case "spectate": {
+ const team = action === "join-red" ? "red" : action === "join-blue" ? "blue" : "none";
+ const verdict = canJoin(state.slots, team, me(), rules().mode);
+ if (!verdict.ok) {
+ api.toast("Football: " + verdict.reason);
+ return false;
+ }
+ const data = { op: "slot", team, peerId: me(), name: nameOf(me()), at };
+ applySlotOp(data);
+ api.send(data);
+ return true;
+ }
+ case "start": {
+ if (state.started) return false;
+ const data = { op: "start", at };
+ applyStart(data);
+ api.send(data);
+ return true;
+ }
+ case "new-match": {
+ const data = { op: "reset", at };
+ applyReset(data);
+ api.send(data);
+ return true;
+ }
+ case "swap-sides": {
+ const data = { op: "swap", at };
+ applySwap(data);
+ api.send(data);
+ return true;
+ }
+ case "serve":
+ return serve("button");
+ default:
+ return false;
+ }
+ }
+ function setRules(patch) {
+ const data = { op: "rules", rules: normalizeRules({ ...rules(), ...patch }), at: now() };
+ applyRulesOp(data);
+ api.send(data);
+ }
+ function serve(why) {
+ if (!isAuthority()) return false;
+ const object = ball();
+ if (!object) return false;
+ const at = now();
+ const mass = Number(object.userData?.physics?.mass) || 1;
+ const r = rules();
+ const centre = pitchCentre();
+ centre[1] = object.position.y;
+ const impulse = serveImpulse(object.position.toArray(), centre, mass, r.serveSpeed, at);
+ const pushed = api.physics.applyImpulse(object.uuid, impulse);
+ const data = { op: "serve", at, why };
+ applyServeOp(data);
+ api.send(data);
+ return pushed;
+ }
+ function pitchCentre() {
+ const group = api.objectsGroup();
+ const gates = Object.keys(config.gates).map((uuid) => group?.getObjectByProperty("uuid", uuid)).filter(Boolean);
+ if (gates.length < 2) return [0, 0, 0];
+ const c = new THREE.Vector3();
+ for (const g of gates) c.add(g.getWorldPosition(new THREE.Vector3()));
+ return c.multiplyScalar(1 / gates.length).toArray();
+ }
+ const inside = {};
+ const _box = new THREE.Box3();
+ const _pos = new THREE.Vector3();
+ function watchGoals() {
+ const object = ball();
+ if (!object) return;
+ object.getWorldPosition(_pos);
+ const group = api.objectsGroup();
+ for (const [uuid, gate] of Object.entries(config.gates)) {
+ const sensor = group?.getObjectByProperty("uuid", uuid);
+ if (!sensor) continue;
+ _box.setFromObject(sensor);
+ const isIn = _box.containsPoint(_pos);
+ const was = !!inside[uuid];
+ inside[uuid] = isIn;
+ if (!isIn || was || !state.started || state.serveAt) continue;
+ const r = rules();
+ const a = attributeGoal({ gateTeam: gate.team, lastTouch: state.lastTouch, slots: state.slots, mode: r.mode, ownGoals: r.ownGoals });
+ const data = { op: "goal", gate: uuid, gateTeam: gate.team, team: a.team, by: a.by, own: a.own, counts: a.counts, credit: a.credit, at: now() };
+ applyGoalOp(data);
+ api.send(data);
+ }
+ }
+ let restPos = (
+ /** @type {number[] | null} */
+ null
+ );
+ let restSince = 0;
+ function watchRest(t) {
+ const object = ball();
+ if (!object || !state.started || state.serveAt) {
+ restPos = null;
+ return;
+ }
+ const p = object.position.toArray();
+ if (!restPos || Math.hypot(p[0] - restPos[0], p[1] - restPos[1], p[2] - restPos[2]) > REST_DISTANCE) {
+ restPos = p;
+ restSince = t;
+ return;
+ }
+ if (t - restSince >= REST_SECONDS) {
+ restPos = null;
+ if (rules().serve === "auto") serve("rest");
+ }
+ }
+ function watchEnd() {
+ if (!state.started) return;
+ const outcome = matchOutcome({ score: state.score, rules: rules(), elapsed: now() - state.startedAt, playerGoals: state.playerGoals });
+ if (!outcome) return;
+ const data = { op: "over", at: now(), ...outcome };
+ applyOver(data);
+ api.send(data);
+ writeMatchLog();
+ }
+ function writeMatchLog() {
+ if (!api.game?.setVar || !api.game?.getVar) return;
+ const rows = sheetRows().map((r) => ({ name: r.name, goals: r.goals }));
+ const entry = matchLogEntry({ at: now(), score: state.score, winner: outcomeWinnerName(), rows });
+ const current = api.game.getVar(LOG_VAR, null);
+ const matches = appendMatchLog(current && typeof current === "object" ? current.matches : [], entry, MATCH_LOG_CAP);
+ api.game.setVar(LOG_VAR, { matches });
+ }
+ function matchLog() {
+ const v = api.game?.getVar?.(LOG_VAR, null);
+ return v && typeof v === "object" && Array.isArray(v.matches) ? v.matches : [];
+ }
+ function onHit(hit) {
+ if (!hit || !config.ballUuid || hit.uuid !== config.ballUuid) return;
+ const by = String(hit.by ?? "");
+ if (!by) return;
+ const team = teamOf(state.slots, by);
+ state.lastTouch = { by, team, at: Number(hit.at) || now() };
+ if (by === me() && state.started && api.peerVars?.setMine) api.peerVars.setMine("touches", api.peerVars.mine("touches", 0) + 1);
+ emit("touch", state.lastTouch);
+ if (by === me()) fireEvent("touch");
+ }
+ function wireHits() {
+ if (typeof api.onHit === "function") {
+ api.onHit(onHit);
+ return "api.onHit";
+ }
+ const knock = typeof window !== "undefined" ? (
+ /** @type {any} */
+ window.__stores?.knock
+ ) : null;
+ if (knock?.registerHitListener) {
+ knock.registerHitListener((hit) => onHit(hit));
+ return "__stores.knock";
+ }
+ return "none";
+ }
+ let firing = false;
+ function fireEvent(kind) {
+ if (!lastOpLocal || typeof api.fireNodeTrigger !== "function") return;
+ if (firing) return;
+ firing = true;
+ try {
+ api.fireNodeTrigger("fbevent", (d) => (d?.event ?? "goal") === kind);
+ } finally {
+ firing = false;
+ }
+ }
+ let lastOpLocal = true;
+ function handleMessage(data) {
+ lastOpLocal = false;
+ try {
+ switch (data?.op) {
+ case "slot":
+ return applySlotOp(data);
+ case "start":
+ return applyStart(data);
+ case "reset":
+ return applyReset(data);
+ case "swap":
+ return applySwap(data);
+ case "goal":
+ return applyGoalOp(data);
+ case "serve":
+ return applyServeOp(data);
+ case "over":
+ return applyOver(data);
+ case "rules":
+ return applyRulesOp(data);
+ default:
+ return false;
+ }
+ } finally {
+ lastOpLocal = true;
+ }
+ }
+ function getState() {
+ return {
+ v: 1,
+ at: state.at,
+ rulesOverride: state.rulesOverride,
+ slots: { red: [...state.slots.red], blue: [...state.slots.blue] },
+ names: { ...state.names },
+ started: state.started,
+ startedAt: state.startedAt,
+ endedAt: state.endedAt,
+ score: { ...state.score },
+ lastTouch: state.lastTouch ? { ...state.lastTouch } : null,
+ serveAt: state.serveAt,
+ serves: state.serves,
+ goals: state.goals,
+ playerGoals: { ...state.playerGoals },
+ outcome: state.outcome ? { ...state.outcome } : null
+ };
+ }
+ function applyState(remote) {
+ if (!remote || typeof remote !== "object") return;
+ if ((Number(remote.at) || 0) < state.at) return;
+ lastOpLocal = false;
+ state.at = Number(remote.at) || 0;
+ state.rulesOverride = remote.rulesOverride ? normalizeRules(remote.rulesOverride) : null;
+ state.slots = { red: [...remote.slots?.red ?? []], blue: [...remote.slots?.blue ?? []] };
+ state.names = { ...remote.names ?? {} };
+ state.started = !!remote.started;
+ state.startedAt = Number(remote.startedAt) || 0;
+ state.endedAt = Number(remote.endedAt) || 0;
+ state.score = { red: Number(remote.score?.red) || 0, blue: Number(remote.score?.blue) || 0 };
+ state.lastTouch = remote.lastTouch ? { by: String(remote.lastTouch.by), team: remote.lastTouch.team ?? null, at: Number(remote.lastTouch.at) || 0 } : null;
+ state.serveAt = Number(remote.serveAt) || 0;
+ state.serves = Number(remote.serves) || 0;
+ state.goals = Number(remote.goals) || 0;
+ state.playerGoals = { ...remote.playerGoals ?? {} };
+ state.outcome = remote.outcome ? { winner: String(remote.outcome.winner), reason: String(remote.outcome.reason ?? "") } : null;
+ lastOpLocal = true;
+ emit("state");
+ }
+ function clear() {
+ state.rulesOverride = null;
+ state.slots = emptySlots();
+ state.names = {};
+ state.started = false;
+ state.startedAt = 0;
+ state.endedAt = 0;
+ state.score = { red: 0, blue: 0 };
+ state.lastTouch = null;
+ state.serveAt = 0;
+ state.serves = 0;
+ state.goals = 0;
+ state.playerGoals = {};
+ state.outcome = null;
+ state.at = 0;
+ config.gates = {};
+ config.ballUuid = null;
+ for (const key of Object.keys(inside)) delete inside[key];
+ emit("reset");
+ }
+ let lastSweep = 0;
+ function tick(t) {
+ if (t - lastSweep > 1) {
+ lastSweep = t;
+ const { slots, freed } = freeVanished(state.slots, liveIds());
+ if (freed.length) {
+ state.slots = slots;
+ emit("slots");
+ }
+ }
+ if (!isAuthority()) return;
+ if (state.started && state.serveAt && now() >= state.serveAt) serve("auto");
+ watchGoals();
+ watchRest(t);
+ watchEnd();
+ }
+ function outcomeWinnerName() {
+ const o = state.outcome;
+ if (!o) return "";
+ if (o.winner === "red" || o.winner === "blue" || o.winner === "draw") return o.winner;
+ return nameOf(o.winner);
+ }
+ function outcomeText() {
+ const o = state.outcome;
+ if (!o) return "";
+ const who = o.winner === "draw" ? "Draw" : o.winner === "red" ? "RED wins" : o.winner === "blue" ? "BLUE wins" : nameOf(o.winner) + " wins";
+ return who + (o.reason === "time" ? " \u2014 time's up" : "") + " \xB7 " + scoreLine(state.score);
+ }
+ function sheetRows() {
+ const rows = api.peerVars?.all ? api.peerVars.all("goals") : [];
+ const byId = new Map(rows.map((r) => [r.id, r]));
+ const ids = /* @__PURE__ */ new Set([...state.slots.red, ...state.slots.blue, ...rows.map((r) => r.id)]);
+ const read = (id, name) => id === me() ? api.peerVars?.mine?.(name, 0) ?? 0 : (api.peerVars?.all?.(name) ?? []).find((r) => r.id === id)?.value ?? 0;
+ return [...ids].map((id) => ({
+ id,
+ name: byId.get(id)?.name || nameOf(id),
+ team: teamOf(state.slots, id),
+ goals: read(id, "goals"),
+ touches: read(id, "touches"),
+ owngoals: read(id, "owngoals"),
+ me: id === me()
+ }));
+ }
+ const left = () => state.started ? secondsLeft(rules(), now() - state.startedAt) : null;
+ return {
+ state,
+ config,
+ rules,
+ me,
+ nameOf,
+ isAuthority,
+ act,
+ setRules,
+ serve,
+ handleMessage,
+ getState,
+ applyState,
+ clear,
+ tick,
+ wireHits,
+ onHit,
+ sheetRows,
+ matchLog,
+ writeMatchLog,
+ secondsLeft: left,
+ scoreLine: () => scoreLine(state.score),
+ outcomeText,
+ pitchCentre,
+ ball,
+ onChange: (fn) => {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ }
+ };
+}
+
+// modules/football/src/pitch.js
+var RED = 14240330;
+var BLUE = 4881881;
+var LAMP_DIM = 2237998;
+var LAMPS_PER_GATE = 10;
+var DEFAULT_DIMS = {
+ length: 5,
+ width: 3,
+ height: 2.4,
+ gateWidth: 1.2,
+ gateHeight: 0.8,
+ mouthY: 1.35,
+ ballY: 1.3,
+ ballRadius: 0.22,
+ sensorDepth: 0.5
+};
+function num2(v, lo, hi, d) {
+ const n = Number(v);
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d;
+}
+function normalizeDims(raw) {
+ const r = raw && typeof raw === "object" ? raw : {};
+ const d = DEFAULT_DIMS;
+ const length = num2(r.length, 2, 30, d.length);
+ const width = num2(r.width, 1.5, 20, d.width);
+ return {
+ length,
+ width,
+ height: num2(r.height, 1.8, 8, d.height),
+ gateWidth: num2(r.gateWidth, 0.5, Math.min(width, 6), Math.min(d.gateWidth, width)),
+ gateHeight: num2(r.gateHeight, 0.3, 3, d.gateHeight),
+ mouthY: num2(r.mouthY, 0.5, 2.2, d.mouthY),
+ ballY: num2(r.ballY, 0.3, 2.2, d.ballY),
+ ballRadius: num2(r.ballRadius, 0.08, 0.6, d.ballRadius),
+ sensorDepth: num2(r.sensorDepth, 0.3, 1.5, d.sensorDepth)
+ };
+}
+var NAMES = {
+ pitch: "Pitch",
+ ball: "Football",
+ redGate: "Red gate",
+ blueGate: "Blue gate",
+ joinRed: "Join red",
+ joinBlue: "Join blue",
+ start: "Start match",
+ newMatch: "New match",
+ lamp: (team, i) => (team === "red" ? "Red" : "Blue") + " lamp " + i
+};
+function pitchObjects(dims) {
+ const d = normalizeDims(dims);
+ const hl = d.length / 2;
+ const hw = d.width / 2;
+ const wall = 0.05;
+ const mouthZ = hl - 0.3;
+ const sensorZ = mouthZ + d.sensorDepth / 2;
+ const endZ = mouthZ + d.sensorDepth + 0.05;
+ const post = 0.06;
+ const out = [];
+ const stat = (extra) => ({ mode: "static", ...extra ?? {} });
+ out.push({ type: "box", name: NAMES.pitch, color: 3820093, size: [d.width, wall, endZ * 2], pos: [0, -wall / 2, 0], roughness: 0.95, physics: stat({ friction: 0.6 }) });
+ const ghost = { color: 10413823, opacity: 0.06 };
+ out.push({ type: "box", name: "Wall left", ...ghost, size: [wall, d.height, endZ * 2], pos: [-hw - wall / 2, d.height / 2, 0], physics: stat() });
+ out.push({ type: "box", name: "Wall right", ...ghost, size: [wall, d.height, endZ * 2], pos: [hw + wall / 2, d.height / 2, 0], physics: stat() });
+ out.push({ type: "box", name: "Wall red end", ...ghost, size: [d.width, d.height, wall], pos: [0, d.height / 2, -endZ - wall / 2], physics: stat() });
+ out.push({ type: "box", name: "Wall blue end", ...ghost, size: [d.width, d.height, wall], pos: [0, d.height / 2, endZ + wall / 2], physics: stat() });
+ out.push({ type: "box", name: "Ceiling", ...ghost, size: [d.width, wall, endZ * 2], pos: [0, d.height + wall / 2, 0], physics: stat() });
+ for (
+ const team of
+ /** @type {const} */
+ ["red", "blue"]
+ ) {
+ const sign = team === "red" ? -1 : 1;
+ const color = team === "red" ? RED : BLUE;
+ const z = sign * mouthZ;
+ const gx = d.gateWidth / 2;
+ const gy = d.gateHeight / 2;
+ const frame = { color, emissive: color, emissiveIntensity: 0.9, roughness: 0.4 };
+ const T = team === "red" ? "Red" : "Blue";
+ out.push({ type: "box", name: T + " post left", ...frame, size: [post, d.gateHeight + post, post], pos: [-gx, d.mouthY, z], physics: stat() });
+ out.push({ type: "box", name: T + " post right", ...frame, size: [post, d.gateHeight + post, post], pos: [gx, d.mouthY, z], physics: stat() });
+ out.push({ type: "box", name: T + " bar top", ...frame, size: [d.gateWidth + post, post, post], pos: [0, d.mouthY + gy, z], physics: stat() });
+ out.push({ type: "box", name: T + " bar bottom", ...frame, size: [d.gateWidth + post, post, post], pos: [0, d.mouthY - gy, z], physics: stat() });
+ out.push({
+ type: "box",
+ name: team === "red" ? NAMES.redGate : NAMES.blueGate,
+ color,
+ opacity: 0.12,
+ size: [d.gateWidth, d.gateHeight, d.sensorDepth],
+ pos: [0, d.mouthY, sign * sensorZ],
+ physics: stat({ sensor: true, collider: "box" })
+ });
+ const lampY0 = d.mouthY + gy + 0.25;
+ for (let i = 1; i <= LAMPS_PER_GATE; i++) {
+ const row = i <= 5 ? 0 : 1;
+ const col = (i - 1) % 5;
+ out.push({
+ type: "box",
+ name: NAMES.lamp(team, i),
+ color: LAMP_DIM,
+ emissive: LAMP_DIM,
+ emissiveIntensity: 0.2,
+ size: [0.16, 0.16, 0.06],
+ pos: [-0.5 + col * 0.25, lampY0 + row * 0.22, z],
+ physics: stat()
+ });
+ }
+ out.push({
+ type: "box",
+ name: team === "red" ? NAMES.joinRed : NAMES.joinBlue,
+ color,
+ emissive: color,
+ emissiveIntensity: 0.5,
+ size: [0.3, 0.12, 0.3],
+ pos: [sign * -1 * (hw - 0.3), 1.05, sign * (mouthZ - 0.6)],
+ physics: stat()
+ });
+ }
+ out.push({ type: "box", name: NAMES.start, color: 5021290, emissive: 5021290, emissiveIntensity: 0.5, size: [0.3, 0.12, 0.3], pos: [hw - 0.3, 1.05, -0.35], physics: stat() });
+ out.push({ type: "box", name: NAMES.newMatch, color: 9080730, emissive: 9080730, emissiveIntensity: 0.4, size: [0.3, 0.12, 0.3], pos: [hw - 0.3, 1.05, 0.35], physics: stat() });
+ out.push({
+ type: "sphere",
+ name: NAMES.ball,
+ color: 15921906,
+ r: d.ballRadius,
+ pos: [0, d.ballY, 0],
+ roughness: 0.6,
+ physics: { mode: "dynamic", mass: 0.45, restitution: 0.7, friction: 0.2 }
+ });
+ return out;
+}
+var PITCH_PHYSICS = {
+ gravity: 0,
+ ground: { enabled: false },
+ damping: { linear: 0.35, angular: 0.5 },
+ ccd: true,
+ knock: { enabled: true, gain: 1, maxSpeed: 10, spin: 0.8 },
+ play: { interaction: "grab", grounded: false, simOnPlay: true }
+};
+function createCommand(o) {
+ if (o.type === "sphere") return "/create Sphere " + o.r;
+ const [w, h, dpt] = o.size;
+ return "/create Box " + w + " " + h + " " + dpt;
+}
+function pitchGraph(names, opts = {}) {
+ const sel = (name) => names?.[name] ?? name;
+ const nodes = [];
+ const edges = [];
+ const N = (id, type, label, x, y2, data) => {
+ nodes.push({ id, type, position: { x, y: y2 }, data: { label, ...data }, class: "w-[150px]" });
+ return id;
+ };
+ const E = (source, target, handle) => {
+ edges.push({ id: "e-" + source + "-" + target + (handle ? "." + handle : ""), source, target, ...handle ? { targetHandle: handle } : {} });
+ };
+ let y = 40;
+ const row = () => y += 150;
+ N("rules", "fbrules", "Match Rules", 280, y, {});
+ N("selpitch", "objectselector", "Pitch", 520, y, { selected: sel(NAMES.pitch) });
+ E("rules", "selpitch");
+ N("selball", "objectselector", "Football", 40, y, { selected: sel(NAMES.ball) });
+ E("selball", "rules", "ball");
+ row();
+ N("gater", "fbgate", "Red gate", 280, y, { team: "red" });
+ N("selgater", "objectselector", "Red gate sensor", 520, y, { selected: sel(NAMES.redGate) });
+ E("gater", "selgater");
+ row();
+ N("gateb", "fbgate", "Blue gate", 280, y, { team: "blue" });
+ N("selgateb", "objectselector", "Blue gate sensor", 520, y, { selected: sel(NAMES.blueGate) });
+ E("gateb", "selgateb");
+ row();
+ N("records", "fbrecords", "Records", 280, y, {
+ show: "all",
+ element: opts.hudButtons ? "fb-sheet,fb-sheet-play" : "fb-sheet",
+ scoreElement: opts.hudButtons ? "fb-score,fb-score-over" : "fb-score",
+ logElement: opts.hudButtons ? "fb-log" : ""
+ });
+ E("records", "selpitch");
+ row();
+ const buttons = [
+ ["bjoinr", NAMES.joinRed, "join-red"],
+ ["bjoinb", NAMES.joinBlue, "join-blue"],
+ ["bstart", NAMES.start, "start"],
+ ["bnew", NAMES.newMatch, "new-match"]
+ ];
+ for (const [id, name, action] of buttons) {
+ N(id, "fbbutton", "Button: " + action, 280, y, { action });
+ N("sel" + id, "objectselector", name, 520, y, { selected: sel(name) });
+ E(id, "sel" + id);
+ if (opts.hudButtons) {
+ N("h" + id, "hudbutton", "HUD " + action, 40, y, { element: "fb-" + action, perPlayer: true });
+ N("d" + id, "delay", "HUD " + action + " press", 160, y, { seconds: 0.05, pulse: 0.3 });
+ E("h" + id, "d" + id, "trigger");
+ E("d" + id, id, "press");
+ }
+ row();
+ }
+ for (const team of ["red", "blue"]) {
+ for (let i = 1; i <= LAMPS_PER_GATE; i++) {
+ const id = "lamp" + team + i;
+ const x = 40 + (i - 1) % 5 * 240;
+ if ((i - 1) % 5 === 0 && i > 1) row();
+ N(id, "fblamp", (team === "red" ? "Red" : "Blue") + " lamp " + i, x, y, { team, index: i });
+ N("sel" + id, "objectselector", NAMES.lamp(team, i), x, y + 70, { selected: sel(NAMES.lamp(team, i)) });
+ E(id, "sel" + id);
+ }
+ row();
+ }
+ if (opts.hudButtons) {
+ for (const [id, element] of [["bnewover", "fb-new-match"], ["bnewpause", "fb-new-match-pause"]]) {
+ N(id, "fbbutton", "Button: new-match (" + element + ")", 280, y, { action: "new-match" });
+ E(id, "selbnew");
+ N("h" + id, "hudbutton", "HUD " + element, 40, y, { element, perPlayer: true });
+ N("d" + id, "delay", "HUD " + element + " press", 160, y, { seconds: 0.05, pulse: 0.3 });
+ E("h" + id, "d" + id, "trigger");
+ E("d" + id, id, "press");
+ row();
+ }
+ N("pkey", "keypress", "Press P", 40, y, { code: "KeyP", edge: "down", pulse: 0.3 });
+ N("pausetoggle", "hudscreen", "Toggle pause menu", 280, y, { screen: "pause", action: "toggle" });
+ E("pkey", "pausetoggle", "trigger");
+ row();
+ N("bresume", "hudbutton", "Resume button", 40, y, { element: "resume-btn" });
+ N("resumehide", "hudscreen", "Close pause menu", 280, y, { screen: "pause", action: "hide" });
+ E("bresume", "resumehide", "trigger");
+ row();
+ N("bquit", "hudbutton", "Quit to menu button", 40, y, { element: "quit-btn" });
+ N("doquit", "setgamestate", "Quit to menu", 280, y, { state: "menu", outcome: "", reset: true });
+ N("quithide", "hudscreen", "Close pause on quit", 520, y, { screen: "pause", action: "hide" });
+ E("bquit", "doquit", "trigger");
+ E("bquit", "quithide", "trigger");
+ row();
+ }
+ N("evstart", "fbevent", "On match start", 40, y, { event: "start" });
+ N("gostart", "setgamestate", "Match: playing", 280, y, { state: "playing", outcome: "", reset: false });
+ E("evstart", "gostart", "trigger");
+ row();
+ N("evover", "fbevent", "On match over", 40, y, { event: "over" });
+ N("goover", "setgamestate", "Match: over", 280, y, { state: "over", outcome: "Match over", reset: false });
+ E("evover", "goover", "trigger");
+ row();
+ N("evnew", "fbevent", "On new match", 40, y, { event: "reset" });
+ N("gomenu", "setgamestate", "Match: menu", 280, y, { state: "menu", outcome: "", reset: true });
+ E("evnew", "gomenu", "trigger");
+ row();
+ return { nodes, edges };
+}
+
+// modules/football/src/nodes.js
+var EXPIRE_FRAMES = 40;
+function registerNodes(api, game) {
+ let frame = 0;
+ let rulesSeen = -1;
+ const gateSeen = {};
+ const buttons = /* @__PURE__ */ new Map();
+ const pressLevel = /* @__PURE__ */ new Map();
+ const serveLevel = /* @__PURE__ */ new Map();
+ api.registerNodeGroup({
+ group: "Football",
+ items: [
+ {
+ type: "fbrules",
+ label: "Match Rules",
+ defaults: { ...DEFAULT_RULES, ball: "", apply: true },
+ params: [
+ { key: "mode", kind: "select", options: MODES },
+ { key: "winBy", kind: "select", options: WIN_BY },
+ { key: "goalsToWin", kind: "range", min: 1, max: 20, step: 1 },
+ { key: "matchSeconds", kind: "range", min: 30, max: 1800, step: 10 },
+ { key: "serve", kind: "select", options: SERVE },
+ { key: "serveDelay", kind: "range", min: 0.5, max: 5, step: 0.1 },
+ { key: "serveSpeed", kind: "range", min: 0.5, max: 10, step: 0.1 },
+ { key: "ownGoals", kind: "select", options: OWN_GOALS },
+ { key: "apply", kind: "toggle" }
+ ]
+ },
+ {
+ type: "fbgate",
+ label: "Team Gate",
+ defaults: { team: "red", label: "" },
+ params: [
+ { key: "team", kind: "select", options: ["red", "blue"] },
+ { key: "label", kind: "text", placeholder: "gate label", maxLength: 24 }
+ ]
+ },
+ {
+ type: "fbbutton",
+ label: "Match Button",
+ defaults: { action: "start", press: 0 },
+ params: [{ key: "action", kind: "select", options: ACTIONS }]
+ },
+ {
+ type: "fbserve",
+ label: "Serve",
+ defaults: { trigger: 0 },
+ params: []
+ },
+ {
+ type: "fblamp",
+ label: "Score Lamp",
+ defaults: { team: "red", index: 1 },
+ params: [
+ { key: "team", kind: "select", options: ["red", "blue"] },
+ { key: "index", kind: "range", min: 1, max: 20, step: 1 }
+ ]
+ },
+ {
+ type: "fbrecords",
+ label: "Records",
+ defaults: { show: "all", element: "fb-sheet", scoreElement: "fb-score", logElement: "" },
+ params: [
+ { key: "show", kind: "select", options: ["goals", "touches", "owngoals", "all"] },
+ { key: "element", kind: "text", placeholder: "HUD list id (sheet)", maxLength: 40 },
+ { key: "scoreElement", kind: "text", placeholder: "HUD list id (score)", maxLength: 40 },
+ { key: "logElement", kind: "text", placeholder: "HUD list id (match log)", maxLength: 40 }
+ ]
+ },
+ {
+ type: "fbvalue",
+ label: "Football Value",
+ defaults: { read: "red" },
+ params: [
+ {
+ key: "read",
+ kind: "select",
+ options: ["red", "blue", "goals", "myteam", "lastteam", "started", "left", "players", "serves", "mygoals", "matches"]
+ }
+ ]
+ },
+ {
+ type: "fbevent",
+ label: "Football Event",
+ defaults: { event: "goal" },
+ params: [{ key: "event", kind: "select", options: ["goal", "redgoal", "bluegoal", "serve", "start", "over", "reset", "touch"] }]
+ }
+ ]
+ });
+ api.registerEffect(
+ "fbrules",
+ (object, base, data) => {
+ if (data.apply === false) return;
+ rulesSeen = frame;
+ const next = {
+ mode: data.mode,
+ winBy: data.winBy,
+ goalsToWin: data.goalsToWin,
+ matchSeconds: data.matchSeconds,
+ serve: data.serve,
+ serveDelay: data.serveDelay,
+ serveSpeed: data.serveSpeed,
+ ownGoals: data.ownGoals
+ };
+ if (JSON.stringify(game.config.rules) !== JSON.stringify(next)) game.config.rules = next;
+ const wired = typeof data.ball === "string" && data.ball && data.ball !== "-None-" ? data.ball : null;
+ const uuid = wired ?? api.objectsGroup()?.getObjectByName("Football")?.uuid ?? null;
+ if (uuid !== game.config.ballUuid) game.config.ballUuid = uuid;
+ },
+ { inputs: { ball: "object" } }
+ );
+ api.registerEffect("fbgate", (object, base, data) => {
+ const team = data.team === "blue" ? "blue" : "red";
+ gateSeen[object.uuid] = frame;
+ const current = game.config.gates[object.uuid];
+ if (!current || current.team !== team || current.label !== (data.label ?? ""))
+ game.config.gates[object.uuid] = { team, label: data.label ?? "" };
+ });
+ api.registerEffect(
+ "fbbutton",
+ (object, base, data, time, ctx) => {
+ const action = ACTIONS.includes(data.action) ? data.action : "none";
+ buttons.set(object.uuid, { action, frame, id: ctx?.id ?? object.uuid });
+ const level = Number(data.press) > 0 ? 1 : 0;
+ const key = ctx?.id ?? object.uuid;
+ const was = pressLevel.get(key);
+ pressLevel.set(key, level);
+ if (was === void 0 || was === level) return;
+ if (level === 1) game.act(action);
+ },
+ // 'number', not 'event': a pulse reaches a module node as a VALUE (1 while the
+ // window is open), and an event output coerces to a number — so an On Click, a
+ // Delay bridging a HUD Button, a Compare or a Toggle all drive this input
+ { inputs: { press: "number" } }
+ );
+ api.registerEffect(
+ "fbserve",
+ (object, base, data, time, ctx) => {
+ const level = Number(data.trigger) > 0 ? 1 : 0;
+ const key = ctx?.id ?? object.uuid;
+ const was = serveLevel.get(key);
+ serveLevel.set(key, level);
+ if (was === void 0 || was === level) return;
+ if (level === 1) game.serve("button");
+ },
+ { inputs: { trigger: "number" } }
+ // the `press` rule, one node over
+ );
+ api.registerEffect("fblamp", (object, base, data) => {
+ const team = data.team === "blue" ? "blue" : "red";
+ const index = Math.max(1, Math.round(Number(data.index) || 1));
+ const lit = (game.state.score[team] ?? 0) >= index;
+ const material = object.material;
+ if (!material || Array.isArray(material)) return;
+ const want = lit ? team === "red" ? RED : BLUE : LAMP_DIM;
+ if (material.userData.fbLit === lit && material.userData.fbTeam === team) return;
+ material.userData.fbLit = lit;
+ material.userData.fbTeam = team;
+ material.color?.setHex(want);
+ if (material.emissive) {
+ material.emissive.setHex(want);
+ material.emissiveIntensity = lit ? 1.4 : 0.2;
+ }
+ });
+ let lastRows = "";
+ api.registerEffect("fbrecords", (object, base, data) => {
+ if (!api.hud?.rows) return;
+ const show = data.show ?? "all";
+ const rows = game.sheetRows().map((r) => {
+ const team = r.team ? r.team.toUpperCase() : "watching";
+ if (show === "goals") return r.name + " \u2014 " + r.goals;
+ if (show === "touches") return r.name + " \u2014 " + r.touches;
+ if (show === "owngoals") return r.name + " \u2014 " + r.owngoals;
+ return r.name + " (" + team + ") \u2014 " + r.goals + " goals \xB7 " + r.touches + " touches \xB7 " + r.owngoals + " own";
+ });
+ const touch = game.state.lastTouch ? "last touch: " + game.nameOf(game.state.lastTouch.by) : "last touch: \u2014";
+ const left = game.secondsLeft();
+ const score = [game.scoreLine(), touch, ...left == null ? [] : [Math.ceil(left) + "s left"], ...game.state.outcome ? [game.outcomeText()] : []];
+ const log = game.matchLog().slice(-8).reverse().map((m) => "RED " + m.red + " \u2014 " + m.blue + " BLUE \xB7 " + m.winner);
+ const key = JSON.stringify([rows, score, log]);
+ if (key === lastRows) return;
+ lastRows = key;
+ const each = (field, list) => {
+ for (const id of String(field ?? "").split(",")) if (id.trim()) api.hud.rows(id.trim(), list);
+ };
+ each(data.element, rows);
+ each(data.scoreElement, score);
+ each(data.logElement, log);
+ });
+ api.registerValueNode(
+ "fbvalue",
+ (data) => {
+ const s = game.state;
+ switch (data?.read) {
+ case "blue":
+ return s.score.blue;
+ case "goals":
+ return s.goals;
+ case "myteam":
+ return teamOf(s.slots, game.me()) === "red" ? 1 : teamOf(s.slots, game.me()) === "blue" ? 2 : 0;
+ case "lastteam":
+ return s.lastTouch?.team === "red" ? 1 : s.lastTouch?.team === "blue" ? 2 : 0;
+ case "started":
+ return s.started ? 1 : 0;
+ case "left": {
+ const left = game.secondsLeft();
+ return left == null ? -1 : Math.ceil(left);
+ }
+ case "players":
+ return s.slots.red.length + s.slots.blue.length;
+ case "serves":
+ return s.serves;
+ case "mygoals":
+ return api.peerVars?.mine?.("goals", 0) ?? 0;
+ case "matches":
+ return game.matchLog().length;
+ default:
+ return s.score.red;
+ }
+ },
+ { vtype: "number" }
+ );
+ api.registerValueNode("fbevent", () => 0, { vtype: "event" });
+ function clickButton(mesh) {
+ let cursor = mesh;
+ while (cursor && !buttons.has(cursor.uuid)) cursor = cursor.parent;
+ if (!cursor) return false;
+ const entry = buttons.get(cursor.uuid);
+ if (!entry || frame - entry.frame > EXPIRE_FRAMES) return false;
+ game.act(entry.action);
+ if (typeof api.haptic === "function") api.haptic(0.6, 40);
+ return true;
+ }
+ function tick() {
+ frame++;
+ if (rulesSeen >= 0 && frame - rulesSeen > EXPIRE_FRAMES) {
+ rulesSeen = -1;
+ game.config.rules = null;
+ }
+ for (const [uuid, at] of Object.entries(gateSeen)) {
+ if (frame - at > EXPIRE_FRAMES) {
+ delete gateSeen[uuid];
+ delete game.config.gates[uuid];
+ }
+ }
+ for (const [uuid, entry] of buttons) if (frame - entry.frame > EXPIRE_FRAMES) buttons.delete(uuid);
+ }
+ return { tick, clickButton, buttons: () => [...buttons.entries()].map(([uuid, e]) => ({ uuid, action: e.action })) };
+}
+
+// modules/football/src/toolbox.js
+function registerToolbox(api, game, info) {
+ function elem(tag, props, css) {
+ const node = document.createElement(tag);
+ Object.assign(node, props ?? {});
+ if (css) node.setAttribute("style", css);
+ return node;
+ }
+ const SELECT_CSS = "background:rgba(0,0,0,0.25);color:inherit;border:1px solid rgba(255,255,255,0.15);border-radius:4px;font-size:11px;padding:1px 3px;";
+ const rulesNode = () => api.flow?.nodes ? api.flow.nodes("fbrules")[0] ?? null : null;
+ function writeRules(patch) {
+ const node = rulesNode();
+ if (node && api.flow.setNodeData(node.id, patch)) return;
+ game.setRules(patch);
+ }
+ let built = {};
+ async function buildPitch(dims) {
+ if (!api.create || !api.flow?.addNodes || !api.physics?.set) {
+ api.toast("Football: this app cannot build a pitch (needs api.create / flow.addNodes)");
+ return null;
+ }
+ const objects = pitchObjects(dims);
+ const names = {};
+ for (const o of objects) {
+ const [uuid] = await api.create(createCommand(o), { at: o.pos });
+ if (!uuid) continue;
+ names[o.name] = uuid;
+ const object = api.objectsGroup()?.getObjectByProperty("uuid", uuid);
+ if (object) {
+ object.name = o.name;
+ const m = object.material;
+ if (m && !Array.isArray(m)) {
+ if (o.color != null) m.color.setHex(o.color);
+ if (o.emissive != null && m.emissive) {
+ m.emissive.setHex(o.emissive);
+ m.emissiveIntensity = o.emissiveIntensity ?? 1;
+ }
+ if (o.roughness != null) m.roughness = o.roughness;
+ if (o.opacity != null && o.opacity < 1) {
+ m.transparent = true;
+ m.opacity = o.opacity;
+ }
+ }
+ }
+ if (o.physics) api.physics.set(uuid, o.physics);
+ }
+ const graph = pitchGraph(names, { hudButtons: true });
+ const index = new Map(graph.nodes.map((n, i) => [n.id, i]));
+ api.flow.addNodes({
+ nodes: graph.nodes.map((n) => ({ type: n.type, x: n.position.x, y: n.position.y, data: n.data })),
+ edges: graph.edges.map((e) => ({ from: index.get(e.source), to: index.get(e.target), ...e.targetHandle ? { handle: e.targetHandle } : {} }))
+ });
+ if (typeof api.physics.setScene === "function") api.physics.setScene(PITCH_PHYSICS);
+ else api.toast("Pitch built \u2014 set Inspector \u25B8 Physics: gravity 0, ground off, Knock on");
+ built = names;
+ return names;
+ }
+ function fitPitch(dims, centre = [0, 0, 0]) {
+ const group = api.objectsGroup();
+ if (!group || !api.moveObject) return 0;
+ let moved = 0;
+ for (const o of pitchObjects(dims)) {
+ if (o.physics?.mode === "dynamic") continue;
+ const uuid = built[o.name] ?? group.getObjectByName(o.name)?.uuid;
+ const object = uuid ? group.getObjectByProperty("uuid", uuid) : null;
+ if (!object) continue;
+ const pos = [o.pos[0] + centre[0], o.pos[1] + centre[1], o.pos[2] + centre[2]];
+ const scale = o.type === "box" ? o.size.map((v, i) => v / (object.userData.fbSize?.[i] ?? object.geometry?.parameters?.[["width", "height", "depth"][i]] ?? v)) : void 0;
+ api.moveObject(uuid, { pos, ...scale ? { scale } : {} });
+ moved++;
+ }
+ return moved;
+ }
+ function roomBounds() {
+ const session = api.xrSession?.() ?? null;
+ const space = api.xrReferenceSpace?.() ?? null;
+ const geometry = space?.boundsGeometry;
+ if (!session || !geometry || geometry.length < 3) return null;
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
+ for (const p of geometry) {
+ minX = Math.min(minX, p.x);
+ maxX = Math.max(maxX, p.x);
+ minZ = Math.min(minZ, p.z);
+ maxZ = Math.max(maxZ, p.z);
+ }
+ return { width: maxX - minX, length: maxZ - minZ, centre: [(minX + maxX) / 2, 0, (minZ + maxZ) / 2] };
+ }
+ function roomAnchor() {
+ const a = api.colocation?.roomAnchor?.() ?? api.roomAnchor?.() ?? null;
+ return a && Array.isArray(a.position) ? a : null;
+ }
+ function mount(el) {
+ el.innerHTML = "";
+ const r = game.rules();
+ const dims = { ...DEFAULT_DIMS };
+ const status = elem("div", { className: "tbx-label" }, "white-space:pre-wrap;font-size:11px;opacity:0.85;margin-bottom:6px;");
+ el.appendChild(status);
+ const row = (label, control) => {
+ const line = elem("div", { className: "tbx-row" }, "display:flex;align-items:center;gap:6px;margin:3px 0;");
+ line.appendChild(elem("span", { className: "tbx-label", textContent: label }, "flex:1;font-size:11px;"));
+ line.appendChild(control);
+ el.appendChild(line);
+ return line;
+ };
+ const select = (key, options, value) => {
+ const node = (
+ /** @type {HTMLSelectElement} */
+ elem("select", {}, SELECT_CSS)
+ );
+ for (const o of options) node.appendChild(elem("option", { value: o, textContent: o }));
+ node.value = value;
+ node.onchange = () => writeRules({ [key]: node.value });
+ return node;
+ };
+ const number = (key, value, min, max, step) => {
+ const node = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", value: String(value), min: String(min), max: String(max), step: String(step) }, SELECT_CSS + "width:64px;")
+ );
+ node.onchange = () => writeRules({ [key]: Number(node.value) });
+ return node;
+ };
+ row("Mode", select("mode", MODES, r.mode));
+ row("Win by", select("winBy", WIN_BY, r.winBy));
+ row("Goals to win", number("goalsToWin", r.goalsToWin, 1, 20, 1));
+ row("Match seconds", number("matchSeconds", r.matchSeconds, 30, 1800, 10));
+ row("Serve", select("serve", SERVE, r.serve));
+ row("Own goals", select("ownGoals", OWN_GOALS, r.ownGoals));
+ const buttons = elem("div", {}, "display:flex;flex-wrap:wrap;gap:4px;margin:6px 0;");
+ const button = (label, fn, primary) => {
+ const b = elem("button", { className: "tbx-btn" + (primary ? " tbx-primary" : ""), textContent: label });
+ b.onclick = fn;
+ buttons.appendChild(b);
+ return b;
+ };
+ button("Start", () => game.act("start"), true);
+ button("New match", () => game.act("new-match"));
+ button("Serve", () => game.act("serve"));
+ button("Swap sides", () => game.act("swap-sides"));
+ button("Join red", () => game.act("join-red"));
+ button("Join blue", () => game.act("join-blue"));
+ button("Spectate", () => game.act("spectate"));
+ el.appendChild(buttons);
+ el.appendChild(elem("div", { className: "tbx-label", textContent: "Pitch (metres)" }, "margin-top:8px;font-size:11px;opacity:0.7;"));
+ const dim = (key, min, max) => {
+ const node = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", value: String(dims[key]), min: String(min), max: String(max), step: "0.1" }, SELECT_CSS + "width:64px;")
+ );
+ node.onchange = () => dims[key] = Number(node.value);
+ return node;
+ };
+ const lengthInput = dim("length", 2, 30);
+ const widthInput = dim("width", 1.5, 20);
+ row("Length", lengthInput);
+ row("Width", widthInput);
+ row("Gate height", dim("mouthY", 0.5, 2.2));
+ const pitchButtons = elem("div", {}, "display:flex;flex-wrap:wrap;gap:4px;margin:6px 0;");
+ el.appendChild(pitchButtons);
+ const pbutton = (label, fn) => {
+ const b = elem("button", { className: "tbx-btn", textContent: label });
+ b.onclick = fn;
+ pitchButtons.appendChild(b);
+ return b;
+ };
+ pbutton("Build pitch", () => {
+ buildPitch(dims).then((names) => names && api.toast("Football pitch built (" + Object.keys(names).length + " objects) \u2014 press Play"));
+ });
+ pbutton("Fit pitch", () => {
+ const moved = fitPitch(normalizeDims(dims));
+ api.toast(moved ? "Pitch fitted (" + moved + " objects moved)" : "No pitch to fit \u2014 build one first");
+ });
+ pbutton("Fit to room", () => {
+ const bounds = roomBounds();
+ if (!bounds) {
+ api.toast("No room bounds from the headset \u2014 use the Length / Width sliders");
+ return;
+ }
+ dims.length = Math.max(2, bounds.length - 0.4);
+ dims.width = Math.max(1.5, bounds.width - 0.4);
+ lengthInput.value = dims.length.toFixed(1);
+ widthInput.value = dims.width.toFixed(1);
+ fitPitch(normalizeDims(dims), bounds.centre);
+ api.toast("Pitch fitted to the room bounds");
+ });
+ pbutton("Centre on room", () => {
+ const anchor = roomAnchor();
+ if (!anchor) {
+ api.toast("Not colocated \u2014 no room anchor to centre on");
+ return;
+ }
+ fitPitch(normalizeDims(dims), [anchor.position[0], 0, anchor.position[2]]);
+ api.toast("Pitch centred on the room anchor");
+ });
+ const timer = setInterval(() => {
+ const s = game.state;
+ const left = game.secondsLeft();
+ status.textContent = game.scoreLine() + (s.started ? " \xB7 playing" : s.outcome ? " \xB7 " + game.outcomeText() : " \xB7 menu") + (left == null ? "" : " \xB7 " + Math.ceil(left) + "s") + "\nred: " + (s.slots.red.map(game.nameOf).join(", ") || "\u2014") + " \xB7 blue: " + (s.slots.blue.map(game.nameOf).join(", ") || "\u2014") + "\nlast touch: " + (s.lastTouch ? game.nameOf(s.lastTouch.by) : "\u2014") + " \xB7 " + (game.isAuthority() ? "authority: me" : "authority: peer") + " \xB7 hits: " + info.hitSource();
+ }, 500);
+ return () => clearInterval(timer);
+ }
+ const id = api.registerToolbox({ id: "match", title: "Football", width: 280, minW: 240, playMode: true, mount });
+ api.registerMenu("Open Football", () => api.openToolbox(id));
+ return { buildPitch, fitPitch, roomBounds, roomAnchor, builtNames: () => built, NAMES };
+}
+
+// modules/football/src/hud.js
+var PANEL = { bg: "rgba(20, 26, 36, 0.92)", radius: 16, border: "1px solid rgba(136, 192, 208, 0.25)" };
+var BUTTON = (bg) => ({ size: 16, weight: "600", bg, color: "#ffffff", radius: 10 });
+function pitchHud() {
+ return {
+ scene: {
+ active: "",
+ changedAt: 0,
+ screens: [
+ {
+ id: "menu",
+ name: "Menu",
+ showWhile: "menu",
+ input: "menu",
+ elements: [
+ { id: "menu-panel", kind: "panel", anchor: "center", x: 0, y: 0, w: 480, h: 400, z: 0, label: "", style: PANEL },
+ { id: "title", kind: "text", anchor: "center", x: 0, y: -150, w: 400, h: 54, z: 1, label: "FOOTBALL", style: { size: 40, weight: "700", color: "#ffd45e", align: "center" } },
+ { id: "subtitle", kind: "text", anchor: "center", x: 0, y: -100, w: 440, h: 44, z: 1, label: "Pick a side, then Start. Hit the floating ball with your hands; a ball through the other gate is a goal.", style: { size: 14, color: "#d8dee9", align: "center" }, wrap: true },
+ { id: "fb-join-red", kind: "button", anchor: "center", x: -110, y: -30, w: 190, h: 44, z: 1, label: "Join RED", enabled: true, style: BUTTON("#c94a4a") },
+ { id: "fb-join-blue", kind: "button", anchor: "center", x: 110, y: -30, w: 190, h: 44, z: 1, label: "Join BLUE", enabled: true, style: BUTTON("#3b7dd8") },
+ { id: "fb-start", kind: "button", anchor: "center", x: 0, y: 30, w: 220, h: 48, z: 1, label: "Start match", enabled: true, style: BUTTON("#4c9e6a") },
+ { id: "fb-sheet", kind: "list", anchor: "center", x: 0, y: 105, w: 440, h: 70, z: 1, label: "", rows: [], style: { size: 12, color: "#c8d0dc", align: "center" } },
+ { id: "menu-hint", kind: "text", anchor: "center", x: 0, y: 165, w: 440, h: 30, z: 1, label: "Walk into the ball to knock it \xB7 Grab: hold click \xB7 Pause: P", style: { size: 12, color: "#8b97a8", align: "center" }, wrap: true }
+ ]
+ },
+ {
+ id: "hud",
+ name: "HUD",
+ showWhile: "playing",
+ input: "game",
+ elements: [
+ { id: "fb-score", kind: "list", anchor: "top-center", x: 0, y: 14, w: 420, h: 70, z: 1, label: "", rows: [], style: { size: 18, weight: "600", color: "#e5e9f0", align: "center" } },
+ { id: "fb-sheet-play", kind: "list", anchor: "top-right", x: 16, y: 14, w: 260, h: 120, z: 1, label: "", rows: [], style: { size: 12, color: "#c8d0dc", align: "right" } },
+ { id: "play-hint", kind: "text", anchor: "bottom-center", x: 0, y: 12, w: 520, h: 20, z: 1, label: "Hit the ball toward the other gate. Press P to pause.", style: { size: 11, color: "#8b97a8", align: "center" } }
+ ]
+ },
+ {
+ id: "pause",
+ name: "Pause",
+ input: "menu",
+ elements: [
+ { id: "pause-panel", kind: "panel", anchor: "center", x: 0, y: 0, w: 380, h: 300, z: 0, label: "", style: PANEL },
+ { id: "pause-title", kind: "text", anchor: "center", x: 0, y: -95, w: 340, h: 36, z: 1, label: "PAUSED", style: { size: 26, weight: "700", color: "#e5e9f0", align: "center" } },
+ { id: "resume-btn", kind: "button", anchor: "center", x: 0, y: -30, w: 240, h: 42, z: 1, label: "Resume", enabled: true, style: BUTTON("#3b7dd8") },
+ { id: "fb-new-match-pause", kind: "button", anchor: "center", x: 0, y: 22, w: 240, h: 42, z: 1, label: "New match", enabled: true, style: BUTTON("#4c9e6a") },
+ { id: "quit-btn", kind: "button", anchor: "center", x: 0, y: 74, w: 240, h: 42, z: 1, label: "Quit to menu", enabled: true, style: { size: 15, weight: "500", bg: "#3a4150", color: "#e5e9f0", radius: 10 } }
+ ]
+ },
+ {
+ id: "over",
+ name: "Match over",
+ showWhile: "over",
+ input: "menu",
+ elements: [
+ { id: "over-panel", kind: "panel", anchor: "center", x: 0, y: 0, w: 460, h: 360, z: 0, label: "", style: PANEL },
+ { id: "over-title", kind: "text", anchor: "center", x: 0, y: -130, w: 420, h: 40, z: 1, label: "MATCH OVER", style: { size: 30, weight: "700", color: "#ffd45e", align: "center" } },
+ { id: "fb-score-over", kind: "list", anchor: "center", x: 0, y: -70, w: 420, h: 60, z: 1, label: "", rows: [], style: { size: 16, color: "#e5e9f0", align: "center" } },
+ { id: "fb-log", kind: "list", anchor: "center", x: 0, y: 20, w: 420, h: 100, z: 1, label: "", rows: [], style: { size: 12, color: "#c8d0dc", align: "center" } },
+ { id: "fb-new-match", kind: "button", anchor: "center", x: 0, y: 120, w: 220, h: 44, z: 1, label: "New match", enabled: true, style: BUTTON("#3b7dd8") }
+ ]
+ }
+ ]
+ }
+ };
+}
+
+// modules/football/src/index.js
+var index_default = {
+ id: "football",
+ name: "Football",
+ version: "1.0.0",
+ description: "VR football on the knock: floating ball, two team gates, last-touch attribution, modes, per-player records and a saved match log \u2014 every rule a flow node.",
+ /** @param {any} api the module SDK surface */
+ register(api) {
+ const game = createGame(api);
+ const nodes = registerNodes(api, game);
+ const hitSource = game.wireHits();
+ const toolbox = registerToolbox(api, game, { hitSource: () => hitSource });
+ api.registerClickHandler((mesh) => nodes.clickButton(mesh));
+ api.onMessage((data) => game.handleMessage(data));
+ api.registerStateSync({
+ getState: () => game.getState(),
+ applyState: (remote) => game.applyState(remote)
+ });
+ api.onSceneClear(() => game.clear());
+ api.registerFrameTask((time) => {
+ nodes.tick();
+ game.tick(time);
+ });
+ if (api.hud?.registerDebugLine)
+ api.hud.registerDebugLine(() => "football " + game.scoreLine() + (game.state.started ? " playing" : "") + " \xB7 hits via " + hitSource);
+ if (api.hud?.registerAction)
+ for (const action of ["join-red", "join-blue", "start", "new-match", "spectate", "swap-sides"])
+ api.hud.registerAction({ key: action, label: "Football: " + action, group: "Football", role: "press", node: "fbbutton", data: { action }, handle: "press" });
+ if (typeof window !== "undefined") {
+ window.__football = {
+ game,
+ nodes,
+ toolbox,
+ hud: pitchHud,
+ hitSource: () => hitSource,
+ snapshot: () => ({
+ ...game.getState(),
+ rules: game.rules(),
+ gates: { ...game.config.gates },
+ ball: game.config.ballUuid,
+ authority: game.isAuthority(),
+ buttons: nodes.buttons(),
+ sheet: game.sheetRows(),
+ log: game.matchLog(),
+ hitSource
+ })
+ };
+ }
+ }
+};
+export {
+ index_default as default
+};
diff --git a/modules/football/src/def.js b/modules/football/src/def.js
new file mode 100644
index 0000000..7390c81
--- /dev/null
+++ b/modules/football/src/def.js
@@ -0,0 +1,37 @@
+// football — THE DEF (B4), the shape core's scripts/author-templates.cjs builds a game
+// template from (kind, slug, modules, installModules, env, physics, post, graphs, hud,
+// objects). One source: the objects and graph are pitch.js, the HUD is hud.js. The
+// orchestrator pastes/imports `football.def.json` (emitted by `npm run build:football`)
+// into the author script and runs `--only football` against the scenes checkout.
+
+import { pitchObjects, pitchGraph, PITCH_PHYSICS, DEFAULT_DIMS } from './pitch.js';
+import { pitchHud } from './hud.js';
+
+export function footballDef() {
+ return {
+ kind: 'game',
+ slug: 'football',
+ title: 'Football',
+ description:
+ 'VR football: hit the floating ball with your hands, a ball through the other gate is a goal. Pick a side, Start, five goals wins. Colocation-ready.',
+ license: 'CC0-1.0',
+ author: 'theprototype',
+ tags: ['vr', 'competitive', 'colocation', 'physics'],
+ modules: [{ id: 'football', version: '1.0.0' }],
+ installModules: ['football'],
+ env: { preset: 'daylight', exposure: 0.9 },
+ physics: PITCH_PHYSICS,
+ post: {
+ enabled: true,
+ effects: [
+ { id: 'tone', kind: 'tonemapping', enabled: true, params: { mode: 'AGX' } },
+ { id: 'bloom', kind: 'bloom', enabled: true, params: { intensity: 0.6, luminanceThreshold: 0.8 } },
+ { id: 'aa', kind: 'smaa', enabled: true, params: {} }
+ ],
+ changedAt: 0
+ },
+ graphs: { scene: pitchGraph(undefined, { hudButtons: true }) },
+ hud: pitchHud(),
+ objects: pitchObjects(DEFAULT_DIMS)
+ };
+}
diff --git a/modules/football/src/game.js b/modules/football/src/game.js
new file mode 100644
index 0000000..509d13a
--- /dev/null
+++ b/modules/football/src/game.js
@@ -0,0 +1,639 @@
+// football — THE GAME LAYER: replicated match state, the ops that change it, who is
+// allowed to decide what, and the per-frame tick that watches the ball.
+//
+// Replication, one model per feature (AUTHORING.md §4):
+// slots / start / new-match / swap EVENTS from the peer who pressed; every peer applies
+// the same pure rule (rules.js) to the same event.
+// last touch DERIVED from the knock's hit feed, which every peer
+// receives (api.onHit, A2) — no module message at all.
+// goal / serve / over AUTHORITATIVE: the physics initiator (the only peer
+// stepping the ball) detects and broadcasts; receivers
+// apply. With no simulation anywhere, the lowest peer
+// id stands in so a stalled session still ends.
+// per-player sheet api.peerVars: the ONE peer whose id the goal names
+// bumps its OWN row (F7). Nobody else can.
+// late joiners registerStateSync carries the whole match state.
+//
+// Everything here is applied through the same function whether it came from a local press
+// or the wire; receivers never re-send.
+
+import {
+ DEFAULT_RULES,
+ MATCH_LOG_CAP,
+ REST_DISTANCE,
+ REST_SECONDS,
+ normalizeRules,
+ emptySlots,
+ teamOf,
+ canJoin,
+ applySlot,
+ freeVanished,
+ swapSlots,
+ attributeGoal,
+ applyGoal,
+ matchOutcome,
+ secondsLeft,
+ serveImpulse,
+ matchLogEntry,
+ appendMatchLog,
+ scoreLine
+} from './rules.js';
+
+export const MODULE_ID = 'football';
+/** gameState.vars key the saved sheet lives under (B5) */
+export const LOG_VAR = 'football';
+
+/** @param {any} api */
+export function createGame(api) {
+ const THREE = api.THREE;
+
+ const state = {
+ /** rules from the toolbox when no Match Rules node is alive */
+ rulesOverride: /** @type {any} */ (null),
+ slots: emptySlots(),
+ /** @type {Record} peerId -> name at join time (the sheet's names) */
+ names: {},
+ started: false,
+ startedAt: 0,
+ endedAt: 0,
+ score: { red: 0, blue: 0 },
+ /** @type {import('./rules.js').LastTouch} */
+ lastTouch: null,
+ /** when the next serve is due (synced seconds), 0 = none pending */
+ serveAt: 0,
+ serves: 0,
+ goals: 0,
+ /** free-for-all: goals per player, replicated with the goal op */
+ playerGoals: /** @type {Record} */ ({}),
+ /** @type {{winner: string, reason: string} | null} */
+ outcome: null,
+ /** the last op stamp — a late joiner adopts the newer state */
+ at: 0
+ };
+
+ // live node-config (nodes.js writes; absent node = null = toolbox/default)
+ const config = {
+ rules: /** @type {any} */ (null),
+ /** @type {Record} gate object uuid -> team */
+ gates: {},
+ ballUuid: /** @type {string | null} */ (null)
+ };
+
+ /** @type {Set<(what: string, data?: any) => void>} */
+ const listeners = new Set();
+ const emit = (what, data) => {
+ for (const fn of listeners) {
+ try {
+ fn(what, data);
+ } catch (error) {
+ console.log('football listener failed', error);
+ }
+ }
+ };
+
+ const me = () => api.peerId() ?? 'me';
+ const now = () => api.now();
+ const rules = () => normalizeRules(config.rules ?? state.rulesOverride ?? DEFAULT_RULES);
+
+ /** @returns {{id: string, name: string, label: string, me: boolean}[]} */
+ function roster() {
+ const rows = typeof api.peerNames === 'function' ? api.peerNames() : [];
+ if (!rows.some((r) => r.me)) rows.push({ id: me(), name: '', label: 'you', me: true });
+ return rows;
+ }
+ /** @param {string} peerId */
+ function nameOf(peerId) {
+ if (peerId === me()) {
+ const mine = roster().find((r) => r.me);
+ return mine?.name || state.names[peerId] || 'you';
+ }
+ return roster().find((r) => r.id === peerId)?.label || state.names[peerId] || 'peer ' + String(peerId).slice(0, 4);
+ }
+ const liveIds = () => {
+ const ids = typeof api.peerIds === 'function' ? api.peerIds() : [];
+ return ids.includes(me()) ? ids : [me(), ...ids];
+ };
+
+ /** Who decides goals, serves and the end: the physics initiator; with no sim anywhere,
+ * the lowest peer id (deterministic on every peer). */
+ function isAuthority() {
+ if (api.physics?.running?.()) return !!api.physics.isInitiator();
+ const ids = liveIds().slice().sort();
+ return ids[0] === me();
+ }
+
+ const ball = () => (config.ballUuid ? api.objectsGroup()?.getObjectByProperty('uuid', config.ballUuid) ?? null : null);
+
+ // ---- applying ops (shared by the local press and the wire) ----------------------
+
+ /** @param {any} data */
+ function stamp(data) {
+ const at = Number(data?.at) || now();
+ if (at > state.at) state.at = at;
+ return at;
+ }
+
+ /** @param {{team: 'red'|'blue'|'none', peerId: string, name?: string, at?: number}} data */
+ function applySlotOp(data) {
+ const team = data.team === 'red' || data.team === 'blue' ? data.team : 'none';
+ const verdict = canJoin(state.slots, team, data.peerId, rules().mode);
+ if (!verdict.ok) return false;
+ state.slots = applySlot(state.slots, team, data.peerId);
+ if (data.name) state.names[data.peerId] = data.name;
+ stamp(data);
+ emit('slots');
+ return true;
+ }
+
+ /** @param {{at: number}} data */
+ function applyStart(data) {
+ if (state.started) return false;
+ const at = stamp(data);
+ state.started = true;
+ state.startedAt = at;
+ state.endedAt = 0;
+ state.score = { red: 0, blue: 0 };
+ state.playerGoals = {};
+ state.lastTouch = null;
+ state.outcome = null;
+ state.goals = 0;
+ state.serveAt = at + rules().serveDelay;
+ emit('start');
+ fireEvent('start');
+ api.game?.setState?.('playing', '');
+ return true;
+ }
+
+ /** @param {{at: number}} data */
+ function applyReset(data) {
+ const at = stamp(data);
+ state.started = false;
+ state.startedAt = 0;
+ state.endedAt = 0;
+ state.score = { red: 0, blue: 0 };
+ state.playerGoals = {};
+ state.lastTouch = null;
+ state.outcome = null;
+ state.serveAt = 0;
+ emit('reset');
+ fireEvent('reset');
+ api.game?.setState?.('menu', '');
+ void at;
+ return true;
+ }
+
+ /** @param {{at: number}} data */
+ function applySwap(data) {
+ stamp(data);
+ state.slots = swapSlots(state.slots);
+ emit('slots');
+ return true;
+ }
+
+ /**
+ * A goal, as the authority saw it. Every peer applies the shared score; ONLY the peer
+ * the goal names touches its own sheet row.
+ * @param {{gate: string, gateTeam: 'red'|'blue', by: string|null, own: boolean, counts: boolean,
+ * credit: string|null, team: string|null, at: number}} data
+ */
+ function applyGoalOp(data) {
+ if (!state.started) return false;
+ const at = stamp(data);
+ const a = { team: data.team ?? null, by: data.by ?? null, own: !!data.own, counts: !!data.counts, credit: data.credit ?? null, reason: '' };
+ state.score = applyGoal(state.score, a);
+ if (a.counts && a.by && rules().mode === 'freeforall') state.playerGoals[a.by] = (state.playerGoals[a.by] ?? 0) + 1;
+ if (a.counts) state.goals++;
+ // F7: my row is mine alone to write
+ if (a.by === me() && a.credit && api.peerVars?.setMine) api.peerVars.setMine(a.credit, api.peerVars.mine(a.credit, 0) + 1);
+ state.lastTouch = null;
+ state.serveAt = rules().serve === 'auto' ? at + rules().serveDelay : 0;
+ const gate = api.objectsGroup()?.getObjectByProperty('uuid', data.gate);
+ const where = gate ? gate.getWorldPosition(new THREE.Vector3()).toArray() : undefined;
+ if (a.counts) api.playSound?.('ding', where);
+ emit('goal', a);
+ fireEvent('goal');
+ if (a.counts && a.team) fireEvent(a.team + 'goal');
+ return true;
+ }
+
+ /** @param {{at: number, dir?: number[]}} data */
+ function applyServeOp(data) {
+ stamp(data);
+ state.serves++;
+ state.serveAt = 0;
+ state.lastTouch = null;
+ emit('serve');
+ fireEvent('serve');
+ return true;
+ }
+
+ /** @param {{at: number, winner: string, reason: string, log?: any}} data */
+ function applyOver(data) {
+ if (!state.started) return false;
+ const at = stamp(data);
+ state.started = false;
+ state.endedAt = at;
+ state.serveAt = 0;
+ state.outcome = { winner: String(data.winner ?? 'draw'), reason: String(data.reason ?? '') };
+ emit('over', state.outcome);
+ fireEvent('over');
+ api.game?.setState?.('over', outcomeText());
+ return true;
+ }
+
+ /** @param {{rules: any, at: number}} data */
+ function applyRulesOp(data) {
+ stamp(data);
+ state.rulesOverride = normalizeRules(data.rules);
+ emit('rules');
+ return true;
+ }
+
+ // ---- the actions a button, a HUD button or the toolbox can take -------------------
+
+ /** @param {string} action @returns {boolean} */
+ function act(action) {
+ const at = now();
+ switch (action) {
+ case 'join-red':
+ case 'join-blue':
+ case 'spectate': {
+ const team = action === 'join-red' ? 'red' : action === 'join-blue' ? 'blue' : 'none';
+ const verdict = canJoin(state.slots, team, me(), rules().mode);
+ if (!verdict.ok) {
+ api.toast('Football: ' + verdict.reason);
+ return false;
+ }
+ const data = { op: 'slot', team, peerId: me(), name: nameOf(me()), at };
+ applySlotOp(data);
+ api.send(data);
+ return true;
+ }
+ case 'start': {
+ if (state.started) return false;
+ const data = { op: 'start', at };
+ applyStart(data);
+ api.send(data);
+ return true;
+ }
+ case 'new-match': {
+ const data = { op: 'reset', at };
+ applyReset(data);
+ api.send(data);
+ return true;
+ }
+ case 'swap-sides': {
+ const data = { op: 'swap', at };
+ applySwap(data);
+ api.send(data);
+ return true;
+ }
+ case 'serve':
+ return serve('button');
+ default:
+ return false;
+ }
+ }
+
+ /** the toolbox's host settings when no Match Rules node owns them @param {any} patch */
+ function setRules(patch) {
+ const data = { op: 'rules', rules: normalizeRules({ ...rules(), ...patch }), at: now() };
+ applyRulesOp(data);
+ api.send(data);
+ }
+
+ // ---- the authority's half: serve, goal detection, the end ---------------------------
+
+ /** @param {'auto'|'button'|'rest'} why */
+ function serve(why) {
+ if (!isAuthority()) return false;
+ const object = ball();
+ if (!object) return false;
+ const at = now();
+ const mass = Number(object.userData?.physics?.mass) || 1;
+ const r = rules();
+ const centre = pitchCentre();
+ centre[1] = object.position.y;
+ const impulse = serveImpulse(object.position.toArray(), centre, mass, r.serveSpeed, at);
+ const pushed = api.physics.applyImpulse(object.uuid, impulse);
+ const data = { op: 'serve', at, why };
+ applyServeOp(data);
+ api.send(data);
+ return pushed;
+ }
+
+ /** the pitch centre = the midpoint between the two gates (or the origin) */
+ function pitchCentre() {
+ const group = api.objectsGroup();
+ const gates = Object.keys(config.gates)
+ .map((uuid) => group?.getObjectByProperty('uuid', uuid))
+ .filter(Boolean);
+ if (gates.length < 2) return [0, 0, 0];
+ const c = new THREE.Vector3();
+ for (const g of gates) c.add(g.getWorldPosition(new THREE.Vector3()));
+ return c.multiplyScalar(1 / gates.length).toArray();
+ }
+
+ /** @type {Record} gate uuid -> the ball was inside last frame */
+ const inside = {};
+ const _box = new THREE.Box3();
+ const _pos = new THREE.Vector3();
+
+ function watchGoals() {
+ const object = ball();
+ if (!object) return;
+ object.getWorldPosition(_pos);
+ const group = api.objectsGroup();
+ for (const [uuid, gate] of Object.entries(config.gates)) {
+ const sensor = group?.getObjectByProperty('uuid', uuid);
+ if (!sensor) continue;
+ _box.setFromObject(sensor);
+ const isIn = _box.containsPoint(_pos);
+ const was = !!inside[uuid];
+ inside[uuid] = isIn;
+ // ENTER edge only, and only while a match runs with no serve pending
+ if (!isIn || was || !state.started || state.serveAt) continue;
+ const r = rules();
+ const a = attributeGoal({ gateTeam: gate.team, lastTouch: state.lastTouch, slots: state.slots, mode: r.mode, ownGoals: r.ownGoals });
+ const data = { op: 'goal', gate: uuid, gateTeam: gate.team, team: a.team, by: a.by, own: a.own, counts: a.counts, credit: a.credit, at: now() };
+ applyGoalOp(data);
+ api.send(data);
+ }
+ }
+
+ let restPos = /** @type {number[] | null} */ (null);
+ let restSince = 0;
+ /** a ball that sits still mid-match for REST_SECONDS is re-served toward the centre */
+ function watchRest(t) {
+ const object = ball();
+ if (!object || !state.started || state.serveAt) {
+ restPos = null;
+ return;
+ }
+ const p = object.position.toArray();
+ if (!restPos || Math.hypot(p[0] - restPos[0], p[1] - restPos[1], p[2] - restPos[2]) > REST_DISTANCE) {
+ restPos = p;
+ restSince = t;
+ return;
+ }
+ if (t - restSince >= REST_SECONDS) {
+ restPos = null;
+ if (rules().serve === 'auto') serve('rest');
+ }
+ }
+
+ function watchEnd() {
+ if (!state.started) return;
+ const outcome = matchOutcome({ score: state.score, rules: rules(), elapsed: now() - state.startedAt, playerGoals: state.playerGoals });
+ if (!outcome) return;
+ const data = { op: 'over', at: now(), ...outcome };
+ applyOver(data);
+ api.send(data);
+ writeMatchLog();
+ }
+
+ // ---- B5: the saved sheet ------------------------------------------------------------
+
+ function writeMatchLog() {
+ if (!api.game?.setVar || !api.game?.getVar) return;
+ const rows = sheetRows().map((r) => ({ name: r.name, goals: r.goals }));
+ const entry = matchLogEntry({ at: now(), score: state.score, winner: outcomeWinnerName(), rows });
+ const current = api.game.getVar(LOG_VAR, null);
+ const matches = appendMatchLog(current && typeof current === 'object' ? current.matches : [], entry, MATCH_LOG_CAP);
+ api.game.setVar(LOG_VAR, { matches });
+ }
+
+ /** the saved matches, newest last @returns {any[]} */
+ function matchLog() {
+ const v = api.game?.getVar?.(LOG_VAR, null);
+ return v && typeof v === 'object' && Array.isArray(v.matches) ? v.matches : [];
+ }
+
+ // ---- last touch: the knock's hit feed ----------------------------------------------
+
+ /** @param {any} hit */
+ function onHit(hit) {
+ if (!hit || !config.ballUuid || hit.uuid !== config.ballUuid) return;
+ const by = String(hit.by ?? '');
+ if (!by) return;
+ const team = teamOf(state.slots, by);
+ // a spectator's hit moves the ball (that is physics) but attributes nothing
+ state.lastTouch = { by, team, at: Number(hit.at) || now() };
+ if (by === me() && state.started && api.peerVars?.setMine) api.peerVars.setMine('touches', api.peerVars.mine('touches', 0) + 1);
+ emit('touch', state.lastTouch);
+ if (by === me()) fireEvent('touch');
+ }
+
+ /** A2's seam, feature-detected; the debug hook is the A1-only fallback. */
+ function wireHits() {
+ if (typeof api.onHit === 'function') {
+ api.onHit(onHit);
+ return 'api.onHit';
+ }
+ const knock = typeof window !== 'undefined' ? /** @type {any} */ (window).__stores?.knock : null;
+ if (knock?.registerHitListener) {
+ knock.registerHitListener((/** @type {any} */ hit) => onHit(hit));
+ return '__stores.knock';
+ }
+ return 'none';
+ }
+
+ // ---- events into the graph -----------------------------------------------------------
+
+ /** pulse `fbevent` nodes of this kind. REPLICATED, so only the peer where the event
+ * originated fires — which is whoever ran the op locally first: for goal/serve/over
+ * the authority, for start/reset the presser, for touch the hitter. @param {string} kind */
+ let firing = false;
+ function fireEvent(kind) {
+ if (!lastOpLocal || typeof api.fireNodeTrigger !== 'function') return;
+ if (firing) return;
+ firing = true;
+ try {
+ api.fireNodeTrigger('fbevent', (d) => (d?.event ?? 'goal') === kind);
+ } finally {
+ firing = false;
+ }
+ }
+ /** true while the op being applied was born on THIS peer (set by act/serve/watch) */
+ let lastOpLocal = true;
+
+ // ---- netcode --------------------------------------------------------------------------
+
+ /** @param {any} data */
+ function handleMessage(data) {
+ lastOpLocal = false;
+ try {
+ switch (data?.op) {
+ case 'slot':
+ return applySlotOp(data);
+ case 'start':
+ return applyStart(data);
+ case 'reset':
+ return applyReset(data);
+ case 'swap':
+ return applySwap(data);
+ case 'goal':
+ return applyGoalOp(data);
+ case 'serve':
+ return applyServeOp(data);
+ case 'over':
+ return applyOver(data);
+ case 'rules':
+ return applyRulesOp(data);
+ default:
+ return false;
+ }
+ } finally {
+ lastOpLocal = true;
+ }
+ }
+
+ function getState() {
+ return {
+ v: 1,
+ at: state.at,
+ rulesOverride: state.rulesOverride,
+ slots: { red: [...state.slots.red], blue: [...state.slots.blue] },
+ names: { ...state.names },
+ started: state.started,
+ startedAt: state.startedAt,
+ endedAt: state.endedAt,
+ score: { ...state.score },
+ lastTouch: state.lastTouch ? { ...state.lastTouch } : null,
+ serveAt: state.serveAt,
+ serves: state.serves,
+ goals: state.goals,
+ playerGoals: { ...state.playerGoals },
+ outcome: state.outcome ? { ...state.outcome } : null
+ };
+ }
+
+ /** @param {any} remote */
+ function applyState(remote) {
+ if (!remote || typeof remote !== 'object') return;
+ if ((Number(remote.at) || 0) < state.at) return; // ours is newer
+ lastOpLocal = false;
+ state.at = Number(remote.at) || 0;
+ state.rulesOverride = remote.rulesOverride ? normalizeRules(remote.rulesOverride) : null;
+ state.slots = { red: [...(remote.slots?.red ?? [])], blue: [...(remote.slots?.blue ?? [])] };
+ state.names = { ...(remote.names ?? {}) };
+ state.started = !!remote.started;
+ state.startedAt = Number(remote.startedAt) || 0;
+ state.endedAt = Number(remote.endedAt) || 0;
+ state.score = { red: Number(remote.score?.red) || 0, blue: Number(remote.score?.blue) || 0 };
+ state.lastTouch = remote.lastTouch ? { by: String(remote.lastTouch.by), team: remote.lastTouch.team ?? null, at: Number(remote.lastTouch.at) || 0 } : null;
+ state.serveAt = Number(remote.serveAt) || 0;
+ state.serves = Number(remote.serves) || 0;
+ state.goals = Number(remote.goals) || 0;
+ state.playerGoals = { ...(remote.playerGoals ?? {}) };
+ state.outcome = remote.outcome ? { winner: String(remote.outcome.winner), reason: String(remote.outcome.reason ?? '') } : null;
+ lastOpLocal = true;
+ emit('state');
+ }
+
+ function clear() {
+ state.rulesOverride = null;
+ state.slots = emptySlots();
+ state.names = {};
+ state.started = false;
+ state.startedAt = 0;
+ state.endedAt = 0;
+ state.score = { red: 0, blue: 0 };
+ state.lastTouch = null;
+ state.serveAt = 0;
+ state.serves = 0;
+ state.goals = 0;
+ state.playerGoals = {};
+ state.outcome = null;
+ state.at = 0;
+ config.gates = {};
+ config.ballUuid = null;
+ for (const key of Object.keys(inside)) delete inside[key];
+ emit('reset');
+ }
+
+ // ---- the tick --------------------------------------------------------------------------
+
+ let lastSweep = 0;
+ /** @param {number} t seconds */
+ function tick(t) {
+ // a vanished peer frees its slot (DEVX #15 — the roster is on the api now)
+ if (t - lastSweep > 1) {
+ lastSweep = t;
+ const { slots, freed } = freeVanished(state.slots, liveIds());
+ if (freed.length) {
+ state.slots = slots;
+ emit('slots');
+ }
+ }
+ if (!isAuthority()) return;
+ if (state.started && state.serveAt && now() >= state.serveAt) serve('auto');
+ watchGoals();
+ watchRest(t);
+ watchEnd();
+ }
+
+ // ---- reads for the nodes, the HUD and the toolbox --------------------------------------
+
+ function outcomeWinnerName() {
+ const o = state.outcome;
+ if (!o) return '';
+ if (o.winner === 'red' || o.winner === 'blue' || o.winner === 'draw') return o.winner;
+ return nameOf(o.winner);
+ }
+ function outcomeText() {
+ const o = state.outcome;
+ if (!o) return '';
+ const who = o.winner === 'draw' ? 'Draw' : o.winner === 'red' ? 'RED wins' : o.winner === 'blue' ? 'BLUE wins' : nameOf(o.winner) + ' wins';
+ return who + (o.reason === 'time' ? " — time's up" : '') + ' · ' + scoreLine(state.score);
+ }
+ /** every player on the sheet: slotted peers plus anyone holding a row */
+ function sheetRows() {
+ const rows = api.peerVars?.all ? api.peerVars.all('goals') : [];
+ const byId = new Map(rows.map((r) => [r.id, r]));
+ const ids = new Set([...state.slots.red, ...state.slots.blue, ...rows.map((r) => r.id)]);
+ const read = (/** @type {string} */ id, /** @type {string} */ name) =>
+ id === me() ? api.peerVars?.mine?.(name, 0) ?? 0 : (api.peerVars?.all?.(name) ?? []).find((r) => r.id === id)?.value ?? 0;
+ return [...ids].map((id) => ({
+ id,
+ name: byId.get(id)?.name || nameOf(id),
+ team: teamOf(state.slots, id),
+ goals: read(id, 'goals'),
+ touches: read(id, 'touches'),
+ owngoals: read(id, 'owngoals'),
+ me: id === me()
+ }));
+ }
+ const left = () => (state.started ? secondsLeft(rules(), now() - state.startedAt) : null);
+
+ return {
+ state,
+ config,
+ rules,
+ me,
+ nameOf,
+ isAuthority,
+ act,
+ setRules,
+ serve,
+ handleMessage,
+ getState,
+ applyState,
+ clear,
+ tick,
+ wireHits,
+ onHit,
+ sheetRows,
+ matchLog,
+ writeMatchLog,
+ secondsLeft: left,
+ scoreLine: () => scoreLine(state.score),
+ outcomeText,
+ pitchCentre,
+ ball,
+ onChange: (/** @type {(what: string, data?: any) => void} */ fn) => {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ }
+ };
+}
diff --git a/modules/football/src/hud.js b/modules/football/src/hud.js
new file mode 100644
index 0000000..448a439
--- /dev/null
+++ b/modules/football/src/hud.js
@@ -0,0 +1,80 @@
+// football — THE DOM HUD, as data (B2). The headset never sees this (HudLayer hides in
+// VR, F8); a desktop peer and the editor do. It reads the SAME nodes the physical UI
+// reads: the four `fb-*` HUD Buttons pulse the Match Button nodes (perPlayer, so a press
+// is the presser's alone), the `fb-score` / `fb-sheet` / `fb-log` lists are filled by the
+// Records node through api.hud.rows, and the screens follow the game shell the Football
+// Event nodes drive. Towers' menu / hud / pause / over shape, so D1 can compare.
+
+const PANEL = { bg: 'rgba(20, 26, 36, 0.92)', radius: 16, border: '1px solid rgba(136, 192, 208, 0.25)' };
+const BUTTON = (bg) => ({ size: 16, weight: '600', bg, color: '#ffffff', radius: 10 });
+
+/** the def's `hud` field */
+export function pitchHud() {
+ return {
+ scene: {
+ active: '',
+ changedAt: 0,
+ screens: [
+ {
+ id: 'menu',
+ name: 'Menu',
+ showWhile: 'menu',
+ input: 'menu',
+ elements: [
+ { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 480, h: 400, z: 0, label: '', style: PANEL },
+ { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -150, w: 400, h: 54, z: 1, label: 'FOOTBALL', style: { size: 40, weight: '700', color: '#ffd45e', align: 'center' } },
+ { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -100, w: 440, h: 44, z: 1, label: 'Pick a side, then Start. Hit the floating ball with your hands; a ball through the other gate is a goal.', style: { size: 14, color: '#d8dee9', align: 'center' }, wrap: true },
+ { id: 'fb-join-red', kind: 'button', anchor: 'center', x: -110, y: -30, w: 190, h: 44, z: 1, label: 'Join RED', enabled: true, style: BUTTON('#c94a4a') },
+ { id: 'fb-join-blue', kind: 'button', anchor: 'center', x: 110, y: -30, w: 190, h: 44, z: 1, label: 'Join BLUE', enabled: true, style: BUTTON('#3b7dd8') },
+ { id: 'fb-start', kind: 'button', anchor: 'center', x: 0, y: 30, w: 220, h: 48, z: 1, label: 'Start match', enabled: true, style: BUTTON('#4c9e6a') },
+ { id: 'fb-sheet', kind: 'list', anchor: 'center', x: 0, y: 105, w: 440, h: 70, z: 1, label: '', rows: [], style: { size: 12, color: '#c8d0dc', align: 'center' } },
+ { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 165, w: 440, h: 30, z: 1, label: 'Walk into the ball to knock it · Grab: hold click · Pause: P', style: { size: 12, color: '#8b97a8', align: 'center' }, wrap: true }
+ ]
+ },
+ {
+ id: 'hud',
+ name: 'HUD',
+ showWhile: 'playing',
+ input: 'game',
+ elements: [
+ { id: 'fb-score', kind: 'list', anchor: 'top-center', x: 0, y: 14, w: 420, h: 70, z: 1, label: '', rows: [], style: { size: 18, weight: '600', color: '#e5e9f0', align: 'center' } },
+ { id: 'fb-sheet-play', kind: 'list', anchor: 'top-right', x: 16, y: 14, w: 260, h: 120, z: 1, label: '', rows: [], style: { size: 12, color: '#c8d0dc', align: 'right' } },
+ { id: 'play-hint', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 520, h: 20, z: 1, label: 'Hit the ball toward the other gate. Press P to pause.', style: { size: 11, color: '#8b97a8', align: 'center' } }
+ ]
+ },
+ {
+ id: 'pause',
+ name: 'Pause',
+ input: 'menu',
+ elements: [
+ { id: 'pause-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 380, h: 300, z: 0, label: '', style: PANEL },
+ { id: 'pause-title', kind: 'text', anchor: 'center', x: 0, y: -95, w: 340, h: 36, z: 1, label: 'PAUSED', style: { size: 26, weight: '700', color: '#e5e9f0', align: 'center' } },
+ { id: 'resume-btn', kind: 'button', anchor: 'center', x: 0, y: -30, w: 240, h: 42, z: 1, label: 'Resume', enabled: true, style: BUTTON('#3b7dd8') },
+ { id: 'fb-new-match-pause', kind: 'button', anchor: 'center', x: 0, y: 22, w: 240, h: 42, z: 1, label: 'New match', enabled: true, style: BUTTON('#4c9e6a') },
+ { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 74, w: 240, h: 42, z: 1, label: 'Quit to menu', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } }
+ ]
+ },
+ {
+ id: 'over',
+ name: 'Match over',
+ showWhile: 'over',
+ input: 'menu',
+ elements: [
+ { id: 'over-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 460, h: 360, z: 0, label: '', style: PANEL },
+ { id: 'over-title', kind: 'text', anchor: 'center', x: 0, y: -130, w: 420, h: 40, z: 1, label: 'MATCH OVER', style: { size: 30, weight: '700', color: '#ffd45e', align: 'center' } },
+ { id: 'fb-score-over', kind: 'list', anchor: 'center', x: 0, y: -70, w: 420, h: 60, z: 1, label: '', rows: [], style: { size: 16, color: '#e5e9f0', align: 'center' } },
+ { id: 'fb-log', kind: 'list', anchor: 'center', x: 0, y: 20, w: 420, h: 100, z: 1, label: '', rows: [], style: { size: 12, color: '#c8d0dc', align: 'center' } },
+ { id: 'fb-new-match', kind: 'button', anchor: 'center', x: 0, y: 120, w: 220, h: 44, z: 1, label: 'New match', enabled: true, style: BUTTON('#3b7dd8') }
+ ]
+ }
+ ]
+ }
+ };
+}
+
+/** the HUD list ids the Records node writes, by screen — one Records node per id set */
+export const HUD_LISTS = {
+ sheet: ['fb-sheet', 'fb-sheet-play'],
+ score: ['fb-score', 'fb-score-over'],
+ log: ['fb-log']
+};
diff --git a/modules/football/src/index.js b/modules/football/src/index.js
new file mode 100644
index 0000000..007e50c
--- /dev/null
+++ b/modules/football/src/index.js
@@ -0,0 +1,79 @@
+// football — the module entry. Bundled to one self-contained module.js by esbuild
+// (npm run build:football); everything outside comes through `api`.
+//
+// The game in one breath: two gates float at chest height, one ball floats between them
+// (the scene's zero-g physics block, fork 2), you hit it with your hands (core's knock,
+// 24-A A1) and it flies at the speed you hit it. Every peer receives every hit, so every
+// peer derives the same last touch; the physics initiator alone sees the ball enter a
+// gate and broadcasts the goal, naming who touched it last; the ONE peer that goal names
+// bumps its own sheet row (peerVars, one writer per row). Rules, gates, buttons, lamps and
+// the sheet are flow nodes (rule ownership, the dungeon-realms pattern); a saved scene
+// keeps the match log in gameState.vars (fork 3).
+
+import { createGame } from './game.js';
+import { registerNodes } from './nodes.js';
+import { registerToolbox } from './toolbox.js';
+import { pitchHud } from './hud.js';
+
+export default {
+ id: 'football',
+ name: 'Football',
+ version: '1.0.0',
+ description:
+ 'VR football on the knock: floating ball, two team gates, last-touch attribution, modes, per-player records and a saved match log — every rule a flow node.',
+
+ /** @param {any} api the module SDK surface */
+ register(api) {
+ const game = createGame(api);
+ const nodes = registerNodes(api, game);
+ const hitSource = game.wireHits();
+ const toolbox = registerToolbox(api, game, { hitSource: () => hitSource });
+
+ // clicks on button objects: desktop click, play-mode tap and the VR trigger all
+ // dispatch through the same handler with the exact mesh that was hit
+ api.registerClickHandler((mesh) => nodes.clickButton(mesh));
+
+ api.onMessage((data) => game.handleMessage(data));
+ api.registerStateSync({
+ getState: () => game.getState(),
+ applyState: (remote) => game.applyState(remote)
+ });
+ api.onSceneClear(() => game.clear());
+
+ api.registerFrameTask((time) => {
+ nodes.tick();
+ game.tick(time);
+ });
+
+ if (api.hud?.registerDebugLine)
+ api.hud.registerDebugLine(() => 'football ' + game.scoreLine() + (game.state.started ? ' playing' : '') + ' · hits via ' + hitSource);
+
+ // the HUD editor's Actions picker: a HUD Button that presses a Match Button node
+ if (api.hud?.registerAction)
+ for (const action of ['join-red', 'join-blue', 'start', 'new-match', 'spectate', 'swap-sides'])
+ api.hud.registerAction({ key: action, label: 'Football: ' + action, group: 'Football', role: 'press', node: 'fbbutton', data: { action }, handle: 'press' });
+
+ // test/debug hook (never serialized): the flight drives the game through this
+ // instead of reaching into module scope — the dungeon-realms `_dr.game` shape
+ if (typeof window !== 'undefined') {
+ /** @type {any} */ (window).__football = {
+ game,
+ nodes,
+ toolbox,
+ hud: pitchHud,
+ hitSource: () => hitSource,
+ snapshot: () => ({
+ ...game.getState(),
+ rules: game.rules(),
+ gates: { ...game.config.gates },
+ ball: game.config.ballUuid,
+ authority: game.isAuthority(),
+ buttons: nodes.buttons(),
+ sheet: game.sheetRows(),
+ log: game.matchLog(),
+ hitSource
+ })
+ };
+ }
+ }
+};
diff --git a/modules/football/src/nodes.js b/modules/football/src/nodes.js
new file mode 100644
index 0000000..a01cf43
--- /dev/null
+++ b/modules/football/src/nodes.js
@@ -0,0 +1,298 @@
+// football — THE NODE FAMILY (group "Football"), the dungeon-realms rule-ownership
+// pattern: a node alive in a running graph owns its rule group on every peer (its node
+// data is replicated, so every peer reads the same rules with no netcode); gone from the
+// graph, the defaults (or the toolbox's host settings) return within a second.
+//
+// A module effect node runs only when it is wired to an Object Selector (or sits in an
+// object's own graph), and the runtime RE-SEATS a targeted object's base pose every frame
+// — so no node here may ever target the BALL. Match Rules targets the pitch and names the
+// ball through its `ball` object input; Team Gate targets the gate's sensor box; Button
+// targets the button object; Score Lamp targets one lamp. The two value nodes (Football
+// Value, Football Event) need no target at all.
+
+import { MODES, WIN_BY, SERVE, OWN_GOALS, ACTIONS, DEFAULT_RULES, teamOf } from './rules.js';
+import { RED, BLUE, LAMP_DIM } from './pitch.js';
+
+const EXPIRE_FRAMES = 40;
+
+/** @param {any} api @param {ReturnType} game */
+export function registerNodes(api, game) {
+ let frame = 0;
+ let rulesSeen = -1;
+ /** @type {Record} gate uuid -> last frame seen */
+ const gateSeen = {};
+ /** @type {Map} button object uuid -> action */
+ const buttons = new Map();
+ /** @type {Map} node id -> last `press` level (rising-edge detection) */
+ const pressLevel = new Map();
+ /** @type {Map} node id -> last `trigger` level (Serve) */
+ const serveLevel = new Map();
+
+ api.registerNodeGroup({
+ group: 'Football',
+ items: [
+ {
+ type: 'fbrules',
+ label: 'Match Rules',
+ defaults: { ...DEFAULT_RULES, ball: '', apply: true },
+ params: [
+ { key: 'mode', kind: 'select', options: MODES },
+ { key: 'winBy', kind: 'select', options: WIN_BY },
+ { key: 'goalsToWin', kind: 'range', min: 1, max: 20, step: 1 },
+ { key: 'matchSeconds', kind: 'range', min: 30, max: 1800, step: 10 },
+ { key: 'serve', kind: 'select', options: SERVE },
+ { key: 'serveDelay', kind: 'range', min: 0.5, max: 5, step: 0.1 },
+ { key: 'serveSpeed', kind: 'range', min: 0.5, max: 10, step: 0.1 },
+ { key: 'ownGoals', kind: 'select', options: OWN_GOALS },
+ { key: 'apply', kind: 'toggle' }
+ ]
+ },
+ {
+ type: 'fbgate',
+ label: 'Team Gate',
+ defaults: { team: 'red', label: '' },
+ params: [
+ { key: 'team', kind: 'select', options: ['red', 'blue'] },
+ { key: 'label', kind: 'text', placeholder: 'gate label', maxLength: 24 }
+ ]
+ },
+ {
+ type: 'fbbutton',
+ label: 'Match Button',
+ defaults: { action: 'start', press: 0 },
+ params: [{ key: 'action', kind: 'select', options: ACTIONS }]
+ },
+ {
+ type: 'fbserve',
+ label: 'Serve',
+ defaults: { trigger: 0 },
+ params: []
+ },
+ {
+ type: 'fblamp',
+ label: 'Score Lamp',
+ defaults: { team: 'red', index: 1 },
+ params: [
+ { key: 'team', kind: 'select', options: ['red', 'blue'] },
+ { key: 'index', kind: 'range', min: 1, max: 20, step: 1 }
+ ]
+ },
+ {
+ type: 'fbrecords',
+ label: 'Records',
+ defaults: { show: 'all', element: 'fb-sheet', scoreElement: 'fb-score', logElement: '' },
+ params: [
+ { key: 'show', kind: 'select', options: ['goals', 'touches', 'owngoals', 'all'] },
+ { key: 'element', kind: 'text', placeholder: 'HUD list id (sheet)', maxLength: 40 },
+ { key: 'scoreElement', kind: 'text', placeholder: 'HUD list id (score)', maxLength: 40 },
+ { key: 'logElement', kind: 'text', placeholder: 'HUD list id (match log)', maxLength: 40 }
+ ]
+ },
+ {
+ type: 'fbvalue',
+ label: 'Football Value',
+ defaults: { read: 'red' },
+ params: [
+ {
+ key: 'read',
+ kind: 'select',
+ options: ['red', 'blue', 'goals', 'myteam', 'lastteam', 'started', 'left', 'players', 'serves', 'mygoals', 'matches']
+ }
+ ]
+ },
+ {
+ type: 'fbevent',
+ label: 'Football Event',
+ defaults: { event: 'goal' },
+ params: [{ key: 'event', kind: 'select', options: ['goal', 'redgoal', 'bluegoal', 'serve', 'start', 'over', 'reset', 'touch'] }]
+ }
+ ]
+ });
+
+ // ---- Match Rules: owns the rules and names the ball --------------------------------
+ api.registerEffect(
+ 'fbrules',
+ (object, base, data) => {
+ if (data.apply === false) return;
+ rulesSeen = frame;
+ const next = {
+ mode: data.mode,
+ winBy: data.winBy,
+ goalsToWin: data.goalsToWin,
+ matchSeconds: data.matchSeconds,
+ serve: data.serve,
+ serveDelay: data.serveDelay,
+ serveSpeed: data.serveSpeed,
+ ownGoals: data.ownGoals
+ };
+ if (JSON.stringify(game.config.rules) !== JSON.stringify(next)) game.config.rules = next;
+ // the wired Object Selector's value is the ball's uuid; an unwired node falls
+ // back to an object NAMED Football (the def's name)
+ const wired = typeof data.ball === 'string' && data.ball && data.ball !== '-None-' ? data.ball : null;
+ const uuid = wired ?? api.objectsGroup()?.getObjectByName('Football')?.uuid ?? null;
+ if (uuid !== game.config.ballUuid) game.config.ballUuid = uuid;
+ },
+ { inputs: { ball: 'object' } }
+ );
+
+ // ---- Team Gate: the object this node targets IS the gate's sensor ---------------------
+ api.registerEffect('fbgate', (object, base, data) => {
+ const team = data.team === 'blue' ? 'blue' : 'red';
+ gateSeen[object.uuid] = frame;
+ const current = game.config.gates[object.uuid];
+ if (!current || current.team !== team || current.label !== (data.label ?? ''))
+ game.config.gates[object.uuid] = { team, label: data.label ?? '' };
+ });
+
+ // ---- Match Button: a click on the target object, or a rising `press`, acts ------------
+ api.registerEffect(
+ 'fbbutton',
+ (object, base, data, time, ctx) => {
+ const action = ACTIONS.includes(data.action) ? data.action : 'none';
+ buttons.set(object.uuid, { action, frame, id: ctx?.id ?? object.uuid });
+ // a HUD Button (perPlayer) or an On Click wired into `press` reads 1 for its
+ // pulse window; act on the rising edge, and never on the level first seen (a
+ // joiner arriving inside a pulse must not press a button it never touched)
+ const level = Number(data.press) > 0 ? 1 : 0;
+ const key = ctx?.id ?? object.uuid;
+ const was = pressLevel.get(key);
+ pressLevel.set(key, level);
+ if (was === undefined || was === level) return;
+ if (level === 1) game.act(action);
+ },
+ // 'number', not 'event': a pulse reaches a module node as a VALUE (1 while the
+ // window is open), and an event output coerces to a number — so an On Click, a
+ // Delay bridging a HUD Button, a Compare or a Toggle all drive this input
+ { inputs: { press: 'number' } }
+ );
+
+ // ---- Serve: a rising trigger serves now (an On Rest, a Delay, a button) ----------------
+ api.registerEffect(
+ 'fbserve',
+ (object, base, data, time, ctx) => {
+ const level = Number(data.trigger) > 0 ? 1 : 0;
+ const key = ctx?.id ?? object.uuid;
+ const was = serveLevel.get(key);
+ serveLevel.set(key, level);
+ if (was === undefined || was === level) return;
+ if (level === 1) game.serve('button');
+ },
+ { inputs: { trigger: 'number' } } // the `press` rule, one node over
+ );
+
+ // ---- Score Lamp: lit while its team's score reaches its index ----------------------------
+ api.registerEffect('fblamp', (object, base, data) => {
+ const team = data.team === 'blue' ? 'blue' : 'red';
+ const index = Math.max(1, Math.round(Number(data.index) || 1));
+ const lit = (game.state.score[team] ?? 0) >= index;
+ const material = object.material;
+ if (!material || Array.isArray(material)) return;
+ const want = lit ? (team === 'red' ? RED : BLUE) : LAMP_DIM;
+ if (material.userData.fbLit === lit && material.userData.fbTeam === team) return;
+ material.userData.fbLit = lit;
+ material.userData.fbTeam = team;
+ material.color?.setHex(want);
+ if (material.emissive) {
+ material.emissive.setHex(want);
+ material.emissiveIntensity = lit ? 1.4 : 0.2;
+ }
+ });
+
+ // ---- Records: rows into HUD lists (the sheet, the score line, the saved log) ----------
+ let lastRows = '';
+ api.registerEffect('fbrecords', (object, base, data) => {
+ if (!api.hud?.rows) return;
+ const show = data.show ?? 'all';
+ const rows = game.sheetRows().map((r) => {
+ const team = r.team ? r.team.toUpperCase() : 'watching';
+ if (show === 'goals') return r.name + ' — ' + r.goals;
+ if (show === 'touches') return r.name + ' — ' + r.touches;
+ if (show === 'owngoals') return r.name + ' — ' + r.owngoals;
+ return r.name + ' (' + team + ') — ' + r.goals + ' goals · ' + r.touches + ' touches · ' + r.owngoals + ' own';
+ });
+ const touch = game.state.lastTouch ? 'last touch: ' + game.nameOf(game.state.lastTouch.by) : 'last touch: —';
+ const left = game.secondsLeft();
+ const score = [game.scoreLine(), touch, ...(left == null ? [] : [Math.ceil(left) + 's left']), ...(game.state.outcome ? [game.outcomeText()] : [])];
+ const log = game.matchLog().slice(-8).reverse().map((m) => 'RED ' + m.red + ' — ' + m.blue + ' BLUE · ' + m.winner);
+ const key = JSON.stringify([rows, score, log]);
+ if (key === lastRows) return;
+ lastRows = key;
+ // an element field may name SEVERAL lists (comma-separated): the menu's sheet and
+ // the in-play sheet are two elements on two screens fed by one node
+ const each = (/** @type {any} */ field, /** @type {string[]} */ list) => {
+ for (const id of String(field ?? '').split(',')) if (id.trim()) api.hud.rows(id.trim(), list);
+ };
+ each(data.element, rows);
+ each(data.scoreElement, score);
+ each(data.logElement, log);
+ });
+
+ // ---- the readable half: a number a core HUD Text / Compare can consume --------------------
+ api.registerValueNode(
+ 'fbvalue',
+ (data) => {
+ const s = game.state;
+ switch (data?.read) {
+ case 'blue':
+ return s.score.blue;
+ case 'goals':
+ return s.goals;
+ case 'myteam':
+ return teamOf(s.slots, game.me()) === 'red' ? 1 : teamOf(s.slots, game.me()) === 'blue' ? 2 : 0;
+ case 'lastteam':
+ return s.lastTouch?.team === 'red' ? 1 : s.lastTouch?.team === 'blue' ? 2 : 0;
+ case 'started':
+ return s.started ? 1 : 0;
+ case 'left': {
+ const left = game.secondsLeft();
+ return left == null ? -1 : Math.ceil(left);
+ }
+ case 'players':
+ return s.slots.red.length + s.slots.blue.length;
+ case 'serves':
+ return s.serves;
+ case 'mygoals':
+ return api.peerVars?.mine?.('goals', 0) ?? 0;
+ case 'matches':
+ // B5: how many matches the saved sheet holds (gameState.vars, in the .tpscene)
+ return game.matchLog().length;
+ default:
+ return s.score.red;
+ }
+ },
+ { vtype: 'number' }
+ );
+ // the event half is pulsed by game.js through api.fireNodeTrigger
+ api.registerValueNode('fbevent', () => 0, { vtype: 'event' });
+
+ /** the click path: desktop click or VR trigger on a button object (play mode included)
+ * @param {any} mesh */
+ function clickButton(mesh) {
+ let cursor = mesh;
+ while (cursor && !buttons.has(cursor.uuid)) cursor = cursor.parent;
+ if (!cursor) return false;
+ const entry = buttons.get(cursor.uuid);
+ if (!entry || frame - entry.frame > EXPIRE_FRAMES) return false;
+ game.act(entry.action);
+ if (typeof api.haptic === 'function') api.haptic(0.6, 40);
+ return true;
+ }
+
+ /** from the module frame task: expire what vanished */
+ function tick() {
+ frame++;
+ if (rulesSeen >= 0 && frame - rulesSeen > EXPIRE_FRAMES) {
+ rulesSeen = -1;
+ game.config.rules = null;
+ }
+ for (const [uuid, at] of Object.entries(gateSeen)) {
+ if (frame - at > EXPIRE_FRAMES) {
+ delete gateSeen[uuid];
+ delete game.config.gates[uuid];
+ }
+ }
+ for (const [uuid, entry] of buttons) if (frame - entry.frame > EXPIRE_FRAMES) buttons.delete(uuid);
+ }
+
+ return { tick, clickButton, buttons: () => [...buttons.entries()].map(([uuid, e]) => ({ uuid, action: e.action })) };
+}
diff --git a/modules/football/src/pitch.js b/modules/football/src/pitch.js
new file mode 100644
index 0000000..78933dc
--- /dev/null
+++ b/modules/football/src/pitch.js
@@ -0,0 +1,313 @@
+// football — THE PITCH AS DATA, pure. `pitchObjects(dims)` is the object list a pitch is
+// made of (the def's `objects` shape from core's author-templates.cjs: type, name, size,
+// pos, colour, emissive, opacity, physics) and `pitchGraph(names)` the node graph that
+// wires the Football nodes to it. The toolbox's "Build pitch" recipe creates exactly
+// these through api.create / api.physics.set / api.flow.addNodes, and the orchestrator's
+// FOOTBALL_DEF is the same two lists serialised — one source, two consumers.
+//
+// Frame: the pitch is centred on the origin, LENGTH runs along z (gates at -z for red,
+// +z for blue), WIDTH along x, y up. B3 sizes it for a real room by default.
+
+export const RED = 0xd94a4a;
+export const BLUE = 0x4a7dd9;
+export const LAMP_DIM = 0x22262e;
+export const LAMPS_PER_GATE = 10;
+
+/** B3 defaults: a living room. Mouths centred at chest height, ball served at 1.3 m. */
+export const DEFAULT_DIMS = {
+ length: 5,
+ width: 3,
+ height: 2.4,
+ gateWidth: 1.2,
+ gateHeight: 0.8,
+ mouthY: 1.35,
+ ballY: 1.3,
+ ballRadius: 0.22,
+ sensorDepth: 0.5
+};
+
+/** @param {any} v @param {number} lo @param {number} hi @param {number} d */
+function num(v, lo, hi, d) {
+ const n = Number(v);
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d;
+}
+
+/** Clamp dims to what fits a room and a headset. @param {any} raw */
+export function normalizeDims(raw) {
+ const r = raw && typeof raw === 'object' ? raw : {};
+ const d = DEFAULT_DIMS;
+ const length = num(r.length, 2, 30, d.length);
+ const width = num(r.width, 1.5, 20, d.width);
+ return {
+ length,
+ width,
+ height: num(r.height, 1.8, 8, d.height),
+ gateWidth: num(r.gateWidth, 0.5, Math.min(width, 6), Math.min(d.gateWidth, width)),
+ gateHeight: num(r.gateHeight, 0.3, 3, d.gateHeight),
+ mouthY: num(r.mouthY, 0.5, 2.2, d.mouthY),
+ ballY: num(r.ballY, 0.3, 2.2, d.ballY),
+ ballRadius: num(r.ballRadius, 0.08, 0.6, d.ballRadius),
+ sensorDepth: num(r.sensorDepth, 0.3, 1.5, d.sensorDepth)
+ };
+}
+
+/** Object names — the contract the graph, the recipe and the flight all read. */
+export const NAMES = {
+ pitch: 'Pitch',
+ ball: 'Football',
+ redGate: 'Red gate',
+ blueGate: 'Blue gate',
+ joinRed: 'Join red',
+ joinBlue: 'Join blue',
+ start: 'Start match',
+ newMatch: 'New match',
+ lamp: (/** @type {string} */ team, /** @type {number} */ i) => (team === 'red' ? 'Red' : 'Blue') + ' lamp ' + i
+};
+
+/**
+ * Every object of a pitch, in creation order. Static everything, one dynamic ball.
+ * @param {any} [dims]
+ */
+export function pitchObjects(dims) {
+ const d = normalizeDims(dims);
+ const hl = d.length / 2;
+ const hw = d.width / 2;
+ const wall = 0.05;
+ const mouthZ = hl - 0.3; // the gate mouth sits 30 cm in from the end wall
+ const sensorZ = mouthZ + d.sensorDepth / 2;
+ const endZ = mouthZ + d.sensorDepth + 0.05; // the end wall closes just past the sensor
+ const post = 0.06;
+ /** @type {any[]} */
+ const out = [];
+ const stat = (/** @type {any} */ extra) => ({ mode: 'static', ...(extra ?? {}) });
+
+ // the floor is the pitch; the real floor when colocated
+ out.push({ type: 'box', name: NAMES.pitch, color: 0x3a4a3d, size: [d.width, wall, endZ * 2], pos: [0, -wall / 2, 0], roughness: 0.95, physics: stat({ friction: 0.6 }) });
+ // six invisible walls keep the ball in play (floor above is the seventh face)
+ const ghost = { color: 0x9ee6ff, opacity: 0.06 };
+ out.push({ type: 'box', name: 'Wall left', ...ghost, size: [wall, d.height, endZ * 2], pos: [-hw - wall / 2, d.height / 2, 0], physics: stat() });
+ out.push({ type: 'box', name: 'Wall right', ...ghost, size: [wall, d.height, endZ * 2], pos: [hw + wall / 2, d.height / 2, 0], physics: stat() });
+ out.push({ type: 'box', name: 'Wall red end', ...ghost, size: [d.width, d.height, wall], pos: [0, d.height / 2, -endZ - wall / 2], physics: stat() });
+ out.push({ type: 'box', name: 'Wall blue end', ...ghost, size: [d.width, d.height, wall], pos: [0, d.height / 2, endZ + wall / 2], physics: stat() });
+ out.push({ type: 'box', name: 'Ceiling', ...ghost, size: [d.width, wall, endZ * 2], pos: [0, d.height + wall / 2, 0], physics: stat() });
+
+ for (const team of /** @type {const} */ (['red', 'blue'])) {
+ const sign = team === 'red' ? -1 : 1;
+ const color = team === 'red' ? RED : BLUE;
+ const z = sign * mouthZ;
+ const gx = d.gateWidth / 2;
+ const gy = d.gateHeight / 2;
+ const frame = { color, emissive: color, emissiveIntensity: 0.9, roughness: 0.4 };
+ const T = team === 'red' ? 'Red' : 'Blue';
+ out.push({ type: 'box', name: T + ' post left', ...frame, size: [post, d.gateHeight + post, post], pos: [-gx, d.mouthY, z], physics: stat() });
+ out.push({ type: 'box', name: T + ' post right', ...frame, size: [post, d.gateHeight + post, post], pos: [gx, d.mouthY, z], physics: stat() });
+ out.push({ type: 'box', name: T + ' bar top', ...frame, size: [d.gateWidth + post, post, post], pos: [0, d.mouthY + gy, z], physics: stat() });
+ out.push({ type: 'box', name: T + ' bar bottom', ...frame, size: [d.gateWidth + post, post, post], pos: [0, d.mouthY - gy, z], physics: stat() });
+ // the sensor: a box BEHIND the mouth, deep enough that a 10 m/s ball cannot cross
+ // it in one 16 ms step (0.16 m) — the B4 tunnelling note
+ out.push({
+ type: 'box',
+ name: team === 'red' ? NAMES.redGate : NAMES.blueGate,
+ color,
+ opacity: 0.12,
+ size: [d.gateWidth, d.gateHeight, d.sensorDepth],
+ pos: [0, d.mouthY, sign * sensorZ],
+ physics: stat({ sensor: true, collider: 'box' })
+ });
+ // score lamps: two rows of five above the bar, lit from the outside in by fblamp
+ const lampY0 = d.mouthY + gy + 0.25;
+ for (let i = 1; i <= LAMPS_PER_GATE; i++) {
+ const row = i <= 5 ? 0 : 1;
+ const col = (i - 1) % 5;
+ out.push({
+ type: 'box',
+ name: NAMES.lamp(team, i),
+ color: LAMP_DIM,
+ emissive: LAMP_DIM,
+ emissiveIntensity: 0.2,
+ size: [0.16, 0.16, 0.06],
+ pos: [-0.5 + col * 0.25, lampY0 + row * 0.22, z],
+ physics: stat()
+ });
+ }
+ // the join button stands AT the gate: claiming a team is walking to your end
+ out.push({
+ type: 'box',
+ name: team === 'red' ? NAMES.joinRed : NAMES.joinBlue,
+ color,
+ emissive: color,
+ emissiveIntensity: 0.5,
+ size: [0.3, 0.12, 0.3],
+ pos: [sign * -1 * (hw - 0.3), 1.05, sign * (mouthZ - 0.6)],
+ physics: stat()
+ });
+ }
+ // Start / New match at the pitch's side, within reach from the centre line
+ out.push({ type: 'box', name: NAMES.start, color: 0x4c9e6a, emissive: 0x4c9e6a, emissiveIntensity: 0.5, size: [0.3, 0.12, 0.3], pos: [hw - 0.3, 1.05, -0.35], physics: stat() });
+ out.push({ type: 'box', name: NAMES.newMatch, color: 0x8a8f9a, emissive: 0x8a8f9a, emissiveIntensity: 0.4, size: [0.3, 0.12, 0.3], pos: [hw - 0.3, 1.05, 0.35], physics: stat() });
+ // the ball, last: one dynamic body, floating (the scene's zero-g block keeps it up)
+ out.push({
+ type: 'sphere',
+ name: NAMES.ball,
+ color: 0xf2f2f2,
+ r: d.ballRadius,
+ pos: [0, d.ballY, 0],
+ roughness: 0.6,
+ physics: { mode: 'dynamic', mass: 0.45, restitution: 0.7, friction: 0.2 }
+ });
+ return out;
+}
+
+/** The scene-physics block the pitch needs (fork 2: floating ball). Moon = gravity -1.6. */
+export const PITCH_PHYSICS = {
+ gravity: 0,
+ ground: { enabled: false },
+ damping: { linear: 0.35, angular: 0.5 },
+ ccd: true,
+ knock: { enabled: true, gain: 1, maxSpeed: 10, spin: 0.8 },
+ play: { interaction: 'grab', grounded: false, simOnPlay: true }
+};
+
+/** The `/create` command for one object. @param {any} o */
+export function createCommand(o) {
+ if (o.type === 'sphere') return '/create Sphere ' + o.r;
+ const [w, h, dpt] = o.size;
+ return '/create Box ' + w + ' ' + h + ' ' + dpt;
+}
+
+/**
+ * The graph, with Object Selectors keyed by NAME (the author script remaps names to
+ * uuids; the recipe passes `names` = {name: uuid} and gets uuids straight away).
+ * Layout: one row per binding, the Towers `N`/`E` conventions.
+ * @param {Record} [names] name -> uuid; absent = keep the name
+ * @param {{hudButtons?: boolean}} [opts] also wire the DOM HUD's buttons (the def does)
+ * (each through a Delay — see THE HUD-BUTTON BRIDGE above)
+ */
+// THE HUD-BUTTON BRIDGE, and it is not decoration. A `hudbutton` node has NO value in
+// core's evaluator (flowRuntime: "hudbutton contributes no runtime value") — core's own
+// consumers read it as a trigger STAMP through triggerStampFor, which a MODULE effect
+// cannot reach (its inputs are resolved as VALUES). So a HUD Button wired straight into
+// a module node's input reads `undefined` and nothing ever happens. A `delay` node is the
+// bridge core already ships: it consumes the wired stamp and EMITS a numeric pulse
+// (`pulseAt(stamp + seconds, …)`), so the module sees an ordinary rising edge. Filed as
+// DEVX #22; the physical buttons need none of this — a click reaches the module directly.
+export function pitchGraph(names, opts = {}) {
+ const sel = (/** @type {string} */ name) => names?.[name] ?? name;
+ /** @type {any[]} */ const nodes = [];
+ /** @type {any[]} */ const edges = [];
+ const N = (id, type, label, x, y, data) => {
+ nodes.push({ id, type, position: { x, y }, data: { label, ...data }, class: 'w-[150px]' });
+ return id;
+ };
+ const E = (source, target, handle) => {
+ edges.push({ id: 'e-' + source + '-' + target + (handle ? '.' + handle : ''), source, target, ...(handle ? { targetHandle: handle } : {}) });
+ };
+ let y = 40;
+ const row = () => (y += 150);
+
+ // Match Rules sits on the pitch (a static object — a football node may never TARGET
+ // the ball, the runtime re-seats a targeted object's base pose every frame) and names
+ // the ball through its object input
+ N('rules', 'fbrules', 'Match Rules', 280, y, {});
+ N('selpitch', 'objectselector', 'Pitch', 520, y, { selected: sel(NAMES.pitch) });
+ E('rules', 'selpitch');
+ N('selball', 'objectselector', 'Football', 40, y, { selected: sel(NAMES.ball) });
+ E('selball', 'rules', 'ball');
+ row();
+ N('gater', 'fbgate', 'Red gate', 280, y, { team: 'red' });
+ N('selgater', 'objectselector', 'Red gate sensor', 520, y, { selected: sel(NAMES.redGate) });
+ E('gater', 'selgater');
+ row();
+ N('gateb', 'fbgate', 'Blue gate', 280, y, { team: 'blue' });
+ N('selgateb', 'objectselector', 'Blue gate sensor', 520, y, { selected: sel(NAMES.blueGate) });
+ E('gateb', 'selgateb');
+ row();
+ // the sheet + the scoreboard rows (HUD list ids the def's HUD document carries)
+ N('records', 'fbrecords', 'Records', 280, y, {
+ show: 'all',
+ element: opts.hudButtons ? 'fb-sheet,fb-sheet-play' : 'fb-sheet',
+ scoreElement: opts.hudButtons ? 'fb-score,fb-score-over' : 'fb-score',
+ logElement: opts.hudButtons ? 'fb-log' : ''
+ });
+ E('records', 'selpitch');
+ row();
+ // the physical buttons: each fbbutton targets its button object; a click on the
+ // object (desktop or VR trigger) runs the action on the clicker's peer
+ const buttons = [
+ ['bjoinr', NAMES.joinRed, 'join-red'],
+ ['bjoinb', NAMES.joinBlue, 'join-blue'],
+ ['bstart', NAMES.start, 'start'],
+ ['bnew', NAMES.newMatch, 'new-match']
+ ];
+ for (const [id, name, action] of buttons) {
+ N(id, 'fbbutton', 'Button: ' + action, 280, y, { action });
+ N('sel' + id, 'objectselector', name, 520, y, { selected: sel(name) });
+ E(id, 'sel' + id);
+ if (opts.hudButtons) {
+ // the DOM HUD's button of the same action, pulsed LOCALLY (perPlayer) and
+ // bridged by a Delay so the module sees a value — see THE HUD-BUTTON BRIDGE
+ N('h' + id, 'hudbutton', 'HUD ' + action, 40, y, { element: 'fb-' + action, perPlayer: true });
+ N('d' + id, 'delay', 'HUD ' + action + ' press', 160, y, { seconds: 0.05, pulse: 0.3 });
+ E('h' + id, 'd' + id, 'trigger');
+ E('d' + id, id, 'press');
+ }
+ row();
+ }
+ // score lamps, one node per lamp
+ for (const team of ['red', 'blue']) {
+ for (let i = 1; i <= LAMPS_PER_GATE; i++) {
+ const id = 'lamp' + team + i;
+ const x = 40 + ((i - 1) % 5) * 240;
+ if ((i - 1) % 5 === 0 && i > 1) row();
+ N(id, 'fblamp', (team === 'red' ? 'Red' : 'Blue') + ' lamp ' + i, x, y, { team, index: i });
+ N('sel' + id, 'objectselector', NAMES.lamp(team, i), x, y + 70, { selected: sel(NAMES.lamp(team, i)) });
+ E(id, 'sel' + id);
+ }
+ row();
+ }
+ if (opts.hudButtons) {
+ // the over screen's and the pause menu's New match buttons: their own Match Button
+ // nodes (two edges into one `press` handle would not OR — the last wins)
+ for (const [id, element] of [['bnewover', 'fb-new-match'], ['bnewpause', 'fb-new-match-pause']]) {
+ N(id, 'fbbutton', 'Button: new-match (' + element + ')', 280, y, { action: 'new-match' });
+ E(id, 'selbnew');
+ N('h' + id, 'hudbutton', 'HUD ' + element, 40, y, { element, perPlayer: true });
+ N('d' + id, 'delay', 'HUD ' + element + ' press', 160, y, { seconds: 0.05, pulse: 0.3 });
+ E('h' + id, 'd' + id, 'trigger');
+ E('d' + id, id, 'press');
+ row();
+ }
+ // pause / resume / quit while playing: Towers' rows verbatim (P toggles the menu)
+ N('pkey', 'keypress', 'Press P', 40, y, { code: 'KeyP', edge: 'down', pulse: 0.3 });
+ N('pausetoggle', 'hudscreen', 'Toggle pause menu', 280, y, { screen: 'pause', action: 'toggle' });
+ E('pkey', 'pausetoggle', 'trigger');
+ row();
+ N('bresume', 'hudbutton', 'Resume button', 40, y, { element: 'resume-btn' });
+ N('resumehide', 'hudscreen', 'Close pause menu', 280, y, { screen: 'pause', action: 'hide' });
+ E('bresume', 'resumehide', 'trigger');
+ row();
+ N('bquit', 'hudbutton', 'Quit to menu button', 40, y, { element: 'quit-btn' });
+ N('doquit', 'setgamestate', 'Quit to menu', 280, y, { state: 'menu', outcome: '', reset: true });
+ N('quithide', 'hudscreen', 'Close pause on quit', 520, y, { screen: 'pause', action: 'hide' });
+ E('bquit', 'doquit', 'trigger');
+ E('bquit', 'quithide', 'trigger');
+ row();
+ }
+ // the game shell follows the match: start -> playing, over -> over (the DOM HUD's
+ // screens hang off these; a headset reads the lamps instead)
+ N('evstart', 'fbevent', 'On match start', 40, y, { event: 'start' });
+ N('gostart', 'setgamestate', 'Match: playing', 280, y, { state: 'playing', outcome: '', reset: false });
+ E('evstart', 'gostart', 'trigger');
+ row();
+ N('evover', 'fbevent', 'On match over', 40, y, { event: 'over' });
+ N('goover', 'setgamestate', 'Match: over', 280, y, { state: 'over', outcome: 'Match over', reset: false });
+ E('evover', 'goover', 'trigger');
+ row();
+ N('evnew', 'fbevent', 'On new match', 40, y, { event: 'reset' });
+ N('gomenu', 'setgamestate', 'Match: menu', 280, y, { state: 'menu', outcome: '', reset: true });
+ E('evnew', 'gomenu', 'trigger');
+ row();
+ // a goal chimes at its gate through api.playSound on every peer (each applies the
+ // same goal op), so no sound node and no audio asset is needed here
+ return { nodes, edges };
+}
diff --git a/modules/football/src/rules.js b/modules/football/src/rules.js
new file mode 100644
index 0000000..04394fa
--- /dev/null
+++ b/modules/football/src/rules.js
@@ -0,0 +1,288 @@
+// football — THE RULES, pure. No api, no THREE, no clock: every function here is a
+// plain function of its arguments so `test/rules.test.mjs` proves each one in node with
+// no app running. game.js feeds these with replicated state and applies what they
+// return; nothing in this file knows which peer it runs on.
+//
+// Teams: 'red' defends the red gate (the gate a `fbgate team:red` node names), 'blue'
+// the blue one. A ball entering the RED gate scores for BLUE, whoever touched it last;
+// who touched it last decides whose SHEET the goal lands on (goals or owngoals).
+
+export const TEAMS = ['red', 'blue'];
+export const MODES = ['duel', 'teams', 'freeforall', 'practice'];
+export const WIN_BY = ['goals', 'time', 'either'];
+export const SERVE = ['auto', 'button'];
+export const OWN_GOALS = ['count', 'ignore'];
+export const ACTIONS = ['none', 'join-red', 'join-blue', 'spectate', 'start', 'new-match', 'swap-sides', 'serve'];
+
+export const DEFAULT_RULES = {
+ mode: 'teams',
+ winBy: 'goals',
+ goalsToWin: 5,
+ matchSeconds: 180,
+ serve: 'auto',
+ serveDelay: 2,
+ ownGoals: 'count',
+ serveSpeed: 3
+};
+
+/** how many matches the saved sheet keeps (B5, fork 3) */
+export const MATCH_LOG_CAP = 50;
+/** a ball that moved less than this in REST_SECONDS is re-served */
+export const REST_DISTANCE = 0.03;
+export const REST_SECONDS = 3;
+
+/** @param {any} v @param {number} lo @param {number} hi @param {number} d */
+function num(v, lo, hi, d) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return d;
+ return Math.min(hi, Math.max(lo, n));
+}
+/** @param {any} v @param {string[]} options @param {string} d */
+function pick(v, options, d) {
+ return options.includes(v) ? v : d;
+}
+
+/** Clamp a rules object to what the game accepts. Unknown fields are dropped. @param {any} raw */
+export function normalizeRules(raw) {
+ const r = raw && typeof raw === 'object' ? raw : {};
+ const d = DEFAULT_RULES;
+ return {
+ mode: pick(r.mode, MODES, d.mode),
+ winBy: pick(r.winBy, WIN_BY, d.winBy),
+ goalsToWin: Math.round(num(r.goalsToWin, 1, 20, d.goalsToWin)),
+ matchSeconds: Math.round(num(r.matchSeconds, 30, 1800, d.matchSeconds)),
+ serve: pick(r.serve, SERVE, d.serve),
+ serveDelay: num(r.serveDelay, 0.5, 5, d.serveDelay),
+ ownGoals: pick(r.ownGoals, OWN_GOALS, d.ownGoals),
+ serveSpeed: num(r.serveSpeed, 0.5, 10, d.serveSpeed)
+ };
+}
+
+/** @param {string} team */
+export function otherTeam(team) {
+ return team === 'red' ? 'blue' : team === 'blue' ? 'red' : null;
+}
+
+/** FNV-1a over a seed and parts — the dungeon-realms derivation, so a serve direction is
+ * the same on every peer that agrees on the stamp. @param {number} seed @param {...any} parts */
+export function hash32(seed, ...parts) {
+ let h = 0x811c9dc5 ^ (seed >>> 0);
+ for (const part of parts) {
+ const s = String(part);
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ h ^= 0x9e3779b9;
+ h = Math.imul(h, 0x01000193);
+ }
+ return h >>> 0;
+}
+
+// ---- slots ------------------------------------------------------------------------
+
+/** @typedef {{red: string[], blue: string[]}} Slots */
+
+/** @returns {Slots} */
+export function emptySlots() {
+ return { red: [], blue: [] };
+}
+
+/** @param {Slots} slots @param {string} peerId @returns {'red'|'blue'|null} */
+export function teamOf(slots, peerId) {
+ if (!peerId) return null;
+ if (slots.red.includes(peerId)) return 'red';
+ if (slots.blue.includes(peerId)) return 'blue';
+ return null;
+}
+
+/**
+ * May `peerId` take a place on `team` under `mode`? 'none' (spectate) is always allowed.
+ * @param {Slots} slots @param {'red'|'blue'|'none'} team @param {string} peerId @param {string} mode
+ * @returns {{ok: boolean, reason: string}}
+ */
+export function canJoin(slots, team, peerId, mode) {
+ if (team === 'none') return { ok: true, reason: '' };
+ if (!TEAMS.includes(team)) return { ok: false, reason: 'no such team' };
+ if (teamOf(slots, peerId) === team) return { ok: true, reason: 'already there' };
+ if (mode === 'duel' && slots[team].length >= 1) return { ok: false, reason: team + ' is taken (duel: one per side)' };
+ return { ok: true, reason: '' };
+}
+
+/**
+ * The slots after `peerId` joins `team` (or leaves both with 'none'). Pure: returns a new
+ * object; the caller decides whether canJoin allowed it.
+ * @param {Slots} slots @param {'red'|'blue'|'none'} team @param {string} peerId @returns {Slots}
+ */
+export function applySlot(slots, team, peerId) {
+ const next = { red: slots.red.filter((id) => id !== peerId), blue: slots.blue.filter((id) => id !== peerId) };
+ if (team === 'red' || team === 'blue') next[team] = [...next[team], peerId];
+ return next;
+}
+
+/** Drop every slotted peer not in `liveIds` (a disconnect frees its slot, DEVX #15).
+ * @param {Slots} slots @param {string[]} liveIds @returns {{slots: Slots, freed: string[]}} */
+export function freeVanished(slots, liveIds) {
+ const live = new Set(liveIds);
+ const freed = [];
+ const keep = (/** @type {string} */ id) => {
+ if (live.has(id)) return true;
+ freed.push(id);
+ return false;
+ };
+ return { slots: { red: slots.red.filter(keep), blue: slots.blue.filter(keep) }, freed };
+}
+
+/** Red and blue trade ends: every red player is now blue and vice versa. @param {Slots} slots */
+export function swapSlots(slots) {
+ return { red: [...slots.blue], blue: [...slots.red] };
+}
+
+// ---- goals --------------------------------------------------------------------------
+
+/**
+ * @typedef {{by: string, team: 'red'|'blue'|null, at: number} | null} LastTouch
+ * @typedef {{team: 'red'|'blue'|null, by: string|null, own: boolean, counts: boolean,
+ * credit: 'goals'|'owngoals'|null, reason: string}} Attribution
+ */
+
+/**
+ * Who scores when the ball enters the gate `gateTeam` defends.
+ *
+ * teams / duel: the OTHER team scores. The last toucher's team decides the sheet — an
+ * attacker's touch is a goal on their row, a defender's touch is an own goal on theirs
+ * (and with `ownGoals: 'ignore'` the goal does not count at all: the ball is re-served).
+ * A spectator's or nobody's touch still counts for the attackers, credited to nobody.
+ * freeforall: teams do not exist; the toucher scores one, whoever's gate it was. No
+ * touch = nobody scores.
+ * practice: nothing scores, ever.
+ * @param {{gateTeam: 'red'|'blue', lastTouch: LastTouch, slots: Slots, mode: string, ownGoals: string}} input
+ * @returns {Attribution}
+ */
+export function attributeGoal({ gateTeam, lastTouch, slots, mode, ownGoals }) {
+ if (mode === 'practice') return { team: null, by: null, own: false, counts: false, credit: null, reason: 'practice' };
+ const by = lastTouch?.by ?? null;
+ // the toucher's team is read from the SLOTS at goal time, not from the touch: a
+ // player who swapped sides between touch and goal scores where they stand now
+ const toucherTeam = by ? teamOf(slots, by) : null;
+ if (mode === 'freeforall') {
+ if (!by) return { team: null, by: null, own: false, counts: false, credit: null, reason: 'nobody touched it' };
+ return { team: null, by, own: false, counts: true, credit: 'goals', reason: 'free for all' };
+ }
+ const scoring = otherTeam(gateTeam);
+ if (!by || !toucherTeam) return { team: scoring, by: null, own: false, counts: true, credit: null, reason: by ? 'spectator touch' : 'no touch' };
+ if (toucherTeam === scoring) return { team: scoring, by, own: false, counts: true, credit: 'goals', reason: 'goal' };
+ // a defender put it in their own gate
+ if (ownGoals === 'ignore') return { team: scoring, by, own: true, counts: false, credit: null, reason: 'own goal ignored' };
+ return { team: scoring, by, own: true, counts: true, credit: 'owngoals', reason: 'own goal' };
+}
+
+/** @param {{red: number, blue: number}} score @param {Attribution} a */
+export function applyGoal(score, a) {
+ if (!a.counts || !a.team) return { ...score };
+ return { ...score, [a.team]: (score[a.team] ?? 0) + 1 };
+}
+
+// ---- modes end the match ------------------------------------------------------------
+
+/**
+ * @param {{score: {red: number, blue: number}, rules: any, elapsed: number,
+ * playerGoals?: Record}} input
+ * @returns {{winner: string, reason: 'goals'|'time'} | null} winner is a team, a peer id
+ * (free for all) or 'draw'
+ */
+export function matchOutcome({ score, rules, elapsed, playerGoals }) {
+ const r = normalizeRules(rules);
+ if (r.mode === 'practice') return null;
+ const byGoals = r.winBy === 'goals' || r.winBy === 'either';
+ const byTime = r.winBy === 'time' || r.winBy === 'either';
+ if (byGoals) {
+ if (r.mode === 'freeforall') {
+ for (const [id, n] of Object.entries(playerGoals ?? {})) if (n >= r.goalsToWin) return { winner: id, reason: 'goals' };
+ } else {
+ if (score.red >= r.goalsToWin && score.red > score.blue) return { winner: 'red', reason: 'goals' };
+ if (score.blue >= r.goalsToWin && score.blue > score.red) return { winner: 'blue', reason: 'goals' };
+ }
+ }
+ if (byTime && elapsed >= r.matchSeconds) {
+ if (r.mode === 'freeforall') {
+ let best = null;
+ let tie = false;
+ for (const [id, n] of Object.entries(playerGoals ?? {})) {
+ if (!best || n > best.n) {
+ best = { id, n };
+ tie = false;
+ } else if (n === best.n) tie = true;
+ }
+ return { winner: best && !tie ? best.id : 'draw', reason: 'time' };
+ }
+ if (score.red === score.blue) return { winner: 'draw', reason: 'time' };
+ return { winner: score.red > score.blue ? 'red' : 'blue', reason: 'time' };
+ }
+ return null;
+}
+
+/** seconds left in a timed match, or null when the mode has no clock @param {any} rules @param {number} elapsed */
+export function secondsLeft(rules, elapsed) {
+ const r = normalizeRules(rules);
+ if (r.winBy === 'goals' || r.mode === 'practice') return null;
+ return Math.max(0, r.matchSeconds - elapsed);
+}
+
+// ---- the serve -------------------------------------------------------------------------
+
+/**
+ * A unit direction for the kick-off, seeded by the stamp so every peer that logs the serve
+ * agrees on where it went: along the pitch axis (z, toward one gate — the bit decides
+ * which) with a little sideways lean. Pure and transcendental-free.
+ * @param {number} at @returns {number[]} [x, y, z]
+ */
+export function serveDirection(at) {
+ const h = hash32(Math.floor(at), 'serve');
+ const sign = h & 1 ? 1 : -1;
+ const lean = (((h >>> 8) & 0xff) / 255 - 0.5) * 0.6; // -0.3 .. 0.3
+ const len = Math.hypot(lean, 1);
+ return [lean / len, 0, sign / len];
+}
+
+/**
+ * The kick-off IMPULSE for a ball at `pos` when the centre is `centre`: a ball that sits
+ * away from the centre (inside a gate after a goal) is kicked back toward it; a ball at
+ * the centre is kicked along the seeded serve direction. Impulse = mass x speed.
+ * @param {number[]} pos @param {number[]} centre @param {number} mass @param {number} speed @param {number} at
+ */
+export function serveImpulse(pos, centre, mass, speed, at) {
+ const dx = centre[0] - pos[0];
+ const dz = centre[2] - pos[2];
+ const dist = Math.hypot(dx, dz);
+ const dir = dist > 0.5 ? [dx / dist, 0, dz / dist] : serveDirection(at);
+ const m = Math.max(0.01, mass || 1) * Math.max(0, speed);
+ return [dir[0] * m, dir[1] * m, dir[2] * m];
+}
+
+// ---- the sheet -------------------------------------------------------------------------
+
+/**
+ * One saved match. Names, not ids — ids change per session (fork 3).
+ * @param {{at: number, score: {red: number, blue: number}, winner: string, rows: {name: string, goals: number}[]}} input
+ */
+export function matchLogEntry({ at, score, winner, rows }) {
+ return {
+ at: Math.floor(at),
+ red: score.red,
+ blue: score.blue,
+ winner,
+ scorers: (rows ?? []).filter((r) => r.goals > 0).map((r) => ({ name: String(r.name), goals: r.goals }))
+ };
+}
+
+/** @param {any[]} log @param {any} entry @param {number} [cap] newest LAST, oldest dropped */
+export function appendMatchLog(log, entry, cap = MATCH_LOG_CAP) {
+ const list = Array.isArray(log) ? [...log, entry] : [entry];
+ return list.slice(-Math.max(1, cap));
+}
+
+/** `RED 3 — 2 BLUE`, the scoreboard line @param {{red: number, blue: number}} score */
+export function scoreLine(score) {
+ return 'RED ' + (score.red ?? 0) + ' — ' + (score.blue ?? 0) + ' BLUE';
+}
diff --git a/modules/football/src/toolbox.js b/modules/football/src/toolbox.js
new file mode 100644
index 0000000..da10a13
--- /dev/null
+++ b/modules/football/src/toolbox.js
@@ -0,0 +1,266 @@
+// football — THE TOOLBOX: host settings, never gameplay (registerToolbox's contract: this
+// window is this viewer's; what it changes goes through the replicated paths). Mode,
+// goals to win, match length, serve, swap sides, the status line, and the recipes that
+// build a pitch in an empty scene and (B3) fit it to the room.
+//
+// Rules edits go to the Match Rules NODE when one is alive (api.flow.setNodeData, the
+// replicated graph) and to the module's own replicated `rules` op when there is none —
+// so the toolbox never fights the node that owns the rule.
+
+import { MODES, WIN_BY, SERVE, OWN_GOALS } from './rules.js';
+import { pitchObjects, pitchGraph, createCommand, PITCH_PHYSICS, DEFAULT_DIMS, normalizeDims, NAMES } from './pitch.js';
+
+/** @param {any} api @param {ReturnType} game @param {{hitSource: () => string}} info */
+export function registerToolbox(api, game, info) {
+ /** @param {string} tag @param {Record=} props @param {string=} css */
+ function elem(tag, props, css) {
+ const node = document.createElement(tag);
+ Object.assign(node, props ?? {});
+ if (css) node.setAttribute('style', css);
+ return node;
+ }
+ const SELECT_CSS = 'background:rgba(0,0,0,0.25);color:inherit;border:1px solid rgba(255,255,255,0.15);border-radius:4px;font-size:11px;padding:1px 3px;';
+
+ /** the live Match Rules node, if any */
+ const rulesNode = () => (api.flow?.nodes ? api.flow.nodes('fbrules')[0] ?? null : null);
+ /** @param {any} patch */
+ function writeRules(patch) {
+ const node = rulesNode();
+ if (node && api.flow.setNodeData(node.id, patch)) return;
+ game.setRules(patch);
+ }
+
+ // ---- the pitch recipe --------------------------------------------------------------
+ /** @type {Record} name -> uuid of the last pitch this peer built */
+ let built = {};
+
+ /**
+ * Build a pitch in the shared scene: objects through api.create (replicated like a
+ * typed /create), physics through api.physics.set (replicated objectParameters), the
+ * graph through api.flow.addNodes (replicated, one undo entry). Names and colours are
+ * set on this peer's copy only (the api has no rename/colour write) — the graph binds
+ * by uuid, so peers play the same pitch; a late joiner receives names with the scene.
+ * @param {any} [dims]
+ */
+ async function buildPitch(dims) {
+ if (!api.create || !api.flow?.addNodes || !api.physics?.set) {
+ api.toast('Football: this app cannot build a pitch (needs api.create / flow.addNodes)');
+ return null;
+ }
+ const objects = pitchObjects(dims);
+ /** @type {Record} */
+ const names = {};
+ for (const o of objects) {
+ const [uuid] = await api.create(createCommand(o), { at: o.pos });
+ if (!uuid) continue;
+ names[o.name] = uuid;
+ const object = api.objectsGroup()?.getObjectByProperty('uuid', uuid);
+ if (object) {
+ object.name = o.name;
+ const m = object.material;
+ if (m && !Array.isArray(m)) {
+ if (o.color != null) m.color.setHex(o.color);
+ if (o.emissive != null && m.emissive) {
+ m.emissive.setHex(o.emissive);
+ m.emissiveIntensity = o.emissiveIntensity ?? 1;
+ }
+ if (o.roughness != null) m.roughness = o.roughness;
+ if (o.opacity != null && o.opacity < 1) {
+ m.transparent = true;
+ m.opacity = o.opacity;
+ }
+ }
+ }
+ if (o.physics) api.physics.set(uuid, o.physics);
+ }
+ // the same graph the def carries: the HUD rows are harmless without a HUD document
+ // (a HUD Button node whose element does not exist simply never pulses) and a scene
+ // that later gains one is wired already
+ const graph = pitchGraph(names, { hudButtons: true });
+ const index = new Map(graph.nodes.map((n, i) => [n.id, i]));
+ api.flow.addNodes({
+ nodes: graph.nodes.map((n) => ({ type: n.type, x: n.position.x, y: n.position.y, data: n.data })),
+ edges: graph.edges.map((e) => ({ from: index.get(e.source), to: index.get(e.target), ...(e.targetHandle ? { handle: e.targetHandle } : {}) }))
+ });
+ // the zero-g block is scene physics, which the api cannot write (DEVX #20): say so
+ if (typeof api.physics.setScene === 'function') api.physics.setScene(PITCH_PHYSICS);
+ else api.toast('Pitch built — set Inspector ▸ Physics: gravity 0, ground off, Knock on');
+ built = names;
+ return names;
+ }
+
+ // ---- B3: fit the pitch to the room -------------------------------------------------------
+ /**
+ * Resize/re-centre the pitch objects this peer built (or the def's, found by name) by
+ * moving them with api.moveObject — ordinary replicated `move`s, so a colocated pair
+ * sees one pitch. Objects are re-laid from `pitchObjects(dims)` by NAME.
+ * @param {any} dims @param {number[]} [centre]
+ */
+ function fitPitch(dims, centre = [0, 0, 0]) {
+ const group = api.objectsGroup();
+ if (!group || !api.moveObject) return 0;
+ let moved = 0;
+ for (const o of pitchObjects(dims)) {
+ // the ball is a live body: a `move` on it would engage the external hold
+ if (o.physics?.mode === 'dynamic') continue;
+ const uuid = built[o.name] ?? group.getObjectByName(o.name)?.uuid;
+ const object = uuid ? group.getObjectByProperty('uuid', uuid) : null;
+ if (!object) continue;
+ const pos = [o.pos[0] + centre[0], o.pos[1] + centre[1], o.pos[2] + centre[2]];
+ // boxes are re-sized through scale against the unit they were created with
+ const scale = o.type === 'box' ? o.size.map((v, i) => v / (object.userData.fbSize?.[i] ?? object.geometry?.parameters?.[['width', 'height', 'depth'][i]] ?? v)) : undefined;
+ api.moveObject(uuid, { pos, ...(scale ? { scale } : {}) });
+ moved++;
+ }
+ return moved;
+ }
+
+ /** the room's bounds from the XR session when the app exposes it (DEVX #21), else null */
+ function roomBounds() {
+ const session = api.xrSession?.() ?? null;
+ const space = api.xrReferenceSpace?.() ?? null;
+ const geometry = space?.boundsGeometry;
+ if (!session || !geometry || geometry.length < 3) return null;
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
+ for (const p of geometry) {
+ minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
+ minZ = Math.min(minZ, p.z); maxZ = Math.max(maxZ, p.z);
+ }
+ return { width: maxX - minX, length: maxZ - minZ, centre: [(minX + maxX) / 2, 0, (minZ + maxZ) / 2] };
+ }
+
+ /** the colocation room anchor, when the app exposes it (DEVX #21) */
+ function roomAnchor() {
+ const a = api.colocation?.roomAnchor?.() ?? api.roomAnchor?.() ?? null;
+ return a && Array.isArray(a.position) ? a : null;
+ }
+
+ // ---- the window -------------------------------------------------------------------------------
+ /** @param {HTMLElement} el */
+ function mount(el) {
+ el.innerHTML = '';
+ const r = game.rules();
+ const dims = { ...DEFAULT_DIMS };
+
+ const status = elem('div', { className: 'tbx-label' }, 'white-space:pre-wrap;font-size:11px;opacity:0.85;margin-bottom:6px;');
+ el.appendChild(status);
+
+ /** @param {string} label @param {HTMLElement} control */
+ const row = (label, control) => {
+ const line = elem('div', { className: 'tbx-row' }, 'display:flex;align-items:center;gap:6px;margin:3px 0;');
+ line.appendChild(elem('span', { className: 'tbx-label', textContent: label }, 'flex:1;font-size:11px;'));
+ line.appendChild(control);
+ el.appendChild(line);
+ return line;
+ };
+ /** @param {string} key @param {string[]} options @param {string} value */
+ const select = (key, options, value) => {
+ const node = /** @type {HTMLSelectElement} */ (elem('select', {}, SELECT_CSS));
+ for (const o of options) node.appendChild(elem('option', { value: o, textContent: o }));
+ node.value = value;
+ node.onchange = () => writeRules({ [key]: node.value });
+ return node;
+ };
+ /** @param {string} key @param {number} value @param {number} min @param {number} max @param {number} step */
+ const number = (key, value, min, max, step) => {
+ const node = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', value: String(value), min: String(min), max: String(max), step: String(step) }, SELECT_CSS + 'width:64px;'));
+ node.onchange = () => writeRules({ [key]: Number(node.value) });
+ return node;
+ };
+ row('Mode', select('mode', MODES, r.mode));
+ row('Win by', select('winBy', WIN_BY, r.winBy));
+ row('Goals to win', number('goalsToWin', r.goalsToWin, 1, 20, 1));
+ row('Match seconds', number('matchSeconds', r.matchSeconds, 30, 1800, 10));
+ row('Serve', select('serve', SERVE, r.serve));
+ row('Own goals', select('ownGoals', OWN_GOALS, r.ownGoals));
+
+ const buttons = elem('div', {}, 'display:flex;flex-wrap:wrap;gap:4px;margin:6px 0;');
+ /** @param {string} label @param {() => void} fn @param {boolean=} primary */
+ const button = (label, fn, primary) => {
+ const b = elem('button', { className: 'tbx-btn' + (primary ? ' tbx-primary' : ''), textContent: label });
+ b.onclick = fn;
+ buttons.appendChild(b);
+ return b;
+ };
+ button('Start', () => game.act('start'), true);
+ button('New match', () => game.act('new-match'));
+ button('Serve', () => game.act('serve'));
+ button('Swap sides', () => game.act('swap-sides'));
+ button('Join red', () => game.act('join-red'));
+ button('Join blue', () => game.act('join-blue'));
+ button('Spectate', () => game.act('spectate'));
+ el.appendChild(buttons);
+
+ // ---- the pitch: build, fit, centre ----------------------------------------------
+ el.appendChild(elem('div', { className: 'tbx-label', textContent: 'Pitch (metres)' }, 'margin-top:8px;font-size:11px;opacity:0.7;'));
+ /** @param {'length'|'width'|'mouthY'} key @param {number} min @param {number} max */
+ const dim = (key, min, max) => {
+ const node = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', value: String(dims[key]), min: String(min), max: String(max), step: '0.1' }, SELECT_CSS + 'width:64px;'));
+ node.onchange = () => (dims[key] = Number(node.value));
+ return node;
+ };
+ const lengthInput = dim('length', 2, 30);
+ const widthInput = dim('width', 1.5, 20);
+ row('Length', lengthInput);
+ row('Width', widthInput);
+ row('Gate height', dim('mouthY', 0.5, 2.2));
+ const pitchButtons = elem('div', {}, 'display:flex;flex-wrap:wrap;gap:4px;margin:6px 0;');
+ el.appendChild(pitchButtons);
+ /** @param {string} label @param {() => void} fn */
+ const pbutton = (label, fn) => {
+ const b = elem('button', { className: 'tbx-btn', textContent: label });
+ b.onclick = fn;
+ pitchButtons.appendChild(b);
+ return b;
+ };
+ pbutton('Build pitch', () => {
+ buildPitch(dims).then((names) => names && api.toast('Football pitch built (' + Object.keys(names).length + ' objects) — press Play'));
+ });
+ pbutton('Fit pitch', () => {
+ const moved = fitPitch(normalizeDims(dims));
+ api.toast(moved ? 'Pitch fitted (' + moved + ' objects moved)' : 'No pitch to fit — build one first');
+ });
+ pbutton('Fit to room', () => {
+ const bounds = roomBounds();
+ if (!bounds) {
+ api.toast('No room bounds from the headset — use the Length / Width sliders');
+ return;
+ }
+ dims.length = Math.max(2, bounds.length - 0.4);
+ dims.width = Math.max(1.5, bounds.width - 0.4);
+ lengthInput.value = dims.length.toFixed(1);
+ widthInput.value = dims.width.toFixed(1);
+ fitPitch(normalizeDims(dims), bounds.centre);
+ api.toast('Pitch fitted to the room bounds');
+ });
+ pbutton('Centre on room', () => {
+ const anchor = roomAnchor();
+ if (!anchor) {
+ api.toast('Not colocated — no room anchor to centre on');
+ return;
+ }
+ fitPitch(normalizeDims(dims), [anchor.position[0], 0, anchor.position[2]]);
+ api.toast('Pitch centred on the room anchor');
+ });
+
+ const timer = setInterval(() => {
+ const s = game.state;
+ const left = game.secondsLeft();
+ status.textContent =
+ game.scoreLine() +
+ (s.started ? ' · playing' : s.outcome ? ' · ' + game.outcomeText() : ' · menu') +
+ (left == null ? '' : ' · ' + Math.ceil(left) + 's') +
+ '\nred: ' + (s.slots.red.map(game.nameOf).join(', ') || '—') +
+ ' · blue: ' + (s.slots.blue.map(game.nameOf).join(', ') || '—') +
+ '\nlast touch: ' + (s.lastTouch ? game.nameOf(s.lastTouch.by) : '—') +
+ ' · ' + (game.isAuthority() ? 'authority: me' : 'authority: peer') +
+ ' · hits: ' + info.hitSource();
+ }, 500);
+ return () => clearInterval(timer);
+ }
+
+ const id = api.registerToolbox({ id: 'match', title: 'Football', width: 280, minW: 240, playMode: true, mount });
+ api.registerMenu('Open Football', () => api.openToolbox(id));
+
+ return { buildPitch, fitPitch, roomBounds, roomAnchor, builtNames: () => built, NAMES };
+}
diff --git a/modules/football/test/pitch.test.mjs b/modules/football/test/pitch.test.mjs
new file mode 100644
index 0000000..9e018bd
--- /dev/null
+++ b/modules/football/test/pitch.test.mjs
@@ -0,0 +1,74 @@
+// The pitch as data — the object list and the graph the recipe and the def share.
+import { pitchObjects, pitchGraph, normalizeDims, DEFAULT_DIMS, NAMES, LAMPS_PER_GATE, createCommand } from '../src/pitch.js';
+import { pitchHud } from '../src/hud.js';
+import { footballDef } from '../src/def.js';
+
+/** @param {(ok: boolean, label: string) => void} check */
+export function run(check) {
+ const objects = pitchObjects();
+ const byName = new Map(objects.map((o) => [o.name, o]));
+ check(objects.length === 6 + 2 * (5 + LAMPS_PER_GATE + 1) + 2 + 1, 'pitchObjects: floor, 5 ghost walls, 2 gates x (4 frame + sensor + 10 lamps + join button), 2 buttons, 1 ball (' + objects.length + ')');
+ check(new Set(objects.map((o) => o.name)).size === objects.length, ' every name is unique');
+ const ball = byName.get(NAMES.ball);
+ check(ball?.physics.mode === 'dynamic' && objects.filter((o) => o.physics?.mode === 'dynamic').length === 1, 'exactly one dynamic body: the ball');
+ check(ball.pos[1] === DEFAULT_DIMS.ballY && ball.r === DEFAULT_DIMS.ballRadius, ' served at 1.3 m, radius 0.22');
+ const red = byName.get(NAMES.redGate);
+ const blue = byName.get(NAMES.blueGate);
+ check(red?.physics.sensor === true && blue?.physics.sensor === true && red.pos[2] < 0 && blue.pos[2] > 0, 'the gate sensors are sensor boxes at opposite ends (red -z, blue +z)');
+ check(red.size[2] >= 0.5, ' the sensor is >= 0.5 m deep so a 10 m/s ball cannot cross it in a 16 ms step (' + red.size[2] + ')');
+ check(Math.abs(red.pos[1] - DEFAULT_DIMS.mouthY) < 1e-9, ' mouths at chest height');
+ const endWall = byName.get('Wall red end');
+ check(endWall.pos[2] < red.pos[2] - red.size[2] / 2, ' the end wall closes BEHIND the whole sensor');
+ check(byName.get(NAMES.joinRed).pos[2] < 0 && byName.get(NAMES.joinBlue).pos[2] > 0, 'the join buttons stand at their own gates');
+ const lamps = objects.filter((o) => /lamp/.test(o.name));
+ check(lamps.length === 2 * LAMPS_PER_GATE && lamps.every((l) => l.physics.mode === 'static'), '20 static lamps');
+
+ // dims
+ const small = normalizeDims({ length: 1, width: 0.5, gateWidth: 9 });
+ check(small.length === 2 && small.width === 1.5 && small.gateWidth <= small.width, 'normalizeDims clamps to a playable room and keeps the gate narrower than the pitch');
+ const wide = pitchObjects({ length: 10, width: 6 });
+ check(wide.find((o) => o.name === NAMES.redGate).pos[2] < red.pos[2], ' counterfactual: a longer pitch puts the gates further out');
+ check(createCommand(ball) === '/create Sphere 0.22' && createCommand(red).startsWith('/create Box 1.2 0.8 0.5'), 'createCommand renders the /create the app parses');
+
+ // graph
+ const g = pitchGraph();
+ const ids = new Set(g.nodes.map((n) => n.id));
+ check(g.nodes.every((n) => n.data.label), 'every node carries a label');
+ check(g.edges.every((e) => ids.has(e.source) && ids.has(e.target)), 'every edge joins two existing nodes');
+ check(new Set(g.edges.map((e) => e.id)).size === g.edges.length, ' edge ids are unique (peer dedupe depends on it)');
+ const ballSel = g.nodes.find((n) => n.type === 'objectselector' && n.data.selected === NAMES.ball);
+ const ballTargeted = g.edges.some((e) => e.target === ballSel.id && !e.targetHandle);
+ check(!ballTargeted, 'NO effect node targets the ball (the runtime re-seats a targeted body every frame)');
+ check(g.edges.some((e) => e.source === ballSel.id && e.target === 'rules' && e.targetHandle === 'ball'), ' the ball reaches Match Rules through its `ball` input instead');
+ check(g.nodes.filter((n) => n.type === 'fblamp').length === 2 * LAMPS_PER_GATE, 'one Score Lamp node per lamp');
+ check(g.nodes.filter((n) => n.type === 'fbbutton').length === 4 && !g.nodes.some((n) => n.type === 'hudbutton'), 'four Match Buttons; no HUD buttons unless asked');
+ const withHud = pitchGraph(undefined, { hudButtons: true });
+ const fbButtons = withHud.nodes.filter((n) => n.type === 'hudbutton' && String(n.data.element).startsWith('fb-'));
+ check(fbButtons.length === 6 && fbButtons.every((n) => n.data.perPlayer === true), ' counterfactual: hudButtons adds six perPlayer HUD Buttons (menu x3, over, pause) wired into `press` (' + fbButtons.length + ')');
+ // THE BRIDGE: a hudbutton has no runtime value in core, so it must reach a Match
+ // Button THROUGH a Delay (which re-emits the stamp as a pulse). A direct edge would
+ // read `undefined` in the module and silently do nothing — the bug this guards.
+ const viaDelay = (/** @type {any} */ n) => {
+ const mid = withHud.edges.find((e) => e.source === n.id && e.targetHandle === 'trigger');
+ const delay = mid && withHud.nodes.find((x) => x.id === mid.target && x.type === 'delay');
+ return !!delay && withHud.edges.some((e) => e.source === delay.id && e.targetHandle === 'press');
+ };
+ check(fbButtons.every(viaDelay), ' every HUD button reaches a Match Button `press` through a Delay');
+ check(!withHud.edges.some((e) => fbButtons.some((n) => n.id === e.source) && e.targetHandle === 'press'), ' counterfactual: no HUD button wires STRAIGHT into a module input (it would read undefined)');
+ check(withHud.nodes.some((n) => n.type === 'keypress' && n.data.code === 'KeyP') && withHud.nodes.filter((n) => n.type === 'hudscreen').length === 3, ' the pause menu rows (P toggles, resume hides, quit hides + menu)');
+ const records = withHud.nodes.find((n) => n.type === 'fbrecords');
+ check(records.data.element === 'fb-sheet,fb-sheet-play' && records.data.logElement === 'fb-log', ' Records feeds both sheet lists and the log list');
+ const remapped = pitchGraph({ [NAMES.ball]: 'uuid-ball' });
+ check(remapped.nodes.find((n) => n.id === 'selball').data.selected === 'uuid-ball', 'names remap to uuids for the recipe');
+ check(g.nodes.some((n) => n.type === 'setgamestate' && n.data.state === 'over'), 'the game shell follows the match (start/over/menu)');
+
+ // the HUD document and the def
+ const hud = pitchHud().scene;
+ const hudIds = new Set(hud.screens.flatMap((sc) => sc.elements.map((e) => e.id)));
+ check(hud.screens.map((sc) => sc.id).join() === 'menu,hud,pause,over', 'pitchHud: Towers\' four screens (D1 can compare)');
+ for (const id of ['fb-join-red', 'fb-join-blue', 'fb-start', 'fb-new-match', 'fb-new-match-pause', 'fb-sheet', 'fb-sheet-play', 'fb-score', 'fb-score-over', 'fb-log'])
+ check(hudIds.has(id), ' HUD element ' + id + ' exists for the graph that names it');
+ const def = footballDef();
+ check(def.kind === 'game' && def.slug === 'football' && def.installModules[0] === 'football' && def.objects.length === 41 && def.graphs.scene.nodes.length > 60 && def.hud.scene.screens.length === 4 && def.physics.gravity === 0, 'footballDef: kind game, the module required, 41 objects, the def graph, four screens, zero-g');
+ check(JSON.stringify(def) === JSON.stringify(footballDef()), ' the def is deterministic (byte-identical twice)');
+}
diff --git a/modules/football/test/rules.test.mjs b/modules/football/test/rules.test.mjs
new file mode 100644
index 0000000..74ee2cf
--- /dev/null
+++ b/modules/football/test/rules.test.mjs
@@ -0,0 +1,108 @@
+// Pure rules — run WITHOUT the app. Every rule is proven with its COUNTERFACTUAL: the
+// same call with one input flipped must give the other answer, or the test is only
+// checking that a constant is a constant.
+import {
+ normalizeRules, DEFAULT_RULES, emptySlots, teamOf, canJoin, applySlot, freeVanished, swapSlots,
+ attributeGoal, applyGoal, matchOutcome, secondsLeft, serveDirection, serveImpulse,
+ matchLogEntry, appendMatchLog, scoreLine, hash32
+} from '../src/rules.js';
+
+/** @param {(ok: boolean, label: string) => void} check */
+export function run(check) {
+ // ---- normalize ----
+ const n = normalizeRules({ mode: 'duel', goalsToWin: 99, matchSeconds: 1, serveDelay: 'x', bogus: 1 });
+ check(n.mode === 'duel' && n.goalsToWin === 20 && n.matchSeconds === 30 && n.serveDelay === DEFAULT_RULES.serveDelay && !('bogus' in n), 'normalizeRules clamps ranges, keeps a valid select, drops unknown fields');
+ check(normalizeRules({ mode: 'nope' }).mode === DEFAULT_RULES.mode, ' counterfactual: an unknown mode falls back to the default');
+
+ // ---- slots ----
+ let slots = emptySlots();
+ slots = applySlot(slots, 'red', 'A');
+ slots = applySlot(slots, 'blue', 'B');
+ check(teamOf(slots, 'A') === 'red' && teamOf(slots, 'B') === 'blue' && teamOf(slots, 'C') === null, 'applySlot seats A red, B blue; an unslotted peer has no team');
+ const moved = applySlot(slots, 'blue', 'A');
+ check(teamOf(moved, 'A') === 'blue' && !moved.red.includes('A'), 'joining the other team leaves the first (one seat per peer)');
+ const spectating = applySlot(slots, 'none', 'A');
+ check(teamOf(spectating, 'A') === null && spectating.blue.includes('B'), "'none' frees the seat and touches nobody else");
+ check(canJoin(slots, 'red', 'C', 'duel').ok === false, 'duel refuses a second red');
+ check(canJoin(slots, 'red', 'C', 'teams').ok === true, ' counterfactual: teams mode takes a second red');
+ check(canJoin(slots, 'red', 'A', 'duel').ok === true, ' counterfactual: the peer already in the seat may "join" it again');
+ check(canJoin(slots, 'none', 'C', 'duel').ok === true, ' spectating is always allowed');
+ const { slots: pruned, freed } = freeVanished(slots, ['A']);
+ check(freed.length === 1 && freed[0] === 'B' && pruned.blue.length === 0 && pruned.red.includes('A'), 'freeVanished drops the disconnected peer and keeps the live one');
+ check(freeVanished(slots, ['A', 'B']).freed.length === 0, ' counterfactual: everyone live, nothing freed');
+ const swapped = swapSlots(slots);
+ check(teamOf(swapped, 'A') === 'blue' && teamOf(swapped, 'B') === 'red', 'swapSlots trades ends');
+
+ // ---- attribution: ball into the RED gate ----
+ const base = { gateTeam: 'red', slots, mode: 'teams', ownGoals: 'count' };
+ const goal = attributeGoal({ ...base, lastTouch: { by: 'B', team: 'blue', at: 1 } });
+ check(goal.team === 'blue' && goal.by === 'B' && goal.own === false && goal.counts && goal.credit === 'goals', "an attacker's last touch: goal for blue, on B's goals row");
+ const own = attributeGoal({ ...base, lastTouch: { by: 'A', team: 'red', at: 1 } });
+ check(own.team === 'blue' && own.by === 'A' && own.own === true && own.counts && own.credit === 'owngoals', " counterfactual: the defender's last touch: still blue's goal, on A's OWN GOALS row");
+ const ignored = attributeGoal({ ...base, ownGoals: 'ignore', lastTouch: { by: 'A', team: 'red', at: 1 } });
+ check(ignored.counts === false && ignored.own === true, " counterfactual: ownGoals 'ignore' — the own goal does not count");
+ const nobody = attributeGoal({ ...base, lastTouch: null });
+ check(nobody.team === 'blue' && nobody.by === null && nobody.counts && nobody.credit === null, 'no touch at all: blue scores, nobody is credited');
+ const spectator = attributeGoal({ ...base, lastTouch: { by: 'C', team: null, at: 1 } });
+ check(spectator.team === 'blue' && spectator.by === null && spectator.counts, "a spectator's touch counts for the attackers and credits nobody");
+ const stale = attributeGoal({ ...base, slots: swapped, lastTouch: { by: 'B', team: 'blue', at: 1 } });
+ check(stale.own === true && stale.by === 'B', ' the toucher\'s team is read from the slots at GOAL time (B swapped to red -> own goal)');
+ const practice = attributeGoal({ ...base, mode: 'practice', lastTouch: { by: 'B', team: 'blue', at: 1 } });
+ check(practice.counts === false, 'practice scores nothing');
+ const ffa = attributeGoal({ ...base, mode: 'freeforall', lastTouch: { by: 'A', team: 'red', at: 1 } });
+ check(ffa.team === null && ffa.by === 'A' && ffa.counts && ffa.credit === 'goals' && ffa.own === false, 'free for all: whoever touched it last scores, whichever gate');
+ check(attributeGoal({ ...base, mode: 'freeforall', lastTouch: null }).counts === false, ' counterfactual: free for all with no touch scores nobody');
+ const blueGate = attributeGoal({ ...base, gateTeam: 'blue', lastTouch: { by: 'A', team: 'red', at: 1 } });
+ check(blueGate.team === 'red' && blueGate.credit === 'goals', ' counterfactual: the same touch into the BLUE gate is a red goal');
+
+ // ---- score ----
+ const s1 = applyGoal({ red: 0, blue: 0 }, goal);
+ check(s1.blue === 1 && s1.red === 0, 'applyGoal bumps the scoring team');
+ check(applyGoal({ red: 0, blue: 0 }, ignored).blue === 0, ' counterfactual: an ignored goal leaves the score alone');
+
+ // ---- outcome ----
+ const r5 = { ...DEFAULT_RULES, goalsToWin: 5, winBy: 'goals' };
+ check(matchOutcome({ score: { red: 5, blue: 2 }, rules: r5, elapsed: 10 }) ?.winner === 'red', 'goals: 5 wins');
+ check(matchOutcome({ score: { red: 4, blue: 2 }, rules: r5, elapsed: 10 }) === null, ' counterfactual: 4 does not');
+ check(matchOutcome({ score: { red: 5, blue: 2 }, rules: r5, elapsed: 9999 }) ?.winner === 'red', ' goals mode ignores the clock');
+ check(matchOutcome({ score: { red: 1, blue: 0 }, rules: { ...r5, winBy: 'goals' }, elapsed: 9999 }) === null, ' counterfactual: time up in goals mode ends nothing');
+ const rt = { ...DEFAULT_RULES, winBy: 'time', matchSeconds: 60 };
+ check(matchOutcome({ score: { red: 1, blue: 0 }, rules: rt, elapsed: 60 }) ?.reason === 'time', 'time: the clock ends it');
+ check(matchOutcome({ score: { red: 1, blue: 0 }, rules: rt, elapsed: 59 }) === null, ' counterfactual: one second early ends nothing');
+ check(matchOutcome({ score: { red: 2, blue: 2 }, rules: rt, elapsed: 60 }) ?.winner === 'draw', ' level on time is a draw');
+ check(matchOutcome({ score: { red: 9, blue: 0 }, rules: rt, elapsed: 10 }) === null, ' time mode ignores goals');
+ const re = { ...DEFAULT_RULES, winBy: 'either', goalsToWin: 3, matchSeconds: 60 };
+ check(matchOutcome({ score: { red: 3, blue: 0 }, rules: re, elapsed: 10 }) ?.reason === 'goals' && matchOutcome({ score: { red: 1, blue: 0 }, rules: re, elapsed: 60 }) ?.reason === 'time', 'either: first of the two');
+ check(matchOutcome({ score: { red: 9, blue: 0 }, rules: { ...r5, mode: 'practice' }, elapsed: 9999 }) === null, 'practice never ends');
+ const rf = { ...DEFAULT_RULES, mode: 'freeforall', goalsToWin: 2 };
+ check(matchOutcome({ score: { red: 0, blue: 0 }, rules: rf, elapsed: 0, playerGoals: { A: 2, B: 1 } }) ?.winner === 'A', 'free for all: the player reaching the goals wins');
+ check(matchOutcome({ score: { red: 0, blue: 0 }, rules: { ...rf, winBy: 'time', matchSeconds: 30 }, elapsed: 30, playerGoals: { A: 1, B: 1 } }) ?.winner === 'draw', ' free for all on time with a tie is a draw');
+ check(secondsLeft(rt, 20) === 40 && secondsLeft(r5, 20) === null, 'secondsLeft counts down in time mode and is null in goals mode');
+
+ // ---- the serve ----
+ const d1 = serveDirection(1000);
+ const d2 = serveDirection(1000);
+ check(Math.abs(Math.hypot(d1[0], d1[1], d1[2]) - 1) < 1e-9 && d1[1] === 0 && d1.join() === d2.join(), 'serveDirection is a unit vector in the pitch plane and deterministic per stamp');
+ let differs = false;
+ for (let at = 1; at < 40; at++) if (serveDirection(at)[2] * d1[2] < 0) differs = true;
+ check(differs, ' counterfactual: other stamps serve toward the other gate');
+ check(Math.abs(serveDirection(7)[0]) <= 0.3 + 1e-9, ' the sideways lean is bounded');
+ const centred = serveImpulse([0, 1, 0], [0, 1, 0], 0.45, 3, 1000);
+ check(Math.abs(Math.hypot(...centred) - 1.35) < 1e-9, 'serveImpulse at the centre = mass x speed along the serve direction (0.45 x 3)');
+ const back = serveImpulse([0, 1, 2.4], [0, 1, 0], 0.45, 3, 1000);
+ check(back[2] < 0 && Math.abs(back[0]) < 1e-9, ' counterfactual: a ball in a gate is kicked back toward the centre');
+ check(hash32(1, 'a') !== hash32(1, 'b') && hash32(1, 'a') === hash32(1, 'a'), 'hash32 is stable and distinguishes parts');
+
+ // ---- the sheet ----
+ const entry = matchLogEntry({ at: 1234.9, score: { red: 2, blue: 1 }, winner: 'red', rows: [{ name: 'Ann', goals: 2 }, { name: 'Bob', goals: 0 }] });
+ check(entry.at === 1234 && entry.red === 2 && entry.scorers.length === 1 && entry.scorers[0].name === 'Ann', 'matchLogEntry keeps names, drops non-scorers');
+ let log = [];
+ for (let i = 0; i < 60; i++) log = appendMatchLog(log, { at: i }, 50);
+ check(log.length === 50 && log[0].at === 10 && log[49].at === 59, 'appendMatchLog caps at 50, newest kept');
+ check(appendMatchLog(null, { at: 1 }).length === 1, ' a missing log starts one');
+ check(scoreLine({ red: 3, blue: 2 }) === 'RED 3 — 2 BLUE', 'scoreLine');
+ const draw = matchLogEntry({ at: 5, score: { red: 1, blue: 1 }, winner: 'draw', rows: [] });
+ check(draw.winner === 'draw' && draw.scorers.length === 0, ' a scoreless draw logs with no scorers');
+ const capped = appendMatchLog([{ at: 1 }], { at: 2 }, 1);
+ check(capped.length === 1 && capped[0].at === 2, ' counterfactual: cap 1 keeps only the newest');
+}
diff --git a/modules/football/test/run.mjs b/modules/football/test/run.mjs
new file mode 100644
index 0000000..7dd131c
--- /dev/null
+++ b/modules/football/test/run.mjs
@@ -0,0 +1,16 @@
+// Pure rules + pitch tests — run WITHOUT the app:
+// node modules/football/test/run.mjs (also `npm run test:football`)
+import { run as rulesTests } from './rules.test.mjs';
+import { run as pitchTests } from './pitch.test.mjs';
+
+let failures = 0;
+function check(ok, label) {
+ console.log((ok ? 'PASS ' : 'FAIL ') + label);
+ if (!ok) failures++;
+}
+console.log('=== rules.test.mjs ===');
+rulesTests(check);
+console.log('\n=== pitch.test.mjs ===');
+pitchTests(check);
+console.log('\n' + (failures === 0 ? 'ALL PASS' : failures + ' FAILURES'));
+process.exit(failures === 0 ? 0 : 1);
diff --git a/modules/health/README.md b/modules/health/README.md
new file mode 100644
index 0000000..bec8f45
--- /dev/null
+++ b/modules/health/README.md
@@ -0,0 +1,110 @@
+# health — hit points that converge, with nobody in charge
+
+A `health` node gives an object (or the local player) hit points. `damage` and `heal`
+nodes fire pulses into a core **Counter** that feeds it, and every peer derives the
+number from the same counters. Nobody is an authority, nothing is sent on a timer, and
+there is no `registerStateSync`: every bit of state is already replicated.
+
+```
+modules/health/
+ src/ledger.js PURE: object hp from counters, player hp from a row, regen, pulses per hit
+ src/graph.js PURE: the chain walk over api.flow snapshots, and the recipe
+ src/engine.js the 10 Hz sweep: derive, fire, death/respawn edges, the round reset
+ src/nodes.js the "Health" node family
+ src/toolbox.js the manager: build a chain per selected object, live rows
+ test/ node tests, no app (npm run test:health)
+ module.js the bundled entry (committed)
+```
+
+```bash
+npm run build:health # src/ -> module.js (esbuild)
+npm run test:health # ledger + graph, no app
+npm run pack -- health # -> health.zip
+flock -w 5400 /tmp/tp-e2e.lock env APP_URL=https://theprototype.app:5215/ npm test -- health
+```
+
+## The two ownership models (roadmap 29, fork 2 — locked)
+
+| Scope | Where the number lives | Why it converges |
+|---|---|---|
+| `object` | `hp = max − hits + heals`, where `hits`/`heals` are the values of the Counters wired into the health's `damage`/`heal` inputs | a damage pulse is one trigger-log entry `(node id, stamp)`, applied exactly once per peer; the Counter's count travels **with** the stamp in the trigger-log handshake (DEVX #18), so a late joiner reads the same count |
+| `player` | my own `peerVars` row: `` = hp base, `.at` = the stamp it was written at; regen is `base + regen × (now − at)`, derived at read time, capped at max | one writer per row by construction; regen is a pure function of the clock, so nothing is sent while it ticks |
+
+The counterfactual is the shared-add model: two peers each computing `hp − 1` from their
+own read of a game variable lose a hit. The flight shows it losing one while the ledger
+reads two.
+
+**Who fires a pulse, and does it replicate** (the engine's one rule):
+
+- a **replicated** source — a stamp on an event node wired into `damage.trigger`, the knock
+ feed every peer receives — every peer derives the same pulse and fires it **locally**
+ (`{replicate:false}`); the counters agree because every peer applied the same set;
+- a source only **this** peer saw — its own click, its own touch — this peer fires a
+ **replicated** pulse and the others apply it once (the collectible's model);
+- a **player's** health: the same pulses, always local, plus the row write.
+
+**First sight never fires.** A stamp that was already there when a peer first saw a node
+is history (the trigger-log handshake, an undo), adopted and never acted on. The same rule
+gates the round reset: the first time a peer sees the game shell in use is a seed, and only
+a later change to a *new* round zeroes the counters — otherwise a joiner arriving mid-round
+would reset the counts the handshake had just delivered (it did, once, in the flight).
+
+## Nodes (group "Health")
+
+| Node | Kind | Params / inputs |
+|---|---|---|
+| **Health** `health` | value (its hp) — names its object through the `target` input (wire an Object Selector in; a node in an object's own graph owns that object) | `name` · `scope: object\|player` · `max` · `regen` (per second; player scope) · `deathAction: hide\|respawn\|nothing` · `respawnDelay` · inputs `target` (object), `damage`, `heal` (numbers — wire the Counters), `respawnAt` (object — where a player comes back). **Not an effect, on purpose**: the runtime re-seats an effect target's base pose every frame (football's "no node may target the ball"), which would pin a damageable crate or a walking enemy in place. The module hides a dead object itself, in play only, and gives back exactly what it hid. |
+| **Damage** `damage` | event out | `amount` (points per event, ≤ 20) · `source: wired\|click\|touch\|zone\|hit` · `radius` · `perSecond` · `scale: none\|speed` · `speedRef` · inputs `trigger` (event: what sets it off when `wired`), `zone` (object, for a hazard). It hurts **whatever health it feeds** — through a Counter's `pulse`, or wired straight into `health.damage`. |
+| **Heal** `heal` | event out | `amount` · input `trigger`. The same shape with the opposite sign; a heal past full is remembered (a count-only ledger cannot tell a heal at full from one that landed — the plan's "negative stamp", stated honestly). |
+| **Health Reset** `healthreset` | event out | `name`. Wire it into the Counters' `reset`; the module fires it locally on every peer on a new round and on an object's respawn. |
+| **Health Value** `healthvalue` | value | `name` · `read: fraction\|current\|max\|alive`. Sums over every health carrying the name (a group bar); `fraction` is what a HUD Bar wants. |
+| **Health Event** `healthevent` | event out | `name` · `event: death\|damage\|respawn\|reset`. Fired locally on every peer from the same derived edge, so a Counter of kills converges without a message. |
+
+The recipe (**Health ▸ Make damageable**) builds, per selected object:
+
+```
+damage ──▶ counter.pulse ──▶ health.damage ◀──target── objectselector
+ ▲
+healthreset ─┘ (reset)
+```
+
+**Player health** builds the same chain with `scope: player` and no selector. The
+player's number is read from their row; the Counter in the chain then counts the hits
+this peer took (a free readout).
+
+## Damage sources (`damage.source`)
+
+| Source | What sets it off | Who fires, does it replicate |
+|---|---|---|
+| `wired` | any event wired into `trigger` — On Click, On Impact, On Enter, On Hit, a Delay, a Key, a HUD button through a Delay | the stamp replicated, so every peer fires its own local pulse; a shared source hurts every player who has that chain — make the source `perPlayer` for one |
+| `click` | a desktop click or the VR trigger on the health's object | the clicker, replicated |
+| `touch` | the local player walks within `radius` of the health's object (an edge: leave and come back to touch again) | the toucher, replicated |
+| `zone` | the local player stands within `radius` of the object wired into `zone`, `amount` every `1/perSecond` seconds | the player hurt, local + their row |
+| `hit` | core's knock feed (24-A): a hand or the head probe knocked the object | every peer receives the one `hit` message and fires its own local pulses; `scale: speed` multiplies `amount` by `speed / speedRef` (capped at 3×, floored at one point) |
+
+`touch` and `zone` are self-detected from `api.playerPosition()` — no sensor, no sim, no
+initiator, exactly the collectible's touch. Only a peer **in play** detects; a peer in the
+editor hurts nothing and is hurt by nothing.
+
+## Death and respawn
+
+- `hide`: the object vanishes on every peer while in play; the module gives it back outside play, on a reset and on a respawn (only what it hid).
+- `respawn`: hidden for `respawnDelay` seconds counted from the **killing pulse's stamp**
+ (replicated, so every peer reaches the moment together), then the module fires
+ `healthreset` for the name — the Counters zero, the object returns. A player comes back
+ at full on their own row and the camera flies to the object wired into `respawnAt` —
+ through `api.flyTo`, which moves the **editor** camera; in play the rig owns the camera and
+ there is no seam to move the player (DEVX #23). The flight proves the fly-to in the editor.
+- `nothing`: only the events fire; the graph decides.
+
+## HUD
+
+**Show health (bar)** puts a `healthvalue` (fraction) behind a Bar (min 0, max 1); **Show hit
+points** a `current` behind a Text or Icon Row. The debug pill reads
+`health (hp): 3/6 · 1 of 2 alive`.
+
+## Owed on device
+
+The feel of being hit in VR (a flash, haptics — `api.haptic` exists, wire it to a
+`healthevent damage` is the natural next step), a 3+ player round, and the toolbox in
+non-dark themes.
diff --git a/modules/health/manifest.json b/modules/health/manifest.json
new file mode 100644
index 0000000..70c63f6
--- /dev/null
+++ b/modules/health/manifest.json
@@ -0,0 +1,9 @@
+{
+ "id": "health",
+ "name": "Health",
+ "version": "1.0.0",
+ "format": 1,
+ "description": "Hit points for objects and players: damage and heal nodes, death and respawn, converging on every peer with no authority.",
+ "entry": "module.js",
+ "files": ["module.js"]
+}
diff --git a/modules/health/module.js b/modules/health/module.js
new file mode 100644
index 0000000..e0f821f
--- /dev/null
+++ b/modules/health/module.js
@@ -0,0 +1,935 @@
+// modules/health/src/ledger.js
+var DEFAULTS = Object.freeze({
+ name: "hp",
+ scope: "object",
+ max: 5,
+ regen: 0,
+ deathAction: "hide",
+ respawnDelay: 3
+});
+var SCOPES = ["object", "player"];
+var DEATH_ACTIONS = ["hide", "respawn", "nothing"];
+var DAMAGE_SOURCES = ["wired", "click", "touch", "zone", "hit"];
+var SCALES = ["none", "speed"];
+var MAX_PULSES = 20;
+var HALF_DAY = 43200;
+var DAY = 86400;
+function clamp(n, lo, hi, fallback = lo) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return fallback;
+ return Math.min(hi, Math.max(lo, v));
+}
+function elapsed(later, earlier) {
+ let d = later - earlier;
+ if (d < -HALF_DAY) d += DAY;
+ return d;
+}
+function objectHp({ max, hits, heals }) {
+ const cap = clamp(max, 1, 1e6, DEFAULTS.max);
+ const taken = Math.max(0, (Number(hits) || 0) - (Number(heals) || 0));
+ const hp = clamp(cap - taken, 0, cap, cap);
+ return { hp, dead: hp <= 0 };
+}
+function playerHp({ max, regen, base, at, now }) {
+ const cap = clamp(max, 1, 1e6, DEFAULTS.max);
+ if (base === null || base === void 0 || !Number.isFinite(Number(base))) return { hp: cap, dead: false };
+ const held = clamp(base, 0, cap, cap);
+ if (held <= 0) return { hp: 0, dead: true };
+ const rate = clamp(regen, 0, 1e6, 0);
+ const since = typeof at === "number" ? Math.max(0, elapsed(now, at)) : 0;
+ const hp = Math.min(cap, held + rate * since);
+ return { hp, dead: false };
+}
+function applyDelta(row, delta) {
+ const cap = clamp(row.max, 1, 1e6, DEFAULTS.max);
+ const { hp } = playerHp(row);
+ return { base: clamp(hp + (Number(delta) || 0), 0, cap, cap), at: row.now };
+}
+function pulsesFor({ amount, scale, speed, speedRef }) {
+ const base = clamp(amount, 0, MAX_PULSES, 1);
+ if (base <= 0) return 0;
+ if (scale !== "speed") return Math.round(base);
+ const ref = clamp(speedRef, 0.1, 1e3, 3);
+ const factor = clamp(Number(speed) / ref, 0, 3, 1);
+ return Math.max(1, Math.min(MAX_PULSES, Math.round(base * factor)));
+}
+function fraction(hp, max) {
+ const cap = clamp(max, 1, 1e6, DEFAULTS.max);
+ return clamp(hp / cap, 0, 1, 0);
+}
+function respawnDue(deadAt, delay, now) {
+ if (typeof deadAt !== "number") return false;
+ return elapsed(now, deadAt) >= clamp(delay, 0, 3600, DEFAULTS.respawnDelay);
+}
+
+// modules/health/src/graph.js
+var SCENE = "scene";
+function indexGraph(nodes, edges) {
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const bySource = /* @__PURE__ */ new Map();
+ const byTarget = /* @__PURE__ */ new Map();
+ for (const edge of edges) {
+ if (!bySource.has(edge.source)) bySource.set(edge.source, []);
+ bySource.get(edge.source)?.push(edge);
+ if (!byTarget.has(edge.target)) byTarget.set(edge.target, []);
+ byTarget.get(edge.target)?.push(edge);
+ }
+ return { byId, bySource, byTarget, nodes, edges };
+}
+function targetsOf(node, g) {
+ const out = [];
+ let wired = false;
+ for (const edge of g.byTarget.get(node.id) ?? []) {
+ if ((edge.targetHandle ?? null) !== "target") continue;
+ const src = g.byId.get(edge.source);
+ if (src?.type !== "objectselector") continue;
+ wired = true;
+ const selected = String(src.data?.selected ?? "");
+ if (selected && selected !== "-None-") out.push(selected);
+ }
+ if (!wired && node.graphId && node.graphId !== SCENE) out.push(node.graphId);
+ return out;
+}
+function sourcesInto(nodeId, handle, g) {
+ return (g.byTarget.get(nodeId) ?? []).filter((e) => (e.targetHandle ?? null) === handle).map((e) => e.source);
+}
+function chainsOf(node, g) {
+ const out = [];
+ const seen = /* @__PURE__ */ new Set();
+ const add = (health, via, counter) => {
+ const key = health.id + ":" + via + ":" + (counter?.id ?? "");
+ if (seen.has(key)) return;
+ seen.add(key);
+ out.push({ health, via: via === "heal" ? "heal" : "damage", counter });
+ };
+ for (const edge of g.bySource.get(node.id) ?? []) {
+ const target = g.byId.get(edge.target);
+ if (!target) continue;
+ const handle = edge.targetHandle ?? null;
+ if (target.type === "health" && (handle === "damage" || handle === "heal")) add(target, handle, null);
+ if (target.type === "counter" && (handle === "pulse" || handle === null)) {
+ for (const next of g.bySource.get(target.id) ?? []) {
+ const health = g.byId.get(next.target);
+ const h2 = next.targetHandle ?? null;
+ if (health?.type === "health" && (h2 === "damage" || h2 === "heal")) add(health, h2, target);
+ }
+ }
+ }
+ return out;
+}
+function feedersOf(health, g) {
+ const pick = (handle) => sourcesInto(health.id, handle, g).map((id) => g.byId.get(id)).filter(Boolean);
+ return { damage: pick("damage"), heal: pick("heal") };
+}
+function triggerSourcesOf(node, g) {
+ return sourcesInto(node.id, "trigger", g);
+}
+function zoneOf(node, g) {
+ for (const edge of g.byTarget.get(node.id) ?? []) {
+ if ((edge.targetHandle ?? null) !== "zone") continue;
+ const src = g.byId.get(edge.source);
+ if (src?.type !== "objectselector") continue;
+ const selected = String(src.data?.selected ?? "");
+ if (selected && selected !== "-None-") return selected;
+ }
+ return null;
+}
+var nameOf = (node) => String(node?.data?.name ?? "").trim() || "hp";
+function recipe(spec, layout = {}) {
+ const COL = layout.col ?? 220;
+ const ROW = layout.rowHeight ?? 200;
+ const x0 = layout.x ?? 60;
+ const y = (layout.y ?? 40) + spec.row * ROW;
+ const name = String(spec.health.name ?? "").trim() || "hp";
+ const health = { ...spec.health, name, scope: spec.uuid ? "object" : "player" };
+ const nodes = [
+ { type: "damage", x: x0, y, data: { ...spec.damage } },
+ { type: "counter", x: x0 + COL, y, data: { op: "up", step: 1 } },
+ { type: "health", x: x0 + 2 * COL, y, data: health },
+ { type: "healthreset", x: x0, y: y + 100, data: { name } }
+ ];
+ const edges = [
+ { from: 0, to: 1, handle: "pulse" },
+ { from: 1, to: 2, handle: "damage" },
+ { from: 3, to: 1, handle: "reset" }
+ ];
+ if (spec.uuid) {
+ nodes.push({ type: "objectselector", x: x0 + 3 * COL, y, data: { selected: spec.uuid } });
+ edges.push({ from: 4, to: 2, handle: "target" });
+ }
+ return { nodes, edges };
+}
+
+// modules/health/src/engine.js
+var SWEEP = 0.1;
+var AT_SUFFIX = ".at";
+var KILLS_ROW = "kills";
+function createEngine(api) {
+ const state = /* @__PURE__ */ new Map();
+ const acted = /* @__PURE__ */ new Map();
+ const wasDead = /* @__PURE__ */ new Map();
+ const lastHp = /* @__PURE__ */ new Map();
+ const respawned = /* @__PURE__ */ new Map();
+ const inside = /* @__PURE__ */ new Map();
+ const zoneTick = /* @__PURE__ */ new Map();
+ const knocked = /* @__PURE__ */ new Map();
+ const hidden = /* @__PURE__ */ new Set();
+ let roundSeen = (
+ /** @type {any} */
+ void 0
+ );
+ const listeners = /* @__PURE__ */ new Set();
+ const now = () => api.now();
+ function graphView() {
+ const nodes = api.flow.nodes();
+ const edges2 = api.flow.edges();
+ return indexGraph(nodes, edges2);
+ }
+ function numberValue(node) {
+ const v = api.flow.nodeValue(node.id);
+ return Number.isFinite(Number(v)) ? Number(v) : 0;
+ }
+ function myRow(name) {
+ const base = api.peerVars.mine(name, null);
+ const at = api.peerVars.mine(name + AT_SUFFIX, null);
+ return {
+ base: typeof base === "number" ? base : null,
+ at: typeof at === "number" ? at : null
+ };
+ }
+ function writeRow(name, base, at) {
+ api.peerVars.setMine(name, base);
+ api.peerVars.setMine(name + AT_SUFFIX, at);
+ }
+ function objectOf(uuid) {
+ if (!uuid) return null;
+ return api.objectsGroup()?.getObjectByProperty("uuid", uuid) ?? null;
+ }
+ function derive(node, g) {
+ const d = node.data ?? {};
+ const name = nameOf(node);
+ const scope = d.scope === "player" ? "player" : "object";
+ const max = clamp(d.max, 1, 1e6, DEFAULTS.max);
+ const regen = clamp(d.regen, 0, 1e6, 0);
+ const t = now();
+ let hp = max;
+ let dead = false;
+ let deadAt = null;
+ let uuid = null;
+ if (scope === "player") {
+ const row = myRow(name);
+ ({ hp, dead } = playerHp({ max, regen, base: row.base, at: row.at, now: t }));
+ deadAt = dead ? row.at : null;
+ } else {
+ const feeders = feedersOf(node, g);
+ let hits = 0;
+ let heals = 0;
+ let latest = null;
+ for (const f of feeders.damage) {
+ hits += numberValue(f);
+ const stamp = api.flow.triggerStamp(f.id);
+ if (stamp && (latest === null || stamp.stamp > latest)) latest = stamp.stamp;
+ }
+ for (const f of feeders.heal) heals += numberValue(f);
+ ({ hp, dead } = objectHp({ max, hits, heals }));
+ deadAt = dead ? latest : null;
+ uuid = targetsOf(node, g)[0] ?? null;
+ }
+ const object = objectOf(uuid);
+ return {
+ id: node.id,
+ name,
+ scope,
+ max,
+ regen,
+ deathAction: String(d.deathAction ?? DEFAULTS.deathAction),
+ respawnDelay: clamp(d.respawnDelay, 0, 3600, DEFAULTS.respawnDelay),
+ hp,
+ dead,
+ uuid,
+ deadAt,
+ label: scope === "player" ? "player" : object?.name || (uuid ? uuid.slice(0, 8) + "\u2026" : "(no target)")
+ };
+ }
+ function pulse(node, n, how, g) {
+ if (n <= 0) return 0;
+ const chains = chainsOf(node, g);
+ if (!chains.length) return 0;
+ const live = chains.filter((c) => {
+ const s = state.get(c.health.id);
+ return !s || !s.dead || how.sign > 0;
+ });
+ if (!live.length) return 0;
+ const t = now();
+ for (let i = 0; i < n; i++)
+ api.fireNodeTrigger(node.type, (_d, id) => id === node.id, how.local ? { replicate: false } : void 0);
+ if (how.credit && how.sign < 0)
+ for (const chain of live) {
+ const s = state.get(chain.health.id);
+ if (!s || s.scope === "player" || s.dead) continue;
+ if (s.hp - n <= 0) api.peerVars.setMine(KILLS_ROW, api.peerVars.mine(KILLS_ROW, 0) + 1);
+ }
+ for (const chain of live) {
+ const s = state.get(chain.health.id);
+ if (!s || s.scope !== "player") continue;
+ const row = myRow(s.name);
+ const next = applyDelta({ max: s.max, regen: s.regen, base: row.base, at: row.at, now: t }, how.sign * n);
+ writeRow(s.name, next.base, next.at);
+ }
+ return n;
+ }
+ function wiredSweep(g) {
+ for (const node of g.nodes) {
+ if (node.type !== "damage" && node.type !== "heal") continue;
+ const sources = triggerSourcesOf(node, g);
+ let newest = null;
+ for (const id of sources) {
+ const stamp = api.flow.triggerStamp(id);
+ if (stamp && (newest === null || stamp.stamp > newest)) newest = stamp.stamp;
+ }
+ if (!acted.has(node.id)) {
+ acted.set(node.id, newest);
+ continue;
+ }
+ if (newest === null || acted.get(node.id) === newest) continue;
+ acted.set(node.id, newest);
+ if (node.type === "damage" && (node.data?.source ?? "wired") !== "wired") continue;
+ const n = pulsesFor({ amount: node.data?.amount ?? 1 });
+ pulse(node, n, { local: true, sign: node.type === "heal" ? 1 : -1 }, g);
+ }
+ for (const id of [...acted.keys()]) if (!g.byId.has(id)) acted.delete(id);
+ }
+ function hitObjects(uuids, source, how) {
+ if (!uuids.length) return 0;
+ const wanted = new Set(uuids);
+ const g = how.g ?? graphView();
+ let fired = 0;
+ for (const node of g.nodes) {
+ if (node.type !== "damage" || (node.data?.source ?? "wired") !== source) continue;
+ const chains = chainsOf(node, g).filter((c) => targetsOf(c.health, g).some((u) => wanted.has(u)));
+ if (!chains.length) continue;
+ const n = pulsesFor({
+ amount: node.data?.amount ?? 1,
+ scale: node.data?.scale,
+ speed: how.speed,
+ speedRef: node.data?.speedRef
+ });
+ if (pulse(node, n, { local: how.local, sign: -1, credit: how.credit ?? !how.local }, g)) fired++;
+ }
+ return fired;
+ }
+ if (typeof api.onHit === "function")
+ api.onHit((hit) => {
+ if (!hit?.uuid) return;
+ if (knocked.get(hit.uuid) === hit.at) return;
+ knocked.set(hit.uuid, hit.at);
+ hitObjects([hit.uuid], "hit", { speed: Number(hit.speed) || 0, local: true, credit: !!hit.local });
+ });
+ function proximitySweep(g) {
+ if (!api.isPlaying()) {
+ inside.clear();
+ return;
+ }
+ const position = api.playerPosition();
+ if (!position) return;
+ const here = new api.THREE.Vector3(position[0], position[1], position[2]);
+ const tick = performance.now() / 1e3;
+ for (const node of g.nodes) {
+ if (node.type !== "damage") continue;
+ const source = node.data?.source ?? "wired";
+ if (source !== "touch" && source !== "zone") continue;
+ const radius = clamp(node.data?.radius, 0.1, 100, 1.5);
+ const chains = chainsOf(node, g);
+ const targets = [];
+ for (const chain of chains) {
+ if (chain.health.data?.scope === "player") {
+ const zone = zoneOf(node, g);
+ if (zone) targets.push({ uuid: zone, local: true });
+ } else for (const uuid of targetsOf(chain.health, g)) targets.push({ uuid, local: false });
+ }
+ const was = inside.get(node.id) ?? /* @__PURE__ */ new Set();
+ const nowIn = /* @__PURE__ */ new Set();
+ for (const t of targets) {
+ const object = objectOf(t.uuid);
+ if (!object) continue;
+ if (object.getWorldPosition(new api.THREE.Vector3()).distanceTo(here) > radius) continue;
+ nowIn.add(t.uuid);
+ if (source === "touch" && !was.has(t.uuid)) fireAt(node, t, g);
+ }
+ if (source === "zone" && nowIn.size) {
+ const every = 1 / clamp(node.data?.perSecond, 0.1, 100, 1);
+ const last = zoneTick.get(node.id) ?? -Infinity;
+ if (tick - last >= every) {
+ zoneTick.set(node.id, tick);
+ for (const t of targets) if (nowIn.has(t.uuid)) fireAt(node, t, g);
+ }
+ } else if (source === "zone") zoneTick.delete(node.id);
+ inside.set(node.id, nowIn);
+ }
+ for (const id of [...inside.keys()]) if (!g.byId.has(id)) inside.delete(id);
+ }
+ function fireAt(node, t, g) {
+ const n = pulsesFor({ amount: node.data?.amount ?? 1 });
+ pulse(node, n, { local: t.local, sign: -1, credit: !t.local }, g);
+ }
+ function emit(name, kind) {
+ api.fireNodeTrigger(
+ "healthevent",
+ (d) => nameOf({ data: d }) === name && String(d?.event ?? "death") === kind,
+ { replicate: false }
+ );
+ }
+ function reset(name) {
+ api.fireNodeTrigger("healthreset", (d) => nameOf({ data: d }) === name, { replicate: false });
+ }
+ function edges(s, firstSight) {
+ const before = wasDead.get(s.id);
+ wasDead.set(s.id, s.dead);
+ const prevHp = lastHp.get(s.id);
+ lastHp.set(s.id, s.hp);
+ if (firstSight) return;
+ if (s.dead && before === false) emit(s.name, "death");
+ if (typeof prevHp === "number" && s.hp < prevHp - 1e-9) emit(s.name, "damage");
+ if (!s.dead && before === true) emit(s.name, "respawn");
+ }
+ function respawnSweep(s) {
+ if (s.deathAction !== "respawn") return;
+ if (!s.dead) {
+ respawned.delete(s.id);
+ return;
+ }
+ if (!respawnDue(s.deadAt, s.respawnDelay, now())) return;
+ if (respawned.get(s.id) === s.deadAt) return;
+ respawned.set(s.id, s.deadAt);
+ if (s.scope === "player") {
+ writeRow(s.name, s.max, now());
+ const at = respawnPoint(s);
+ if (at) api.flyTo(at);
+ } else reset(s.name);
+ }
+ function respawnPoint(s) {
+ const g = graphView();
+ const node = g.byId.get(s.id);
+ if (!node) return null;
+ for (const edge of g.byTarget.get(s.id) ?? []) {
+ if ((edge.targetHandle ?? null) !== "respawnAt") continue;
+ const src = g.byId.get(edge.source);
+ const uuid = src?.type === "objectselector" ? String(src.data?.selected ?? "") : "";
+ const object = objectOf(uuid && uuid !== "-None-" ? uuid : null);
+ if (object) return object.getWorldPosition(new api.THREE.Vector3()).toArray();
+ }
+ return null;
+ }
+ function roundSweep() {
+ const cutoff = api.game.roundCutoff();
+ if (cutoff === null) {
+ roundSeen = null;
+ return;
+ }
+ if (roundSeen === void 0 || roundSeen === null) {
+ roundSeen = cutoff;
+ return;
+ }
+ if (cutoff === roundSeen) return;
+ roundSeen = cutoff;
+ if (typeof cutoff !== "number" || !Number.isFinite(cutoff)) return;
+ const names2 = /* @__PURE__ */ new Set();
+ for (const s of state.values()) {
+ names2.add(s.name);
+ if (s.scope === "player") writeRow(s.name, s.max, now());
+ }
+ for (const name of names2) {
+ reset(name);
+ emit(name, "reset");
+ }
+ }
+ function visibilitySweep() {
+ const playing = api.isPlaying();
+ const shouldHide = /* @__PURE__ */ new Set();
+ for (const s of state.values())
+ if (s.scope === "object" && s.uuid && s.dead && playing && s.deathAction !== "nothing") shouldHide.add(s.uuid);
+ for (const uuid of shouldHide) {
+ const object = objectOf(uuid);
+ if (!object) continue;
+ if (!hidden.has(uuid)) hidden.add(uuid);
+ object.visible = false;
+ }
+ for (const uuid of [...hidden]) {
+ if (shouldHide.has(uuid)) continue;
+ hidden.delete(uuid);
+ const object = objectOf(uuid);
+ if (object) object.visible = true;
+ }
+ }
+ function sweep() {
+ const g = graphView();
+ const live = /* @__PURE__ */ new Set();
+ for (const node of g.nodes) {
+ if (node.type !== "health") continue;
+ live.add(node.id);
+ const firstSight = !state.has(node.id);
+ const s = derive(node, g);
+ state.set(node.id, s);
+ edges(s, firstSight);
+ respawnSweep(s);
+ }
+ visibilitySweep();
+ for (const id of [...state.keys()])
+ if (!live.has(id)) {
+ state.delete(id);
+ wasDead.delete(id);
+ lastHp.delete(id);
+ respawned.delete(id);
+ }
+ roundSweep();
+ wiredSweep(g);
+ proximitySweep(g);
+ for (const fn of listeners) fn();
+ }
+ let lastSweep = -1;
+ api.registerFrameTask(() => {
+ const t = performance.now() / 1e3;
+ if (t - lastSweep < SWEEP) return;
+ lastSweep = t;
+ try {
+ sweep();
+ } catch (error) {
+ console.warn("[health] sweep failed", error);
+ }
+ });
+ function all() {
+ return [...state.values()].sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
+ }
+ function read(name) {
+ let hp = 0;
+ let max = 0;
+ let alive = 0;
+ let total = 0;
+ for (const s of state.values()) {
+ if (s.name !== name) continue;
+ total++;
+ hp += s.hp;
+ max += s.max;
+ if (!s.dead) alive++;
+ }
+ return { hp, max, alive, total, fraction: total ? fraction(hp, max) : 1 };
+ }
+ function names() {
+ return [...new Set([...state.values()].map((s) => s.name))].sort();
+ }
+ function onChange(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ }
+ function clear() {
+ state.clear();
+ acted.clear();
+ wasDead.clear();
+ lastHp.clear();
+ respawned.clear();
+ hidden.clear();
+ inside.clear();
+ zoneTick.clear();
+ knocked.clear();
+ roundSeen = void 0;
+ }
+ return {
+ state,
+ stateOf: (id) => state.get(id) ?? null,
+ all,
+ read,
+ names,
+ graphView,
+ hitObjects,
+ pulse,
+ reset,
+ emit,
+ sweep,
+ onChange,
+ clear,
+ myRow
+ };
+}
+
+// modules/health/src/nodes.js
+var READS = ["fraction", "current", "max", "alive"];
+var EVENTS = ["death", "damage", "respawn", "reset"];
+function registerNodes(api, engine) {
+ api.registerNodeGroup({
+ group: "Health",
+ items: [
+ {
+ type: "health",
+ label: "Health",
+ defaults: { ...DEFAULTS, target: "", damage: 0, heal: 0, respawnAt: "" },
+ params: [
+ { key: "name", kind: "text", placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: "scope", kind: "select", options: SCOPES },
+ { key: "max", kind: "range", min: 1, max: 100, step: 1 },
+ { key: "regen", kind: "range", min: 0, max: 10, step: 0.5 },
+ { key: "deathAction", kind: "select", options: DEATH_ACTIONS },
+ { key: "respawnDelay", kind: "range", min: 0, max: 30, step: 1 }
+ ]
+ },
+ {
+ type: "damage",
+ label: "Damage",
+ defaults: { amount: 1, source: "wired", radius: 1.5, perSecond: 1, scale: "none", speedRef: 3, trigger: 0, zone: "" },
+ params: [
+ { key: "amount", kind: "range", min: 1, max: MAX_PULSES, step: 1 },
+ { key: "source", kind: "select", options: DAMAGE_SOURCES },
+ { key: "radius", kind: "range", min: 0.5, max: 10, step: 0.5 },
+ { key: "perSecond", kind: "range", min: 0.5, max: 10, step: 0.5 },
+ { key: "scale", kind: "select", options: SCALES },
+ { key: "speedRef", kind: "range", min: 0.5, max: 10, step: 0.5 }
+ ]
+ },
+ {
+ type: "heal",
+ label: "Heal",
+ defaults: { amount: 1, trigger: 0 },
+ params: [{ key: "amount", kind: "range", min: 1, max: MAX_PULSES, step: 1 }]
+ },
+ {
+ type: "healthreset",
+ label: "Health Reset",
+ defaults: { name: DEFAULTS.name },
+ params: [{ key: "name", kind: "text", placeholder: DEFAULTS.name, maxLength: 40 }]
+ },
+ {
+ type: "healthvalue",
+ label: "Health Value",
+ defaults: { name: DEFAULTS.name, read: "fraction" },
+ params: [
+ { key: "name", kind: "text", placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: "read", kind: "select", options: READS }
+ ]
+ },
+ {
+ type: "healthevent",
+ label: "Health Event",
+ defaults: { name: DEFAULTS.name, event: "death" },
+ params: [
+ { key: "name", kind: "text", placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: "event", kind: "select", options: EVENTS }
+ ]
+ }
+ ]
+ });
+ api.registerValueNode(
+ "health",
+ (_data, _time, ctx) => engine.stateOf(ctx?.id)?.hp ?? 0,
+ { vtype: "number", inputs: { target: "object", damage: "number", heal: "number", respawnAt: "object" } }
+ );
+ api.registerValueNode("damage", () => 0, { vtype: "event", inputs: { trigger: "event", zone: "object" } });
+ api.registerValueNode("heal", () => 0, { vtype: "event", inputs: { trigger: "event" } });
+ api.registerValueNode("healthreset", () => 0, { vtype: "event" });
+ api.registerValueNode("healthevent", () => 0, { vtype: "event" });
+ api.registerValueNode(
+ "healthvalue",
+ (data) => {
+ const r = engine.read(String(data?.name ?? "").trim() || DEFAULTS.name);
+ switch (String(data?.read ?? "fraction")) {
+ case "current":
+ return r.hp;
+ case "max":
+ return r.max;
+ case "alive":
+ return r.alive;
+ default:
+ return r.fraction;
+ }
+ },
+ { vtype: "number" }
+ );
+}
+
+// modules/health/src/toolbox.js
+var SELECT_CSS = "background:rgba(0,0,0,0.25);color:inherit;border:1px solid rgba(255,255,255,0.15);border-radius:4px;font-size:11px;padding:1px 3px;";
+var INPUT_CSS = SELECT_CSS + "width:100%;";
+function elem(tag, props, css) {
+ const node = document.createElement(tag);
+ Object.assign(node, props ?? {});
+ if (css) node.setAttribute("style", css);
+ return node;
+}
+function select(options, value) {
+ const node = (
+ /** @type {HTMLSelectElement} */
+ elem("select", {}, SELECT_CSS)
+ );
+ for (const option of options) node.appendChild(elem("option", { value: option, textContent: option }));
+ node.value = value;
+ return node;
+}
+function registerToolbox(api, engine) {
+ function makeDamageable(options) {
+ const uuids = api.selectedUuids();
+ if (!uuids.length) {
+ api.toast("Select an object first, then make it damageable");
+ return { built: 0, skipped: 0 };
+ }
+ const g = engine.graphView();
+ const taken = /* @__PURE__ */ new Set();
+ let row = 0;
+ for (const node of g.nodes) {
+ if (node.type !== "health") continue;
+ row++;
+ for (const uuid of targetsOf(node, g)) taken.add(uuid);
+ }
+ let built = 0;
+ let skipped = 0;
+ for (const uuid of uuids) {
+ if (taken.has(uuid)) {
+ skipped++;
+ continue;
+ }
+ api.flow.addNodes(recipe({ uuid, row, health: options.health, damage: options.damage }));
+ taken.add(uuid);
+ row++;
+ built++;
+ }
+ api.toast(
+ built ? built + " object" + (built === 1 ? "" : "s") + ' now damageable as "' + options.health.name + '"' + (skipped ? " (" + skipped + " already had health)" : "") : "Already damageable \u2014 nothing to add"
+ );
+ return { built, skipped };
+ }
+ function makePlayerHealth(options) {
+ const g = engine.graphView();
+ const name = String(options.health.name ?? "").trim() || DEFAULTS.name;
+ let row = 0;
+ for (const node of g.nodes) {
+ if (node.type !== "health") continue;
+ row++;
+ if (node.data?.scope === "player" && (String(node.data?.name ?? "").trim() || DEFAULTS.name) === name) {
+ api.toast('Player health "' + name + '" already exists');
+ return { built: 0, skipped: 1 };
+ }
+ }
+ api.flow.addNodes(recipe({ uuid: null, row, health: options.health, damage: options.damage }));
+ api.toast('Player health "' + name + '" added');
+ return { built: 1, skipped: 0 };
+ }
+ function mount(el) {
+ el.textContent = "";
+ el.classList.add("health-manager");
+ el.appendChild(
+ elem("style", {
+ textContent: ".health-manager{display:flex;flex-direction:column;gap:8px;min-width:230px;font-size:12px}.hm-form{display:grid;grid-template-columns:auto 1fr;gap:4px 6px;align-items:center}.hm-form label{opacity:0.75;font-size:11px}.hm-buttons{display:flex;gap:6px}.hm-row{display:flex;align-items:center;gap:6px;padding:3px 5px;border-radius:5px;background:rgba(255,255,255,0.04);cursor:pointer;margin-bottom:2px}.hm-row:hover{background:rgba(255,255,255,0.09)}.hm-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hm-hp{flex:0 0 auto;font-variant-numeric:tabular-nums}.hm-status{flex:0 0 auto;font-size:10px;opacity:0.7;white-space:nowrap}.hm-bar{flex:0 0 46px;height:6px;border-radius:3px;background:rgba(255,255,255,0.12);overflow:hidden}.hm-bar i{display:block;height:100%;background:#6fcf7a}.hm-empty{opacity:0.6;font-size:11px}"
+ })
+ );
+ el.appendChild(elem("div", { className: "tbx-label", textContent: "Give the selection health" }));
+ const form = elem("div", { className: "hm-form" });
+ const name = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "text", value: DEFAULTS.name, maxLength: 40 }, INPUT_CSS)
+ );
+ const max = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "1", max: "100", step: "1", value: String(DEFAULTS.max) }, INPUT_CSS)
+ );
+ const regen = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "0", max: "10", step: "0.5", value: "0" }, INPUT_CSS)
+ );
+ const deathAction = select(DEATH_ACTIONS, DEFAULTS.deathAction);
+ const respawnDelay = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "0", max: "30", step: "1", value: String(DEFAULTS.respawnDelay) }, INPUT_CSS)
+ );
+ const source = select(DAMAGE_SOURCES, "click");
+ const amount = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "1", max: String(MAX_PULSES), step: "1", value: "1" }, INPUT_CSS)
+ );
+ const radius = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "0.5", max: "10", step: "0.5", value: "1.5" }, INPUT_CSS)
+ );
+ radius.title = "For touch and zone: how close the player has to be";
+ for (const [label, control] of [
+ ["Name", name],
+ ["Max hp", max],
+ ["Regen /s", regen],
+ ["On death", deathAction],
+ ["Respawn (s)", respawnDelay],
+ ["Damage from", source],
+ ["Damage", amount],
+ ["Radius", radius]
+ ]) {
+ form.appendChild(elem("label", { textContent: label }));
+ form.appendChild(
+ /** @type {any} */
+ control
+ );
+ }
+ el.appendChild(form);
+ const options = () => ({
+ health: {
+ name: name.value.trim() || DEFAULTS.name,
+ max: Math.max(1, Math.min(100, Number(max.value) || DEFAULTS.max)),
+ regen: Math.max(0, Math.min(10, Number(regen.value) || 0)),
+ deathAction: deathAction.value,
+ respawnDelay: Math.max(0, Math.min(30, Number(respawnDelay.value) || 0))
+ },
+ damage: {
+ amount: Math.max(1, Math.min(MAX_PULSES, Number(amount.value) || 1)),
+ source: source.value,
+ radius: Math.max(0.5, Math.min(10, Number(radius.value) || 1.5))
+ }
+ });
+ const buttons = elem("div", { className: "hm-buttons" });
+ const buildObjects = elem("button", { className: "tbx-btn tbx-primary", textContent: "Make damageable" });
+ buildObjects.addEventListener("click", () => {
+ makeDamageable(options());
+ refresh(true);
+ });
+ const buildPlayer = elem("button", { className: "tbx-btn", textContent: "Player health" });
+ buildPlayer.title = "One health for the local player, on their own peer row";
+ buildPlayer.addEventListener("click", () => {
+ makePlayerHealth(options());
+ refresh(true);
+ });
+ buttons.appendChild(buildObjects);
+ buttons.appendChild(buildPlayer);
+ el.appendChild(buttons);
+ const list = elem("div", {});
+ el.appendChild(list);
+ let signature = "";
+ const rows = /* @__PURE__ */ new Map();
+ function statusText(s) {
+ if (s.scope === "object" && s.uuid && !api.objectsGroup()?.getObjectByProperty("uuid", s.uuid)) return "missing";
+ if (!s.dead) return s.hp >= s.max ? "full" : "hurt";
+ if (s.deathAction === "respawn" && typeof s.deadAt === "number") {
+ const left = Math.max(0, s.respawnDelay - (api.now() - s.deadAt));
+ return "back in " + Math.max(1, Math.ceil(left)) + "s";
+ }
+ return "dead";
+ }
+ function rebuild(model) {
+ list.textContent = "";
+ rows.clear();
+ if (!model.length) {
+ list.appendChild(elem("div", { className: "hm-empty", textContent: "No health yet. Select an object and press the button." }));
+ return;
+ }
+ for (const s of model) {
+ const row = elem("div", { className: "hm-row" });
+ row.dataset.node = s.id;
+ row.addEventListener("click", () => {
+ if (s.uuid) api.selectObject(s.uuid);
+ });
+ row.appendChild(elem("div", { className: "hm-name", textContent: s.label + " \xB7 " + s.name }));
+ const bar = elem("div", { className: "hm-bar" });
+ const fill = elem("i", {});
+ bar.appendChild(fill);
+ row.appendChild(bar);
+ const hp = elem("div", { className: "hm-hp", textContent: "" });
+ const status = elem("div", { className: "hm-status", textContent: "" });
+ row.appendChild(hp);
+ row.appendChild(status);
+ rows.set(s.id, { hp, status, bar: fill });
+ list.appendChild(row);
+ }
+ }
+ function refresh(force) {
+ const model = engine.all();
+ const next = JSON.stringify(model.map((s) => [s.id, s.label, s.name, s.scope, s.deathAction]));
+ if (force || next !== signature) {
+ signature = next;
+ rebuild(model);
+ }
+ for (const s of model) {
+ const r = rows.get(s.id);
+ if (!r) continue;
+ r.hp.textContent = Math.round(s.hp * 10) / 10 + " / " + s.max;
+ r.status.textContent = statusText(s);
+ r.bar.style.width = Math.round((s.max ? s.hp / s.max : 0) * 100) + "%";
+ r.bar.style.background = s.dead ? "#c94a4a" : s.hp / s.max < 0.35 ? "#e0a23b" : "#6fcf7a";
+ }
+ }
+ refresh(true);
+ const timer = setInterval(refresh, 500);
+ return () => clearInterval(timer);
+ }
+ const toolbox = api.registerToolbox({
+ id: "manager",
+ title: "Health",
+ width: 270,
+ minW: 240,
+ sidebar: false,
+ mount
+ });
+ api.registerMenu("Open Health", () => api.openToolbox(toolbox));
+ return { toolbox, makeDamageable, makePlayerHealth };
+}
+
+// modules/health/src/index.js
+var index_default = {
+ id: "health",
+ name: "Health",
+ version: "1.0.0",
+ description: "Hit points for objects and players: damage and heal nodes, death and respawn, converging on every peer with no authority.",
+ /** @param {any} api the module SDK surface */
+ register(api) {
+ if (!api.flow?.addNodes || !api.game?.roundCutoff || !api.peerVars?.setMine || !api.registerValueNode) {
+ api.toast("Health needs a newer app build (the game SDK seams are missing)");
+ return;
+ }
+ const engine = createEngine(api);
+ registerNodes(api, engine);
+ const toolbox = registerToolbox(api, engine);
+ api.registerClickHandler((object) => {
+ const chain = uuidChain(api, object);
+ if (chain.length) engine.hitObjects(chain, "click", { local: false });
+ return false;
+ });
+ api.hud.registerDebugLine(() => {
+ const names = engine.names();
+ if (!names.length) return null;
+ return names.map((name) => {
+ const r = engine.read(name);
+ return "health (" + name + "): " + Math.round(r.hp * 10) / 10 + "/" + r.max + (r.total > 1 ? " \xB7 " + r.alive + " of " + r.total + " alive" : r.alive ? "" : " \xB7 dead");
+ }).join(" \xB7 ");
+ });
+ api.hud.registerAction({
+ key: "showhealth",
+ label: "Show health (bar)",
+ group: "Data",
+ role: "drives",
+ node: "",
+ via: { node: "healthvalue", data: { name: DEFAULTS.name, read: "fraction" }, handle: "value" },
+ hint: "0..1 for a Bar (min 0, max 1). Derived from the health nodes, so every peer agrees."
+ });
+ api.hud.registerAction({
+ key: "showhp",
+ label: "Show hit points",
+ group: "Data",
+ role: "drives",
+ node: "",
+ via: { node: "healthvalue", data: { name: DEFAULTS.name, read: "current" }, handle: "value" },
+ hint: "The current hit points as a number \u2014 a Text or an Icon Row."
+ });
+ api.onSceneClear(() => engine.clear());
+ if (typeof window !== "undefined")
+ window.__health = {
+ api,
+ engine,
+ toolbox,
+ snapshot: () => engine.all().map((s) => ({ ...s }))
+ };
+ }
+};
+function uuidChain(api, object) {
+ const root = api.objectsGroup();
+ const out = [];
+ let current = object;
+ while (current && current !== root && out.length < 32) {
+ if (current.uuid) out.push(current.uuid);
+ current = current.parent;
+ }
+ return current === root ? out : [];
+}
+export {
+ index_default as default
+};
diff --git a/modules/health/src/engine.js b/modules/health/src/engine.js
new file mode 100644
index 0000000..3b363db
--- /dev/null
+++ b/modules/health/src/engine.js
@@ -0,0 +1,528 @@
+// health — THE ENGINE: one 10 Hz sweep that DERIVES every health from replicated state
+// and fires the pulses that change it. Nothing here is sent on a timer; the only things
+// that ever leave this peer are pulses through core's own `nodetrigger` path (a click,
+// a touch, a knock this peer reports) and this peer's own peerVars row.
+//
+// THE RULE EVERY SOURCE FOLLOWS — who fires, and does it replicate:
+// - a REPLICATED source (a stamp on a wired event node, the knock feed every peer
+// receives): every peer derives the same pulse and fires it LOCALLY
+// (`{replicate:false}`); the counters converge because every peer applied the same
+// set of pulses, and a joiner gets the counts in the handshake.
+// - a source only THIS peer can see (its own click, its own touch): this peer fires a
+// REPLICATED pulse, and the others apply it once — the collectible's model.
+// - a PLAYER's health: the same pulses, always local, plus the row write — one writer.
+//
+// FIRST SIGHT NEVER FIRES (the collectible's rule, core's `actionSeenAt`): a stamp that
+// was already there when this peer first saw a node is history, adopted and never acted
+// on. It is what stops a late joiner from re-applying every hit in the handshake.
+
+import { objectHp, playerHp, applyDelta, pulsesFor, fraction, respawnDue, DEFAULTS, clamp } from './ledger.js';
+import { indexGraph, targetsOf, chainsOf, feedersOf, triggerSourcesOf, zoneOf, nameOf } from './graph.js';
+
+const SWEEP = 0.1;
+const AT_SUFFIX = '.at';
+/** the per-player kill count, on the killer's own row */
+const KILLS_ROW = 'kills';
+
+/** @param {any} api */
+export function createEngine(api) {
+ /** derived, per health node id
+ * @type {Map} */
+ const state = new Map();
+ /** node id -> last stamp acted on (damage/heal wired sources); null = seeded, none yet */
+ const acted = new Map();
+ /** health id -> previous `dead`, for the death edge */
+ const wasDead = new Map();
+ /** health id -> previous hp, for the `damage` event edge */
+ const lastHp = new Map();
+ /** health id -> the deadAt a respawn was already fired for */
+ const respawned = new Map();
+ /** damage node id -> the object uuids the local player is currently inside (touch/zone) */
+ const inside = new Map();
+ /** damage node id -> performance.now()/1000 of the last zone tick */
+ const zoneTick = new Map();
+ /** uuid -> the knock stamp already applied (the feed fires once per hit per peer;
+ * this is the belt to that brace) */
+ const knocked = new Map();
+ /** the objects THIS module hid (uuid -> true), so it gives back exactly those and
+ * never fights a manual hide — the 21-F2 rule, kept by hand because health is a value
+ * node, not an effect (an effect would pin its target's pose) */
+ const hidden = new Set();
+ /** the round cutoff we last saw (null = not seen yet) */
+ let roundSeen = /** @type {any} */ (undefined);
+ /** the listeners the toolbox uses (a local refresh, never a wire) @type {Set<() => void>} */
+ const listeners = new Set();
+
+ const now = () => api.now();
+
+ // ---- reads ----------------------------------------------------------------------
+ function graphView() {
+ const nodes = api.flow.nodes();
+ const edges = api.flow.edges();
+ return indexGraph(nodes, edges);
+ }
+
+ /** @param {any} node */
+ function numberValue(node) {
+ const v = api.flow.nodeValue(node.id);
+ return Number.isFinite(Number(v)) ? Number(v) : 0;
+ }
+
+ /** the local player's row for a name @param {string} name */
+ function myRow(name) {
+ const base = api.peerVars.mine(name, null);
+ const at = api.peerVars.mine(name + AT_SUFFIX, null);
+ return {
+ base: typeof base === 'number' ? base : null,
+ at: typeof at === 'number' ? at : null
+ };
+ }
+
+ /** @param {string} name @param {number} base @param {number} at */
+ function writeRow(name, base, at) {
+ api.peerVars.setMine(name, base);
+ api.peerVars.setMine(name + AT_SUFFIX, at);
+ }
+
+ /** the object an id names, if it is in the replicated scene @param {string|null} uuid */
+ function objectOf(uuid) {
+ if (!uuid) return null;
+ return api.objectsGroup()?.getObjectByProperty('uuid', uuid) ?? null;
+ }
+
+ // ---- the derivation -------------------------------------------------------------
+ /** @param {any} node @param {import('./graph.js').GraphIndex} g */
+ function derive(node, g) {
+ const d = node.data ?? {};
+ const name = nameOf(node);
+ const scope = d.scope === 'player' ? 'player' : 'object';
+ const max = clamp(d.max, 1, 1e6, DEFAULTS.max);
+ const regen = clamp(d.regen, 0, 1e6, 0);
+ const t = now();
+ let hp = max;
+ let dead = false;
+ let deadAt = null;
+ let uuid = null;
+ if (scope === 'player') {
+ const row = myRow(name);
+ ({ hp, dead } = playerHp({ max, regen, base: row.base, at: row.at, now: t }));
+ deadAt = dead ? row.at : null;
+ } else {
+ const feeders = feedersOf(node, g);
+ let hits = 0;
+ let heals = 0;
+ let latest = null;
+ for (const f of feeders.damage) {
+ hits += numberValue(f);
+ const stamp = api.flow.triggerStamp(f.id);
+ if (stamp && (latest === null || stamp.stamp > latest)) latest = stamp.stamp;
+ }
+ for (const f of feeders.heal) heals += numberValue(f);
+ ({ hp, dead } = objectHp({ max, hits, heals }));
+ deadAt = dead ? latest : null;
+ uuid = targetsOf(node, g)[0] ?? null;
+ }
+ const object = objectOf(uuid);
+ return {
+ id: node.id,
+ name,
+ scope,
+ max,
+ regen,
+ deathAction: String(d.deathAction ?? DEFAULTS.deathAction),
+ respawnDelay: clamp(d.respawnDelay, 0, 3600, DEFAULTS.respawnDelay),
+ hp,
+ dead,
+ uuid,
+ deadAt,
+ label: scope === 'player' ? 'player' : object?.name || (uuid ? uuid.slice(0, 8) + '…' : '(no target)')
+ };
+ }
+
+ // ---- pulses ---------------------------------------------------------------------
+ /**
+ * Fire `n` pulses from a damage/heal node. `local` keeps them in this peer's log
+ * (every peer derives the same ones); otherwise they replicate (only this peer saw
+ * the cause). Player-scoped chains also write this peer's row.
+ * `credit` names this peer as the one who caused it (its click, its own hand's knock):
+ * a killing blow then bumps this peer's own `kills` row — per-player scoring on the
+ * peerVars one-writer rule, the football sheet's shape.
+ * @param {any} node the damage or heal node
+ * @param {number} n pulses (hit points)
+ * @param {{local: boolean, sign: 1|-1, credit?: boolean}} how
+ * @param {import('./graph.js').GraphIndex} g
+ * @returns {number} pulses fired
+ */
+ function pulse(node, n, how, g) {
+ if (n <= 0) return 0;
+ const chains = chainsOf(node, g);
+ if (!chains.length) return 0;
+ // the overkill guard: a pulse aimed only at the dead is dropped, so a hit landing
+ // after the killing blow does not carry into the next life (every peer derives
+ // `dead` from the same counters, so every peer drops the same pulse)
+ const live = chains.filter((c) => {
+ const s = state.get(c.health.id);
+ return !s || !s.dead || how.sign > 0;
+ });
+ if (!live.length) return 0;
+ const t = now();
+ for (let i = 0; i < n; i++)
+ api.fireNodeTrigger(node.type, (/** @type {any} */ _d, /** @type {string} */ id) => id === node.id, how.local ? { replicate: false } : undefined);
+ // the counter's value republishes ~6/s, so "did this kill" is read off the LAST
+ // sweep's number minus what we just fired — the same arithmetic every peer does
+ if (how.credit && how.sign < 0)
+ for (const chain of live) {
+ const s = state.get(chain.health.id);
+ if (!s || s.scope === 'player' || s.dead) continue;
+ if (s.hp - n <= 0) api.peerVars.setMine(KILLS_ROW, api.peerVars.mine(KILLS_ROW, 0) + 1);
+ }
+ for (const chain of live) {
+ const s = state.get(chain.health.id);
+ if (!s || s.scope !== 'player') continue;
+ const row = myRow(s.name);
+ const next = applyDelta({ max: s.max, regen: s.regen, base: row.base, at: row.at, now: t }, how.sign * n);
+ writeRow(s.name, next.base, next.at);
+ }
+ return n;
+ }
+
+ /** the source-side sweep: wired stamps on every damage/heal node
+ * @param {import('./graph.js').GraphIndex} g */
+ function wiredSweep(g) {
+ for (const node of g.nodes) {
+ if (node.type !== 'damage' && node.type !== 'heal') continue;
+ const sources = triggerSourcesOf(node, g);
+ let newest = null;
+ for (const id of sources) {
+ const stamp = api.flow.triggerStamp(id);
+ if (stamp && (newest === null || stamp.stamp > newest)) newest = stamp.stamp;
+ }
+ if (!acted.has(node.id)) {
+ acted.set(node.id, newest); // seed, never act
+ continue;
+ }
+ if (newest === null || acted.get(node.id) === newest) continue;
+ acted.set(node.id, newest);
+ if (node.type === 'damage' && (node.data?.source ?? 'wired') !== 'wired') continue;
+ const n = pulsesFor({ amount: node.data?.amount ?? 1 });
+ // the stamp REPLICATED, so every peer fires its own local copy of this pulse
+ pulse(node, n, { local: true, sign: node.type === 'heal' ? 1 : -1 }, g);
+ }
+ // forgotten nodes forget their seed, so an undo that brings one back re-seeds
+ for (const id of [...acted.keys()]) if (!g.byId.has(id)) acted.delete(id);
+ }
+
+ /**
+ * Damage nodes of `source` kind whose health targets one of `uuids`, fired with
+ * `amount` pulses (speed-scaled when the node says so). `local` says whether every
+ * peer derives this cause itself (a knock message everyone receives) or only this
+ * peer saw it (its own click, its own touch), in which case the pulse replicates.
+ * @param {string[]} uuids @param {string} source @param {{speed?: number, local: boolean, credit?: boolean, g?: any}} how
+ * @returns {number} damage nodes fired
+ */
+ function hitObjects(uuids, source, how) {
+ if (!uuids.length) return 0;
+ const wanted = new Set(uuids);
+ const g = how.g ?? graphView();
+ let fired = 0;
+ for (const node of g.nodes) {
+ if (node.type !== 'damage' || (node.data?.source ?? 'wired') !== source) continue;
+ const chains = chainsOf(node, g).filter((c) => targetsOf(c.health, g).some((u) => wanted.has(u)));
+ if (!chains.length) continue;
+ const n = pulsesFor({
+ amount: node.data?.amount ?? 1,
+ scale: node.data?.scale,
+ speed: how.speed,
+ speedRef: node.data?.speedRef
+ });
+ if (pulse(node, n, { local: how.local, sign: -1, credit: how.credit ?? !how.local }, g)) fired++;
+ }
+ return fired;
+ }
+
+ // ---- the knock feed: `hit` ---------------------------------------------------------
+ // Every peer receives every knock (one `hit` message, identical `at` stamps), so every
+ // peer fires its own LOCAL pulses — the replicated-source rule. A joiner's log starts
+ // empty, which is exactly "first sight never fires" for free.
+ if (typeof api.onHit === 'function')
+ api.onHit((/** @type {any} */ hit) => {
+ if (!hit?.uuid) return;
+ if (knocked.get(hit.uuid) === hit.at) return;
+ knocked.set(hit.uuid, hit.at);
+ hitObjects([hit.uuid], 'hit', { speed: Number(hit.speed) || 0, local: true, credit: !!hit.local });
+ });
+
+ // ---- self-proximity: `touch` (an edge) and `zone` (per second, while inside) ----------
+ // Each peer detects ITSELF near an object — no sensor, no sim, no initiator. An OBJECT
+ // chain hurts the object the toucher walked into (this peer alone saw it: replicated);
+ // a PLAYER chain hurts the toucher, near the object wired into `zone` (local + the row).
+ /** @param {import('./graph.js').GraphIndex} g */
+ function proximitySweep(g) {
+ if (!api.isPlaying()) {
+ inside.clear();
+ return;
+ }
+ const position = api.playerPosition();
+ if (!position) return;
+ const here = new api.THREE.Vector3(position[0], position[1], position[2]);
+ const tick = performance.now() / 1000;
+ for (const node of g.nodes) {
+ if (node.type !== 'damage') continue;
+ const source = node.data?.source ?? 'wired';
+ if (source !== 'touch' && source !== 'zone') continue;
+ const radius = clamp(node.data?.radius, 0.1, 100, 1.5);
+ const chains = chainsOf(node, g);
+ /** @type {{uuid: string, local: boolean}[]} */
+ const targets = [];
+ for (const chain of chains) {
+ if (chain.health.data?.scope === 'player') {
+ const zone = zoneOf(node, g);
+ if (zone) targets.push({ uuid: zone, local: true });
+ } else for (const uuid of targetsOf(chain.health, g)) targets.push({ uuid, local: false });
+ }
+ const was = inside.get(node.id) ?? new Set();
+ const nowIn = new Set();
+ for (const t of targets) {
+ const object = objectOf(t.uuid);
+ if (!object) continue;
+ if (object.getWorldPosition(new api.THREE.Vector3()).distanceTo(here) > radius) continue;
+ nowIn.add(t.uuid);
+ if (source === 'touch' && !was.has(t.uuid)) fireAt(node, t, g);
+ }
+ if (source === 'zone' && nowIn.size) {
+ const every = 1 / clamp(node.data?.perSecond, 0.1, 100, 1);
+ const last = zoneTick.get(node.id) ?? -Infinity;
+ if (tick - last >= every) {
+ zoneTick.set(node.id, tick);
+ for (const t of targets) if (nowIn.has(t.uuid)) fireAt(node, t, g);
+ }
+ } else if (source === 'zone') zoneTick.delete(node.id);
+ inside.set(node.id, nowIn);
+ }
+ for (const id of [...inside.keys()]) if (!g.byId.has(id)) inside.delete(id);
+ }
+
+ /** @param {any} node @param {{uuid: string, local: boolean}} t @param {import('./graph.js').GraphIndex} g */
+ function fireAt(node, t, g) {
+ const n = pulsesFor({ amount: node.data?.amount ?? 1 });
+ pulse(node, n, { local: t.local, sign: -1, credit: !t.local }, g);
+ }
+
+ // ---- events + respawn -----------------------------------------------------------
+ /** a LOCAL pulse on every healthevent node of this name and kind (each peer derives
+ * the same edge, so each fires its own) @param {string} name @param {string} kind */
+ function emit(name, kind) {
+ api.fireNodeTrigger(
+ 'healthevent',
+ (/** @type {any} */ d) => nameOf({ data: d }) === name && String(d?.event ?? 'death') === kind,
+ { replicate: false }
+ );
+ }
+
+ /** a LOCAL pulse on every healthreset node of this name — the counters it feeds
+ * zero on this peer, and every peer does the same at the same derived moment
+ * @param {string} name */
+ function reset(name) {
+ api.fireNodeTrigger('healthreset', (/** @type {any} */ d) => nameOf({ data: d }) === name, { replicate: false });
+ }
+
+ /** @param {ReturnType} s @param {boolean} firstSight */
+ function edges(s, firstSight) {
+ const before = wasDead.get(s.id);
+ wasDead.set(s.id, s.dead);
+ const prevHp = lastHp.get(s.id);
+ lastHp.set(s.id, s.hp);
+ if (firstSight) return;
+ if (s.dead && before === false) emit(s.name, 'death');
+ if (typeof prevHp === 'number' && s.hp < prevHp - 1e-9) emit(s.name, 'damage');
+ if (!s.dead && before === true) emit(s.name, 'respawn');
+ }
+
+ /** @param {ReturnType} s */
+ function respawnSweep(s) {
+ if (s.deathAction !== 'respawn') return;
+ if (!s.dead) {
+ respawned.delete(s.id);
+ return;
+ }
+ if (!respawnDue(s.deadAt, s.respawnDelay, now())) return;
+ if (respawned.get(s.id) === s.deadAt) return;
+ respawned.set(s.id, s.deadAt);
+ if (s.scope === 'player') {
+ writeRow(s.name, s.max, now());
+ const at = respawnPoint(s);
+ if (at) api.flyTo(at);
+ } else reset(s.name);
+ }
+
+ /** where a player comes back: the object wired into `respawnAt`, if any
+ * @param {ReturnType} s @returns {number[]|null} */
+ function respawnPoint(s) {
+ const g = graphView();
+ const node = g.byId.get(s.id);
+ if (!node) return null;
+ for (const edge of g.byTarget.get(s.id) ?? []) {
+ if ((edge.targetHandle ?? null) !== 'respawnAt') continue;
+ const src = g.byId.get(edge.source);
+ const uuid = src?.type === 'objectselector' ? String(src.data?.selected ?? '') : '';
+ const object = objectOf(uuid && uuid !== '-None-' ? uuid : null);
+ if (object) return object.getWorldPosition(new api.THREE.Vector3()).toArray();
+ }
+ return null;
+ }
+
+ /** a new round: every counter this module resets goes to zero, every player row to full.
+ * THE JOINER TRAP: the game state lands a moment after the graph, so a joiner's first
+ * sweeps see no shell and then a round already under way — that is history, and firing
+ * a reset on it would zero the counters the handshake just delivered. So the FIRST
+ * sight of the shell in use is a seed, and only a later change to a NEW round acts.
+ * No clock is compared (a joiner's api.now() re-bases on connect), only identity. */
+ function roundSweep() {
+ const cutoff = api.game.roundCutoff();
+ if (cutoff === null) {
+ roundSeen = null; // shell not in use: nothing to seed on
+ return;
+ }
+ if (roundSeen === undefined || roundSeen === null) {
+ roundSeen = cutoff; // first sight of the shell: history, never a start we witnessed
+ return;
+ }
+ if (cutoff === roundSeen) return;
+ roundSeen = cutoff;
+ if (typeof cutoff !== 'number' || !Number.isFinite(cutoff)) return; // menu/over: nothing to zero
+ const names = new Set();
+ for (const s of state.values()) {
+ names.add(s.name);
+ if (s.scope === 'player') writeRow(s.name, s.max, now());
+ }
+ for (const name of names) {
+ reset(name);
+ emit(name, 'reset');
+ }
+ }
+
+ /** hide a dead object while playing; give back what we hid the moment it is not
+ * (alive again, out of play, action changed) — and only what WE hid */
+ function visibilitySweep() {
+ const playing = api.isPlaying();
+ const shouldHide = new Set();
+ for (const s of state.values())
+ if (s.scope === 'object' && s.uuid && s.dead && playing && s.deathAction !== 'nothing') shouldHide.add(s.uuid);
+ for (const uuid of shouldHide) {
+ const object = objectOf(uuid);
+ if (!object) continue;
+ if (!hidden.has(uuid)) hidden.add(uuid);
+ object.visible = false;
+ }
+ for (const uuid of [...hidden]) {
+ if (shouldHide.has(uuid)) continue;
+ hidden.delete(uuid);
+ const object = objectOf(uuid);
+ if (object) object.visible = true;
+ }
+ }
+
+ // ---- the sweep ------------------------------------------------------------------
+ function sweep() {
+ const g = graphView();
+ const live = new Set();
+ for (const node of g.nodes) {
+ if (node.type !== 'health') continue;
+ live.add(node.id);
+ const firstSight = !state.has(node.id);
+ const s = derive(node, g);
+ state.set(node.id, s);
+ edges(s, firstSight);
+ respawnSweep(s);
+ }
+ visibilitySweep();
+ for (const id of [...state.keys()])
+ if (!live.has(id)) {
+ state.delete(id);
+ wasDead.delete(id);
+ lastHp.delete(id);
+ respawned.delete(id);
+ }
+ roundSweep();
+ wiredSweep(g);
+ proximitySweep(g);
+ for (const fn of listeners) fn();
+ }
+
+ let lastSweep = -1;
+ api.registerFrameTask(() => {
+ // a LOCAL schedule (performance.now, not the synced clock, which wraps at midnight)
+ const t = performance.now() / 1000;
+ if (t - lastSweep < SWEEP) return;
+ lastSweep = t;
+ try {
+ sweep();
+ } catch (error) {
+ console.warn('[health] sweep failed', error);
+ }
+ });
+
+ // ---- reads for the nodes, the HUD and the toolbox --------------------------------
+ /** every derived health, deterministic order */
+ function all() {
+ return [...state.values()].sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
+ }
+
+ /** the numbers for one name: sums over every health carrying it (a group bar), with
+ * `alive` counting the living @param {string} name */
+ function read(name) {
+ let hp = 0;
+ let max = 0;
+ let alive = 0;
+ let total = 0;
+ for (const s of state.values()) {
+ if (s.name !== name) continue;
+ total++;
+ hp += s.hp;
+ max += s.max;
+ if (!s.dead) alive++;
+ }
+ return { hp, max, alive, total, fraction: total ? fraction(hp, max) : 1 };
+ }
+
+ /** every name in use, sorted */
+ function names() {
+ return [...new Set([...state.values()].map((s) => s.name))].sort();
+ }
+
+ /** @param {() => void} fn */
+ function onChange(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ }
+
+ function clear() {
+ state.clear();
+ acted.clear();
+ wasDead.clear();
+ lastHp.clear();
+ respawned.clear();
+ hidden.clear();
+ inside.clear();
+ zoneTick.clear();
+ knocked.clear();
+ roundSeen = undefined;
+ }
+
+ return {
+ state,
+ stateOf: (/** @type {string} */ id) => state.get(id) ?? null,
+ all,
+ read,
+ names,
+ graphView,
+ hitObjects,
+ pulse,
+ reset,
+ emit,
+ sweep,
+ onChange,
+ clear,
+ myRow
+ };
+}
diff --git a/modules/health/src/graph.js b/modules/health/src/graph.js
new file mode 100644
index 0000000..2405c66
--- /dev/null
+++ b/modules/health/src/graph.js
@@ -0,0 +1,161 @@
+// health — WALKING THE GRAPH, pure over `api.flow.nodes()` / `api.flow.edges()` snapshots.
+//
+// The chain a damage source rides, as the recipe wires it and as a user may re-wire it:
+//
+// [any event]──trigger──▶ damage ──▶ counter.pulse ──▶ health.damage ◀──target── objectselector
+// ▲ ▲
+// healthreset ─────┘ (reset) heal ──▶ counter.pulse ──▶ health.heal
+//
+// A `damage` node hurts WHATEVER HEALTH IT FEEDS (through a counter, or wired straight in);
+// it never names a target itself. A `health` node names its object through its `target`
+// input (an Object Selector wired IN — a value, not an effect target, so the runtime never
+// pins the object's pose); a node in an object's own graph implicitly owns that object.
+
+const SCENE = 'scene';
+
+/** @param {any[]} nodes @param {any[]} edges */
+export function indexGraph(nodes, edges) {
+ /** @type {Map} */
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ /** @type {Map} */
+ const bySource = new Map();
+ /** @type {Map} */
+ const byTarget = new Map();
+ for (const edge of edges) {
+ if (!bySource.has(edge.source)) bySource.set(edge.source, []);
+ bySource.get(edge.source)?.push(edge);
+ if (!byTarget.has(edge.target)) byTarget.set(edge.target, []);
+ byTarget.get(edge.target)?.push(edge);
+ }
+ return { byId, bySource, byTarget, nodes, edges };
+}
+
+/** @typedef {ReturnType} GraphIndex */
+
+/** the object uuids a health node acts on: Object Selectors wired into `target` win, and
+ * a node in an object's own graph implicitly owns it
+ * @param {any} node @param {GraphIndex} g @returns {string[]} */
+export function targetsOf(node, g) {
+ /** @type {string[]} */
+ const out = [];
+ let wired = false;
+ for (const edge of g.byTarget.get(node.id) ?? []) {
+ if ((edge.targetHandle ?? null) !== 'target') continue;
+ const src = g.byId.get(edge.source);
+ if (src?.type !== 'objectselector') continue;
+ wired = true;
+ const selected = String(src.data?.selected ?? '');
+ if (selected && selected !== '-None-') out.push(selected);
+ }
+ if (!wired && node.graphId && node.graphId !== SCENE) out.push(node.graphId);
+ return out;
+}
+
+/** the node ids wired INTO `nodeId` on `handle`
+ * @param {string} nodeId @param {string} handle @param {GraphIndex} g @returns {string[]} */
+export function sourcesInto(nodeId, handle, g) {
+ return (g.byTarget.get(nodeId) ?? [])
+ .filter((e) => (e.targetHandle ?? null) === handle)
+ .map((e) => e.source);
+}
+
+/**
+ * Every health node a pulse from `node` reaches, and through which input: directly
+ * (`damage -> health.damage`) or through one counter (`damage -> counter.pulse ->
+ * health.damage`). `via` is the health input the chain lands on.
+ * @param {any} node @param {GraphIndex} g
+ * @returns {{health: any, via: 'damage'|'heal', counter: any|null}[]}
+ */
+export function chainsOf(node, g) {
+ /** @type {{health: any, via: 'damage'|'heal', counter: any|null}[]} */
+ const out = [];
+ const seen = new Set();
+ /** @param {any} health @param {string} via @param {any} counter */
+ const add = (health, via, counter) => {
+ const key = health.id + ':' + via + ':' + (counter?.id ?? '');
+ if (seen.has(key)) return;
+ seen.add(key);
+ out.push({ health, via: via === 'heal' ? 'heal' : 'damage', counter });
+ };
+ for (const edge of g.bySource.get(node.id) ?? []) {
+ const target = g.byId.get(edge.target);
+ if (!target) continue;
+ const handle = edge.targetHandle ?? null;
+ if (target.type === 'health' && (handle === 'damage' || handle === 'heal')) add(target, handle, null);
+ if (target.type === 'counter' && (handle === 'pulse' || handle === null)) {
+ for (const next of g.bySource.get(target.id) ?? []) {
+ const health = g.byId.get(next.target);
+ const h2 = next.targetHandle ?? null;
+ if (health?.type === 'health' && (h2 === 'damage' || h2 === 'heal')) add(health, h2, target);
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * What feeds a health node's `damage` and `heal` inputs — the counters (or any value
+ * node) whose values the ledger sums.
+ * @param {any} health @param {GraphIndex} g
+ * @returns {{damage: any[], heal: any[]}}
+ */
+export function feedersOf(health, g) {
+ const pick = (/** @type {string} */ handle) =>
+ sourcesInto(health.id, handle, g).map((id) => g.byId.get(id)).filter(Boolean);
+ return { damage: pick('damage'), heal: pick('heal') };
+}
+
+/** the stamp source a damage/heal node listens to: whatever is wired into its `trigger`
+ * @param {any} node @param {GraphIndex} g @returns {string[]} */
+export function triggerSourcesOf(node, g) {
+ return sourcesInto(node.id, 'trigger', g);
+}
+
+/** the object wired into a damage node's `zone` input (an Object Selector), or null
+ * @param {any} node @param {GraphIndex} g @returns {string|null} */
+export function zoneOf(node, g) {
+ for (const edge of g.byTarget.get(node.id) ?? []) {
+ if ((edge.targetHandle ?? null) !== 'zone') continue;
+ const src = g.byId.get(edge.source);
+ if (src?.type !== 'objectselector') continue;
+ const selected = String(src.data?.selected ?? '');
+ if (selected && selected !== '-None-') return selected;
+ }
+ return null;
+}
+
+/** @param {any} node @returns {string} */
+export const nameOf = (node) => String(node?.data?.name ?? '').trim() || 'hp';
+
+/**
+ * THE RECIPE for one object (or the local player when `uuid` is null), laid out on one
+ * row. Returned in `api.flow.addNodes` shape; indices in `edges` refer to `nodes`.
+ * @param {{uuid: string|null, row: number, health: Record, damage: Record}} spec
+ * @param {{col?: number, rowHeight?: number, x?: number, y?: number}=} layout
+ */
+export function recipe(spec, layout = {}) {
+ const COL = layout.col ?? 220;
+ const ROW = layout.rowHeight ?? 200;
+ const x0 = layout.x ?? 60;
+ const y = (layout.y ?? 40) + spec.row * ROW;
+ const name = String(spec.health.name ?? '').trim() || 'hp';
+ const health = { ...spec.health, name, scope: spec.uuid ? 'object' : 'player' };
+ /** @type {any[]} */
+ const nodes = [
+ { type: 'damage', x: x0, y, data: { ...spec.damage } },
+ { type: 'counter', x: x0 + COL, y, data: { op: 'up', step: 1 } },
+ { type: 'health', x: x0 + 2 * COL, y, data: health },
+ { type: 'healthreset', x: x0, y: y + 100, data: { name } }
+ ];
+ /** @type {any[]} */
+ const edges = [
+ { from: 0, to: 1, handle: 'pulse' },
+ { from: 1, to: 2, handle: 'damage' },
+ { from: 3, to: 1, handle: 'reset' }
+ ];
+ if (spec.uuid) {
+ nodes.push({ type: 'objectselector', x: x0 + 3 * COL, y, data: { selected: spec.uuid } });
+ edges.push({ from: 4, to: 2, handle: 'target' });
+ }
+ return { nodes, edges };
+}
diff --git a/modules/health/src/index.js b/modules/health/src/index.js
new file mode 100644
index 0000000..272aeb4
--- /dev/null
+++ b/modules/health/src/index.js
@@ -0,0 +1,95 @@
+// health — the module entry. Bundled to one self-contained module.js by esbuild
+// (npm run build:health); everything outside comes through `api`.
+//
+// The mechanic in one breath: a `health` node gives an object (or the local player) hit
+// points; `damage` and `heal` nodes fire pulses into a core Counter that feeds it; every
+// peer derives the number from the same counters (a late joiner reads the same count in
+// the trigger-log handshake), and a player's own points ride their own peerVars row.
+// Nobody is an authority, nothing is sent on a timer, and there is no registerStateSync:
+// every bit of state is already replicated. See src/ledger.js for the two models.
+
+import { createEngine } from './engine.js';
+import { registerNodes } from './nodes.js';
+import { registerToolbox } from './toolbox.js';
+import { DEFAULTS } from './ledger.js';
+
+export default {
+ id: 'health',
+ name: 'Health',
+ version: '1.0.0',
+ description:
+ 'Hit points for objects and players: damage and heal nodes, death and respawn, converging on every peer with no authority.',
+
+ /** @param {any} api the module SDK surface */
+ register(api) {
+ if (!api.flow?.addNodes || !api.game?.roundCutoff || !api.peerVars?.setMine || !api.registerValueNode) {
+ api.toast('Health needs a newer app build (the game SDK seams are missing)');
+ return;
+ }
+ const engine = createEngine(api);
+ registerNodes(api, engine);
+ const toolbox = registerToolbox(api, engine);
+
+ // a click on a damageable object: this peer alone saw it, so the pulse REPLICATES
+ // (the collectible's model). A pure observer — selection is untouched.
+ api.registerClickHandler((/** @type {any} */ object) => {
+ const chain = uuidChain(api, object);
+ if (chain.length) engine.hitObjects(chain, 'click', { local: false });
+ return false;
+ });
+
+ api.hud.registerDebugLine(() => {
+ const names = engine.names();
+ if (!names.length) return null;
+ return names
+ .map((name) => {
+ const r = engine.read(name);
+ return 'health (' + name + '): ' + Math.round(r.hp * 10) / 10 + '/' + r.max + (r.total > 1 ? ' · ' + r.alive + ' of ' + r.total + ' alive' : r.alive ? '' : ' · dead');
+ })
+ .join(' · ');
+ });
+ api.hud.registerAction({
+ key: 'showhealth',
+ label: 'Show health (bar)',
+ group: 'Data',
+ role: 'drives',
+ node: '',
+ via: { node: 'healthvalue', data: { name: DEFAULTS.name, read: 'fraction' }, handle: 'value' },
+ hint: '0..1 for a Bar (min 0, max 1). Derived from the health nodes, so every peer agrees.'
+ });
+ api.hud.registerAction({
+ key: 'showhp',
+ label: 'Show hit points',
+ group: 'Data',
+ role: 'drives',
+ node: '',
+ via: { node: 'healthvalue', data: { name: DEFAULTS.name, read: 'current' }, handle: 'value' },
+ hint: 'The current hit points as a number — a Text or an Icon Row.'
+ });
+
+ api.onSceneClear(() => engine.clear());
+
+ // test/debug hook (never serialized): the flight reads the derivation through this
+ if (typeof window !== 'undefined')
+ /** @type {any} */ (window).__health = {
+ api,
+ engine,
+ toolbox,
+ snapshot: () => engine.all().map((s) => ({ ...s }))
+ };
+ }
+};
+
+/** the clicked MESH up to the top-level object, as a chain of uuids (the collectible's)
+ * @param {any} api @param {any} object @returns {string[]} */
+function uuidChain(api, object) {
+ const root = api.objectsGroup();
+ /** @type {string[]} */
+ const out = [];
+ let current = object;
+ while (current && current !== root && out.length < 32) {
+ if (current.uuid) out.push(current.uuid);
+ current = current.parent;
+ }
+ return current === root ? out : [];
+}
diff --git a/modules/health/src/ledger.js b/modules/health/src/ledger.js
new file mode 100644
index 0000000..78f553b
--- /dev/null
+++ b/modules/health/src/ledger.js
@@ -0,0 +1,126 @@
+// health — THE LEDGER, pure. Every number a peer shows is derived here from replicated
+// inputs, so two peers holding the same inputs show the same number by construction.
+//
+// TWO OWNERSHIP MODELS, fork 2 of roadmap 29 (locked):
+// object hp = max - hits + heals, where `hits`/`heals` are core COUNTER values fed by
+// damage/heal pulses. A pulse is a trigger-log entry `(node id, stamp)` applied
+// exactly once per peer, and the count travels WITH the stamp in the DEVX #18
+// handshake, so a late joiner reads the same number. Nobody is an authority.
+// player my own hit points ride my own peerVars row (ONE writer per row): the row
+// holds the hp base at a stamp, and regen is a pure function of (base, stamp,
+// now) — nothing is sent on a timer.
+//
+// What this file does NOT know: nodes, peers, the clock. It is the part a node test can
+// break on purpose.
+
+/** the health node's defaults, shared by the node spec and the toolbox form */
+export const DEFAULTS = Object.freeze({
+ name: 'hp',
+ scope: 'object',
+ max: 5,
+ regen: 0,
+ deathAction: 'hide',
+ respawnDelay: 3
+});
+
+export const SCOPES = ['object', 'player'];
+export const DEATH_ACTIONS = ['hide', 'respawn', 'nothing'];
+export const DAMAGE_SOURCES = ['wired', 'click', 'touch', 'zone', 'hit'];
+export const SCALES = ['none', 'speed'];
+/** the most pulses one event may fire — the spawner's per-fire cap, same reasoning */
+export const MAX_PULSES = 20;
+/** the synced clock is seconds of day; a stamp this far "in the future" wrapped midnight */
+const HALF_DAY = 43200;
+const DAY = 86400;
+
+/** @param {any} n @param {number} lo @param {number} hi @param {number} fallback */
+export function clamp(n, lo, hi, fallback = lo) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return fallback;
+ return Math.min(hi, Math.max(lo, v));
+}
+
+/** `later - earlier` on the seconds-of-day clock, midnight-safe. @param {number} later @param {number} earlier */
+export function elapsed(later, earlier) {
+ let d = later - earlier;
+ if (d < -HALF_DAY) d += DAY;
+ return d;
+}
+
+/**
+ * An OBJECT's health from its counters. Heals count 1:1 against hits: a heal past full
+ * is REMEMBERED (a shield), because a count-only ledger cannot tell a heal that landed
+ * at full from one that did not — the plan's "negative stamp", stated honestly.
+ * @param {{max: any, hits: any, heals?: any}} input
+ * @returns {{hp: number, dead: boolean}}
+ */
+export function objectHp({ max, hits, heals }) {
+ const cap = clamp(max, 1, 1e6, DEFAULTS.max);
+ const taken = Math.max(0, (Number(hits) || 0) - (Number(heals) || 0));
+ const hp = clamp(cap - taken, 0, cap, cap);
+ return { hp, dead: hp <= 0 };
+}
+
+/**
+ * A PLAYER's health from their row: `base` is the hp written at `at`, and regen has
+ * been ticking since — read at `now`, never stored. A missing row (`base === null`) is a
+ * player nobody has hurt yet: full. A base of 0 is DEAD, and the dead do not regenerate;
+ * a respawn writes a fresh base.
+ * @param {{max: any, regen?: any, base: number|null|undefined, at?: number|null, now: number}} input
+ * @returns {{hp: number, dead: boolean}}
+ */
+export function playerHp({ max, regen, base, at, now }) {
+ const cap = clamp(max, 1, 1e6, DEFAULTS.max);
+ if (base === null || base === undefined || !Number.isFinite(Number(base))) return { hp: cap, dead: false };
+ const held = clamp(base, 0, cap, cap);
+ if (held <= 0) return { hp: 0, dead: true };
+ const rate = clamp(regen, 0, 1e6, 0);
+ const since = typeof at === 'number' ? Math.max(0, elapsed(now, at)) : 0;
+ const hp = Math.min(cap, held + rate * since);
+ return { hp, dead: false };
+}
+
+/**
+ * The next row after `delta` hit points land (negative = damage) on a player, on top of
+ * whatever regen had restored by `now`. The regen'd value is folded into the new base,
+ * which is what makes the stored pair `{base, at}` exact without a heal log.
+ * @param {{max: any, regen?: any, base: number|null|undefined, at?: number|null, now: number}} row
+ * @param {number} delta
+ * @returns {{base: number, at: number}}
+ */
+export function applyDelta(row, delta) {
+ const cap = clamp(row.max, 1, 1e6, DEFAULTS.max);
+ const { hp } = playerHp(row);
+ return { base: clamp(hp + (Number(delta) || 0), 0, cap, cap), at: row.now };
+}
+
+/**
+ * How many pulses one event fires. `amount` is hit points per event; `scale: 'speed'`
+ * multiplies by how hard the hit was relative to `speedRef` (a knock at twice the
+ * reference does double), floored at one point so a hit is never free.
+ * @param {{amount: any, scale?: string, speed?: any, speedRef?: any}} input
+ */
+export function pulsesFor({ amount, scale, speed, speedRef }) {
+ const base = clamp(amount, 0, MAX_PULSES, 1);
+ if (base <= 0) return 0;
+ if (scale !== 'speed') return Math.round(base);
+ const ref = clamp(speedRef, 0.1, 1e3, 3);
+ const factor = clamp(Number(speed) / ref, 0, 3, 1);
+ return Math.max(1, Math.min(MAX_PULSES, Math.round(base * factor)));
+}
+
+/** 0..1 for a HUD Bar. @param {number} hp @param {number} max */
+export function fraction(hp, max) {
+ const cap = clamp(max, 1, 1e6, DEFAULTS.max);
+ return clamp(hp / cap, 0, 1, 0);
+}
+
+/**
+ * Has the respawn moment passed? `deadAt` is the killing pulse's stamp (replicated, so
+ * every peer computes the same moment), `delay` the node's seconds.
+ * @param {number|null|undefined} deadAt @param {any} delay @param {number} now
+ */
+export function respawnDue(deadAt, delay, now) {
+ if (typeof deadAt !== 'number') return false;
+ return elapsed(now, deadAt) >= clamp(delay, 0, 3600, DEFAULTS.respawnDelay);
+}
diff --git a/modules/health/src/nodes.js b/modules/health/src/nodes.js
new file mode 100644
index 0000000..8c90d8b
--- /dev/null
+++ b/modules/health/src/nodes.js
@@ -0,0 +1,123 @@
+// health — THE NODE FAMILY (group "Health").
+//
+// health VALUE (its hp). Names its object through the `target` input (an Object
+// Selector wire; a node in an object's own graph implicitly owns it) and
+// owns the numbers: `max`, `scope`, `regen`, `deathAction`, `respawnDelay`.
+// Inputs `damage` / `heal` (numbers — wire the counters) and `respawnAt`
+// (an object: where a player comes back). NOT an effect, on purpose: the
+// runtime re-seats an effect target's base pose every frame (football's
+// "no node may target the ball"), and a damageable thing must be free to
+// move — an enemy walks, a crate gets knocked. So the engine hides a dead
+// object itself, in play only, and gives back exactly what it hid.
+// damage EVENT out. Fires hit points into whatever health it feeds. `source`
+// says what sets it off: a wired event (`trigger` in), a click, a touch,
+// a zone, a knock (H2); `amount` is points per event.
+// heal EVENT out, the same shape with the opposite sign.
+// healthreset EVENT out. Wire it into the counters' `reset`; the module fires it on
+// a new round and on a respawn — locally on every peer, same moment.
+// healthvalue VALUE. `current` / `max` / `fraction` / `alive` for a name — `fraction`
+// is what a HUD Bar wants, and it is why nobody has to wire a Math node.
+// healthevent EVENT out. `death` / `damage` / `respawn` / `reset` for a name, fired
+// locally on every peer from the same derived edge.
+
+import { DEFAULTS, SCOPES, DEATH_ACTIONS, DAMAGE_SOURCES, SCALES, MAX_PULSES } from './ledger.js';
+
+export const READS = ['fraction', 'current', 'max', 'alive'];
+export const EVENTS = ['death', 'damage', 'respawn', 'reset'];
+
+/** @param {any} api @param {ReturnType} engine */
+export function registerNodes(api, engine) {
+ api.registerNodeGroup({
+ group: 'Health',
+ items: [
+ {
+ type: 'health',
+ label: 'Health',
+ defaults: { ...DEFAULTS, target: '', damage: 0, heal: 0, respawnAt: '' },
+ params: [
+ { key: 'name', kind: 'text', placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: 'scope', kind: 'select', options: SCOPES },
+ { key: 'max', kind: 'range', min: 1, max: 100, step: 1 },
+ { key: 'regen', kind: 'range', min: 0, max: 10, step: 0.5 },
+ { key: 'deathAction', kind: 'select', options: DEATH_ACTIONS },
+ { key: 'respawnDelay', kind: 'range', min: 0, max: 30, step: 1 }
+ ]
+ },
+ {
+ type: 'damage',
+ label: 'Damage',
+ defaults: { amount: 1, source: 'wired', radius: 1.5, perSecond: 1, scale: 'none', speedRef: 3, trigger: 0, zone: '' },
+ params: [
+ { key: 'amount', kind: 'range', min: 1, max: MAX_PULSES, step: 1 },
+ { key: 'source', kind: 'select', options: DAMAGE_SOURCES },
+ { key: 'radius', kind: 'range', min: 0.5, max: 10, step: 0.5 },
+ { key: 'perSecond', kind: 'range', min: 0.5, max: 10, step: 0.5 },
+ { key: 'scale', kind: 'select', options: SCALES },
+ { key: 'speedRef', kind: 'range', min: 0.5, max: 10, step: 0.5 }
+ ]
+ },
+ {
+ type: 'heal',
+ label: 'Heal',
+ defaults: { amount: 1, trigger: 0 },
+ params: [{ key: 'amount', kind: 'range', min: 1, max: MAX_PULSES, step: 1 }]
+ },
+ {
+ type: 'healthreset',
+ label: 'Health Reset',
+ defaults: { name: DEFAULTS.name },
+ params: [{ key: 'name', kind: 'text', placeholder: DEFAULTS.name, maxLength: 40 }]
+ },
+ {
+ type: 'healthvalue',
+ label: 'Health Value',
+ defaults: { name: DEFAULTS.name, read: 'fraction' },
+ params: [
+ { key: 'name', kind: 'text', placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: 'read', kind: 'select', options: READS }
+ ]
+ },
+ {
+ type: 'healthevent',
+ label: 'Health Event',
+ defaults: { name: DEFAULTS.name, event: 'death' },
+ params: [
+ { key: 'name', kind: 'text', placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: 'event', kind: 'select', options: EVENTS }
+ ]
+ }
+ ]
+ });
+
+ // THE NUMBER: a health node reads as its own hp, so a card shows it live and a HUD
+ // can take it straight. The derivation is the engine's (one place), never a copy here.
+ api.registerValueNode(
+ 'health',
+ (/** @type {any} */ _data, /** @type {number} */ _time, /** @type {any} */ ctx) => engine.stateOf(ctx?.id)?.hp ?? 0,
+ { vtype: 'number', inputs: { target: 'object', damage: 'number', heal: 'number', respawnAt: 'object' } }
+ );
+
+ // the event nodes: their pulses are fired by the engine through api.fireNodeTrigger
+ api.registerValueNode('damage', () => 0, { vtype: 'event', inputs: { trigger: 'event', zone: 'object' } });
+ api.registerValueNode('heal', () => 0, { vtype: 'event', inputs: { trigger: 'event' } });
+ api.registerValueNode('healthreset', () => 0, { vtype: 'event' });
+ api.registerValueNode('healthevent', () => 0, { vtype: 'event' });
+
+ api.registerValueNode(
+ 'healthvalue',
+ (/** @type {any} */ data) => {
+ const r = engine.read(String(data?.name ?? '').trim() || DEFAULTS.name);
+ switch (String(data?.read ?? 'fraction')) {
+ case 'current':
+ return r.hp;
+ case 'max':
+ return r.max;
+ case 'alive':
+ return r.alive;
+ default:
+ return r.fraction;
+ }
+ },
+ { vtype: 'number' }
+ );
+}
diff --git a/modules/health/src/toolbox.js b/modules/health/src/toolbox.js
new file mode 100644
index 0000000..a6df94c
--- /dev/null
+++ b/modules/health/src/toolbox.js
@@ -0,0 +1,248 @@
+// health — THE MANAGER TOOLBOX (the collectible precedent): LOCAL always; what it CHANGES
+// goes through the replicated paths (api.flow.addNodes for the recipe, setNodeData for an
+// edit). One form builds a chain per selected object — or one for the local player —
+// and a live list shows every health in the scene with its number and state.
+
+import { DEFAULTS, SCOPES, DEATH_ACTIONS, DAMAGE_SOURCES, MAX_PULSES } from './ledger.js';
+import { recipe, targetsOf } from './graph.js';
+
+const SELECT_CSS =
+ 'background:rgba(0,0,0,0.25);color:inherit;border:1px solid rgba(255,255,255,0.15);' +
+ 'border-radius:4px;font-size:11px;padding:1px 3px;';
+const INPUT_CSS = SELECT_CSS + 'width:100%;';
+
+/** @param {string} tag @param {Record=} props @param {string=} css */
+function elem(tag, props, css) {
+ const node = document.createElement(tag);
+ Object.assign(node, props ?? {});
+ if (css) node.setAttribute('style', css);
+ return node;
+}
+
+/** @param {string[]} options @param {string} value */
+function select(options, value) {
+ const node = /** @type {HTMLSelectElement} */ (elem('select', {}, SELECT_CSS));
+ for (const option of options) node.appendChild(elem('option', { value: option, textContent: option }));
+ node.value = value;
+ return node;
+}
+
+/** @param {any} api @param {ReturnType} engine */
+export function registerToolbox(api, engine) {
+ /**
+ * THE RECIPE. One chain per selected object (skipping objects that already have a
+ * health), each through api.flow.addNodes — replicated node by node, ONE undo entry
+ * per object, an ordinary graph afterwards.
+ * @param {{health: Record, damage: Record}} options
+ */
+ function makeDamageable(options) {
+ const uuids = api.selectedUuids();
+ if (!uuids.length) {
+ api.toast('Select an object first, then make it damageable');
+ return { built: 0, skipped: 0 };
+ }
+ const g = engine.graphView();
+ const taken = new Set();
+ let row = 0;
+ for (const node of g.nodes) {
+ if (node.type !== 'health') continue;
+ row++;
+ for (const uuid of targetsOf(node, g)) taken.add(uuid);
+ }
+ let built = 0;
+ let skipped = 0;
+ for (const uuid of uuids) {
+ if (taken.has(uuid)) {
+ skipped++;
+ continue;
+ }
+ api.flow.addNodes(recipe({ uuid, row, health: options.health, damage: options.damage }));
+ taken.add(uuid);
+ row++;
+ built++;
+ }
+ api.toast(
+ built
+ ? built + ' object' + (built === 1 ? '' : 's') + ' now damageable as "' + options.health.name + '"' +
+ (skipped ? ' (' + skipped + ' already had health)' : '')
+ : 'Already damageable — nothing to add'
+ );
+ return { built, skipped };
+ }
+
+ /** ONE player health per name — the local player's own row
+ * @param {{health: Record, damage: Record}} options */
+ function makePlayerHealth(options) {
+ const g = engine.graphView();
+ const name = String(options.health.name ?? '').trim() || DEFAULTS.name;
+ let row = 0;
+ for (const node of g.nodes) {
+ if (node.type !== 'health') continue;
+ row++;
+ if (node.data?.scope === 'player' && (String(node.data?.name ?? '').trim() || DEFAULTS.name) === name) {
+ api.toast('Player health "' + name + '" already exists');
+ return { built: 0, skipped: 1 };
+ }
+ }
+ api.flow.addNodes(recipe({ uuid: null, row, health: options.health, damage: options.damage }));
+ api.toast('Player health "' + name + '" added');
+ return { built: 1, skipped: 0 };
+ }
+
+ /** @param {HTMLElement} el */
+ function mount(el) {
+ el.textContent = '';
+ el.classList.add('health-manager');
+ el.appendChild(
+ elem('style', {
+ textContent:
+ '.health-manager{display:flex;flex-direction:column;gap:8px;min-width:230px;font-size:12px}' +
+ '.hm-form{display:grid;grid-template-columns:auto 1fr;gap:4px 6px;align-items:center}' +
+ '.hm-form label{opacity:0.75;font-size:11px}' +
+ '.hm-buttons{display:flex;gap:6px}' +
+ '.hm-row{display:flex;align-items:center;gap:6px;padding:3px 5px;border-radius:5px;' +
+ 'background:rgba(255,255,255,0.04);cursor:pointer;margin-bottom:2px}' +
+ '.hm-row:hover{background:rgba(255,255,255,0.09)}' +
+ '.hm-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
+ '.hm-hp{flex:0 0 auto;font-variant-numeric:tabular-nums}' +
+ '.hm-status{flex:0 0 auto;font-size:10px;opacity:0.7;white-space:nowrap}' +
+ '.hm-bar{flex:0 0 46px;height:6px;border-radius:3px;background:rgba(255,255,255,0.12);overflow:hidden}' +
+ '.hm-bar i{display:block;height:100%;background:#6fcf7a}' +
+ '.hm-empty{opacity:0.6;font-size:11px}'
+ })
+ );
+
+ el.appendChild(elem('div', { className: 'tbx-label', textContent: 'Give the selection health' }));
+ const form = elem('div', { className: 'hm-form' });
+ const name = /** @type {HTMLInputElement} */ (elem('input', { type: 'text', value: DEFAULTS.name, maxLength: 40 }, INPUT_CSS));
+ const max = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '1', max: '100', step: '1', value: String(DEFAULTS.max) }, INPUT_CSS));
+ const regen = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '0', max: '10', step: '0.5', value: '0' }, INPUT_CSS));
+ const deathAction = select(DEATH_ACTIONS, DEFAULTS.deathAction);
+ const respawnDelay = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '0', max: '30', step: '1', value: String(DEFAULTS.respawnDelay) }, INPUT_CSS));
+ const source = select(DAMAGE_SOURCES, 'click');
+ const amount = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '1', max: String(MAX_PULSES), step: '1', value: '1' }, INPUT_CSS));
+ const radius = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '0.5', max: '10', step: '0.5', value: '1.5' }, INPUT_CSS));
+ radius.title = 'For touch and zone: how close the player has to be';
+ for (const [label, control] of [
+ ['Name', name],
+ ['Max hp', max],
+ ['Regen /s', regen],
+ ['On death', deathAction],
+ ['Respawn (s)', respawnDelay],
+ ['Damage from', source],
+ ['Damage', amount],
+ ['Radius', radius]
+ ]) {
+ form.appendChild(elem('label', { textContent: label }));
+ form.appendChild(/** @type {any} */ (control));
+ }
+ el.appendChild(form);
+ const options = () => ({
+ health: {
+ name: name.value.trim() || DEFAULTS.name,
+ max: Math.max(1, Math.min(100, Number(max.value) || DEFAULTS.max)),
+ regen: Math.max(0, Math.min(10, Number(regen.value) || 0)),
+ deathAction: deathAction.value,
+ respawnDelay: Math.max(0, Math.min(30, Number(respawnDelay.value) || 0))
+ },
+ damage: {
+ amount: Math.max(1, Math.min(MAX_PULSES, Number(amount.value) || 1)),
+ source: source.value,
+ radius: Math.max(0.5, Math.min(10, Number(radius.value) || 1.5))
+ }
+ });
+ const buttons = elem('div', { className: 'hm-buttons' });
+ const buildObjects = elem('button', { className: 'tbx-btn tbx-primary', textContent: 'Make damageable' });
+ buildObjects.addEventListener('click', () => {
+ makeDamageable(options());
+ refresh(true);
+ });
+ const buildPlayer = elem('button', { className: 'tbx-btn', textContent: 'Player health' });
+ buildPlayer.title = 'One health for the local player, on their own peer row';
+ buildPlayer.addEventListener('click', () => {
+ makePlayerHealth(options());
+ refresh(true);
+ });
+ buttons.appendChild(buildObjects);
+ buttons.appendChild(buildPlayer);
+ el.appendChild(buttons);
+
+ const list = elem('div', {});
+ el.appendChild(list);
+ let signature = '';
+ /** @type {Map} */
+ const rows = new Map();
+
+ /** @param {any} s */
+ function statusText(s) {
+ if (s.scope === 'object' && s.uuid && !api.objectsGroup()?.getObjectByProperty('uuid', s.uuid)) return 'missing';
+ if (!s.dead) return s.hp >= s.max ? 'full' : 'hurt';
+ if (s.deathAction === 'respawn' && typeof s.deadAt === 'number') {
+ const left = Math.max(0, s.respawnDelay - (api.now() - s.deadAt));
+ return 'back in ' + Math.max(1, Math.ceil(left)) + 's';
+ }
+ return 'dead';
+ }
+
+ /** @param {any[]} model */
+ function rebuild(model) {
+ list.textContent = '';
+ rows.clear();
+ if (!model.length) {
+ list.appendChild(elem('div', { className: 'hm-empty', textContent: 'No health yet. Select an object and press the button.' }));
+ return;
+ }
+ for (const s of model) {
+ const row = elem('div', { className: 'hm-row' });
+ row.dataset.node = s.id;
+ row.addEventListener('click', () => {
+ if (s.uuid) api.selectObject(s.uuid);
+ });
+ row.appendChild(elem('div', { className: 'hm-name', textContent: s.label + ' · ' + s.name }));
+ const bar = elem('div', { className: 'hm-bar' });
+ const fill = elem('i', {});
+ bar.appendChild(fill);
+ row.appendChild(bar);
+ const hp = elem('div', { className: 'hm-hp', textContent: '' });
+ const status = elem('div', { className: 'hm-status', textContent: '' });
+ row.appendChild(hp);
+ row.appendChild(status);
+ rows.set(s.id, { hp, status, bar: fill });
+ list.appendChild(row);
+ }
+ }
+
+ /** @param {boolean=} force */
+ function refresh(force) {
+ const model = engine.all();
+ const next = JSON.stringify(model.map((s) => [s.id, s.label, s.name, s.scope, s.deathAction]));
+ if (force || next !== signature) {
+ signature = next;
+ rebuild(model);
+ }
+ for (const s of model) {
+ const r = rows.get(s.id);
+ if (!r) continue;
+ r.hp.textContent = Math.round(s.hp * 10) / 10 + ' / ' + s.max;
+ r.status.textContent = statusText(s);
+ r.bar.style.width = Math.round((s.max ? s.hp / s.max : 0) * 100) + '%';
+ r.bar.style.background = s.dead ? '#c94a4a' : s.hp / s.max < 0.35 ? '#e0a23b' : '#6fcf7a';
+ }
+ }
+
+ refresh(true);
+ const timer = setInterval(refresh, 500);
+ return () => clearInterval(timer);
+ }
+
+ const toolbox = api.registerToolbox({
+ id: 'manager',
+ title: 'Health',
+ width: 270,
+ minW: 240,
+ sidebar: false,
+ mount
+ });
+ api.registerMenu('Open Health', () => api.openToolbox(toolbox));
+ return { toolbox, makeDamageable, makePlayerHealth };
+}
diff --git a/modules/health/test/engine.test.mjs b/modules/health/test/engine.test.mjs
new file mode 100644
index 0000000..6ae4fd8
--- /dev/null
+++ b/modules/health/test/engine.test.mjs
@@ -0,0 +1,278 @@
+// the engine against a FAKE api — the sweep's derivation, sources and edges without a
+// browser. Everything the engine touches on `api` is a tiny stand-in here.
+import { createEngine } from '../src/engine.js';
+import { recipe } from '../src/graph.js';
+
+class V3 {
+ constructor(x = 0, y = 0, z = 0) {
+ this.x = x;
+ this.y = y;
+ this.z = z;
+ }
+ distanceTo(o) {
+ return Math.hypot(this.x - o.x, this.y - o.y, this.z - o.z);
+ }
+ toArray() {
+ return [this.x, this.y, this.z];
+ }
+}
+
+/** a scene + graph + trigger log the engine can drive */
+function fakeApi() {
+ const nodes = [];
+ const edges = [];
+ const triggers = new Map(); // node id -> {count, lastT}
+ const rows = new Map();
+ const objects = new Map(); // uuid -> {position}
+ const fired = [];
+ let clock = 100;
+ let playing = true;
+ let player = [0, 0, 0];
+ let frame = null;
+ let nextId = 1;
+ const api = {
+ now: () => clock,
+ isPlaying: () => playing,
+ playerPosition: () => player.slice(),
+ objectsGroup: () => ({
+ getObjectByProperty: (_k, uuid) => {
+ const o = objects.get(uuid);
+ return o ? o.handle : null;
+ }
+ }),
+ THREE: { Vector3: V3 },
+ flow: {
+ nodes: () => nodes.map((n) => ({ ...n, data: { ...n.data } })),
+ edges: () => edges.map((e) => ({ ...e })),
+ nodeValue: (id) => (nodes.find((n) => n.id === id)?.type === 'counter' ? triggers.get(id)?.count ?? 0 : undefined),
+ triggerStamp: (id) => (triggers.has(id) ? { stamp: triggers.get(id).lastT, age: clock - triggers.get(id).lastT } : null),
+ addNodes: (spec) => {
+ const ids = spec.nodes.map((n) => {
+ const id = 'n' + nextId++;
+ nodes.push({ id, type: n.type, graphId: 'scene', data: { ...(n.data ?? {}) } });
+ return id;
+ });
+ for (const e of spec.edges ?? []) {
+ const source = typeof e.from === 'number' ? ids[e.from] : e.from;
+ const target = typeof e.to === 'number' ? ids[e.to] : e.to;
+ edges.push({ id: 'e-' + source + '-' + target + (e.handle ? '.' + e.handle : ''), source, target, targetHandle: e.handle ?? null, sourceHandle: null, graphId: 'scene' });
+ }
+ return ids;
+ },
+ setNodeData: (id, patch) => {
+ const n = nodes.find((x) => x.id === id);
+ if (n) Object.assign(n.data, patch);
+ return !!n;
+ }
+ },
+ // core's applyNodeTrigger in miniature: stamp the node, bump downstream counters
+ fireNodeTrigger: (type, match, opts) => {
+ for (const n of nodes) {
+ if (n.type !== type || (match && !match(n.data, n.id))) continue;
+ fired.push({ id: n.id, type, replicate: opts?.replicate !== false });
+ triggers.set(n.id, { count: triggers.get(n.id)?.count ?? 0, lastT: clock });
+ for (const e of edges) {
+ if (e.source !== n.id) continue;
+ const t = nodes.find((x) => x.id === e.target);
+ if (t?.type !== 'counter') continue;
+ if (e.targetHandle === 'reset') triggers.set(t.id, { count: 0, lastT: clock });
+ else triggers.set(t.id, { count: (triggers.get(t.id)?.count ?? 0) + 1, lastT: clock });
+ }
+ }
+ },
+ peerVars: {
+ mine: (name, fallback) => (rows.has(name) ? rows.get(name) : fallback),
+ setMine: (name, v) => rows.set(name, v),
+ all: () => []
+ },
+ game: { roundCutoff: () => round },
+ registerFrameTask: (fn) => (frame = fn),
+ flyTo: (pos) => flights.push(pos),
+ onHit: (fn) => (hitListener = fn)
+ };
+ let round = null;
+ const flights = [];
+ let hitListener = null;
+ return {
+ api,
+ nodes,
+ edges,
+ triggers,
+ rows,
+ fired,
+ flights,
+ addObject: (uuid, position, name = uuid) => {
+ const o = { position, name, handle: { name, visible: true, getWorldPosition: () => new V3(...o.position) } };
+ objects.set(uuid, o);
+ },
+ moveObject: (uuid, position) => (objects.get(uuid).position = position),
+ visible: (uuid) => objects.get(uuid)?.handle.visible,
+ tick: (dt = 0.2) => {
+ clock += dt;
+ frame?.(clock);
+ },
+ setPlayer: (p) => (player = p),
+ setPlaying: (v) => (playing = v),
+ setRound: (v) => (round = v),
+ stamp: (id) => triggers.set(id, { count: 0, lastT: clock }),
+ knock: (hit) => hitListener?.(hit),
+ clock: () => clock
+ };
+}
+
+// the engine throttles on performance.now(); a fake clock that always advances
+let perf = 0;
+globalThis.performance = { now: () => (perf += 200) };
+
+/** @param {(ok: boolean, label: string) => void} check */
+export function run(check) {
+ // ---- an object chain: click, wired, first sight --------------------------------------
+ {
+ const f = fakeApi();
+ f.addObject('crate', [0, 0, 0], 'Crate');
+ const engine = createEngine(f.api);
+ const ids = f.api.flow.addNodes(recipe({ uuid: 'crate', row: 0, health: { name: 'hp', max: 3 }, damage: { amount: 1, source: 'click' } }));
+ f.tick();
+ const s0 = engine.stateOf(ids[2]);
+ check(s0?.hp === 3 && !s0.dead && s0.uuid === 'crate' && s0.label === 'Crate', 'a fresh chain derives 3/3 on its object');
+ engine.hitObjects(['crate'], 'click', { local: false });
+ f.tick();
+ check(f.fired.length === 1 && f.fired[0].replicate === true, 'a click fires ONE replicated pulse');
+ check(engine.stateOf(ids[2])?.hp === 2, 'and the counter takes it to 2/3');
+ engine.hitObjects(['crate'], 'click', { local: false });
+ f.tick(); // a sweep between clicks, as in the app (the credit reads the last sweep's number)
+ engine.hitObjects(['crate'], 'click', { local: false });
+ f.tick();
+ check(engine.stateOf(ids[2])?.dead === true && engine.stateOf(ids[2])?.hp === 0, 'three hits: dead');
+ check(f.rows.get('kills') === 1, 'the killing click credits ONE kill on this peer\'s own row');
+ check(f.visible('crate') === false, 'and hidden (deathAction hide, in play)');
+ f.setPlaying(false);
+ f.tick();
+ check(f.visible('crate') === true, 'out of play the object is given back (the module hid it, the module restores it)');
+ f.setPlaying(true);
+ f.tick();
+ check(f.visible('crate') === false, 'back in play: hidden again');
+ const before = f.fired.length;
+ engine.hitObjects(['crate'], 'click', { local: false });
+ check(f.fired.length === before, 'COUNTERFACTUAL guard: a hit on the dead fires nothing');
+ // a wired source: a stamp already there at first sight is history
+ const [key] = f.api.flow.addNodes({ nodes: [{ type: 'keypress', x: 0, y: 0, data: {} }], edges: [{ from: 0, to: ids[0], handle: 'trigger' }] });
+ f.api.flow.setNodeData(ids[0], { source: 'wired' });
+ f.stamp(key);
+ f.fired.length = 0;
+ f.tick();
+ f.tick();
+ check(f.fired.length === 0, 'FIRST SIGHT: a stamp present when the node was first seen fires nothing');
+ f.api.fireNodeTrigger('healthreset', (d) => d.name === 'hp', { replicate: false });
+ f.tick();
+ check(engine.stateOf(ids[2])?.hp === 3, 'a reset pulse zeroes the counter: 3/3');
+ check(f.visible('crate') === true, 'and the object is back');
+ f.fired.length = 0;
+ f.tick();
+ f.stamp(key);
+ f.tick();
+ check(f.fired.length === 1 && f.fired[0].id === ids[0] && f.fired[0].replicate === false, 'a NEW stamp on the wired source fires one LOCAL pulse');
+ f.tick();
+ check(engine.stateOf(ids[2])?.hp === 2, 'which the counter counts (2/3)');
+ }
+
+ // ---- the round rule --------------------------------------------------------------------
+ {
+ const f = fakeApi();
+ f.addObject('crate', [0, 0, 0]);
+ const engine = createEngine(f.api);
+ const ids = f.api.flow.addNodes(recipe({ uuid: 'crate', row: 0, health: { name: 'hp', max: 3 }, damage: { amount: 1, source: 'click' } }));
+ f.tick();
+ engine.hitObjects(['crate'], 'click', { local: false });
+ f.tick();
+ check(engine.stateOf(ids[2])?.hp === 2, 'premise: 2/3');
+ f.setRound(500); // a joiner sees a round already under way
+ f.tick();
+ check(engine.stateOf(ids[2])?.hp === 2, 'THE JOINER RULE: the first sight of a round is a seed, nothing resets');
+ f.setRound(Infinity); // back to the menu
+ f.tick();
+ f.setRound(600); // a NEW round
+ f.tick();
+ f.tick();
+ check(engine.stateOf(ids[2])?.hp === 3, 'a witnessed new round resets to 3/3');
+ }
+
+ // ---- touch on an object, zone on the player, respawn -------------------------------------
+ {
+ const f = fakeApi();
+ f.addObject('lava', [10, 0, 0], 'Lava');
+ f.addObject('crate', [5, 0, 0], 'Crate');
+ f.addObject('home', [40, 0, 40], 'Home');
+ const engine = createEngine(f.api);
+ const crate = f.api.flow.addNodes(recipe({ uuid: 'crate', row: 0, health: { name: 'hp', max: 3 }, damage: { amount: 1, source: 'touch', radius: 2 } }));
+ const me = f.api.flow.addNodes(recipe({ uuid: null, row: 1, health: { name: 'me', max: 5, deathAction: 'respawn', respawnDelay: 2 }, damage: { amount: 1, source: 'wired' } }));
+ const zone = f.api.flow.addNodes({
+ nodes: [
+ { type: 'damage', x: 0, y: 0, data: { source: 'zone', amount: 1, perSecond: 2, radius: 2 } },
+ { type: 'objectselector', x: 0, y: 0, data: { selected: 'lava' } },
+ { type: 'counter', x: 0, y: 0, data: {} },
+ { type: 'objectselector', x: 0, y: 0, data: { selected: 'home' } }
+ ],
+ edges: [{ from: 1, to: 0, handle: 'zone' }, { from: 0, to: 2, handle: 'pulse' }, { from: 2, to: me[2], handle: 'damage' }, { from: 3, to: me[2], handle: 'respawnAt' }]
+ });
+ f.tick();
+ check(engine.stateOf(me[2])?.hp === 5 && engine.stateOf(crate[2])?.hp === 3, 'premise: player 5/5, crate 3/3');
+ f.setPlayer([5, 0.5, 0]);
+ f.tick();
+ f.tick();
+ check(engine.stateOf(crate[2])?.hp === 2 && f.fired.at(-1)?.replicate === true, 'walking into the crate: one REPLICATED touch pulse, 2/3');
+ f.tick();
+ f.tick();
+ check(engine.stateOf(crate[2])?.hp === 2, 'standing there does not keep hurting it (an edge)');
+ f.setPlayer([0, 0, 0]);
+ f.tick();
+ f.setPlayer([5, 0.5, 0]);
+ f.tick();
+ f.tick();
+ check(engine.stateOf(crate[2])?.hp === 1, 'out and back in: a second touch, 1/3');
+ f.setPlayer([10, 0.5, 0]); // into the lava
+ f.fired.length = 0;
+ f.tick(0.6);
+ f.tick(0.1);
+ check(engine.stateOf(me[2])?.hp === 4 && f.fired.length === 1 && f.fired[0].replicate === false, 'in the zone: one LOCAL pulse and the row drops to 4 (' + JSON.stringify(f.fired) + ' hp ' + engine.stateOf(me[2])?.hp + ')');
+ check(f.rows.get('me') === 4 && typeof f.rows.get('me.at') === 'number', 'the row holds {base 4, at}');
+ let died = false;
+ for (let i = 0; i < 12 && !died; i++) {
+ f.tick(0.3); // the fake's performance clock is coarse: ~every other sweep ticks the zone
+ died = engine.stateOf(me[2])?.dead === true;
+ }
+ check(died, 'staying kills the player (hp ' + engine.stateOf(me[2])?.hp + ')');
+ f.setPlaying(false); // out of play: the zone stops, and the respawn is on the clock
+ f.tick(1);
+ f.tick(1.5);
+ f.tick(0.1);
+ const s = engine.stateOf(me[2]);
+ check(s && !s.dead && s.hp === 5, 'respawn after the delay: 5/5');
+ check(f.flights.length === 1 && f.flights[0][0] === 40 && f.flights[0][2] === 40, 'and the camera flew to the respawnAt object');
+ void zone;
+ }
+
+ // ---- the knock feed ---------------------------------------------------------------------
+ {
+ const f = fakeApi();
+ f.addObject('crate', [0, 0, 0]);
+ const engine = createEngine(f.api);
+ const ids = f.api.flow.addNodes(recipe({ uuid: 'crate', row: 0, health: { name: 'hp', max: 5 }, damage: { amount: 1, source: 'hit', scale: 'speed', speedRef: 3 } }));
+ f.tick();
+ f.knock({ uuid: 'crate', speed: 6, at: 1, local: false });
+ f.tick();
+ check(engine.stateOf(ids[2])?.hp === 3 && f.fired.length === 2 && f.fired.every((x) => !x.replicate), 'a 6 m/s knock at ref 3 fires TWO local pulses: 3/5');
+ f.knock({ uuid: 'crate', speed: 6, at: 1, local: false });
+ f.tick();
+ check(engine.stateOf(ids[2])?.hp === 3, 'the same knock stamp again is dropped');
+ f.knock({ uuid: 'other', speed: 6, at: 2, local: false });
+ f.tick();
+ check(engine.stateOf(ids[2])?.hp === 3, 'a knock on another object is not ours');
+ f.knock({ uuid: 'crate', speed: 9, at: 3, local: true });
+ f.tick();
+ check(engine.stateOf(ids[2])?.dead === true && f.rows.get('kills') === 1, 'my own hand\'s killing knock credits my kills row');
+ f.knock({ uuid: 'crate', speed: 9, at: 4, local: false });
+ f.tick();
+ check(f.rows.get('kills') === 1, 'COUNTERFACTUAL: a knock by another hand credits nothing here');
+ }
+}
diff --git a/modules/health/test/graph.test.mjs b/modules/health/test/graph.test.mjs
new file mode 100644
index 0000000..cc5f014
--- /dev/null
+++ b/modules/health/test/graph.test.mjs
@@ -0,0 +1,68 @@
+// the graph walk over api.flow snapshots, and the recipe's shape — no app
+import { indexGraph, targetsOf, chainsOf, feedersOf, triggerSourcesOf, zoneOf, recipe, nameOf } from '../src/graph.js';
+
+/** @param {(ok: boolean, label: string) => void} check */
+export function run(check) {
+ const n = (id, type, data = {}, graphId = 'scene') => ({ id, type, graphId, data });
+ const e = (source, target, targetHandle = null) => ({ id: 'e-' + source + '-' + target + (targetHandle ? '.' + targetHandle : ''), source, target, targetHandle });
+
+ // the recipe's chain: click -> damage -> counter.pulse -> health.damage -> selector
+ const nodes = [
+ n('dmg', 'damage', { amount: 2, source: 'click' }),
+ n('cnt', 'counter', { op: 'up', step: 1 }),
+ n('hp', 'health', { name: 'hp', scope: 'object' }),
+ n('sel', 'objectselector', { selected: 'crate-uuid' }),
+ n('rst', 'healthreset', { name: 'hp' }),
+ n('heal', 'heal', { amount: 1 }),
+ n('hcnt', 'counter', {}),
+ n('click', 'onclick', {}),
+ n('direct', 'damage', { amount: 1 }),
+ n('owner', 'health', { name: 'owner' }, 'owner-uuid'),
+ n('zdmg', 'damage', { source: 'zone' }),
+ n('zsel', 'objectselector', { selected: 'lava-uuid' })
+ ];
+ const edges = [
+ e('dmg', 'cnt', 'pulse'),
+ e('cnt', 'hp', 'damage'),
+ e('sel', 'hp', 'target'),
+ e('rst', 'cnt', 'reset'),
+ e('heal', 'hcnt', 'pulse'),
+ e('hcnt', 'hp', 'heal'),
+ e('click', 'dmg', 'trigger'),
+ e('direct', 'hp', 'damage'),
+ e('zsel', 'zdmg', 'zone')
+ ];
+ const g = indexGraph(nodes, edges);
+
+ check(targetsOf(g.byId.get('hp'), g).join() === 'crate-uuid', 'a health targets the object wired into `target`');
+ check(targetsOf(g.byId.get('owner'), g).join() === 'owner-uuid', 'a health in an object graph implicitly targets its owner');
+ check(targetsOf(g.byId.get('direct'), g).length === 0, 'a node with neither has no target');
+
+ const chains = chainsOf(g.byId.get('dmg'), g);
+ check(chains.length === 1 && chains[0].health.id === 'hp' && chains[0].via === 'damage' && chains[0].counter?.id === 'cnt', 'damage -> counter -> health.damage resolves to the health, via damage, through the counter');
+ const healChains = chainsOf(g.byId.get('heal'), g);
+ check(healChains.length === 1 && healChains[0].via === 'heal', 'heal -> counter -> health.heal lands on the heal input');
+ const directChains = chainsOf(g.byId.get('direct'), g);
+ check(directChains.length === 1 && directChains[0].counter === null, 'damage wired straight into health.damage resolves with no counter');
+ check(chainsOf(g.byId.get('rst'), g).length === 0, 'COUNTERFACTUAL: a reset wire is not a damage chain (it lands on counter.reset)');
+
+ const feeders = feedersOf(g.byId.get('hp'), g);
+ check(feeders.damage.map((f) => f.id).sort().join() === 'cnt,direct' && feeders.heal.map((f) => f.id).join() === 'hcnt', 'a health knows what feeds each input');
+ check(triggerSourcesOf(g.byId.get('dmg'), g).join() === 'click', 'a damage node knows the event wired into its trigger');
+ check(zoneOf(g.byId.get('zdmg'), g) === 'lava-uuid', 'a zone damage node reads the object wired into `zone`');
+ check(zoneOf(g.byId.get('dmg'), g) === null, 'COUNTERFACTUAL: a node with no zone wire has no zone');
+ check(nameOf({ data: { name: ' hp ' } }) === 'hp' && nameOf({ data: {} }) === 'hp', 'names trim and default');
+
+ // ---- the recipe ----------------------------------------------------------------------
+ const r = recipe({ uuid: 'u1', row: 2, health: { name: 'enemy', max: 3, deathAction: 'respawn' }, damage: { amount: 2, source: 'click' } });
+ check(r.nodes.map((x) => x.type).join() === 'damage,counter,health,healthreset,objectselector', 'object recipe: damage, counter, health, reset, selector');
+ check(r.nodes[2].data.scope === 'object' && r.nodes[2].data.name === 'enemy' && !('whilePlaying' in r.nodes[2].data), 'the health carries the form (and is no effect: no whilePlaying)');
+ check(r.nodes[3].data.name === 'enemy', 'the reset node carries the same name');
+ check(r.nodes[4].data.selected === 'u1', 'the selector names the object');
+ check(JSON.stringify(r.edges) === JSON.stringify([{ from: 0, to: 1, handle: 'pulse' }, { from: 1, to: 2, handle: 'damage' }, { from: 3, to: 1, handle: 'reset' }, { from: 4, to: 2, handle: 'target' }]), 'the wires: pulse, damage, reset, selector -> health.target');
+ check(r.nodes[0].y === 40 + 2 * 200 && r.nodes[3].y === r.nodes[0].y + 100, 'row 2 sits two rows down, the reset under the damage');
+ const p = recipe({ uuid: null, row: 0, health: { name: 'hp' }, damage: { amount: 1, source: 'wired' } });
+ check(p.nodes.length === 4 && p.nodes[2].data.scope === 'player' && p.edges.length === 3, 'player recipe: no selector, scope player');
+ const twice = JSON.stringify(recipe({ uuid: 'u1', row: 0, health: {}, damage: {} }));
+ check(twice === JSON.stringify(recipe({ uuid: 'u1', row: 0, health: {}, damage: {} })), 'the recipe is deterministic');
+}
diff --git a/modules/health/test/ledger.test.mjs b/modules/health/test/ledger.test.mjs
new file mode 100644
index 0000000..051fa5f
--- /dev/null
+++ b/modules/health/test/ledger.test.mjs
@@ -0,0 +1,61 @@
+// the pure ledger, with a counterfactual per rule — run WITHOUT the app (npm run test:health)
+import { objectHp, playerHp, applyDelta, pulsesFor, fraction, respawnDue, elapsed, MAX_PULSES } from '../src/ledger.js';
+
+/** @param {(ok: boolean, label: string) => void} check */
+export function run(check) {
+ // ---- object health from counters -------------------------------------------------
+ check(objectHp({ max: 5, hits: 0 }).hp === 5, 'untouched: hp = max');
+ check(objectHp({ max: 5, hits: 3 }).hp === 2 && !objectHp({ max: 5, hits: 3 }).dead, 'three hits on five: 2, alive');
+ check(objectHp({ max: 5, hits: 5 }).dead, 'five hits on five: dead');
+ check(objectHp({ max: 5, hits: 9 }).hp === 0, 'overkill clamps at 0, never negative');
+ check(objectHp({ max: 5, hits: 4, heals: 2 }).hp === 3, 'a heal counts against hits (4 - 2 on 5 = 3)');
+ check(objectHp({ max: 5, hits: 1, heals: 4 }).hp === 5, 'a heal never goes past max');
+ // THE SAME INPUTS GIVE THE SAME NUMBER — that is the whole convergence argument. The
+ // counterfactual is the shared-add model: two peers each computing hp-1 from their
+ // own view lose a hit; here two peers holding the same counters cannot disagree.
+ const peerA = objectHp({ max: 5, hits: 2 });
+ const peerB = objectHp({ max: 5, hits: 2 });
+ check(peerA.hp === peerB.hp && peerA.hp === 3, 'two peers with the same counter read the same hp (3)');
+ const raceA = 5 - 1; // A applies its own hit to the value it read
+ const raceB = 5 - 1; // B applies its own hit to the value IT read - one hit is lost
+ check(raceA === raceB && raceA !== objectHp({ max: 5, hits: 2 }).hp, 'COUNTERFACTUAL: a read-modify-write add loses a hit (4 vs the ledger\'s 3)');
+ check(objectHp({ max: 'x', hits: 1 }).hp === 4, 'a bad max falls back to the default (5)');
+
+ // ---- player health from a row ----------------------------------------------------
+ const t0 = 1000;
+ check(playerHp({ max: 5, base: null, now: t0 }).hp === 5, 'no row yet: full');
+ check(playerHp({ max: 5, base: 2, at: t0, now: t0 }).hp === 2, 'a row at its stamp reads its base');
+ check(playerHp({ max: 5, regen: 1, base: 2, at: t0, now: t0 + 2 }).hp === 4, 'regen 1/s adds 2 after 2 s');
+ check(playerHp({ max: 5, regen: 1, base: 2, at: t0, now: t0 + 20 }).hp === 5, 'regen caps at max');
+ check(playerHp({ max: 5, regen: 1, base: 0, at: t0, now: t0 + 20 }).dead, 'the dead do not regenerate');
+ // FRAME-RATE INDEPENDENCE: the value depends on (base, at, now) only, so two peers
+ // sampling at different rates read the same number for the same clock
+ const slow = playerHp({ max: 5, regen: 0.5, base: 1, at: t0, now: t0 + 3 }).hp;
+ const fast = playerHp({ max: 5, regen: 0.5, base: 1, at: t0, now: t0 + 3 }).hp;
+ check(slow === fast && slow === 2.5, 'regen is a function of the clock, not of frames (2.5 at +3 s)');
+ check(playerHp({ max: 5, regen: 1, base: 3, at: 86399, now: 1 }).hp === 5, 'a stamp across midnight still counts forward (2 s of regen)');
+ check(elapsed(1, 86399) === 2, 'elapsed() wraps the seconds-of-day clock');
+
+ // applyDelta folds the regen'd value into the new base
+ const hurt = applyDelta({ max: 5, regen: 1, base: 2, at: t0, now: t0 + 2 }, -3);
+ check(hurt.base === 1 && hurt.at === t0 + 2, 'damage lands on the regen\'d value (4 - 3 = 1) and restamps');
+ check(applyDelta({ max: 5, base: 1, at: t0, now: t0 }, -5).base === 0, 'damage clamps at 0 (dead)');
+ check(applyDelta({ max: 5, base: 1, at: t0, now: t0 }, +9).base === 5, 'a heal clamps at max');
+
+ // ---- pulses per event ------------------------------------------------------------
+ check(pulsesFor({ amount: 3 }) === 3, 'amount 3 = 3 pulses');
+ check(pulsesFor({ amount: 99 }) === MAX_PULSES, 'capped at ' + MAX_PULSES);
+ check(pulsesFor({ amount: 0 }) === 0, 'amount 0 fires nothing');
+ check(pulsesFor({ amount: 2, scale: 'speed', speed: 6, speedRef: 3 }) === 4, 'speed scaling: twice the reference doubles (4)');
+ check(pulsesFor({ amount: 2, scale: 'speed', speed: 0.1, speedRef: 3 }) === 1, 'a soft hit is never free (1)');
+ check(pulsesFor({ amount: 2, scale: 'speed', speed: 100, speedRef: 3 }) === 6, 'speed scaling caps at 3x (6)');
+ check(pulsesFor({ amount: 2, scale: 'none', speed: 100 }) === 2, 'no scaling ignores speed');
+
+ // ---- readouts + respawn ------------------------------------------------------------
+ check(fraction(2, 5) === 0.4, 'fraction 2/5 = 0.4');
+ check(fraction(9, 5) === 1, 'fraction clamps to 1');
+ check(!respawnDue(null, 3, 100), 'no death stamp: no respawn');
+ check(!respawnDue(100, 3, 102), 'before the delay: not due');
+ check(respawnDue(100, 3, 103), 'at the delay: due');
+ check(respawnDue(86399, 3, 2), 'due across midnight');
+}
diff --git a/modules/health/test/run.mjs b/modules/health/test/run.mjs
new file mode 100644
index 0000000..dac9f7d
--- /dev/null
+++ b/modules/health/test/run.mjs
@@ -0,0 +1,19 @@
+// Pure ledger + graph tests — run WITHOUT the app:
+// node modules/health/test/run.mjs (also `npm run test:health`)
+import { run as ledgerTests } from './ledger.test.mjs';
+import { run as graphTests } from './graph.test.mjs';
+import { run as engineTests } from './engine.test.mjs';
+
+let failures = 0;
+function check(ok, label) {
+ console.log((ok ? 'PASS ' : 'FAIL ') + label);
+ if (!ok) failures++;
+}
+console.log('=== ledger.test.mjs ===');
+ledgerTests(check);
+console.log('\n=== graph.test.mjs ===');
+graphTests(check);
+console.log('\n=== engine.test.mjs ===');
+engineTests(check);
+console.log('\n' + (failures === 0 ? 'ALL PASS' : failures + ' FAILURES'));
+process.exit(failures === 0 ? 0 : 1);
diff --git a/modules/music-dj/README.md b/modules/music-dj/README.md
new file mode 100644
index 0000000..e31a7a4
--- /dev/null
+++ b/modules/music-dj/README.md
@@ -0,0 +1,20 @@
+# Music DJ (23-C4)
+
+Two **decks** and a **crossfader** as audio devices on the engine.
+
+- **Deck** - drop an audio item from the Explorer on it to load a track by CONTENT HASH
+ (every peer pulls and decodes its own copy). Play/Stop, Cue (stopped: set the cue point
+ here; playing: jump to it), a **platter** you scrub (one revolution = 2 s of audio) and a
+ **pitch** fader (varispeed 0.5x..1.5x: pitch and tempo are one control in v1). The
+ playhead is never streamed: the device document holds `{startedAt, offset, rate}` and
+ every peer derives the position from them and its clock. A scrub or a pitch move is one
+ live gesture: throttled previews while it runs (`api.audio.previewParams`), ONE commit when
+ it stops - one undo entry. The face re-bases offset/startedAt whenever it writes a rate,
+ so the position stays continuous everywhere; a bare `rate` edit from the Inspector keeps
+ playback continuous locally but re-derives the document position (the v1 limit).
+- **Crossfader** - inputs `a` and `b`, one `out`, a `position` (-1..1) and a `curve`
+ (linear / constant power / sharp). Gains glide, never jump.
+
+Sidebar: **Music DJ: booth** adds two decks and a crossfader (and a Music Lab speaker when
+that module is installed). Needs core's `api.registerDropHandler` and
+`api.audio.previewParams` (23-C2/C4).
diff --git a/modules/music-dj/manifest.json b/modules/music-dj/manifest.json
new file mode 100644
index 0000000..0645191
--- /dev/null
+++ b/modules/music-dj/manifest.json
@@ -0,0 +1,9 @@
+{
+ "id": "music-dj",
+ "name": "Music DJ",
+ "version": "0.1.0",
+ "format": 1,
+ "description": "Two decks and a crossfader on the engine: drop a track on a deck, scrub the platter, ride the pitch, fade between them - every peer hears the same position.",
+ "entry": "module.js",
+ "files": ["module.js"]
+}
diff --git a/modules/music-dj/module.js b/modules/music-dj/module.js
new file mode 100644
index 0000000..a2982c2
--- /dev/null
+++ b/modules/music-dj/module.js
@@ -0,0 +1,537 @@
+// Music DJ - roadmap #23 C4: two decks and a crossfader on the engine.
+//
+// THE DECK'S POSITION IS THREE NUMBERS IN THE DEVICE DOCUMENT: `{startedAt, offset, rate}`
+// (the sceneMusic shape widened with a rate). Every peer DERIVES the playhead from them and
+// its own clock - the playhead is never streamed. A scrub on the platter is a live gesture
+// over that shared clock: throttled PREVIEWS (api.audio.previewParams: replicated, applied,
+// no history) while the hand moves, then ONE exact commit (api.audio.setParams) when it
+// stops - one undo entry per scrub, the pattern every mesh tool in the app uses.
+//
+// Varispeed, one control: `rate` is pitch AND tempo. The face re-bases `{offset, startedAt}`
+// whenever it writes a rate, so the position stays continuous on every peer; a bare `rate`
+// write from the Inspector keeps THIS peer's playback continuous but re-derives the document
+// position, which is the documented v1 limit (time-stretching is a project, not a phase).
+//
+// Sound goes nowhere until cabled: a deck's output connects to nothing; the crossfader is a
+// device too (two inputs, one output, a curve) because that is what a DJ reaches for.
+
+const TURN_SECONDS = 2; // one platter revolution scrubs this much audio
+const GESTURE_IDLE_MS = 700;
+const PREVIEW_MS = 66;
+const PITCH_MIN = 0.5;
+const PITCH_MAX = 1.5;
+const PITCH_RAIL = 0.11; // the pitch knob travels -PITCH_RAIL..+PITCH_RAIL along x
+const XF_RAIL = 0.17;
+const DISPLAY_HZ = 20;
+
+/** the resolved namespaced kinds */
+const KINDS = { deck: '', crossfader: '' };
+
+/** every built deck @type {Map} */
+const decks = new Map();
+/** every built crossfader @type {Map} */
+const faders = new Map();
+
+// ---- the deck model (pure) ----------------------------------------------------------------
+
+/** the playhead in seconds from the document and a wall time - what every peer derives
+ * @param {any} doc @param {number} nowMs @param {number} [duration] */
+function positionOf(doc, nowMs, duration) {
+ const rate = Number(doc?.rate) || 1;
+ const offset = Number(doc?.offset) || 0;
+ const startedAt = Number(doc?.startedAt) || 0;
+ let pos = doc?.playing && startedAt ? offset + (Math.max(0, nowMs - startedAt) / 1000) * rate : offset;
+ if (duration && duration > 0) pos = doc?.loop === false ? Math.min(duration, pos) : ((pos % duration) + duration) % duration;
+ return Math.max(0, pos);
+}
+
+// ---- meshes -------------------------------------------------------------------------------
+
+/** the plug convention music-lab uses: the visible plug carries the vrpatch name and holds a
+ * zero-offset `port:` marker @param {any} three @param {'in'|'out'} side @param {string} id @param {number} color @param {number} x @param {number} z */
+function plug(three, side, id, color, x, z) {
+ const mesh = new three.Mesh(
+ new three.CylinderGeometry(0.02, 0.02, 0.04, 12),
+ new three.MeshStandardMaterial({ color, roughness: 0.4, metalness: 0.6 })
+ );
+ mesh.rotation.x = Math.PI / 2;
+ mesh.position.set(x, 0, z);
+ mesh.name = 'vrpatch-' + side + ':' + id;
+ const marker = new three.Object3D();
+ marker.name = 'port:' + id;
+ mesh.add(marker);
+ return mesh;
+}
+
+/** @param {any} three */
+function deckMesh(three) {
+ const body = new three.Mesh(
+ new three.BoxGeometry(0.62, 0.12, 0.5),
+ new three.MeshStandardMaterial({ color: 0x1f2328, roughness: 0.85, metalness: 0.15 })
+ );
+ body.name = 'Deck';
+ const platter = new three.Mesh(
+ new three.CylinderGeometry(0.16, 0.16, 0.02, 48),
+ new three.MeshStandardMaterial({ color: 0x374151, roughness: 0.5, metalness: 0.4 })
+ );
+ platter.position.set(-0.1, 0.07, 0.02);
+ platter.name = 'jog';
+ // a mark on the platter so the rotation reads
+ const mark = new three.Mesh(
+ new three.BoxGeometry(0.02, 0.006, 0.1),
+ new three.MeshStandardMaterial({ color: 0xf8fafc, roughness: 0.6 })
+ );
+ mark.position.set(0, 0.012, -0.1);
+ mark.name = 'jog-mark';
+ platter.add(mark);
+ body.add(platter);
+ /** @param {string} name @param {number} color @param {number} x @param {number} z @param {number} w */
+ const button = (name, color, x, z, w) => {
+ const mesh = new three.Mesh(new three.BoxGeometry(w, 0.03, 0.05), new three.MeshStandardMaterial({ color, roughness: 0.5 }));
+ mesh.position.set(x, 0.075, z);
+ mesh.name = name;
+ body.add(mesh);
+ };
+ button('dk-play', 0x22c55e, -0.18, 0.2, 0.1);
+ button('dk-cue', 0xf59e0b, -0.04, 0.2, 0.08);
+ const rail = new three.Mesh(
+ new three.BoxGeometry(PITCH_RAIL * 2 + 0.04, 0.01, 0.03),
+ new three.MeshStandardMaterial({ color: 0x0f172a, roughness: 0.9 })
+ );
+ rail.position.set(0.16, 0.065, -0.12);
+ rail.name = 'dk-rail';
+ body.add(rail);
+ const knob = new three.Mesh(new three.BoxGeometry(0.03, 0.035, 0.05), new three.MeshStandardMaterial({ color: 0xe5e7eb, roughness: 0.5 }));
+ knob.position.set(0.16, 0.08, -0.12);
+ knob.name = 'dk-pitch';
+ body.add(knob);
+ body.add(plug(three, 'out', 'out', 0xfb923c, 0.2, 0.27));
+ return body;
+}
+
+/** @param {any} three */
+function crossfaderMesh(three) {
+ const body = new three.Mesh(
+ new three.BoxGeometry(0.5, 0.1, 0.18),
+ new three.MeshStandardMaterial({ color: 0x27272a, roughness: 0.85, metalness: 0.15 })
+ );
+ body.name = 'Crossfader';
+ const rail = new three.Mesh(new three.BoxGeometry(XF_RAIL * 2 + 0.04, 0.01, 0.03), new three.MeshStandardMaterial({ color: 0x0f172a, roughness: 0.9 }));
+ rail.position.set(0, 0.055, 0);
+ rail.name = 'xf-rail';
+ body.add(rail);
+ const knob = new three.Mesh(new three.BoxGeometry(0.035, 0.035, 0.06), new three.MeshStandardMaterial({ color: 0xe5e7eb, roughness: 0.5 }));
+ knob.position.set(0, 0.07, 0);
+ knob.name = 'xf-knob';
+ body.add(knob);
+ body.add(plug(three, 'in', 'a', 0x38bdf8, -0.15, 0.11));
+ body.add(plug(three, 'in', 'b', 0xf472b6, 0.15, 0.11));
+ body.add(plug(three, 'out', 'out', 0xfb923c, 0, -0.11));
+ return body;
+}
+
+/** the device object a mesh belongs to @param {any} object */
+function deviceRootOf(object) {
+ let node = object;
+ while (node && !node.userData?.device?.kind) node = node.parent;
+ return node ?? null;
+}
+
+// ---- the deck runtime -------------------------------------------------------------------------
+
+/** load the deck's track by content hash; every peer pulls and decodes its own copy
+ * @param {any} api @param {any} h */
+function loadTrack(api, h) {
+ const hash = String(h.doc.track || '');
+ h.buffer = null;
+ stopSource(h);
+ if (!hash) return;
+ const wanted = hash;
+ Promise.resolve(api.audio.sample(hash))
+ .then((/** @type {any} */ buffer) => {
+ if (!buffer || h.doc.track !== wanted) return;
+ h.buffer = buffer;
+ syncDeck(api, h);
+ })
+ .catch(() => {});
+}
+
+/** @param {any} h */
+function stopSource(h) {
+ const src = h.src;
+ h.src = null;
+ if (!src) return;
+ try {
+ src.stop();
+ } catch {
+ /* already stopped */
+ }
+ try {
+ src.disconnect();
+ } catch {
+ /* never connected */
+ }
+}
+
+/**
+ * Make the audible playback match the document: derive the playhead from the three numbers
+ * and (re)start a BufferSource there. A future `startedAt` starts at its exact audio time
+ * (api.audio.timeFor), a past one at the derived position now - the same answer on every
+ * peer, whenever the message arrives. @param {any} api @param {any} h
+ */
+function syncDeck(api, h) {
+ stopSource(h);
+ const doc = h.doc;
+ const buffer = h.buffer;
+ if (!buffer || !doc.playing) return;
+ const ctx = h.ctx;
+ const now = Date.now();
+ const rate = Math.max(PITCH_MIN, Math.min(PITCH_MAX, Number(doc.rate) || 1));
+ const loop = doc.loop !== false;
+ const src = ctx.createBufferSource();
+ src.buffer = buffer;
+ src.loop = loop;
+ src.playbackRate.value = rate;
+ src.connect(h.out);
+ const startedAt = Number(doc.startedAt) || now;
+ if (startedAt > now + 5) {
+ const offset = ((Number(doc.offset) || 0) % buffer.duration + buffer.duration) % buffer.duration;
+ src.start(api.audio.timeFor(startedAt), offset);
+ } else {
+ const pos = positionOf(doc, now, buffer.duration);
+ if (!loop && pos >= buffer.duration - 0.005) return;
+ src.start(ctx.currentTime, pos);
+ }
+ h.src = src;
+ src.onended = () => {
+ if (h.src === src) h.src = null;
+ };
+}
+
+/** the deck's playhead NOW, for the face and the flights @param {any} h */
+function deckPosition(h) {
+ return positionOf(h.doc, Date.now(), h.buffer?.duration);
+}
+
+/** @param {any} api */
+function deckSpec(api) {
+ return {
+ kind: 'deck',
+ label: 'Deck',
+ icon: '💿',
+ group: 'Music DJ',
+ ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ /** 23-D1: the deck's track is a scene asset by content hash @param {any} p */
+ assets: (p) => (p?.track ? [{ hash: String(p.track), name: String(p.name || 'track') }] : []),
+ params: [
+ { key: 'level', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.9 },
+ { key: 'rate', label: 'Pitch', kind: 'range', min: PITCH_MIN, max: PITCH_MAX, step: 0.005, default: 1 },
+ { key: 'loop', label: 'Loop', kind: 'toggle', default: true }
+ // undeclared, part of the document: track (hash), name, playing, startedAt, offset, cue
+ ],
+ /** @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const out = ctx.createGain();
+ out.gain.value = Number(params.level ?? 0.9);
+ const h = { ctx, node, out, doc: { ...params }, buffer: null, src: null, position: () => deckPosition(h) };
+ decks.set(node.uuid, h);
+ loadTrack(api, h);
+ return {
+ output: out,
+ h,
+ dispose() {
+ stopSource(h);
+ out.disconnect();
+ decks.delete(node.uuid);
+ }
+ };
+ },
+ /** @param {any} built @param {string} key @param {any} value */
+ onParam(built, key, value) {
+ const h = built.h;
+ if (!h) return;
+ h.doc[key] = value;
+ if (key === 'level') h.out.gain.setTargetAtTime(Number(value) || 0, h.ctx.currentTime, 0.01);
+ else if (key === 'track') loadTrack(api, h);
+ else if (key === 'rate' && h.src) h.src.playbackRate.setTargetAtTime(Math.max(PITCH_MIN, Math.min(PITCH_MAX, Number(value) || 1)), h.ctx.currentTime, 0.01);
+ else if (key === 'playing' || key === 'startedAt' || key === 'offset' || key === 'loop') syncDeck(api, h);
+ },
+ mesh: (/** @type {any} */ three) => deckMesh(three)
+ };
+}
+
+// ---- the crossfader -----------------------------------------------------------------------------
+
+/** the two gains for a position and a curve - a pure function, the same on every peer
+ * @param {number} p -1..1 @param {string} curve */
+function faderGains(p, curve) {
+ const x = Math.max(-1, Math.min(1, Number(p) || 0));
+ if (curve === 'power') {
+ const a = ((x + 1) / 2) * (Math.PI / 2);
+ return { a: Math.cos(a), b: Math.sin(a) };
+ }
+ if (curve === 'sharp') {
+ // full until the last fifth of the travel, then a fast cut - the scratch curve
+ return { a: Math.max(0, Math.min(1, (1 - x) / 0.4)), b: Math.max(0, Math.min(1, (1 + x) / 0.4)) };
+ }
+ return { a: (1 - x) / 2, b: (1 + x) / 2 };
+}
+
+/** @param {any} api */
+function crossfaderSpec(api) {
+ return {
+ kind: 'crossfader',
+ label: 'Crossfader',
+ icon: '🎚️',
+ group: 'Music DJ',
+ ports: { in: [{ id: 'a', label: 'A', kind: 'audio' }, { id: 'b', label: 'B', kind: 'audio' }], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ { key: 'position', label: 'Position', kind: 'range', min: -1, max: 1, step: 0.01, default: 0 },
+ { key: 'curve', label: 'Curve', kind: 'select', default: 'power', options: [{ value: 'linear', label: 'Linear' }, { value: 'power', label: 'Constant power' }, { value: 'sharp', label: 'Sharp' }] }
+ ],
+ /** @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const a = ctx.createGain();
+ const b = ctx.createGain();
+ const out = ctx.createGain();
+ a.connect(out);
+ b.connect(out);
+ const h = { ctx, node, a, b, out, doc: { ...params } };
+ const g = faderGains(params.position, params.curve);
+ a.gain.value = g.a;
+ b.gain.value = g.b;
+ faders.set(node.uuid, h);
+ return {
+ inputs: { a, b },
+ outputs: { out },
+ input: a,
+ output: out,
+ h,
+ dispose() {
+ a.disconnect();
+ b.disconnect();
+ out.disconnect();
+ faders.delete(node.uuid);
+ }
+ };
+ },
+ /** a glide, never a jump - a fader move must not click @param {any} built @param {string} key @param {any} value */
+ onParam(built, key, value) {
+ const h = built.h;
+ if (!h) return;
+ h.doc[key] = value;
+ const g = faderGains(h.doc.position, h.doc.curve);
+ h.a.gain.setTargetAtTime(g.a, h.ctx.currentTime, 0.008);
+ h.b.gain.setTargetAtTime(g.b, h.ctx.currentTime, 0.008);
+ },
+ mesh: (/** @type {any} */ three) => crossfaderMesh(three)
+ };
+}
+
+// ---- gestures ------------------------------------------------------------------------------------
+
+/** the open live gesture: jog scrub, pitch fader or crossfader. Previews while it runs, ONE
+ * commit when it ends. @type {{kind: 'jog'|'pitch'|'xf', uuid: string, target: any, before: any, lastAt: number, lastSent: number, pending: any, angle?: number, offset?: number}|null} */
+let gesture = null;
+
+/** @param {any} api */
+function endGesture(api) {
+ const open = gesture;
+ gesture = null;
+ if (!open || !open.pending) return;
+ // the previews already wrote these values: the commit carries the document captured when
+ // the gesture STARTED, so the one history entry restores that on undo
+ api.audio.setParams(open.uuid, open.pending, { before: open.before });
+}
+
+/** @param {any} api @param {any} open @param {any} params */
+function previewGesture(api, open, params) {
+ open.pending = { ...(open.pending ?? {}), ...params };
+ open.lastAt = performance.now();
+ if (open.lastAt - open.lastSent < PREVIEW_MS) return;
+ open.lastSent = open.lastAt;
+ api.audio.previewParams(open.uuid, params);
+}
+
+/** the angle of a world point around the platter's axis, read in the DECK's frame: the
+ * platter itself turns with the playhead every frame, so its own frame would report motion
+ * under a hand that is perfectly still @param {any} platter @param {any} point */
+function platterAngle(platter, point) {
+ const deck = platter.parent ?? platter;
+ const local = deck.worldToLocal(point.clone());
+ return Math.atan2(local.x - platter.position.x, -(local.z - platter.position.z));
+}
+
+/** @param {any} api */
+function updateGesture(api) {
+ const open = gesture;
+ if (!open) return;
+ const now = performance.now();
+ const ray = api.pointerRay?.();
+ if (ray && open.target) {
+ if (open.kind === 'jog') {
+ const hits = ray.intersectObject(open.target, true);
+ if (hits[0]) {
+ const angle = platterAngle(open.target, hits[0].point);
+ let delta = angle - (open.angle ?? angle);
+ if (delta > Math.PI) delta -= 2 * Math.PI;
+ if (delta < -Math.PI) delta += 2 * Math.PI;
+ open.angle = angle;
+ if (Math.abs(delta) > 0.002) {
+ const h = decks.get(open.uuid);
+ const duration = h?.buffer?.duration || 0;
+ let offset = (open.offset ?? 0) + (delta / (2 * Math.PI)) * TURN_SECONDS;
+ if (duration > 0) offset = ((offset % duration) + duration) % duration;
+ open.offset = offset;
+ previewGesture(api, open, { offset, startedAt: Date.now() });
+ }
+ }
+ } else {
+ // sliders: the hit on the device's top face, read along x in the device's frame
+ const device = deviceRootOf(open.target);
+ const hits = device ? ray.intersectObject(device, true) : [];
+ if (hits[0] && device) {
+ const local = device.worldToLocal(hits[0].point.clone());
+ if (open.kind === 'pitch') {
+ const t = Math.max(-1, Math.min(1, (local.x - 0.16) / PITCH_RAIL));
+ const rate = Math.round(((t + 1) / 2) * (PITCH_MAX - PITCH_MIN) * 200) / 200 + PITCH_MIN;
+ const h = decks.get(open.uuid);
+ if (h && Math.abs(rate - (Number(h.doc.rate) || 1)) >= 0.005) {
+ // re-base so the position stays continuous on EVERY peer
+ const offset = deckPosition(h);
+ previewGesture(api, open, h.doc.playing ? { rate, offset, startedAt: Date.now() } : { rate });
+ }
+ } else if (open.kind === 'xf') {
+ const position = Math.round(Math.max(-1, Math.min(1, local.x / XF_RAIL)) * 100) / 100;
+ const h = faders.get(open.uuid);
+ if (h && position !== Number(h.doc.position)) previewGesture(api, open, { position });
+ }
+ }
+ }
+ }
+ if (now - open.lastAt > GESTURE_IDLE_MS) endGesture(api);
+}
+
+/** @param {any} api @param {any} device @param {any} object */
+function clickDeck(api, device, object) {
+ const name = String(object?.name ?? '');
+ const h = decks.get(device.uuid);
+ const doc = api.audio.device(device.uuid)?.params ?? h?.doc ?? {};
+ if (name === 'dk-play') {
+ if (gesture) endGesture(api);
+ if (doc.playing) api.audio.setParams(device.uuid, { playing: false, offset: h ? deckPosition(h) : Number(doc.offset) || 0, startedAt: 0 });
+ else api.audio.setParams(device.uuid, { playing: true, startedAt: Date.now(), offset: Number(doc.offset) || 0 });
+ api.haptic(0.5, 40);
+ return true;
+ }
+ if (name === 'dk-cue') {
+ if (gesture) endGesture(api);
+ // playing: jump to the cue point; stopped: set the cue point here
+ if (doc.playing) api.audio.setParams(device.uuid, { offset: Number(doc.cue) || 0, startedAt: Date.now() });
+ else api.audio.setParams(device.uuid, { cue: Number(doc.offset) || 0 });
+ api.haptic(0.4, 30);
+ return true;
+ }
+ if (name === 'jog' || name === 'jog-mark') {
+ const platter = name === 'jog' ? object : object.parent;
+ if (gesture && gesture.uuid !== device.uuid) endGesture(api);
+ gesture = { kind: 'jog', uuid: device.uuid, target: platter, before: api.audio.device(device.uuid), lastAt: performance.now(), lastSent: 0, pending: null, angle: undefined, offset: h ? deckPosition(h) : Number(doc.offset) || 0 };
+ api.haptic(0.3, 30);
+ return true;
+ }
+ if (name === 'dk-pitch' || name === 'dk-rail') {
+ if (gesture && gesture.uuid !== device.uuid) endGesture(api);
+ gesture = { kind: 'pitch', uuid: device.uuid, target: object, before: api.audio.device(device.uuid), lastAt: performance.now(), lastSent: 0, pending: null };
+ return true;
+ }
+ return false;
+}
+
+/** @param {any} api @param {any} device @param {any} object */
+function clickCrossfader(api, device, object) {
+ const name = String(object?.name ?? '');
+ if (name !== 'xf-knob' && name !== 'xf-rail') return false;
+ if (gesture && gesture.uuid !== device.uuid) endGesture(api);
+ gesture = { kind: 'xf', uuid: device.uuid, target: object, before: api.audio.device(device.uuid), lastAt: performance.now(), lastSent: 0, pending: null };
+ return true;
+}
+
+/** an Explorer audio item dropped on a deck loads it (by content hash), stopped at 0
+ * @param {any} api @param {any} hit @param {{kind: string, hash: string, name: string}} item */
+function dropTrack(api, hit, item) {
+ if (item.kind !== 'audio' || !item.hash) return false;
+ const device = deviceRootOf(hit);
+ if (!device || device.userData.device.kind !== KINDS.deck) return false;
+ api.audio.setParams(device.uuid, { track: item.hash, name: item.name, playing: false, offset: 0, startedAt: 0, cue: 0 });
+ api.toast('Music DJ: "' + item.name + '" on the deck');
+ return true;
+}
+
+let nextDisplay = 0;
+/** platters turn with the derived playhead, knobs sit where the document says
+ * @param {any} api @param {number} time */
+function djFrame(api, time) {
+ updateGesture(api);
+ if (time < nextDisplay) return;
+ nextDisplay = time + 1 / DISPLAY_HZ;
+ for (const h of decks.values()) {
+ const platter = h.node?.getObjectByName?.('jog');
+ const scrubbing = gesture?.kind === 'jog' && gesture.uuid === h.node?.uuid;
+ if (platter && !scrubbing) platter.rotation.y = -((deckPosition(h) % TURN_SECONDS) / TURN_SECONDS) * 2 * Math.PI;
+ const knob = h.node?.getObjectByName?.('dk-pitch');
+ if (knob) {
+ const rate = Math.max(PITCH_MIN, Math.min(PITCH_MAX, Number(h.doc.rate) || 1));
+ knob.position.x = 0.16 + (((rate - PITCH_MIN) / (PITCH_MAX - PITCH_MIN)) * 2 - 1) * PITCH_RAIL;
+ }
+ const play = h.node?.getObjectByName?.('dk-play');
+ if (play?.material?.color) play.material.color.setHex(h.doc.playing ? 0x4ade80 : 0x166534);
+ }
+ for (const h of faders.values()) {
+ const knob = h.node?.getObjectByName?.('xf-knob');
+ if (knob) knob.position.x = Math.max(-1, Math.min(1, Number(h.doc.position) || 0)) * XF_RAIL;
+ }
+}
+
+export default {
+ id: 'music-dj',
+ name: 'Music DJ',
+ version: '0.1.0',
+ description: 'Two decks and a crossfader on the engine: drop a track on a deck, scrub the platter, ride the pitch, fade between them - every peer hears the same position.',
+ /** @param {any} api */
+ register(api) {
+ void api.THREE; // handed to mesh(three) by the registry; never imported
+ api.registerAudioDevice(deckSpec(api)).then((/** @type {string} */ kind) => (KINDS.deck = kind));
+ api.registerAudioDevice(crossfaderSpec(api)).then((/** @type {string} */ kind) => (KINDS.crossfader = kind));
+
+ api.registerClickHandler((/** @type {any} */ object) => {
+ const device = deviceRootOf(object);
+ const kind = device?.userData?.device?.kind;
+ if (!kind) return false;
+ if (kind === KINDS.deck) return clickDeck(api, device, object);
+ if (kind === KINDS.crossfader) return clickCrossfader(api, device, object);
+ return false;
+ });
+ api.registerDropHandler?.((/** @type {any} */ hit, /** @type {any} */ item) => dropTrack(api, hit, item));
+ api.registerFrameTask((/** @type {number} */ time) => djFrame(api, time));
+
+ api.registerMenu('Music DJ: booth', () => {
+ const left = api.audio.addDevice('deck', { position: [-0.9, 0.8, -2] });
+ const right = api.audio.addDevice('deck', { position: [0.9, 0.8, -2] });
+ const fader = api.audio.addDevice('crossfader', { position: [0, 0.8, -1.6] });
+ if (!left || !right || !fader) return api.toast('Music DJ: could not add the devices');
+ api.audio.cable({ from: { uuid: left.uuid, port: 'out' }, to: { uuid: fader.uuid, port: 'a' } });
+ api.audio.cable({ from: { uuid: right.uuid, port: 'out' }, to: { uuid: fader.uuid, port: 'b' } });
+ // a Music Lab speaker if that module is here: the booth reaches the room through it
+ const speaker = api.audio.addDevice('mod-music-lab-speaker', { position: [0, 0, -3.2] });
+ const spec = speaker && api.audio.device(speaker.uuid);
+ if (speaker && spec) api.audio.cable({ from: { uuid: fader.uuid, port: 'out' }, to: { uuid: speaker.uuid, port: 'in' } });
+ api.toast('Music DJ: drop a track from the Explorer on each deck' + (speaker ? '' : ', then cable the crossfader to a speaker'));
+ });
+
+ api.onSceneClear(() => {
+ decks.clear();
+ faders.clear();
+ gesture = null;
+ });
+ }
+};
diff --git a/modules/music-fx/README.md b/modules/music-fx/README.md
new file mode 100644
index 0000000..b0e124c
--- /dev/null
+++ b/modules/music-fx/README.md
@@ -0,0 +1,72 @@
+# Music FX
+
+Six **audio devices** for the app's audio engine (#23 C3): a four-channel mixer and five
+pedals. Every one of them is a `registerAudioDevice` spec and nothing else — core owns
+the object, the replication, the undo, the saving, the cables and the clock.
+
+| Device | Ports | WebAudio | Params |
+|---|---|---|---|
+| **Mixer** | `in1..in4` → `out`, `send` | per channel `StereoPanner` → fader gain → out; fader → send level → send (post-fader) | `master`; per channel `gain`, `pan`, `mute`, `solo`, `send` |
+| **Delay** | `in` → `out` | `DelayNode` + feedback gain | `time` (1/4, 1/8, 1/8T, 1/16 synced to the shared transport, or `free`), `ms`, `feedback`, `mix` |
+| **Reverb** | `in` → `out` | `ConvolverNode` with a **generated** impulse (a decaying noise burst) | `size`, `decay`, `mix` |
+| **Filter** | `in` → `out` | `BiquadFilterNode` | `type` (low / high / band / notch), `cutoff`, `q`, `mix` |
+| **Distortion** | `in` → `out` | drive gain → `WaveShaperNode` (tanh, 4x oversampled) → tone low-pass | `drive`, `tone`, `mix` |
+| **Bitcrush** | `in` → `out` | an `AudioWorklet` loaded from a blob URL built from a string in `module.js` | `bits`, `rate` (sample-and-hold divisor), `mix` |
+
+Add any of them from the viewport **Add ▸ Devices: Music FX**, or use the sidebar
+button **Music FX: demo chain**, which drops the five pedals and the mixer in a row and
+cables them in series into the mixer's `in1`. If a Music Lab piano and speaker are
+already in the scene, the chain plugs in between them; otherwise a toast says what to
+cable.
+
+**Sound goes nowhere until it is cabled.** A pedal's `out` and the mixer's `out` and
+`send` connect to nothing; only the device you cable them into (a Music Lab speaker,
+say) reaches a bus. **Re-order a chain by re-plugging it** — that is what the cables
+are for, and every peer watches them move.
+
+## Every pedal is dry/wet
+
+`in → dry → out` and `in → effect → wet → out`. `mix` 0 is a bypass, `mix` 1 is fully
+wet. The filter defaults to fully wet; the others to a blend.
+
+## A knob is a param write, never a rebuild
+
+Every `onParam` glides an `AudioParam` on the running graph (`setTargetAtTime`, ~15 ms).
+The nodes a cable is plugged into are the same nodes after the write as before, so a
+fader move does not click, does not gap, and does not cut the delay's echoes or the
+reverb's tail. The reverb regenerates its impulse on `size` / `decay`, debounced 120 ms,
+because a knob drag writes many times a second and a 6 s buffer is not free. The impulse
+is a **seeded** noise burst — a pure function of `size` and `decay` — so every peer
+builds the same room and a device rebuilt after an undo sounds like the one it replaced.
+
+## The synced delay follows the shared transport
+
+A synced division is `f(params, bpm)` where `bpm` is the replicated transport
+(`api.audio.transport().bpm`). One frame task, throttled to 4 Hz, re-reads it and
+re-aims every synced delay that it moved — so `setBpm` on any peer moves every peer's
+delays to the same time, computed from the same number.
+
+## The bitcrusher
+
+The worklet processor is a string in `module.js`; it is turned into a blob URL and loaded
+with `audioWorklet.addModule` once per `AudioContext`, so the module stays one file. If
+that fails (no `audioWorklet`, a CSP that refuses blob workers), the same algorithm runs in
+a `ScriptProcessorNode` on the main thread and the built handle's `mode` reads `'script'`
+instead of `'worklet'`. The flight reports which path it took; on the core dev server it
+is the worklet.
+
+## Testing
+
+```bash
+npm run pack -- music-fx
+APP_URL=https://localhost:5216/ npm test -- music-fx
+```
+
+The flight installs the real zip on three peers, registers a test source and a test sink
+in-page (it does not depend on Music Lab), and measures every claim through the
+destination tap: the filter moves the spectral centroid, distortion and the bitcrusher put
+power above the fundamental, the delay and the reverb keep sounding after the source
+stops, the delay time follows `setBpm` on both peers, a re-ordered chain reads
+differently and repeatably, the mixer's mute / solo / send do what they say, a fader move
+neither clicks nor restarts anything, a knob turned on A is heard on B, and a late joiner
+hears the finished rig.
diff --git a/modules/music-fx/manifest.json b/modules/music-fx/manifest.json
new file mode 100644
index 0000000..9c97aa2
--- /dev/null
+++ b/modules/music-fx/manifest.json
@@ -0,0 +1,9 @@
+{
+ "id": "music-fx",
+ "name": "Music FX",
+ "version": "0.1.0",
+ "format": 1,
+ "description": "A four-channel mixer and five pedals (delay, reverb, filter, distortion, bitcrush) as audio devices. Cable them in any order.",
+ "entry": "module.js",
+ "files": ["module.js"]
+}
diff --git a/modules/music-fx/module.js b/modules/music-fx/module.js
new file mode 100644
index 0000000..a69ecaa
--- /dev/null
+++ b/modules/music-fx/module.js
@@ -0,0 +1,833 @@
+// Music FX — roadmap #23 C3: a mixer and five pedals on the audio engine.
+//
+// SIX `registerAudioDevice` SPECS AND NOTHING ELSE. Core owns the object, the
+// replication, the undo, the saving, the cables and the clock; this file owns what a
+// kind sounds like, what it looks like, and which of its params do what. Nothing here
+// keeps replicated state of its own and nothing here sends a message. The one thing a
+// pedal does that the Music Lab devices do not is READ the shared transport: a synced
+// delay's time is `f(params, bpm)`, and bpm is the replicated transport document, so
+// every peer computes the same number from the same inputs.
+//
+// THE RULE THAT MAKES THAT WORK: `build` and `onParam` are PURE FUNCTIONS OF THEIR
+// ARGUMENTS. Every peer runs them from the same document; anything read out of local
+// state desyncs silently, per peer, with no error anywhere.
+//
+// A KNOB IS A PARAM WRITE, NEVER A REBUILD (A1's finding 4, the volume that restarted
+// its source). Every `onParam` here adjusts the running graph through an AudioParam
+// glide (`setTargetAtTime`, ~15 ms); the nodes a cable is plugged into are the SAME
+// nodes after the write as before. A pedal whose params rebuilt its graph would cut
+// the delay's echoes and the reverb's tail on every touch, and a mixer whose faders
+// rebuilt its strips would drop the cables for a frame. The flight measures both.
+//
+// SOUND GOES NOWHERE UNTIL IT IS CABLED. A pedal's `output` and the mixer's `out` and
+// `send` connect to nothing; the only thing here that reaches a bus is whatever you
+// cable them into (a Music Lab speaker, say).
+//
+// EVERY PEDAL: one `in`, one `out`, a dry/wet `mix`. in -> dry -> out and
+// in -> [effect] -> wet -> out, so `mix` 0 is a bypass and `mix` 1 is fully wet.
+
+const SMOOTH = 0.015; // s: the time constant every fader and knob glides with
+const SYNC_HZ = 4; // how often the delays re-read the shared bpm
+const IMPULSE_DEBOUNCE_MS = 120; // a reverb knob regenerates its impulse this long after the last write
+const MAX_DELAY_S = 2;
+const MIXER_CHANNELS = 4;
+const PLUG_COLOR = 0xfb923c; // the app's audio-port colour (flowSockets / audioPatch)
+
+/** Beat fractions a synced delay may take. 'free' reads the `ms` knob instead. */
+const DIVISIONS = /** @type {Record} */ ({ '1/4': 1, '1/8': 1 / 2, '1/8T': 1 / 3, '1/16': 1 / 4 });
+
+/**
+ * Every built delay, so ONE throttled frame task follows the shared bpm for all of
+ * them. Keyed by the object uuid; `dispose` removes the entry.
+ * @type {Map}
+ */
+const delays = new Map();
+
+// ---- small helpers -----------------------------------------------------------------
+
+/** @param {any} value @param {number} fallback */
+function num(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+/** @param {number} value @param {number} lo @param {number} hi */
+function clamp(value, lo, hi) {
+ return Math.max(lo, Math.min(hi, value));
+}
+
+/** A toggle arrives as a boolean from the Inspector, and may arrive as a string or a
+ * number from an older document. @param {any} value */
+function on(value) {
+ return value === true || value === 1 || value === 'true' || value === '1';
+}
+
+/**
+ * Move an AudioParam WITHOUT a step: an exponential approach with a short time
+ * constant, which is what keeps a fader move from clicking. Never `.value =` on a
+ * param that is audible while it moves.
+ * @param {any} param @param {number} value @param {any} ctx @param {number} [tau]
+ */
+function glide(param, value, ctx, tau = SMOOTH) {
+ param.setTargetAtTime(value, ctx.currentTime, tau);
+}
+
+/** @param {any} nodes */
+function disconnectAll(nodes) {
+ for (const node of nodes) {
+ try {
+ node.disconnect();
+ } catch {
+ /* already gone */
+ }
+ }
+}
+
+/**
+ * The shape every pedal shares: `input -> dry -> output`, `wet -> output`, and the
+ * effect goes between `input` and `wet`. `mix` sets the dry/wet balance.
+ * @param {any} ctx @param {any} params
+ */
+function pedalGraph(ctx, params) {
+ const input = ctx.createGain();
+ const output = ctx.createGain();
+ const dry = ctx.createGain();
+ const wet = ctx.createGain();
+ const mix = clamp(num(params.mix, 0.5), 0, 1);
+ dry.gain.value = 1 - mix;
+ wet.gain.value = mix;
+ input.connect(dry);
+ dry.connect(output);
+ wet.connect(output);
+ return {
+ input,
+ output,
+ dry,
+ wet,
+ ctx,
+ params: { ...params },
+ /** @type {any[]} the effect's own nodes, disconnected on dispose */
+ nodes: [],
+ disposed: false,
+ dispose() {
+ this.disposed = true;
+ disconnectAll([input, dry, wet, output, ...this.nodes]);
+ }
+ };
+}
+
+/** @param {any} handle @param {any} value */
+function setMix(handle, value) {
+ const mix = clamp(num(value, 0.5), 0, 1);
+ glide(handle.dry.gain, 1 - mix, handle.ctx);
+ glide(handle.wet.gain, mix, handle.ctx);
+}
+
+// ---- meshes --------------------------------------------------------------------------
+//
+// A cable attaches to the child named for the port: `vrpatch-in:` / `vrpatch-out:`
+// is what the VR patching work and the cable renderer look for, and each plug holds a
+// zero-offset `port:` marker for the older name. THE DEVICE ROOT IS A MESH, NOT A
+// GROUP (music-lab's finding): the late-joiner object sync sends a Group as a bare
+// message, and a device whose root is a Group reached a joiner with its document gone
+// on the build C1 ran against. A Mesh with children goes as toJSON(), userData intact.
+
+/**
+ * @param {any} three @param {'in'|'out'} side @param {string} id @param {number[]} at
+ * @param {'x'|'z'} axis which way the jack points
+ */
+function plug(three, side, id, at, axis) {
+ const mesh = new three.Mesh(
+ new three.CylinderGeometry(0.014, 0.014, 0.03, 12),
+ new three.MeshStandardMaterial({ color: PLUG_COLOR, roughness: 0.4, metalness: 0.6 })
+ );
+ if (axis === 'x') mesh.rotation.z = Math.PI / 2;
+ else mesh.rotation.x = Math.PI / 2;
+ mesh.position.set(at[0], at[1], at[2]);
+ mesh.name = 'vrpatch-' + side + ':' + id;
+ const marker = new three.Object3D();
+ marker.name = 'port:' + id;
+ mesh.add(marker);
+ return mesh;
+}
+
+/** @param {any} three @param {number} color @param {number} roughness @param {number} metalness */
+function material(three, color, roughness = 0.6, metalness = 0.2) {
+ return new three.MeshStandardMaterial({ color, roughness, metalness });
+}
+
+/**
+ * A stomp box: a dark body, a coloured top plate (the one thing that tells the five
+ * apart across a room), a footswitch, an LED, a row of knobs, and the in jack on the
+ * right / out jack on the left the way a real pedal has them.
+ * @param {any} three @param {string} label @param {number} color @param {number} knobs
+ */
+function pedalMesh(three, label, color, knobs) {
+ const body = new three.Mesh(new three.BoxGeometry(0.16, 0.06, 0.24), material(three, 0x26262b, 0.7, 0.3));
+ body.name = label;
+ const top = new three.Mesh(new three.BoxGeometry(0.15, 0.006, 0.23), material(three, color, 0.5, 0.2));
+ top.position.y = 0.033;
+ top.name = 'top';
+ body.add(top);
+ const stomp = new three.Mesh(new three.CylinderGeometry(0.018, 0.02, 0.012, 16), material(three, 0xc8c8cc, 0.35, 0.8));
+ stomp.position.set(0, 0.042, 0.075);
+ stomp.name = 'footswitch';
+ body.add(stomp);
+ const led = new three.Mesh(
+ new three.SphereGeometry(0.006, 8, 6),
+ new three.MeshStandardMaterial({ color: 0xff3b30, emissive: 0xff3b30, emissiveIntensity: 0.8 })
+ );
+ led.position.set(0, 0.04, 0.03);
+ led.name = 'led';
+ body.add(led);
+ const knobMaterial = material(three, 0x111114, 0.5, 0.1);
+ for (let k = 0; k < knobs; k++) {
+ const knob = new three.Mesh(new three.CylinderGeometry(0.012, 0.012, 0.012, 16), knobMaterial);
+ knob.position.set((k - (knobs - 1) / 2) * 0.045, 0.042, -0.06);
+ knob.name = 'knob-' + k;
+ body.add(knob);
+ }
+ body.add(plug(three, 'in', 'in', [0.09, 0, -0.03], 'x'));
+ body.add(plug(three, 'out', 'out', [-0.09, 0, -0.03], 'x'));
+ return body;
+}
+
+/** A small desk: four strips with a fader slot and a pan knob, the inputs along the
+ * back, out and send on the right side. @param {any} three */
+function mixerMesh(three) {
+ const desk = new three.Mesh(new three.BoxGeometry(0.6, 0.05, 0.32), material(three, 0x2f3340, 0.75, 0.25));
+ desk.name = 'Mixer';
+ const slotMaterial = material(three, 0x0c0d10, 0.9, 0);
+ const capMaterial = material(three, 0xe5e7eb, 0.4, 0.3);
+ const knobMaterial = material(three, 0x111114, 0.5, 0.1);
+ for (let i = 0; i < MIXER_CHANNELS; i++) {
+ const x = -0.21 + i * 0.14;
+ const slot = new three.Mesh(new three.BoxGeometry(0.01, 0.002, 0.16), slotMaterial);
+ slot.position.set(x, 0.026, 0.04);
+ slot.name = 'slot-' + (i + 1);
+ desk.add(slot);
+ const cap = new three.Mesh(new three.BoxGeometry(0.03, 0.012, 0.02), capMaterial);
+ cap.position.set(x, 0.031, 0.06);
+ cap.name = 'fader-' + (i + 1);
+ desk.add(cap);
+ const knob = new three.Mesh(new three.CylinderGeometry(0.012, 0.012, 0.012, 16), knobMaterial);
+ knob.position.set(x, 0.031, -0.09);
+ knob.name = 'pan-' + (i + 1);
+ desk.add(knob);
+ desk.add(plug(three, 'in', 'in' + (i + 1), [x, 0, -0.17], 'z'));
+ }
+ desk.add(plug(three, 'out', 'out', [0.31, 0, -0.06], 'x'));
+ desk.add(plug(three, 'out', 'send', [0.31, 0, 0.06], 'x'));
+ return desk;
+}
+
+// ---- the mixer -------------------------------------------------------------------------
+
+/**
+ * A channel's fader level from the document: muted is silent, and when ANY channel is
+ * soloed every un-soloed one is silent. A pure function of the params, which is what
+ * lets every peer's mixer agree. @param {any} params @param {number} channel 1-based
+ */
+function channelLevel(params, channel) {
+ if (on(params['mute' + channel])) return 0;
+ let anySolo = false;
+ for (let i = 1; i <= MIXER_CHANNELS; i++) if (on(params['solo' + i])) anySolo = true;
+ if (anySolo && !on(params['solo' + channel])) return 0;
+ return clamp(num(params['gain' + channel], 1), 0, 2);
+}
+
+/** @param {any} handle */
+function refreshLevels(handle) {
+ handle.channels.forEach((/** @type {any} */ strip, /** @type {number} */ index) =>
+ glide(strip.fader.gain, channelLevel(handle.params, index + 1), handle.ctx)
+ );
+}
+
+function mixerSpec() {
+ /** @type {any[]} */
+ const params = [{ key: 'master', label: 'Master', kind: 'range', min: 0, max: 1.5, step: 0.01, default: 1 }];
+ const inPorts = [];
+ for (let i = 1; i <= MIXER_CHANNELS; i++) {
+ inPorts.push({ id: 'in' + i, label: 'In ' + i, kind: 'audio' });
+ params.push(
+ { key: 'gain' + i, label: 'Ch ' + i + ' gain', kind: 'range', min: 0, max: 1.5, step: 0.01, default: 1 },
+ { key: 'pan' + i, label: 'Ch ' + i + ' pan', kind: 'range', min: -1, max: 1, step: 0.01, default: 0 },
+ { key: 'mute' + i, label: 'Ch ' + i + ' mute', kind: 'toggle', default: false },
+ { key: 'solo' + i, label: 'Ch ' + i + ' solo', kind: 'toggle', default: false },
+ { key: 'send' + i, label: 'Ch ' + i + ' send', kind: 'range', min: 0, max: 1, step: 0.01, default: 0 }
+ );
+ }
+ return {
+ kind: 'mixer',
+ label: 'Mixer',
+ icon: '🎚️',
+ group: 'Music FX',
+ ports: {
+ in: inPorts,
+ out: [
+ { id: 'out', label: 'Out', kind: 'audio' },
+ { id: 'send', label: 'Send', kind: 'audio' }
+ ]
+ },
+ params,
+ /**
+ * Per channel: in -> pan -> fader -> out, and fader -> send level -> send (a
+ * post-fader send, so a muted channel leaves the reverb bus too). `inputs` and
+ * `outputs` are the maps core's patch looks a multi-port device's ports up in;
+ * `input`/`output` stand for the first input and the main out (the level meter
+ * reads `output`).
+ * @param {any} ctx @param {any} node @param {any} doc
+ */
+ build(ctx, node, doc) {
+ const out = ctx.createGain();
+ out.gain.value = clamp(num(doc.master, 1), 0, 2);
+ const send = ctx.createGain();
+ /** @type {Record} */
+ const inputs = {};
+ /** @type {any[]} */
+ const channels = [];
+ for (let i = 1; i <= MIXER_CHANNELS; i++) {
+ const input = ctx.createGain();
+ const pan = ctx.createStereoPanner();
+ pan.pan.value = clamp(num(doc['pan' + i], 0), -1, 1);
+ const fader = ctx.createGain();
+ fader.gain.value = channelLevel(doc, i);
+ const sendGain = ctx.createGain();
+ sendGain.gain.value = clamp(num(doc['send' + i], 0), 0, 1);
+ input.connect(pan);
+ pan.connect(fader);
+ fader.connect(out);
+ fader.connect(sendGain);
+ sendGain.connect(send);
+ inputs['in' + i] = input;
+ channels.push({ input, pan, fader, sendGain });
+ }
+ return {
+ inputs,
+ outputs: { out, send },
+ input: inputs.in1,
+ output: out,
+ channels,
+ ctx,
+ params: { ...doc },
+ dispose() {
+ for (const strip of channels) disconnectAll([strip.input, strip.pan, strip.fader, strip.sendGain]);
+ disconnectAll([out, send]);
+ }
+ };
+ },
+ /** every fader is a glide on a running node — the strips are never rebuilt */
+ onParam(handle, key, value) {
+ handle.params[key] = value;
+ if (key === 'master') return glide(handle.output.gain, clamp(num(value, 1), 0, 2), handle.ctx);
+ const match = /^(gain|pan|mute|solo|send)(\d)$/.exec(key);
+ if (!match) return;
+ const strip = handle.channels[Number(match[2]) - 1];
+ if (!strip) return;
+ if (match[1] === 'pan') glide(strip.pan.pan, clamp(num(value, 0), -1, 1), handle.ctx);
+ else if (match[1] === 'send') glide(strip.sendGain.gain, clamp(num(value, 0), 0, 1), handle.ctx);
+ else refreshLevels(handle); // gain, mute and solo all fold into every channel's level
+ },
+ /** @param {any} three */
+ mesh: (three) => mixerMesh(three)
+ };
+}
+
+// ---- the pedals -------------------------------------------------------------------------
+
+/** @param {any} value */
+function filterType(value) {
+ return value === 'highpass' || value === 'bandpass' || value === 'notch' ? value : 'lowpass';
+}
+
+function filterSpec() {
+ return {
+ kind: 'filter',
+ label: 'Filter',
+ icon: '🎛️',
+ group: 'Music FX',
+ ports: { in: [{ id: 'in', label: 'In', kind: 'audio' }], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ {
+ key: 'type',
+ label: 'Type',
+ kind: 'select',
+ default: 'lowpass',
+ options: [
+ { value: 'lowpass', label: 'Low-pass' },
+ { value: 'highpass', label: 'High-pass' },
+ { value: 'bandpass', label: 'Band-pass' },
+ { value: 'notch', label: 'Notch' }
+ ]
+ },
+ { key: 'cutoff', label: 'Cutoff', kind: 'range', min: 40, max: 16000, step: 1, default: 8000, unit: 'Hz' },
+ { key: 'q', label: 'Q', kind: 'range', min: 0.1, max: 20, step: 0.1, default: 1 },
+ { key: 'mix', label: 'Mix', kind: 'range', min: 0, max: 1, step: 0.01, default: 1 }
+ ],
+ /** in -> biquad -> wet. @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const h = pedalGraph(ctx, params);
+ const biquad = ctx.createBiquadFilter();
+ biquad.type = filterType(params.type);
+ biquad.frequency.value = clamp(num(params.cutoff, 8000), 20, 20000);
+ biquad.Q.value = clamp(num(params.q, 1), 0.01, 40);
+ h.input.connect(biquad);
+ biquad.connect(h.wet);
+ h.nodes.push(biquad);
+ return Object.assign(h, { biquad });
+ },
+ onParam(h, key, value) {
+ h.params[key] = value;
+ if (key === 'type') h.biquad.type = filterType(value);
+ else if (key === 'cutoff') glide(h.biquad.frequency, clamp(num(value, 8000), 20, 20000), h.ctx);
+ else if (key === 'q') glide(h.biquad.Q, clamp(num(value, 1), 0.01, 40), h.ctx);
+ else if (key === 'mix') setMix(h, value);
+ },
+ /** @param {any} three */
+ mesh: (three) => pedalMesh(three, 'Filter', 0xf59e0b, 3)
+ };
+}
+
+/** A soft clipper, once: tanh over [-1, 1], normalised so a signal that never reaches
+ * the knee passes at unit gain. The DRIVE is the pre-gain in front of it, so a hotter
+ * signal folds harder into the same curve. */
+function softClipCurve() {
+ const n = 2048;
+ const curve = new Float32Array(n);
+ const k = 2;
+ const scale = 1 / Math.tanh(k);
+ for (let i = 0; i < n; i++) curve[i] = Math.tanh(k * ((i * 2) / (n - 1) - 1)) * scale;
+ return curve;
+}
+
+function distortionSpec() {
+ return {
+ kind: 'distortion',
+ label: 'Distortion',
+ icon: '🔥',
+ group: 'Music FX',
+ ports: { in: [{ id: 'in', label: 'In', kind: 'audio' }], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ { key: 'drive', label: 'Drive', kind: 'range', min: 1, max: 50, step: 0.5, default: 10 },
+ { key: 'tone', label: 'Tone', kind: 'range', min: 500, max: 16000, step: 10, default: 8000, unit: 'Hz' },
+ { key: 'mix', label: 'Mix', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.8 }
+ ],
+ /** in -> drive -> shaper -> tone (low-pass) -> trim -> wet. @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const h = pedalGraph(ctx, params);
+ const drive = ctx.createGain();
+ drive.gain.value = clamp(num(params.drive, 10), 1, 100);
+ const shaper = ctx.createWaveShaper();
+ shaper.curve = softClipCurve();
+ shaper.oversample = '4x';
+ const tone = ctx.createBiquadFilter();
+ tone.type = 'lowpass';
+ tone.frequency.value = clamp(num(params.tone, 8000), 100, 20000);
+ // a saturated signal sits near full scale whatever came in; bring the wet side
+ // back to about where the dry side is
+ const trim = ctx.createGain();
+ trim.gain.value = 0.4;
+ h.input.connect(drive);
+ drive.connect(shaper);
+ shaper.connect(tone);
+ tone.connect(trim);
+ trim.connect(h.wet);
+ h.nodes.push(drive, shaper, tone, trim);
+ return Object.assign(h, { drive, shaper, tone });
+ },
+ onParam(h, key, value) {
+ h.params[key] = value;
+ if (key === 'drive') glide(h.drive.gain, clamp(num(value, 10), 1, 100), h.ctx);
+ else if (key === 'tone') glide(h.tone.frequency, clamp(num(value, 8000), 100, 20000), h.ctx);
+ else if (key === 'mix') setMix(h, value);
+ },
+ /** @param {any} three */
+ mesh: (three) => pedalMesh(three, 'Distortion', 0xef4444, 3)
+ };
+}
+
+/**
+ * The delay time a document asks for at a tempo. A synced division is a fraction of
+ * the beat at the SHARED bpm; 'free' is the `ms` knob. Pure of its two arguments.
+ * @param {any} params @param {number} bpm
+ */
+function delaySeconds(params, bpm) {
+ const division = DIVISIONS[String(params.time)];
+ const seconds = division ? (60 / clamp(num(bpm, 120), 20, 300)) * division : num(params.ms, 350) / 1000;
+ return clamp(seconds, 0.001, MAX_DELAY_S);
+}
+
+/** Re-aim one delay at a tempo; a no-op when nothing changed. A delay-time change is a
+ * glide too — a step in delayTime is a splice in the echo. @param {any} h @param {number} bpm */
+function applyDelayTime(h, bpm) {
+ const seconds = delaySeconds(h.params, bpm);
+ if (seconds === h.seconds) return;
+ h.seconds = seconds;
+ h.bpm = bpm;
+ glide(h.delay.delayTime, seconds, h.ctx, 0.02);
+}
+
+/** @param {any} api */
+function delaySpec(api) {
+ return {
+ kind: 'delay',
+ label: 'Delay',
+ icon: '🔁',
+ group: 'Music FX',
+ ports: { in: [{ id: 'in', label: 'In', kind: 'audio' }], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ {
+ key: 'time',
+ label: 'Time',
+ kind: 'select',
+ default: '1/8',
+ options: [
+ { value: '1/4', label: '1/4 (sync)' },
+ { value: '1/8', label: '1/8 (sync)' },
+ { value: '1/8T', label: '1/8 triplet (sync)' },
+ { value: '1/16', label: '1/16 (sync)' },
+ { value: 'free', label: 'Free (ms)' }
+ ]
+ },
+ { key: 'ms', label: 'Free time', kind: 'range', min: 10, max: 2000, step: 1, default: 350, unit: 'ms' },
+ { key: 'feedback', label: 'Feedback', kind: 'range', min: 0, max: 0.95, step: 0.01, default: 0.35 },
+ { key: 'mix', label: 'Mix', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.4 }
+ ],
+ /**
+ * in -> delay -> wet, with delay -> feedback -> delay as the loop. The delay time
+ * is set from the transport the moment it is built, so a peer who builds this
+ * after a tempo change lands on the same time as everyone else.
+ * @param {any} ctx @param {any} node @param {any} params
+ */
+ build(ctx, node, params) {
+ const h = pedalGraph(ctx, params);
+ const delay = ctx.createDelay(MAX_DELAY_S);
+ const feedback = ctx.createGain();
+ feedback.gain.value = clamp(num(params.feedback, 0.35), 0, 0.95);
+ h.input.connect(delay);
+ delay.connect(feedback);
+ feedback.connect(delay);
+ delay.connect(h.wet);
+ h.nodes.push(delay, feedback);
+ const bpm = api.audio.transport().bpm;
+ const seconds = delaySeconds(params, bpm);
+ delay.delayTime.value = seconds;
+ const handle = Object.assign(h, { delay, feedback, seconds, bpm, uuid: node.uuid });
+ const dispose = h.dispose;
+ handle.dispose = function () {
+ delays.delete(node.uuid);
+ dispose.call(this);
+ };
+ delays.set(node.uuid, handle);
+ return handle;
+ },
+ onParam(h, key, value) {
+ h.params[key] = value;
+ if (key === 'feedback') glide(h.feedback.gain, clamp(num(value, 0.35), 0, 0.95), h.ctx);
+ else if (key === 'mix') setMix(h, value);
+ else if (key === 'time' || key === 'ms') applyDelayTime(h, api.audio.transport().bpm);
+ },
+ /** @param {any} three */
+ mesh: (three) => pedalMesh(three, 'Delay', 0x2dd4bf, 3)
+ };
+}
+
+/**
+ * A GENERATED impulse: a stereo noise burst that decays as (1 - t/size)^decay. A few
+ * lines, sounds like a room, and keeps the zip a single file. The noise comes from a
+ * SEEDED generator, so the impulse is a pure function of (size, decay): every peer
+ * builds the same room from the same document, and a device rebuilt after an undo
+ * sounds like the one it replaced. (Math.random here would give each peer a room
+ * whose response at any one frequency is a different random draw — the flight
+ * measured a 4x spread in the tail of one sine between builds.)
+ * @param {any} ctx @param {any} size seconds @param {any} decay exponent
+ */
+function impulse(ctx, size, decay) {
+ const seconds = clamp(num(size, 1.5), 0.1, 6);
+ const exponent = clamp(num(decay, 3), 0.5, 10);
+ const length = Math.max(1, Math.floor(ctx.sampleRate * seconds));
+ const buffer = ctx.createBuffer(2, length, ctx.sampleRate);
+ // xorshift32 off the two knobs; never a zero state
+ let state = ((Math.round(seconds * 1000) * 7919 + Math.round(exponent * 1000) * 104729) | 0) || 0x9e3779b9;
+ const noise = () => {
+ state ^= state << 13;
+ state ^= state >>> 17;
+ state ^= state << 5;
+ return ((state >>> 0) / 4294967296) * 2 - 1;
+ };
+ for (let channel = 0; channel < 2; channel++) {
+ const data = buffer.getChannelData(channel);
+ for (let i = 0; i < length; i++) data[i] = noise() * Math.pow(1 - i / length, exponent);
+ }
+ return buffer;
+}
+
+function reverbSpec() {
+ return {
+ kind: 'reverb',
+ label: 'Reverb',
+ icon: '🏛️',
+ group: 'Music FX',
+ ports: { in: [{ id: 'in', label: 'In', kind: 'audio' }], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ { key: 'size', label: 'Size', kind: 'range', min: 0.1, max: 6, step: 0.1, default: 1.5, unit: 's' },
+ { key: 'decay', label: 'Decay', kind: 'range', min: 0.5, max: 10, step: 0.1, default: 3 },
+ { key: 'mix', label: 'Mix', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.35 }
+ ],
+ /** in -> convolver -> wet. @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const h = pedalGraph(ctx, params);
+ const convolver = ctx.createConvolver();
+ convolver.normalize = true;
+ convolver.buffer = impulse(ctx, params.size, params.decay);
+ h.input.connect(convolver);
+ convolver.connect(h.wet);
+ h.nodes.push(convolver);
+ const handle = Object.assign(h, { convolver, timer: null });
+ const dispose = h.dispose;
+ handle.dispose = function () {
+ clearTimeout(this.timer);
+ dispose.call(this);
+ };
+ return handle;
+ },
+ /** size and decay regenerate the impulse, DEBOUNCED — a knob drag writes many
+ * times a second and a 6 s buffer is not free */
+ onParam(h, key, value) {
+ h.params[key] = value;
+ if (key === 'mix') return setMix(h, value);
+ if (key !== 'size' && key !== 'decay') return;
+ clearTimeout(h.timer);
+ h.timer = setTimeout(() => {
+ if (h.disposed) return;
+ h.convolver.buffer = impulse(h.ctx, h.params.size, h.params.decay);
+ }, IMPULSE_DEBOUNCE_MS);
+ },
+ /** @param {any} three */
+ mesh: (three) => pedalMesh(three, 'Reverb', 0x8b5cf6, 3)
+ };
+}
+
+// ---- the bitcrusher --------------------------------------------------------------------
+//
+// An AudioWorklet, loaded from a BLOB URL built from the string below, so the module
+// stays one file: `URL.createObjectURL(new Blob([src]))` + `audioWorklet.addModule(url)`,
+// once per AudioContext. If the worklet cannot load (no `audioWorklet`, a CSP that
+// refuses blob workers), the same algorithm runs in a ScriptProcessorNode on the main
+// thread and the handle says so in `mode`.
+
+const CRUSHER_NAME = 'music-fx-bitcrush';
+
+/** The processor: quantise to `bits` and hold each value for `rate` samples. Both
+ * are k-rate AudioParams, so a knob is a `.value` write on the node. */
+const CRUSHER_SOURCE =
+ 'class MusicFxBitcrush extends AudioWorkletProcessor {\n' +
+ ' static get parameterDescriptors() {\n' +
+ " return [{ name: 'bits', defaultValue: 6, minValue: 1, maxValue: 16, automationRate: 'k-rate' },\n" +
+ " { name: 'rate', defaultValue: 6, minValue: 1, maxValue: 64, automationRate: 'k-rate' }];\n" +
+ ' }\n' +
+ ' constructor() { super(); this.phase = 0; this.held = [0, 0]; }\n' +
+ ' process(inputs, outputs, parameters) {\n' +
+ ' const input = inputs[0]; const output = outputs[0];\n' +
+ ' if (!input || !input.length || !output || !output.length) return true;\n' +
+ ' const step = Math.pow(2, Math.max(1, parameters.bits[0]) - 1);\n' +
+ ' const rate = Math.max(1, parameters.rate[0]);\n' +
+ ' let phase = this.phase;\n' +
+ ' for (let channel = 0; channel < output.length; channel++) {\n' +
+ ' const from = input[channel] || input[0]; const to = output[channel];\n' +
+ ' let held = this.held[channel] || 0; phase = this.phase;\n' +
+ ' for (let i = 0; i < to.length; i++) {\n' +
+ ' phase += 1;\n' +
+ ' if (phase >= rate) { phase -= rate; held = Math.round(from[i] * step) / step; }\n' +
+ ' to[i] = held;\n' +
+ ' }\n' +
+ ' this.held[channel] = held;\n' +
+ ' }\n' +
+ ' this.phase = phase;\n' +
+ ' return true;\n' +
+ ' }\n' +
+ '}\n' +
+ "registerProcessor('" + CRUSHER_NAME + "', MusicFxBitcrush);\n";
+
+/** One load per context: resolves true when the worklet is usable, false to fall back.
+ * @type {WeakMap>} */
+const crusherLoads = new WeakMap();
+
+/** @param {any} ctx */
+function crusherReady(ctx) {
+ let job = crusherLoads.get(ctx);
+ if (!job) {
+ job = (async () => {
+ if (!ctx.audioWorklet || typeof Blob === 'undefined' || typeof URL === 'undefined' || typeof AudioWorkletNode === 'undefined') return false;
+ const url = URL.createObjectURL(new Blob([CRUSHER_SOURCE], { type: 'application/javascript' }));
+ try {
+ await ctx.audioWorklet.addModule(url);
+ return true;
+ } catch (error) {
+ console.warn('[music-fx] the bitcrush worklet did not load; using a ScriptProcessorNode', error);
+ return false;
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+ })();
+ crusherLoads.set(ctx, job);
+ }
+ return job;
+}
+
+/** @param {any} h */
+function attachWorklet(h) {
+ const crusher = new AudioWorkletNode(h.ctx, CRUSHER_NAME, {
+ numberOfInputs: 1,
+ numberOfOutputs: 1,
+ outputChannelCount: [2],
+ parameterData: { bits: clamp(num(h.params.bits, 6), 1, 16), rate: clamp(num(h.params.rate, 6), 1, 64) }
+ });
+ h.input.connect(crusher);
+ crusher.connect(h.wet);
+ h.nodes.push(crusher);
+ h.crusher = crusher;
+ h.mode = 'worklet';
+}
+
+/** The same algorithm on the main thread, reading the params off the handle. @param {any} h */
+function attachScriptProcessor(h) {
+ const processor = h.ctx.createScriptProcessor(1024, 2, 2);
+ let phase = 0;
+ const held = [0, 0];
+ processor.onaudioprocess = (/** @type {any} */ event) => {
+ const step = Math.pow(2, clamp(num(h.params.bits, 6), 1, 16) - 1);
+ const rate = clamp(num(h.params.rate, 6), 1, 64);
+ const start = phase;
+ for (let channel = 0; channel < event.outputBuffer.numberOfChannels; channel++) {
+ const from = event.inputBuffer.getChannelData(Math.min(channel, event.inputBuffer.numberOfChannels - 1));
+ const to = event.outputBuffer.getChannelData(channel);
+ let value = held[channel] || 0;
+ phase = start;
+ for (let i = 0; i < to.length; i++) {
+ phase += 1;
+ if (phase >= rate) {
+ phase -= rate;
+ value = Math.round(from[i] * step) / step;
+ }
+ to[i] = value;
+ }
+ held[channel] = value;
+ }
+ };
+ h.input.connect(processor);
+ processor.connect(h.wet);
+ h.nodes.push(processor);
+ h.crusher = processor;
+ h.mode = 'script';
+}
+
+function bitcrushSpec() {
+ return {
+ kind: 'bitcrush',
+ label: 'Bitcrush',
+ icon: '👾',
+ group: 'Music FX',
+ ports: { in: [{ id: 'in', label: 'In', kind: 'audio' }], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ { key: 'bits', label: 'Bits', kind: 'range', min: 1, max: 16, step: 1, default: 6 },
+ { key: 'rate', label: 'Rate divisor', kind: 'range', min: 1, max: 64, step: 1, default: 6 },
+ { key: 'mix', label: 'Mix', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.8 }
+ ],
+ /**
+ * in -> crusher -> wet. The worklet module loads asynchronously the first time a
+ * crusher is built on a context; until it lands the dry path is all there is,
+ * and `mode` reads 'loading'. @param {any} ctx @param {any} node @param {any} params
+ */
+ build(ctx, node, params) {
+ const h = Object.assign(pedalGraph(ctx, params), { crusher: null, mode: 'loading' });
+ crusherReady(ctx).then((ok) => {
+ if (h.disposed) return;
+ try {
+ if (ok) attachWorklet(h);
+ else attachScriptProcessor(h);
+ } catch (error) {
+ console.warn('[music-fx] bitcrush could not attach its worklet; using a ScriptProcessorNode', error);
+ if (h.mode !== 'script') attachScriptProcessor(h);
+ }
+ });
+ return h;
+ },
+ onParam(h, key, value) {
+ h.params[key] = value;
+ if (key === 'mix') return setMix(h, value);
+ if (h.mode !== 'worklet' || !h.crusher) return; // the script path reads h.params live
+ if (key === 'bits') h.crusher.parameters.get('bits').value = clamp(num(value, 6), 1, 16);
+ else if (key === 'rate') h.crusher.parameters.get('rate').value = clamp(num(value, 6), 1, 64);
+ },
+ /** @param {any} three */
+ mesh: (three) => pedalMesh(three, 'Bitcrush', 0x84cc16, 3)
+ };
+}
+
+// ---- the demo chain ------------------------------------------------------------------
+
+/** Device objects in the scene by kind, for the demo chain to plug into whatever Music
+ * Lab gear is already in the room. @param {any} api @param {string[]} kinds */
+function devicesByKind(api, kinds) {
+ /** @type {Record} */
+ const found = {};
+ api.objectsGroup()?.traverse?.((/** @type {any} */ node) => {
+ const kind = node?.userData?.device?.kind;
+ if (typeof kind === 'string' && kinds.includes(kind) && !found[kind]) found[kind] = node;
+ });
+ return found;
+}
+
+// ---- the module -----------------------------------------------------------------------
+
+export default {
+ id: 'music-fx',
+ name: 'Music FX',
+ version: '0.1.0',
+ description: 'A four-channel mixer and five pedals (delay, reverb, filter, distortion, bitcrush) as audio devices. Cable them in any order.',
+ /** @param {any} api */
+ register(api) {
+ // `mesh(three)` is handed the app's own three by the registry — never `import` it
+ void api.THREE;
+
+ api.registerAudioDevice(mixerSpec());
+ api.registerAudioDevice(filterSpec());
+ api.registerAudioDevice(distortionSpec());
+ api.registerAudioDevice(delaySpec(api));
+ api.registerAudioDevice(reverbSpec());
+ api.registerAudioDevice(bitcrushSpec());
+
+ // ONE task for every synced delay: re-read the shared bpm a few times a second
+ // and re-aim whichever delays it moved. The bpm is the replicated transport, so
+ // every peer's delays land on the same time from the same number.
+ let nextSync = 0;
+ api.registerFrameTask(() => {
+ const now = performance.now();
+ if (now < nextSync || delays.size === 0) return;
+ nextSync = now + 1000 / SYNC_HZ;
+ const bpm = api.audio.transport().bpm;
+ for (const h of delays.values()) if (DIVISIONS[String(h.params.time)] && h.bpm !== bpm) applyDelayTime(h, bpm);
+ });
+
+ api.registerMenu('Music FX: demo chain', () => {
+ const order = ['filter', 'distortion', 'bitcrush', 'delay', 'reverb'];
+ const pedals = order.map((kind, index) => api.audio.addDevice(kind, { position: [-1.6 + index * 0.5, 0.9, -2] }));
+ const mixer = api.audio.addDevice('mixer', { position: [1.2, 0.9, -2] });
+ if (pedals.some((pedal) => !pedal) || !mixer) return api.toast('Music FX: could not add the devices');
+ for (let i = 0; i + 1 < pedals.length; i++)
+ api.audio.cable({ from: { uuid: pedals[i].uuid, port: 'out' }, to: { uuid: pedals[i + 1].uuid, port: 'in' } });
+ api.audio.cable({ from: { uuid: pedals[pedals.length - 1].uuid, port: 'out' }, to: { uuid: mixer.uuid, port: 'in1' } });
+ // Music Lab gear already in the room joins the chain; otherwise say what to plug
+ const lab = devicesByKind(api, ['mod-music-lab-piano', 'mod-music-lab-speaker']);
+ const piano = lab['mod-music-lab-piano'];
+ const speaker = lab['mod-music-lab-speaker'];
+ if (piano) api.audio.cable({ from: { uuid: piano.uuid, port: 'out' }, to: { uuid: pedals[0].uuid, port: 'in' } });
+ if (speaker) api.audio.cable({ from: { uuid: mixer.uuid, port: 'out' }, to: { uuid: speaker.uuid, port: 'in' } });
+ if (piano && speaker) api.toast('Music FX: the piano runs through five pedals into the mixer and out of the speaker');
+ else api.toast('Music FX: cable an instrument into the Filter and the mixer Out into a speaker');
+ });
+
+ api.onSceneClear(() => delays.clear());
+ }
+};
diff --git a/modules/music-lab/README.md b/modules/music-lab/README.md
new file mode 100644
index 0000000..4997803
--- /dev/null
+++ b/modules/music-lab/README.md
@@ -0,0 +1,76 @@
+# Music Lab
+
+Two **audio devices** for the app's audio engine (#23), and one cable between them.
+
+| Device | Ports | What it is |
+|---|---|---|
+| **Piano** | `out` (audio) | One octave, C4..B4. Click a key — desktop click or VR trigger, one path — and every peer hears the same note at the same stamped time and sees the same key dip. |
+| **Speaker** | `in` (audio) | The spatial output: an HRTF `PannerNode` that follows the object, with a cone, so where you put it and which way you point it are both audible. |
+
+Add both from the viewport **Add ▸ Devices: Music Lab**, or use the sidebar button
+**Music Lab: piano + speaker**, which drops one of each and cables them together.
+
+**Sound goes nowhere until it is cabled.** The piano's voices land on the piano's own
+output gain and stop there; the speaker is the only thing here that reaches the
+`instruments` bus. Pull the cable and the room goes quiet — that is what a patch means.
+
+## Params
+
+*Piano* — `level` (0..1), `release` (0.05..2 s), `wave` (sine / triangle / saw /
+square). *Speaker* — `level` (0..1), `cone` (inner angle 10..360°; the outer angle is
+that plus 60°, at a quarter gain), `rolloff` (0.1..5), `maxDistance` (5..200 m,
+default 40 — the distance voice chat carries).
+
+Every one of them is live: `onParam` adjusts the running graph and never rebuilds it.
+
+## How it replicates
+
+Nothing in this module sends a message. A note goes out through `api.audio.note`,
+which stamps it with the wall clock and hands every peer the same
+`onNote(handle, {note, velocity, at})`; each peer builds its own voice and starts it
+at `api.audio.timeFor(at)`. The device objects, their params and the cable are core's
+`userData.device` document and the patch — so they replicate, undo, save and reach a
+late joiner with no state sync of this module's own.
+
+The key dip runs from `onNote`, not from the click: the peer who did not click has to
+dip the same key.
+
+## Testing
+
+```bash
+npm run pack -- music-lab
+APP_URL=https://localhost:5216/ npm test -- music-lab
+```
+
+The flight installs the real zip on three peers and measures actual sound through a
+destination tap: silent uncabled, heard when cabled, quieter when the speaker is moved
+away or turned around, silent again when it is unplugged, and heard by a late joiner.
+
+## The beat lab (23-C2)
+
+Three more devices, all through the same `registerAudioDevice` contract:
+
+- **Transport** - the face of the SHARED clock. Play/Stop, BPM -/+ (5 at a time), tap
+ tempo, and a display with the bar counter that every peer draws itself (a canvas
+ texture made in `build`, never replicated). Only the resulting `play`/`setBpm` writes
+ leave the device, and those are the transport's own replicated messages.
+- **Drum machine** - 16 steps x 8 pads (kick, snare, hat, open hat, clap, tom, rim,
+ crash), synthesized, no assets. **The pattern is the device document**: the `pattern`
+ param is 8 rows of 16 velocity digits (`0`..`9`) joined by `/`, so it replicates,
+ saves and undoes as one thing with no channel of its own. Click a step to toggle it;
+ hold and sweep across the grid to paint - the sweep previews locally and commits ONE
+ write when it ends (one Ctrl+Z reverts the whole drag). A pad button plays the pad now
+ (a replicated note; pad n = note 36 + n, so a flow Note Trigger reaches it too). Steps
+ are scheduled through the engine's look-ahead scheduler: every peer runs the same
+ pattern from the same transport, a late joiner starts on the NEXT beat, and nothing
+ crosses the wire during playback. Per-pad settings (`level`, `pan`, `mute`, `choke`,
+ `sample`) live in the `pads` param, one JSON string keyed by pad index.
+- **Sampler pads** - 4x4. Drop an audio item from the Explorer onto a pad (or onto the
+ body for the first empty pad) and it becomes that pad's sample by CONTENT HASH; every
+ peer pulls and decodes the bytes itself (`api.audio.sample`). Per pad: `sample`,
+ `pitch` (semitones), `start` (0..1), `loop`, `gate` (s), `level`, `pan` - in `pads`.
+ An empty pad still answers with a tick. Pad n = note 36 + n.
+
+Sidebar: **Music Lab: beat lab** adds the four (with a speaker) and a starter pattern.
+Needs core's `api.registerDropHandler` (23-C2) for the drop; everything else runs on
+the A-batch engine.
diff --git a/modules/music-lab/manifest.json b/modules/music-lab/manifest.json
new file mode 100644
index 0000000..7fb765f
--- /dev/null
+++ b/modules/music-lab/manifest.json
@@ -0,0 +1,11 @@
+{
+ "id": "music-lab",
+ "name": "Music Lab",
+ "version": "0.2.0",
+ "format": 1,
+ "description": "A Piano, a Speaker, a Transport, a Drum machine and Sampler pads on the engine. Cable them, paint a beat, press Play.",
+ "entry": "module.js",
+ "files": [
+ "module.js"
+ ]
+}
diff --git a/modules/music-lab/module.js b/modules/music-lab/module.js
new file mode 100644
index 0000000..73d4ea5
--- /dev/null
+++ b/modules/music-lab/module.js
@@ -0,0 +1,1293 @@
+// Music Lab — roadmap #23 C1: the audio-device architecture proven end to end with
+// two devices, a Piano and a Speaker, and one cable between them.
+//
+// THE WHOLE MODULE IS TWO `registerAudioDevice` SPECS. Core owns the object, the
+// replication, the undo, the saving, the cables and the clock; this file owns what a
+// kind sounds like, what it looks like, and which of its params do what. Nothing here
+// keeps replicated state of its own and nothing here sends a message: a note goes out
+// through `api.audio.note`, which stamps it and hands every peer the same
+// `onNote(handle, {note, velocity, at})` to run.
+//
+// THE ONE RULE THAT MAKES THAT WORK: `build`, `onParam` and `onNote` must be PURE
+// FUNCTIONS OF THEIR ARGUMENTS. Every peer runs them, from the same document and the
+// same stamp; anything read out of local state (which key I clicked, what time it is
+// here) desyncs silently, per peer, with no error anywhere. So the key that dips is
+// found from the NOTE inside `onNote`, never from the click that started it — the peer
+// who did not click has to dip the same key.
+//
+// SOUND GOES NOWHERE UNTIL IT IS CABLED. The piano's voices land on its own output
+// gain, and that gain connects to nothing; the speaker is the only thing here that
+// reaches `api.audio.bus('instruments')`. Pull the cable and the master bus drops to
+// the noise floor. That is the design, not an omission.
+
+const WHITE_MIDI = [60, 62, 64, 65, 67, 69, 71]; // C4..B4
+const BLACK_MIDI = { 0: 61, 1: 63, 3: 66, 4: 68, 5: 70 }; // after C, D, F, G, A
+const KEY_STEP = 0.24;
+const KEY_DIP = 0.02;
+const KEY_DIP_MS = 150;
+
+/** How often the panners are re-aimed. 20 Hz is far more than a speaker being
+ * carried across a room needs, and the app's own AudioListener update is 10 Hz. */
+const FOLLOW_HZ = 20;
+
+/** The NAMESPACED kind (`mod-music-lab-piano`), once registerAudioDevice resolves —
+ * what the click handler matches an object's `userData.device.kind` against. */
+let PIANO_KIND = '';
+
+/**
+ * Every built speaker, so ONE frame task moves every panner. A panner that does not
+ * follow its object is a speaker you can carry around silently — the whole point of a
+ * device being an object is that moving it moves where it sounds from.
+ * @type {Map}
+ */
+const speakers = new Map();
+
+// ---- meshes --------------------------------------------------------------------------
+//
+// A cable attaches to the child named for the port. Core's renderer looks for
+// `port:`; the VR patching work names the same thing `vrpatch-in:` /
+// `vrpatch-out:`. Both names are here — the visible plug carries the vrpatch name
+// and holds a zero-offset `port:` marker — so the cable lands on the plug whichever
+// convention the build we are running looks for.
+
+/** @param {any} three @param {'in'|'out'} side @param {string} id @param {number} color @param {number} z */
+function plug(three, side, id, color, z) {
+ const mesh = new three.Mesh(
+ new three.CylinderGeometry(0.02, 0.02, 0.04, 12),
+ new three.MeshStandardMaterial({ color, roughness: 0.4, metalness: 0.6 })
+ );
+ mesh.rotation.x = Math.PI / 2; // lie along Z, sticking out of the back
+ mesh.position.set(0, 0, z);
+ mesh.name = 'vrpatch-' + side + ':' + id;
+ const marker = new three.Object3D();
+ marker.name = 'port:' + id;
+ mesh.add(marker);
+ return mesh;
+}
+
+/**
+ * THE DEVICE ROOT IS A MESH, NOT A GROUP, and that is load-bearing: the late-joiner
+ * object sync sends a `Group` as a bare `{type:'group', name, uuid, pos, rot, scale}`
+ * message with NO userData, so a device whose mesh() returns a Group reaches a joiner
+ * as an ordinary group with its `userData.device` gone — no kind, no sound. A mesh
+ * WITH children goes as `toJSON()` (userData intact) plus its children after it. Core's
+ * own placeholder and default device mesh are meshes for the same reason.
+ * @param {any} three
+ */
+function speakerMesh(three) {
+ const cabinet = new three.Mesh(
+ new three.BoxGeometry(0.5, 0.7, 0.4),
+ new three.MeshStandardMaterial({ color: 0x2b2b30, roughness: 0.85, metalness: 0.05 })
+ );
+ cabinet.name = 'Speaker';
+ // the driver, on the FRONT (-Z). Which way it points is AUDIBLE — the panner's cone
+ // is aimed down this same -Z — so it has to be visible from across the room.
+ const cone = new three.Mesh(
+ new three.ConeGeometry(0.17, 0.12, 24, 1, true),
+ new three.MeshStandardMaterial({ color: 0xd8b26a, roughness: 0.55, metalness: 0.3, side: three.DoubleSide })
+ );
+ cone.rotation.x = -Math.PI / 2; // the cone's +Y axis onto -Z
+ cone.position.set(0, -0.06, -0.23);
+ cone.name = 'grille';
+ cabinet.add(cone);
+ const tweeter = new three.Mesh(
+ new three.CylinderGeometry(0.055, 0.055, 0.03, 16),
+ new three.MeshStandardMaterial({ color: 0x111114, roughness: 0.5 })
+ );
+ tweeter.rotation.x = Math.PI / 2;
+ tweeter.position.set(0, 0.22, -0.21);
+ tweeter.name = 'tweeter';
+ cabinet.add(tweeter);
+ cabinet.add(plug(three, 'in', 'in', 0xfb923c, 0.22));
+ return cabinet;
+}
+
+/** @param {any} three */
+function pianoMesh(three) {
+ const body = new three.Mesh(
+ new three.BoxGeometry(WHITE_MIDI.length * KEY_STEP + 0.08, 0.1, 1.05),
+ new three.MeshStandardMaterial({ color: 0x5c3a21, roughness: 0.8 })
+ );
+ body.name = 'Piano';
+ const left = -((WHITE_MIDI.length - 1) * KEY_STEP) / 2;
+ const white = new three.MeshStandardMaterial({ color: 0xf5f5f0, roughness: 0.6 });
+ const black = new three.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.5 });
+ WHITE_MIDI.forEach((midi, index) => {
+ const key = new three.Mesh(new three.BoxGeometry(0.22, 0.06, 0.9), white);
+ key.position.set(left + index * KEY_STEP, 0.08, 0);
+ // the CLICK reads this (desktop and VR trigger are one path), and so does onNote.
+ // The NAME carries the same number, because userData rides GLTF extras on the
+ // late-joiner path and a name rides the node itself.
+ key.userData.midi = midi;
+ key.name = 'key-' + midi;
+ body.add(key);
+ });
+ for (const [afterWhite, midi] of Object.entries(BLACK_MIDI)) {
+ const key = new three.Mesh(new three.BoxGeometry(0.13, 0.07, 0.55), black);
+ key.position.set(left + (Number(afterWhite) + 0.5) * KEY_STEP, 0.125, -0.17);
+ key.userData.midi = midi;
+ key.name = 'key-' + midi;
+ body.add(key);
+ }
+ body.add(plug(three, 'out', 'out', 0xfb923c, 0.55));
+ return body;
+}
+
+// ---- the key dip ---------------------------------------------------------------------
+
+/**
+ * The note a mesh stands for, from its userData or — when a replication path dropped
+ * that — from its name. @param {any} object @returns {number|null}
+ */
+function noteOf(object) {
+ const midi = object?.userData?.midi;
+ if (typeof midi === 'number') return midi;
+ const named = typeof object?.name === 'string' && object.name.startsWith('key-') ? Number(object.name.slice(4)) : NaN;
+ return Number.isFinite(named) ? named : null;
+}
+
+/**
+ * Dip the key a note belongs to. Driven from `onNote`, so the peer who clicked and the
+ * peer who only heard about it move the same key. Purely visual and purely local: a
+ * dropped dip is a missed frame, never a desync.
+ * @param {any} node the device object @param {number} note
+ */
+function dipKey(node, note) {
+ if (!node) return;
+ let key = node.getObjectByName?.('key-' + note) ?? null;
+ if (!key)
+ node.traverse?.((/** @type {any} */ child) => {
+ if (!key && noteOf(child) === note) key = child;
+ });
+ if (!key || key.userData.dipped) return;
+ key.userData.dipped = true;
+ key.position.y -= KEY_DIP;
+ setTimeout(() => {
+ key.position.y += KEY_DIP;
+ key.userData.dipped = false;
+ }, KEY_DIP_MS);
+}
+
+// ---- the device specs ------------------------------------------------------------------
+
+/** @param {any} panner @param {number} inner the one knob; the outer angle derives from it */
+function applyCone(panner, inner) {
+ const innerAngle = Math.max(10, Math.min(360, Number(inner) || 120));
+ panner.coneInnerAngle = innerAngle;
+ panner.coneOuterAngle = Math.min(360, innerAngle + 60);
+ // not zero: a speaker behind you is quieter, not gone. 0.25 is about a bookshelf
+ // cabinet heard from behind.
+ panner.coneOuterGain = 0.25;
+}
+
+/** @param {any} api */
+function speakerSpec(api) {
+ return {
+ kind: 'speaker',
+ label: 'Speaker',
+ icon: '🔊',
+ group: 'Music Lab',
+ ports: { in: [{ id: 'in', label: 'In', kind: 'audio' }], out: [] },
+ params: [
+ { key: 'level', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.8 },
+ { key: 'cone', label: 'Cone width', kind: 'range', min: 10, max: 360, step: 1, default: 120, unit: '°' },
+ { key: 'rolloff', label: 'Rolloff', kind: 'range', min: 0.1, max: 5, step: 0.1, default: 1 },
+ { key: 'maxDistance', label: 'Max distance', kind: 'range', min: 5, max: 200, step: 1, default: 40, unit: 'm' }
+ ],
+ /**
+ * in -> level -> HRTF panner -> the instruments bus. The panner is what makes this
+ * a place in the room rather than a track in a mix; `maxDistance` defaults to 40 m,
+ * the same distance voice chat carries.
+ * @param {any} ctx @param {any} node @param {any} params
+ */
+ build(ctx, node, params) {
+ const input = ctx.createGain();
+ const gain = ctx.createGain();
+ gain.gain.value = Number(params.level ?? 0.8);
+ const panner = ctx.createPanner();
+ panner.panningModel = 'HRTF';
+ panner.distanceModel = 'inverse';
+ panner.refDistance = 1;
+ panner.rolloffFactor = Number(params.rolloff ?? 1);
+ panner.maxDistance = Number(params.maxDistance ?? 40);
+ applyCone(panner, params.cone ?? 120);
+ input.connect(gain);
+ gain.connect(panner);
+ panner.connect(api.audio.bus('instruments') ?? ctx.destination);
+ // the panner starts WHERE THE OBJECT IS, not at the origin: one frame at the
+ // wrong place is an audible click on a device that was just dropped in
+ aim(node, panner);
+ speakers.set(node.uuid, { node, panner });
+ return {
+ input,
+ panner,
+ gain,
+ dispose() {
+ speakers.delete(node.uuid);
+ try {
+ input.disconnect();
+ gain.disconnect();
+ panner.disconnect();
+ } catch {
+ /* already gone */
+ }
+ }
+ };
+ },
+ /** every param is live — a knob must not restart the audio it is adjusting */
+ onParam(handle, key, value) {
+ if (key === 'level') handle.gain.gain.value = Number(value);
+ else if (key === 'cone') applyCone(handle.panner, value);
+ else if (key === 'rolloff') handle.panner.rolloffFactor = Number(value);
+ else if (key === 'maxDistance') handle.panner.maxDistance = Number(value);
+ },
+ /** @param {any} three */
+ mesh: (three) => speakerMesh(three)
+ };
+}
+
+/** @param {any} api */
+function pianoSpec(api) {
+ return {
+ kind: 'piano',
+ label: 'Piano',
+ icon: '🎹',
+ group: 'Music Lab',
+ ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ { key: 'level', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.8 },
+ { key: 'release', label: 'Release', kind: 'range', min: 0.05, max: 2, step: 0.01, default: 0.4, unit: 's' },
+ {
+ key: 'wave',
+ label: 'Wave',
+ kind: 'select',
+ default: 'triangle',
+ options: [
+ { value: 'sine', label: 'Sine' },
+ { value: 'triangle', label: 'Triangle' },
+ { value: 'sawtooth', label: 'Saw' },
+ { value: 'square', label: 'Square' }
+ ]
+ }
+ ],
+ /**
+ * One gain, connected to NOTHING. Every voice lands here, and here is where a
+ * cable picks it up — an instrument that reached a bus by itself would still be
+ * heard with its cable pulled, which is the one thing a patch has to mean.
+ * The params ride the handle because `onNote` gets the handle, not the document.
+ * @param {any} ctx @param {any} node @param {any} params
+ */
+ build(ctx, node, params) {
+ const output = ctx.createGain();
+ output.gain.value = 1;
+ return {
+ output,
+ params: { ...params },
+ /** @type {any[]} */
+ voices: [],
+ dispose() {
+ for (const voice of this.voices) {
+ try {
+ voice.dispose();
+ } catch {
+ /* already disposed */
+ }
+ }
+ this.voices.length = 0;
+ output.disconnect();
+ }
+ };
+ },
+ onParam(handle, key, value) {
+ handle.params[key] = value;
+ },
+ /**
+ * A note, on every peer, from the same stamp. `at` is WALL CLOCK and comes down the
+ * wire raw; `api.audio.timeFor` is the only correct way to turn it into a time this
+ * context can schedule at — a `ctx.currentTime + something` of our own would put
+ * two peers' notes tens of milliseconds apart, which is audible as a flam.
+ * @param {any} handle @param {any} event @param {any} node
+ */
+ onNote(handle, { note, velocity, at }, node) {
+ const level = Number(handle.params.level ?? 0.8);
+ const gain = Math.max(0.001, Math.min(1, Number(velocity ?? 1))) * level;
+ const voice = api.audio.voice({
+ type: handle.params.wave ?? 'triangle',
+ freq: 440 * Math.pow(2, (Number(note) - 69) / 12),
+ gain,
+ attack: 0.005,
+ decay: 0.08,
+ sustain: 0.5,
+ release: Number(handle.params.release ?? 0.4),
+ destination: handle.output
+ });
+ if (!voice) return;
+ const t = api.audio.timeFor(typeof at === 'number' ? at : Date.now());
+ voice.start(t);
+ voice.stop(t + 0.35);
+ handle.voices.push(voice);
+ // polyphony is a LIST of voices, each cleaned up after its own release
+ setTimeout(() => {
+ const index = handle.voices.indexOf(voice);
+ if (index >= 0) handle.voices.splice(index, 1);
+ voice.dispose();
+ }, 1000);
+ dipKey(node, Number(note));
+ },
+ /** @param {any} three */
+ mesh: (three) => pianoMesh(three)
+ };
+}
+
+// ---- the panner follow ------------------------------------------------------------------
+
+/** Point one panner at its object's world pose. @param {any} node @param {any} panner */
+function aim(node, panner) {
+ node.updateWorldMatrix?.(true, false);
+ const m = node.matrixWorld?.elements;
+ if (!m) return;
+ const px = m[12];
+ const py = m[13];
+ const pz = m[14];
+ // the -Z column of the world matrix is the object's forward, normalized (the matrix
+ // carries scale, and an unnormalized orientation makes the cone nonsense)
+ let fx = -m[8];
+ let fy = -m[9];
+ let fz = -m[10];
+ const length = Math.hypot(fx, fy, fz) || 1;
+ fx /= length;
+ fy /= length;
+ fz /= length;
+ if (panner.positionX) {
+ panner.positionX.value = px;
+ panner.positionY.value = py;
+ panner.positionZ.value = pz;
+ panner.orientationX.value = fx;
+ panner.orientationY.value = fy;
+ panner.orientationZ.value = fz;
+ } else {
+ // Safari and anything else still on the pre-AudioParam PannerNode
+ panner.setPosition(px, py, pz);
+ panner.setOrientation(fx, fy, fz);
+ }
+}
+
+// ---- the module -----------------------------------------------------------------------
+
+// ---- 23-C2: the beat lab ----------------------------------------------------------------
+//
+// Three more devices. A TRANSPORT you walk up to - the face of the SHARED clock: play/stop,
+// BPM, tap tempo, a bar counter - because a room full of sequenced devices needs one obvious
+// place to start and stop. A DRUM MACHINE, 16 steps x 8 pads: THE PATTERN LIVES IN
+// userData.device (the `pattern` param, one string), so it replicates, saves and undoes as
+// one thing with no new channel - the whole payoff of the A3 contract. SAMPLER pads, 4x4,
+// a sample per pad dropped from the Explorer (api.registerDropHandler).
+//
+// Steps are scheduled through the engine's look-ahead scheduler (api.audio.schedule): every
+// peer runs the same pattern from the same transport, so every peer's kick lands on the same
+// sample frame and nothing crosses the wire during playback. The only writes are
+// api.audio.setParams (one undo entry each) and the shared transport's own play/setBpm.
+// A paint stroke across the grid previews LOCALLY (the way a knob scrub previews) and
+// commits ONE write when it ends, so a drag is one undo entry.
+
+const STEPS = 16;
+const DRUM_PADS = 8;
+const DRUM_NAMES = ['Kick', 'Snare', 'Hat', 'Open hat', 'Clap', 'Tom', 'Rim', 'Crash'];
+const DRUM_COLORS = [0xef4444, 0xf59e0b, 0xfacc15, 0xa3e635, 0x22d3ee, 0x818cf8, 0xf472b6, 0xe5e7eb];
+const SAMPLER_PADS = 16;
+/** pad n of a drum machine or a sampler plays as note BASE + n, so a flow Note Trigger
+ * (23-B3) and the piano's own note path reach the pads with no extra API */
+const PAD_BASE_NOTE = 36;
+const STEP_ON = '7'; // the velocity digit a click paints (0..9 -> 0..1)
+const STROKE_IDLE_MS = 900; // a paint stroke commits this long after its last new step
+const FLASH_MS = 90;
+const DISPLAY_HZ = 12;
+const CELL_X0 = -0.33;
+const CELL_DX = 0.05;
+const ROW_Z0 = -0.19;
+const ROW_DZ = 0.054;
+
+/** the resolved namespaced kinds, once registerAudioDevice resolves */
+const KINDS = { drums: '', sampler: '', transport: '' };
+
+// ---- the pattern (pure helpers) ---------------------------------------------------------
+
+/** @returns {string} DRUM_PADS rows of STEPS velocity digits, joined by '/' */
+function emptyPattern() {
+ return Array.from({ length: DRUM_PADS }, () => '0'.repeat(STEPS)).join('/');
+}
+/** @param {any} pattern @returns {string[]} */
+function patternRows(pattern) {
+ const rows = String(pattern ?? '').split('/');
+ return Array.from({ length: DRUM_PADS }, (_, r) => (rows[r] ?? '').padEnd(STEPS, '0').slice(0, STEPS));
+}
+/** @param {string[]} rows @param {number} pad @param {number} step */
+function stepDigit(rows, pad, step) {
+ const d = Number.parseInt(rows[pad]?.[step] ?? '0', 10);
+ return Number.isFinite(d) ? Math.max(0, Math.min(9, d)) : 0;
+}
+/** @param {string[]} rows @param {number} pad @param {number} step @param {number|string} digit */
+function withStepDigit(rows, pad, step, digit) {
+ return rows.map((row, r) => (r === pad ? row.slice(0, step) + String(digit) + row.slice(step + 1) : row)).join('/');
+}
+/** per-pad settings live in ONE JSON string param: {"": {sample, name, level, pan, mute, choke, pitch, start, loop, gate}}
+ * @param {any} json */
+function padTable(json) {
+ try {
+ const v = JSON.parse(json || '{}');
+ return v && typeof v === 'object' ? v : {};
+ } catch {
+ return {};
+ }
+}
+/** @param {any} table @param {number} pad @param {string} key @param {any} fallback */
+function padSetting(table, pad, key, fallback) {
+ const v = table?.[pad]?.[key];
+ return v === undefined || v === null ? fallback : v;
+}
+
+// ---- voices ------------------------------------------------------------------------------
+
+/** one deterministic noise buffer per context (an LCG, so every peer's snare is the same
+ * noise) @type {WeakMap} */
+const noiseBuffers = new WeakMap();
+/** @param {any} ctx */
+function noiseBuffer(ctx) {
+ let buffer = noiseBuffers.get(ctx);
+ if (buffer) return buffer;
+ buffer = ctx.createBuffer(1, Math.floor(ctx.sampleRate * 1.5), ctx.sampleRate);
+ const data = buffer.getChannelData(0);
+ let seed = 1234567;
+ for (let i = 0; i < data.length; i++) {
+ seed = (seed * 1664525 + 1013904223) >>> 0;
+ data[i] = (seed / 4294967296) * 2 - 1;
+ }
+ noiseBuffers.set(ctx, buffer);
+ return buffer;
+}
+
+/**
+ * A synthesized drum at audio time `at` - a pure function of (pad, velocity, at), which is
+ * what lets every peer play the identical hit from the identical schedule.
+ * @param {any} ctx @param {any} destination @param {number} pad @param {number} velocity @param {number} at
+ */
+function playDrum(ctx, destination, pad, velocity, at) {
+ const v = Math.max(0.05, Math.min(1, velocity));
+ /** @type {any[]} */
+ const sources = [];
+ /** @param {string} type @param {number} f0 @param {number} f1 @param {number} dur @param {number} peak */
+ const tone = (type, f0, f1, dur, peak) => {
+ const osc = ctx.createOscillator();
+ osc.type = type;
+ osc.frequency.setValueAtTime(f0, at);
+ if (f1 !== f0) osc.frequency.exponentialRampToValueAtTime(f1, at + dur * 0.8);
+ const gain = ctx.createGain();
+ gain.gain.setValueAtTime(peak * v, at);
+ gain.gain.exponentialRampToValueAtTime(0.001, at + dur);
+ osc.connect(gain);
+ gain.connect(destination);
+ osc.start(at);
+ osc.stop(at + dur + 0.02);
+ sources.push(osc);
+ };
+ /** @param {string} filter @param {number} freq @param {number} q @param {number} dur @param {number} peak @param {number} [delay] */
+ const noise = (filter, freq, q, dur, peak, delay = 0) => {
+ const src = ctx.createBufferSource();
+ src.buffer = noiseBuffer(ctx);
+ const biquad = ctx.createBiquadFilter();
+ biquad.type = filter;
+ biquad.frequency.value = freq;
+ biquad.Q.value = q;
+ const gain = ctx.createGain();
+ gain.gain.setValueAtTime(0.0001, at + delay);
+ gain.gain.linearRampToValueAtTime(peak * v, at + delay + 0.003);
+ gain.gain.exponentialRampToValueAtTime(0.001, at + delay + dur);
+ src.connect(biquad);
+ biquad.connect(gain);
+ gain.connect(destination);
+ src.start(at + delay);
+ src.stop(at + delay + dur + 0.02);
+ sources.push(src);
+ };
+ switch (pad) {
+ case 0: tone('sine', 160, 45, 0.28, 1.0); noise('lowpass', 900, 0.7, 0.02, 0.4); break;
+ case 1: tone('triangle', 200, 160, 0.12, 0.5); noise('bandpass', 1800, 0.8, 0.18, 0.8); break;
+ case 2: noise('highpass', 7000, 1, 0.05, 0.6); break;
+ case 3: noise('highpass', 6500, 1, 0.35, 0.5); break;
+ case 4: noise('bandpass', 1300, 1.2, 0.05, 0.7); noise('bandpass', 1300, 1.2, 0.05, 0.6, 0.02); noise('bandpass', 1300, 1.2, 0.16, 0.7, 0.04); break;
+ case 5: tone('sine', 220, 90, 0.3, 0.9); break;
+ case 6: tone('square', 1000, 1000, 0.03, 0.35); noise('bandpass', 3000, 2, 0.04, 0.4); break;
+ default: noise('highpass', 5000, 0.5, 1.2, 0.5); break;
+ }
+ return {
+ /** @param {number} t */
+ stop(t) {
+ for (const s of sources) {
+ try { s.stop(t); } catch { /* already stopped */ }
+ }
+ }
+ };
+}
+
+/**
+ * A sample at audio time `at`: pitch in semitones, start as a fraction of the buffer, loop,
+ * gate (seconds, 0 = one-shot to the end).
+ * @param {any} ctx @param {any} destination @param {any} buffer @param {number} at
+ * @param {{pitch?: number, start?: number, loop?: boolean, gate?: number, level?: number, velocity?: number}} opts
+ */
+function playSample(ctx, destination, buffer, at, opts = {}) {
+ const src = ctx.createBufferSource();
+ src.buffer = buffer;
+ src.playbackRate.value = Math.pow(2, (Number(opts.pitch) || 0) / 12);
+ src.loop = !!opts.loop;
+ const gain = ctx.createGain();
+ gain.gain.value = Math.max(0, Math.min(1, Number(opts.level ?? 1))) * Math.max(0, Math.min(1, Number(opts.velocity ?? 1)));
+ src.connect(gain);
+ gain.connect(destination);
+ const start = Math.max(0, Math.min(buffer.duration, (Number(opts.start) || 0) * buffer.duration));
+ src.start(at, start);
+ const gate = Number(opts.gate) || 0;
+ if (!opts.loop && gate > 0) src.stop(at + Math.max(0.02, gate));
+ return {
+ /** @param {number} t */
+ stop(t) {
+ try {
+ gain.gain.setTargetAtTime(0, t, 0.01);
+ src.stop(t + 0.06);
+ } catch { /* already stopped */ }
+ }
+ };
+}
+
+/** the pad's own panner, made once and connected to the device output @param {any} h @param {number} pad @param {number} pan */
+function padDestination(h, pad, pan) {
+ let node = h.panners.get(pad);
+ if (!node) {
+ node = h.ctx.createStereoPanner ? h.ctx.createStereoPanner() : h.ctx.createGain();
+ node.connect(h.out);
+ h.panners.set(pad, node);
+ }
+ if (node.pan) node.pan.value = Math.max(-1, Math.min(1, Number(pan) || 0));
+ return node;
+}
+
+/** fetch every pad sample the table names and is not decoded yet @param {any} api @param {any} h */
+function prefetchPadSamples(api, h) {
+ for (const pad of Object.keys(h.pads)) {
+ const hash = padSetting(h.pads, Number(pad), 'sample', '');
+ if (!hash || h.buffers.has(hash) || h.pending.has(hash)) continue;
+ h.pending.add(hash);
+ Promise.resolve(api.audio.sample(hash))
+ .then((/** @type {any} */ buffer) => {
+ if (buffer) h.buffers.set(hash, buffer);
+ })
+ .catch(() => {})
+ .finally(() => h.pending.delete(hash));
+ }
+}
+
+/**
+ * One pad hit at audio time `at`: its sample when it has one and it is decoded, else the
+ * synthesized drum. A pad in a choke group stops the group's other pads (the closed hat
+ * closes the open one). Every peer runs this from the same schedule or the same note stamp.
+ * @param {any} api @param {any} h @param {number} pad @param {number} velocity @param {number} at
+ */
+function hitPad(api, h, pad, velocity, at) {
+ const level = Number(padSetting(h.pads, pad, 'level', 1));
+ const pan = Number(padSetting(h.pads, pad, 'pan', 0));
+ const dest = padDestination(h, pad, pan);
+ const group = Number(padSetting(h.pads, pad, 'choke', h.kind === 'drums' && (pad === 2 || pad === 3) ? 1 : 0)) || 0;
+ if (group)
+ for (const [other, live] of h.live) {
+ if (other !== pad && live.group === group) {
+ live.stop(at);
+ h.live.delete(other);
+ }
+ }
+ const hash = String(padSetting(h.pads, pad, 'sample', '') || '');
+ const buffer = hash ? h.buffers.get(hash) : null;
+ /** @type {any} */
+ let voice;
+ if (buffer) {
+ voice = playSample(h.ctx, dest, buffer, at, {
+ level,
+ velocity,
+ pitch: Number(padSetting(h.pads, pad, 'pitch', 0)),
+ start: Number(padSetting(h.pads, pad, 'start', 0)),
+ loop: !!padSetting(h.pads, pad, 'loop', false),
+ gate: Number(padSetting(h.pads, pad, 'gate', 0))
+ });
+ } else if (h.kind === 'drums') {
+ voice = playDrum(h.ctx, dest, pad, velocity * level, at);
+ } else {
+ // an empty sampler pad still answers: a soft tick, so the pad is felt before it is filled
+ voice = playDrum(h.ctx, dest, 6, velocity * level * 0.4, at);
+ }
+ h.live.set(pad, { stop: voice.stop, group });
+ flashLater(h, h.kind === 'drums' ? 'pad-' + pad : 'spad-' + pad, at);
+}
+
+/** a visual flash at the moment the hit is HEARD (audio time -> a local timeout). Purely
+ * visual and purely local. @param {any} h @param {string} name @param {number} at */
+function flashLater(h, name, at) {
+ const mesh = h.node?.getObjectByName?.(name);
+ if (!mesh) return;
+ const wait = Math.max(0, (at - h.ctx.currentTime) * 1000);
+ setTimeout(() => {
+ if (flashing.has(mesh)) return;
+ flashing.add(mesh);
+ mesh.scale.set(1.35, 1.6, 1.35);
+ setTimeout(() => {
+ mesh.scale.set(1, 1, 1);
+ flashing.delete(mesh);
+ }, FLASH_MS);
+ }, wait);
+}
+
+/** the scheduler's callback for a drum machine: a 16th, on every peer, from the same
+ * transport. `beat` is the unswung beat (the step index), `at` the swung audio time.
+ * @param {any} api @param {any} h @param {{beat: number, at: number, late: boolean}} e */
+function drumStep(api, h, e) {
+ if (e.late) return; // a beat already gone is not a hit to fire now (a late joiner's catch-up)
+ const step = (((Math.round(e.beat * 4) % STEPS) + STEPS) % STEPS);
+ for (let pad = 0; pad < DRUM_PADS; pad++) {
+ const digit = stepDigit(h.rows, pad, step);
+ if (!digit || padSetting(h.pads, pad, 'mute', false)) continue;
+ hitPad(api, h, pad, digit / 9, e.at);
+ }
+ h.hits.push({ step, at: e.at, wall: Date.now() + (e.at - h.ctx.currentTime) * 1000 });
+ if (h.hits.length > 512) h.hits.splice(0, h.hits.length - 512);
+}
+
+// ---- meshes ------------------------------------------------------------------------------
+
+/** the x of a step column @param {number} step */
+function cellX(step) {
+ return CELL_X0 + step * CELL_DX;
+}
+/** the z of a pad row @param {number} pad */
+function rowZ(pad) {
+ return ROW_Z0 + pad * ROW_DZ;
+}
+
+/** @param {any} three */
+function drumsMesh(three) {
+ const body = new three.Mesh(
+ new three.BoxGeometry(1.0, 0.12, 0.55),
+ new three.MeshStandardMaterial({ color: 0x2a2a2e, roughness: 0.85, metalness: 0.1 })
+ );
+ body.name = 'Drum machine';
+ const off = new three.MeshStandardMaterial({ color: 0x4b4b52, roughness: 0.7 });
+ for (let pad = 0; pad < DRUM_PADS; pad++) {
+ const button = new three.Mesh(
+ new three.BoxGeometry(0.06, 0.03, 0.045),
+ new three.MeshStandardMaterial({ color: DRUM_COLORS[pad], roughness: 0.5 })
+ );
+ button.position.set(-0.42, 0.075, rowZ(pad));
+ button.name = 'pad-' + pad;
+ button.userData.drumPad = pad;
+ body.add(button);
+ for (let step = 0; step < STEPS; step++) {
+ const cell = new three.Mesh(new three.BoxGeometry(0.038, 0.02, 0.04), off);
+ cell.position.set(cellX(step), 0.07, rowZ(pad));
+ cell.name = 'step-' + pad + '-' + step;
+ cell.userData.drumPad = pad;
+ cell.userData.drumStep = step;
+ body.add(cell);
+ }
+ }
+ const playhead = new three.Mesh(
+ new three.BoxGeometry(0.044, 0.006, 0.5),
+ new three.MeshStandardMaterial({ color: 0xffffff, emissive: 0xffffff, emissiveIntensity: 0.4, transparent: true, opacity: 0.35 })
+ );
+ playhead.position.set(cellX(0), 0.085, 0);
+ playhead.name = 'playhead';
+ body.add(playhead);
+ body.add(plug(three, 'out', 'out', 0xfb923c, 0.3));
+ return body;
+}
+
+/** @param {any} three */
+function samplerMesh(three) {
+ const body = new three.Mesh(
+ new three.BoxGeometry(0.5, 0.12, 0.5),
+ new three.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.85, metalness: 0.1 })
+ );
+ body.name = 'Sampler';
+ for (let pad = 0; pad < SAMPLER_PADS; pad++) {
+ const col = pad % 4;
+ const row = Math.floor(pad / 4);
+ const button = new three.Mesh(
+ new three.BoxGeometry(0.095, 0.03, 0.095),
+ new three.MeshStandardMaterial({ color: 0x9ca3af, roughness: 0.6 })
+ );
+ button.position.set(-0.165 + col * 0.11, 0.075, 0.165 - row * 0.11);
+ button.name = 'spad-' + pad;
+ button.userData.samplerPad = pad;
+ body.add(button);
+ }
+ body.add(plug(three, 'out', 'out', 0xfb923c, 0.27));
+ return body;
+}
+
+/** @param {any} three */
+function transportMesh(three) {
+ const stand = new three.Mesh(
+ new three.BoxGeometry(0.42, 0.95, 0.3),
+ new three.MeshStandardMaterial({ color: 0x374151, roughness: 0.8, metalness: 0.2 })
+ );
+ stand.name = 'Transport';
+ const display = new three.Mesh(
+ new three.PlaneGeometry(0.36, 0.14),
+ new three.MeshBasicMaterial({ color: 0x0b1220 })
+ );
+ display.position.set(0, 0.32, -0.151);
+ display.rotation.y = Math.PI; // faces -Z, the front, like the speaker's driver
+ display.name = 'tp-display';
+ stand.add(display);
+ /** @param {string} name @param {number} color @param {number} x @param {number} w */
+ const button = (name, color, x, w) => {
+ const mesh = new three.Mesh(
+ new three.BoxGeometry(w, 0.06, 0.03),
+ new three.MeshStandardMaterial({ color, roughness: 0.5 })
+ );
+ mesh.position.set(x, 0.18, -0.16);
+ mesh.name = name;
+ mesh.userData.transport = name.slice(3);
+ stand.add(mesh);
+ };
+ button('tp-play', 0x22c55e, -0.12, 0.12);
+ button('tp-bpm-', 0x64748b, 0.0, 0.05);
+ button('tp-bpm+', 0x64748b, 0.07, 0.05);
+ button('tp-tap', 0xf59e0b, 0.155, 0.08);
+ return stand;
+}
+
+/** the grid's materials per device object - a module-level WeakMap, NEVER userData:
+ * userData replicates (toJSON, GLTF extras), and a Map or a material serialized into it
+ * arrives on a late joiner as a plain object and breaks the build there
+ * @type {WeakMap}>} */
+const gridStyles = new WeakMap();
+/** meshes mid-flash (local, transient) @type {WeakSet} */
+const flashing = new WeakSet();
+/** cells currently lit (a WeakSet for the same reason) @type {WeakSet} */
+const litCells = new WeakSet();
+/** @param {any} api @param {any} node */
+function gridStyle(api, node) {
+ let style = gridStyles.get(node);
+ if (style) return style;
+ const three = api.THREE;
+ style = { off: new three.MeshStandardMaterial({ color: 0x4b4b52, roughness: 0.7 }), lit: new Map() };
+ gridStyles.set(node, style);
+ return style;
+}
+/** paint the grid from the rows: a lit cell shares its pad's colored material, an unlit
+ * one the grey; nothing per cell to allocate @param {any} api @param {any} node @param {string[]} rows */
+function paintPattern(api, node, rows) {
+ if (!node?.traverse || !api?.THREE) return;
+ const style = gridStyle(api, node);
+ node.traverse((/** @type {any} */ child) => {
+ const parsed = stepOf(child);
+ if (!parsed) return;
+ const digit = stepDigit(rows, parsed.pad, parsed.step);
+ if (!digit) {
+ child.material = style.off;
+ litCells.delete(child);
+ return;
+ }
+ let material = style.lit.get(parsed.pad);
+ if (!material) {
+ material = new api.THREE.MeshStandardMaterial({ color: DRUM_COLORS[parsed.pad], emissive: DRUM_COLORS[parsed.pad], emissiveIntensity: 0.6, roughness: 0.5 });
+ style.lit.set(parsed.pad, material);
+ }
+ child.material = material;
+ litCells.add(child);
+ });
+}
+/** is this step cell lit? (for the face and the flights) @param {any} cell */
+function isLit(cell) {
+ return litCells.has(cell);
+}
+
+/** the step a mesh stands for, from userData or - when a replication path dropped that -
+ * from its name @param {any} object @returns {{pad: number, step: number}|null} */
+function stepOf(object) {
+ if (typeof object?.userData?.drumStep === 'number' && typeof object.userData.drumPad === 'number')
+ return { pad: object.userData.drumPad, step: object.userData.drumStep };
+ const name = typeof object?.name === 'string' ? object.name : '';
+ if (!name.startsWith('step-')) return null;
+ const [pad, step] = name.slice(5).split('-').map(Number);
+ return Number.isFinite(pad) && Number.isFinite(step) ? { pad, step } : null;
+}
+/** @param {any} object @param {string} prefix @returns {number|null} */
+function padOf(object, prefix) {
+ const key = prefix === 'pad-' ? 'drumPad' : 'samplerPad';
+ if (typeof object?.userData?.[key] === 'number' && !stepOf(object)) return object.userData[key];
+ const name = typeof object?.name === 'string' ? object.name : '';
+ if (!name.startsWith(prefix)) return null;
+ const n = Number(name.slice(prefix.length));
+ return Number.isFinite(n) ? n : null;
+}
+/** the device object a mesh belongs to (itself or an ancestor carrying a device) @param {any} object */
+function deviceRootOf(object) {
+ let node = object;
+ while (node && !node.userData?.device?.kind) node = node.parent;
+ return node ?? null;
+}
+
+// ---- the device specs (C2) ---------------------------------------------------------------
+
+/** the per-object state a drum machine or a sampler carries on its handle
+ * @param {any} ctx @param {any} node @param {any} params @param {'drums'|'sampler'} kind */
+function padHandle(ctx, node, params, kind) {
+ const out = ctx.createGain();
+ out.gain.value = Number(params.level ?? 0.8);
+ return {
+ kind,
+ ctx,
+ node,
+ out,
+ rows: patternRows(params.pattern),
+ pads: padTable(params.pads),
+ /** @type {Map} */ buffers: new Map(),
+ /** @type {Set} */ pending: new Set(),
+ /** @type {Map} */ panners: new Map(),
+ /** @type {Map void, group: number}>} */ live: new Map(),
+ /** @type {{step: number, at: number, wall: number}[]} */ hits: [],
+ /** @type {(() => void)|null} */ cancel: null
+ };
+}
+
+/** 23-D1: what a pad device references by content hash - every pad sample in `pads` - so
+ * the Scene manifest (and a .tpscene export) carries the bytes @param {any} params */
+function padAssets(params) {
+ const table = padTable(params?.pads);
+ /** @type {{hash: string, name: string}[]} */
+ const refs = [];
+ for (const key of Object.keys(table)) {
+ const hash = padSetting(table, Number(key), 'sample', '');
+ if (hash) refs.push({ hash: String(hash), name: String(padSetting(table, Number(key), 'name', '') || 'pad ' + (Number(key) + 1)) });
+ }
+ return refs;
+}
+
+/** @param {any} api */
+function drumsSpec(api) {
+ return {
+ kind: 'drums',
+ label: 'Drum machine',
+ icon: '🥁',
+ group: 'Music Lab',
+ ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ assets: padAssets,
+ params: [
+ { key: 'level', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.8 },
+ // `pattern` and `pads` are part of the document too (undeclared: the face edits
+ // them, the Inspector does not list them); their defaults are applied in build
+ ],
+ /** @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const h = padHandle(ctx, node, params, 'drums');
+ if (typeof params.pattern !== 'string') h.rows = patternRows(emptyPattern());
+ paintPattern(api, node, h.rows);
+ prefetchPadSamples(api, h);
+ // a 16th, forever, from beat 0: the callback is pure of (beat, at), so every peer
+ // fires the same pads at the same audio time from the same transport
+ h.cancel = api.audio.schedule(0, (/** @type {any} */ e) => drumStep(api, h, e), { every: 0.25 });
+ padDevices.set(node.uuid, h);
+ return {
+ output: h.out,
+ h,
+ dispose() {
+ h.cancel?.();
+ h.cancel = null;
+ for (const live of h.live.values()) live.stop(ctx.currentTime);
+ h.live.clear();
+ h.out.disconnect();
+ padDevices.delete(node.uuid);
+ }
+ };
+ },
+ /** @param {any} built @param {string} key @param {any} value @param {any} node */
+ onParam(built, key, value, node) {
+ const h = built.h;
+ if (!h) return;
+ if (key === 'level') h.out.gain.value = Number(value) || 0;
+ else if (key === 'pattern') {
+ h.rows = patternRows(value);
+ paintPattern(api, node ?? h.node, h.rows);
+ } else if (key === 'pads') {
+ h.pads = padTable(value);
+ prefetchPadSamples(api, h);
+ }
+ },
+ /** a one-shot pad hit, replicated as a note: pad = note - PAD_BASE_NOTE
+ * @param {any} built @param {{note: number, velocity: number, at: number}} ev */
+ onNote(built, { note, velocity, at }) {
+ const h = built.h;
+ const pad = Math.round(Number(note)) - PAD_BASE_NOTE;
+ if (!h || pad < 0 || pad >= DRUM_PADS) return;
+ hitPad(api, h, pad, typeof velocity === 'number' ? velocity : 0.9, api.audio.timeFor(typeof at === 'number' ? at : Date.now()));
+ },
+ mesh: (/** @type {any} */ three) => drumsMesh(three)
+ };
+}
+
+/** @param {any} api */
+function samplerSpec(api) {
+ return {
+ kind: 'sampler',
+ label: 'Sampler pads',
+ icon: '🎛️',
+ group: 'Music Lab',
+ ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ assets: padAssets,
+ params: [{ key: 'level', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.8 }],
+ /** @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const h = padHandle(ctx, node, params, 'sampler');
+ prefetchPadSamples(api, h);
+ padDevices.set(node.uuid, h);
+ return {
+ output: h.out,
+ h,
+ dispose() {
+ for (const live of h.live.values()) live.stop(ctx.currentTime);
+ h.live.clear();
+ h.out.disconnect();
+ padDevices.delete(node.uuid);
+ }
+ };
+ },
+ /** @param {any} built @param {string} key @param {any} value */
+ onParam(built, key, value) {
+ const h = built.h;
+ if (!h) return;
+ if (key === 'level') h.out.gain.value = Number(value) || 0;
+ else if (key === 'pads') {
+ h.pads = padTable(value);
+ prefetchPadSamples(api, h);
+ }
+ },
+ /** @param {any} built @param {{note: number, velocity: number, at: number}} ev */
+ onNote(built, { note, velocity, at }) {
+ const h = built.h;
+ const pad = Math.round(Number(note)) - PAD_BASE_NOTE;
+ if (!h || pad < 0 || pad >= SAMPLER_PADS) return;
+ hitPad(api, h, pad, typeof velocity === 'number' ? velocity : 0.9, api.audio.timeFor(typeof at === 'number' ? at : Date.now()));
+ },
+ mesh: (/** @type {any} */ three) => samplerMesh(three)
+ };
+}
+
+/** every built drum machine / sampler, for the playheads and the strokes @type {Map} */
+const padDevices = new Map();
+/** every built transport face @type {Map} */
+const transports = new Map();
+
+/** @param {any} api */
+function transportSpec(api) {
+ return {
+ kind: 'transport',
+ label: 'Transport',
+ icon: '⏯️',
+ group: 'Music Lab',
+ ports: { in: [], out: [] },
+ params: [],
+ /** no audio of its own: the face of the SHARED clock. The display is a canvas
+ * texture made HERE, on every peer, so it never has to replicate.
+ * @param {any} ctx @param {any} node */
+ build(ctx, node) {
+ const three = api.THREE;
+ const display = node.getObjectByName?.('tp-display');
+ /** @type {any} */
+ let entry = null;
+ if (display && three && typeof document !== 'undefined') {
+ const canvas = document.createElement('canvas');
+ canvas.width = 256;
+ canvas.height = 96;
+ const texture = new three.CanvasTexture(canvas);
+ display.material = new three.MeshBasicMaterial({ map: texture });
+ entry = { node, canvas, ctx2d: canvas.getContext('2d'), texture, last: '' };
+ transports.set(node.uuid, entry);
+ drawTransport(entry, api.audio.transport());
+ }
+ return {
+ dispose() {
+ transports.delete(node.uuid);
+ entry?.texture?.dispose?.();
+ }
+ };
+ },
+ mesh: (/** @type {any} */ three) => transportMesh(three)
+ };
+}
+
+/** @param {{canvas: any, ctx2d: any, texture: any, last: string}} entry @param {any} t */
+function drawTransport(entry, t) {
+ const bar = Math.floor(t.beat / 4) + 1;
+ const beat = (Math.floor(t.beat) % 4) + 1;
+ const key = t.bpm + '|' + bar + '|' + beat + '|' + (t.playing ? 1 : 0);
+ if (key === entry.last || !entry.ctx2d) return;
+ entry.last = key;
+ const c = entry.ctx2d;
+ c.fillStyle = '#0b1220';
+ c.fillRect(0, 0, 256, 96);
+ c.fillStyle = t.playing ? '#22c55e' : '#94a3b8';
+ c.font = 'bold 44px monospace';
+ c.textBaseline = 'top';
+ c.fillText(String(Math.round(t.bpm)), 12, 8);
+ c.font = '18px monospace';
+ c.fillStyle = '#cbd5e1';
+ c.fillText('BPM', 130, 30);
+ c.fillText((t.playing ? 'PLAY ' : 'STOP ') + bar + '.' + beat, 12, 62);
+ entry.texture.needsUpdate = true;
+}
+
+// ---- interaction (C2) --------------------------------------------------------------------
+
+/** the open paint stroke on a drum grid, if any: local preview, ONE write at the end
+ * @type {{uuid: string, digit: string, pattern: string, lastAt: number, cells: any[]}|null} */
+let stroke = null;
+/** recent tap-tempo presses (local; only the RESULTING bpm is written, replicated) @type {number[]} */
+let taps = [];
+
+/** @param {any} api @param {string} uuid @param {string} pattern */
+function previewPattern(api, uuid, pattern) {
+ const h = padDevices.get(uuid);
+ if (!h) return;
+ h.rows = patternRows(pattern);
+ paintPattern(api, h.node, h.rows);
+}
+
+/** commit the open stroke as one setParams write (one undo entry) @param {any} api */
+function endStroke(api) {
+ const open = stroke;
+ stroke = null;
+ if (!open) return;
+ const doc = api.audio.device(open.uuid);
+ if (doc && String(doc.params?.pattern ?? emptyPattern()) === open.pattern) return; // nothing changed
+ api.audio.setParams(open.uuid, { pattern: open.pattern });
+}
+
+/** a click on a drum machine: a step toggles (and opens a stroke), a pad button plays the
+ * pad now (replicated as a note) @param {any} api @param {any} device @param {any} object */
+function clickDrums(api, device, object) {
+ const step = stepOf(object);
+ if (step) {
+ const doc = api.audio.device(device.uuid);
+ const base = stroke?.uuid === device.uuid ? stroke.pattern : String(doc?.params?.pattern ?? emptyPattern());
+ const rows = patternRows(base);
+ const digit = stepDigit(rows, step.pad, step.step) ? '0' : STEP_ON;
+ const next = withStepDigit(rows, step.pad, step.step, digit);
+ if (stroke && stroke.uuid !== device.uuid) endStroke(api);
+ stroke = { uuid: device.uuid, digit, pattern: next, lastAt: performance.now(), cells: gridCells(device) };
+ previewPattern(api, device.uuid, next);
+ api.haptic(0.3, 30);
+ return true;
+ }
+ const pad = padOf(object, 'pad-');
+ if (pad !== null) {
+ api.audio.note(device.uuid, { note: PAD_BASE_NOTE + pad, velocity: 0.9 });
+ api.haptic(0.5, 40);
+ return true;
+ }
+ return false;
+}
+
+/** @param {any} device */
+function gridCells(device) {
+ /** @type {any[]} */
+ const cells = [];
+ device.traverse?.((/** @type {any} */ child) => {
+ if (stepOf(child)) cells.push(child);
+ });
+ return cells;
+}
+
+/** the stroke follows the pointer ray across the grid (desktop drag, VR sweep): each new
+ * cell it crosses takes the stroke's digit; idle for STROKE_IDLE_MS ends it @param {any} api */
+function updateStroke(api) {
+ if (!stroke) return;
+ const now = performance.now();
+ const ray = api.pointerRay?.();
+ if (ray && stroke.cells.length) {
+ const hits = ray.intersectObjects(stroke.cells, false);
+ const cell = hits[0]?.object;
+ const parsed = cell ? stepOf(cell) : null;
+ if (parsed) {
+ const rows = patternRows(stroke.pattern);
+ if (String(stepDigit(rows, parsed.pad, parsed.step)) !== stroke.digit) {
+ stroke.pattern = withStepDigit(rows, parsed.pad, parsed.step, stroke.digit);
+ stroke.lastAt = now;
+ previewPattern(api, stroke.uuid, stroke.pattern);
+ }
+ }
+ }
+ if (now - stroke.lastAt > STROKE_IDLE_MS) endStroke(api);
+}
+
+/** @param {any} api @param {any} device @param {any} object */
+function clickSampler(api, device, object) {
+ const pad = padOf(object, 'spad-');
+ if (pad === null) return false;
+ api.audio.note(device.uuid, { note: PAD_BASE_NOTE + pad, velocity: 0.9 });
+ api.haptic(0.5, 40);
+ return true;
+}
+
+/** @param {any} api @param {any} device @param {any} object */
+function clickTransport(api, device, object) {
+ const which = typeof object?.userData?.transport === 'string' ? object.userData.transport : String(object?.name ?? '').startsWith('tp-') ? String(object.name).slice(3) : '';
+ if (!which || which === 'display') return false;
+ const t = api.audio.transport();
+ if (which === 'play') api.audio.play(!t.playing);
+ else if (which === 'bpm-') api.audio.setBpm(Math.max(40, Math.round(t.bpm) - 5));
+ else if (which === 'bpm+') api.audio.setBpm(Math.min(240, Math.round(t.bpm) + 5));
+ else if (which === 'tap') {
+ const now = performance.now();
+ taps = taps.filter((at) => now - at < 2500);
+ taps.push(now);
+ if (taps.length >= 3) {
+ let sum = 0;
+ for (let i = 1; i < taps.length; i++) sum += taps[i] - taps[i - 1];
+ const bpm = Math.round(60000 / (sum / (taps.length - 1)));
+ if (bpm >= 40 && bpm <= 240) api.audio.setBpm(bpm);
+ }
+ } else return false;
+ api.haptic(0.4, 40);
+ return true;
+}
+
+/** the beat lab's per-frame work: transport faces (throttled), playheads, the open stroke
+ * @param {any} api @param {number} time */
+let nextDisplay = 0;
+/** @param {any} api @param {number} time */
+function beatLabFrame(api, time) {
+ updateStroke(api);
+ if (time < nextDisplay) return;
+ nextDisplay = time + 1 / DISPLAY_HZ;
+ const t = api.audio.transport();
+ for (const entry of transports.values()) drawTransport(entry, t);
+ const step = (((Math.floor(t.beat * 4) % STEPS) + STEPS) % STEPS);
+ for (const h of padDevices.values()) {
+ if (h.kind !== 'drums') continue;
+ const playhead = h.node?.getObjectByName?.('playhead');
+ if (playhead) {
+ playhead.position.x = cellX(step);
+ playhead.visible = !!t.playing;
+ }
+ }
+}
+
+/** an Explorer AUDIO item dropped on a pad becomes that pad's sample - one setParams write
+ * @param {any} api @param {any} hit @param {{kind: string, hash: string, name: string}} item */
+function dropSample(api, hit, item) {
+ if (item.kind !== 'audio' || !item.hash) return false;
+ const device = deviceRootOf(hit);
+ if (!device) return false;
+ const kind = device.userData.device.kind;
+ let pad = null;
+ if (kind === KINDS.sampler) pad = padOf(hit, 'spad-');
+ else if (kind === KINDS.drums) pad = padOf(hit, 'pad-');
+ else return false;
+ const doc = api.audio.device(device.uuid);
+ const table = padTable(doc?.params?.pads);
+ if (pad === null) {
+ // dropped on the body: the first EMPTY pad
+ const count = kind === KINDS.sampler ? SAMPLER_PADS : DRUM_PADS;
+ for (let i = 0; i < count && pad === null; i++) if (!padSetting(table, i, 'sample', '')) pad = i;
+ if (pad === null) pad = 0;
+ }
+ table[pad] = { ...(table[pad] ?? {}), sample: item.hash, name: item.name };
+ api.audio.setParams(device.uuid, { pads: JSON.stringify(table) });
+ api.toast('Music Lab: "' + item.name + '" on pad ' + (pad + 1));
+ return true;
+}
+
+/** @param {any} api */
+function registerBeatLab(api) {
+ api.registerAudioDevice(transportSpec(api)).then((/** @type {string} */ kind) => (KINDS.transport = kind));
+ api.registerAudioDevice(drumsSpec(api)).then((/** @type {string} */ kind) => (KINDS.drums = kind));
+ api.registerAudioDevice(samplerSpec(api)).then((/** @type {string} */ kind) => (KINDS.sampler = kind));
+
+ api.registerClickHandler((/** @type {any} */ object) => {
+ const device = deviceRootOf(object);
+ const kind = device?.userData?.device?.kind;
+ if (!kind) return false;
+ if (kind === KINDS.drums) return clickDrums(api, device, object);
+ if (kind === KINDS.sampler) return clickSampler(api, device, object);
+ if (kind === KINDS.transport) return clickTransport(api, device, object);
+ return false;
+ });
+ api.registerDropHandler?.((/** @type {any} */ hit, /** @type {any} */ item) => dropSample(api, hit, item));
+ api.registerFrameTask((/** @type {number} */ time) => beatLabFrame(api, time));
+
+ api.registerMenu('Music Lab: beat lab', () => {
+ const transport = api.audio.addDevice('transport', { position: [-1.6, 0.5, -2] });
+ const drums = api.audio.addDevice('drums', { position: [0, 0.8, -2] });
+ const sampler = api.audio.addDevice('sampler', { position: [1.2, 0.8, -2] });
+ const speaker = api.audio.addDevice('speaker', { position: [0, 0, -3.2] });
+ if (!transport || !drums || !sampler || !speaker) return api.toast('Music Lab: could not add the devices');
+ api.audio.cable({ from: { uuid: drums.uuid, port: 'out' }, to: { uuid: speaker.uuid, port: 'in' } });
+ api.audio.cable({ from: { uuid: sampler.uuid, port: 'out' }, to: { uuid: speaker.uuid, port: 'in' } });
+ api.audio.setParams(drums.uuid, { pattern: '7000700070007000/0000700000007000/7070707070707070/0000000000000070/0000700000007000/0000000000000000/0000000000000000/0000000000000000' });
+ api.toast('Music Lab: press Play on the transport, paint the grid');
+ });
+
+ api.onSceneClear(() => {
+ padDevices.clear();
+ transports.clear();
+ stroke = null;
+ });
+}
+
+export default {
+ id: 'music-lab',
+ name: 'Music Lab',
+ version: '0.2.0',
+ description: 'A Piano, a Speaker, a Transport, a Drum machine and Sampler pads on the engine. Cable them, paint a beat, press Play.',
+ /** @param {any} api */
+ register(api) {
+ // `mesh(three)` is handed the app's own three by the registry, which is the only
+ // place this module needs it — but the rule stands: never `import` it.
+ void api.THREE;
+
+ api.registerAudioDevice(speakerSpec(api));
+ api.registerAudioDevice(pianoSpec(api)).then((/** @type {string} */ kind) => (PIANO_KIND = kind));
+ registerBeatLab(api); // 23-C2: transport, drum machine, sampler pads
+
+ // ONE task for every speaker, throttled — the panners only have to keep up with a
+ // person carrying a cabinet, and `time` is the synced clock in seconds
+ let nextFollow = 0;
+ api.registerFrameTask((/** @type {number} */ time) => {
+ if (time < nextFollow) return;
+ nextFollow = time + 1 / FOLLOW_HZ;
+ for (const entry of speakers.values()) aim(entry.node, entry.panner);
+ });
+
+ // Desktop click AND VR trigger, one path — the handler gets the exact mesh that was
+ // hit, so the key carries the note and the walk up to the device carries the uuid.
+ // The note goes through api.audio.note, which replicates it: no api.send here.
+ api.registerClickHandler((/** @type {any} */ object) => {
+ const midi = noteOf(object);
+ if (midi === null) return false;
+ let node = object;
+ while (node && node.userData?.device?.kind !== PIANO_KIND) node = node.parent;
+ if (!node || !PIANO_KIND) return false;
+ api.audio.note(node.uuid, { note: midi, velocity: 0.9 });
+ api.haptic(0.6, 60);
+ return true;
+ });
+
+ api.registerMenu('Music Lab: piano + speaker', () => {
+ const piano = api.audio.addDevice('piano', { position: [-1, 0, -2] });
+ const speaker = api.audio.addDevice('speaker', { position: [1, 0, -2] });
+ if (!piano || !speaker) return api.toast('Music Lab: could not add the devices');
+ api.audio.cable({ from: { uuid: piano.uuid, port: 'out' }, to: { uuid: speaker.uuid, port: 'in' } });
+ api.toast('Music Lab: click a key');
+ });
+
+ api.onSceneClear(() => speakers.clear());
+ }
+};
diff --git a/modules/music-voice/README.md b/modules/music-voice/README.md
new file mode 100644
index 0000000..628300e
--- /dev/null
+++ b/modules/music-voice/README.md
@@ -0,0 +1,41 @@
+# Music Voice (23-C5)
+
+Four audio devices on the engine: a **Mic**, a **Looper**, a **Synth** and a **Theremin**.
+
+- **Mic** - this peer's own RAW microphone into the graph (`api.audio.captureMic`: a separate
+ capture from voice chat, no echo cancellation / noise suppression / auto gain, never gated
+ by push-to-talk). Params: level, a `monitor` toggle (hear yourself on the local instruments
+ bus without a speaker; off by default because sound goes nowhere until cabled), take length.
+ The red button records a take through core's recorder; the Explorer item's CONTENT HASH is
+ written into the device document (`lastTake`), so it replicates, undoes, and enters the Scene
+ manifest. Pressing the button while a take runs ends it early. Every peer's copy of a Mic is
+ that peer's own mic - the bytes that travel are the takes.
+- **Looper** - `in` and `out`. Press Record: the record window starts at the NEXT bar boundary
+ and lasts `bars` bars, quantized through the transport's scheduler (a stopped transport is
+ started for you). The take is a `MediaRecorder` bounce of a gain gate on the shared audio
+ clock, with a 3 ms SYNC PIP just before the gate opens: every peer finds the pip in the
+ decoded bytes and cuts exactly `bars * 4 * 60 / bpm` seconds after it, so the loop is the
+ same samples everywhere and its period is exact. Playback is scheduled on the bar the window
+ started on (`startedBeat`), every `bars` bars, pure of `(beat, at, bpm)`; a late joiner or a
+ restarted transport starts mid-loop at the phase the transport says. Record again to OVERDUB:
+ the playing loop (scaled by `feedback`) is mixed into the new window and the result is a new
+ hash. One take = one undo entry (previews for the LED, one commit with `before`). Params:
+ bars, level, feedback, play, thru (the live input passes to `out`). Buttons: Record, Play,
+ Clear. The loop is a scene asset by hash. The raw Explorer item keeps the pip at its head.
+- **Synth** - polyphonic osc -> low-pass -> ADSR voices. Params: wave, attack, decay, sustain,
+ release, cutoff, resonance, level, polyphony (the oldest voice is stolen), gate (the note
+ length of a click). One octave of keys on the face; a key is a replicated note, synthesized
+ on every peer at the stamped time. In VR the pressing hand's trigger holds the note and its
+ release ends it (an `off` note); a held note is released after 10 s regardless.
+- **Theremin** - VR-native. In VR a frame task reads `api.vrHand(hand)`: hand height above the
+ body = pitch (four octaves over a metre), horizontal distance from the body = volume (loud at
+ the body, silent past 0.8 m). The pose is written as `api.audio.previewParams` at ~15 Hz -
+ replicated, no history - and EVERY peer synthesizes from the document. Continuous pitch, so
+ no notes. On desktop `pitch` and `volume` are ordinary params (Inspector, toolbox, a script).
+ Params: pitch, volume, wave, hand.
+
+Sidebar: **Music Voice: demo** adds the four, cables the synth into the looper, and cables the
+looper, the mic and the theremin into a Music Lab speaker when that module is installed.
+
+Needs core 23-D1 (`api.audio.captureMic` / `record({stream})` / `recording`, `assets` on the
+device spec) and 23-C4 (`api.audio.previewParams`, `setParams` with `before`).
diff --git a/modules/music-voice/manifest.json b/modules/music-voice/manifest.json
new file mode 100644
index 0000000..043bc47
--- /dev/null
+++ b/modules/music-voice/manifest.json
@@ -0,0 +1,9 @@
+{
+ "id": "music-voice",
+ "name": "Music Voice",
+ "version": "0.1.0",
+ "format": 1,
+ "description": "A Mic, a Looper, a Synth and a Theremin on the engine: sing into the graph, loop what is patched in on the bar, play chords, wave a hand - every peer hears the same thing.",
+ "entry": "module.js",
+ "files": ["module.js"]
+}
diff --git a/modules/music-voice/module.js b/modules/music-voice/module.js
new file mode 100644
index 0000000..73bc134
--- /dev/null
+++ b/modules/music-voice/module.js
@@ -0,0 +1,1230 @@
+// Music Voice - roadmap #23 C5: a Mic, a Looper, a Synth and a Theremin on the engine.
+//
+// Four `registerAudioDevice` specs, one file, no top-level imports (`api.THREE` is handed to
+// `mesh(three)` by the registry). Core owns the object, the replication, the undo, the saving,
+// the cables and the clock; this file owns what each kind sounds like, looks like, and which
+// params do what. NOTHING RUNTIME GOES INTO userData (it replicates - toJSON, GLTF extras -
+// and arrives on a late joiner as a plain object): every live thing lives in the module-level
+// Maps below, keyed by the object's uuid.
+//
+// THE MIC is this peer's OWN raw microphone into the graph (`api.audio.captureMic`: a separate
+// capture from voice chat, no AEC/NS/AGC, never gated by push-to-talk). A take from its face
+// goes through `api.audio.record` into the Explorer, and the item's CONTENT HASH is written
+// into the device document (`lastTake`), so it replicates, undoes and enters the Scene manifest
+// (`assets`). The bytes reach every peer by hash - no new plumbing.
+//
+// THE LOOPER quantizes its RECORD WINDOW to bar boundaries through the transport's scheduler:
+// pressing Record arms it; a one-shot scheduled a beat ahead of the next bar starts a
+// MediaRecorder on a MediaStreamDestination, and a GAIN GATE on the shared audio clock opens
+// exactly at the bar (`at`) and closes exactly `bars` bars later. Because a MediaRecorder's own
+// start is not sample-accurate, the take begins with a 3 ms SYNC PIP that ends exactly where
+// the window opens - every peer locates the pip in the decoded bytes and takes exactly
+// `bars * 4 * 60 / bpm` seconds after it, so the loop is the same samples everywhere and its
+// period is exact. Playback is `api.audio.schedule(startedBeat mod P, fn, {every: P})`, pure of
+// `(beat, at, bpm)`: every peer restarts the loop on the same bar at the same audio time, and a
+// late joiner (or a peer whose take just landed) starts MID-loop at the phase the transport
+// says. An overdub mixes the playing loop (scaled by `feedback`) into the next window; the
+// mixed bytes are a new hash. A take is one gesture: `state` previews (armed / recording, no
+// history) and ONE `setParams` with `before` when it lands - one undo entry restores the loop
+// that was there before.
+//
+// THE SYNTH is polyphonic osc -> filter -> ADSR voices on the shared context, started at
+// `api.audio.timeFor(at)` from the replicated note. Notes carry an optional `gate` (0 = held
+// until an `off` note - the VR trigger release - with a safety limit); the oldest voice is
+// stolen past `polyphony`.
+//
+// THE THEREMIN is continuous pitch, so it sends NO notes: in VR a frame task reads
+// `api.vrHand(hand)` and writes the hand pose as `previewParams(uuid, {pitch, volume})` at
+// ~15 Hz - replicated, history-free - and EVERY peer synthesizes from the document (`onParam`
+// glides the oscillator). On desktop the same two params come from the Inspector or a script.
+
+const PREVIEW_MS = 66;
+const DISPLAY_HZ = 12;
+const LOOPER_HZ = 10;
+const THEREMIN_HZ = 15;
+
+/** the looper starts its recorder this many beats (at least LOOP_LEAD_MIN_S) before the bar */
+const LOOP_LEAD_BEATS = 1;
+const LOOP_LEAD_MIN_S = 0.3;
+const LOOP_TAIL_S = 0.4;
+/** the sync pip: a short burst that ENDS exactly where the record window opens */
+const PIP_SECONDS = 0.003;
+const PIP_HZ = 3000;
+const PIP_GAIN = 0.5;
+const PIP_THRESHOLD = 0.2;
+const MAX_RECORD_SECONDS = 120; // core's own cap on one take
+
+/** a held synth note (VR trigger, or a note without a gate) is released after this anyway */
+const HOLD_LIMIT_S = 10;
+const STEAL_RELEASE_S = 0.03;
+const KEY_DIP = 0.012;
+const KEY_DIP_MS = 150;
+const WHITE_MIDI = [60, 62, 64, 65, 67, 69, 71]; // C4..B4
+const BLACK_MIDI = { 0: 61, 1: 63, 3: 66, 4: 68, 5: 70 }; // after C, D, F, G, A
+const KEY_STEP = 0.07;
+
+/** the theremin's hand mapping: this many octaves above PITCH_LO over HEIGHT_SPAN metres of
+ * hand height; full volume touching the body, silent past REACH metres */
+const PITCH_LO = 110;
+const PITCH_OCTAVES = 4;
+const HEIGHT_SPAN = 1.0;
+const REACH = 0.8;
+const VOLUME_NEAR = 0.12;
+
+/** the resolved namespaced kinds, once registerAudioDevice resolves */
+const KINDS = { mic: '', looper: '', synth: '', theremin: '' };
+
+/** every built mic @type {Map} */
+const mics = new Map();
+/** every built looper @type {Map} */
+const loopers = new Map();
+/** every built synth @type {Map} */
+const synths = new Map();
+/** every built theremin @type {Map} */
+const theremins = new Map();
+/** keys mid-dip (local, transient) @type {WeakSet} */
+const dipped = new WeakSet();
+
+/** the local recorder's state (`api.audio.recording()`), for the mic faces */
+let recState = { active: false, startedAt: 0, maxSeconds: 0, name: '' };
+/** bumped per register, so a stale store subscription from a reloaded instance is ignored */
+let generation = 0;
+
+// ---- small helpers -------------------------------------------------------------------------
+
+/** @param {any} v @param {number} lo @param {number} hi @param {number} fallback */
+function clamp(v, lo, hi, fallback) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return fallback;
+ return Math.max(lo, Math.min(hi, n));
+}
+
+/** the device object a mesh belongs to (itself or an ancestor carrying a device) @param {any} object */
+function deviceRootOf(object) {
+ let node = object;
+ while (node && !node.userData?.device?.kind) node = node.parent;
+ return node ?? null;
+}
+
+/** a glide, never a jump - a knob must not click @param {any} param @param {number} value @param {any} ctx */
+function glide(param, value, ctx, tc = 0.01) {
+ param.setTargetAtTime(value, ctx.currentTime, tc);
+}
+
+/** @param {any} src */
+function stopNode(src) {
+ if (!src) return;
+ try {
+ src.stop();
+ } catch {
+ /* already stopped */
+ }
+ try {
+ src.disconnect();
+ } catch {
+ /* never connected */
+ }
+}
+
+/** the plug convention the reference modules use: the visible plug carries the vrpatch name and
+ * holds a zero-offset `port:` marker @param {any} three @param {'in'|'out'} side @param {string} id @param {number} color @param {number} x @param {number} y @param {number} z */
+function plug(three, side, id, color, x, y, z) {
+ const mesh = new three.Mesh(
+ new three.CylinderGeometry(0.02, 0.02, 0.04, 12),
+ new three.MeshStandardMaterial({ color, roughness: 0.4, metalness: 0.6 })
+ );
+ mesh.rotation.x = Math.PI / 2;
+ mesh.position.set(x, y, z);
+ mesh.name = 'vrpatch-' + side + ':' + id;
+ const marker = new three.Object3D();
+ marker.name = 'port:' + id;
+ mesh.add(marker);
+ return mesh;
+}
+
+/** @param {any} three @param {string} name @param {number} color @param {number[]} size @param {number[]} at */
+function button(three, name, color, size, at) {
+ const mesh = new three.Mesh(new three.BoxGeometry(size[0], size[1], size[2]), new three.MeshStandardMaterial({ color, roughness: 0.5 }));
+ mesh.position.set(at[0], at[1], at[2]);
+ mesh.name = name;
+ return mesh;
+}
+
+/** @param {any} three @param {string} name @param {number} color @param {number[]} at */
+function led(three, name, color, at) {
+ const mesh = new three.Mesh(new three.SphereGeometry(0.012, 10, 8), new three.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.2, roughness: 0.4 }));
+ mesh.position.set(at[0], at[1], at[2]);
+ mesh.name = name;
+ return mesh;
+}
+
+/** @param {any} mesh @param {number} color @param {number} [intensity] */
+function light(mesh, color, intensity = 0.2) {
+ if (!mesh?.material?.color) return;
+ mesh.material.color.setHex(color);
+ if (mesh.material.emissive) {
+ mesh.material.emissive.setHex(color);
+ mesh.material.emissiveIntensity = intensity;
+ }
+}
+
+// ---- the Mic --------------------------------------------------------------------------------
+
+/** @param {any} three */
+function micMesh(three) {
+ const base = new three.Mesh(
+ new three.BoxGeometry(0.3, 0.06, 0.3),
+ new three.MeshStandardMaterial({ color: 0x1f2937, roughness: 0.85, metalness: 0.15 })
+ );
+ base.name = 'Mic';
+ const stem = new three.Mesh(new three.CylinderGeometry(0.012, 0.012, 0.36, 10), new three.MeshStandardMaterial({ color: 0x4b5563, roughness: 0.5, metalness: 0.6 }));
+ stem.position.set(0, 0.21, 0);
+ stem.name = 'mic-stem';
+ base.add(stem);
+ const capsule = new three.Mesh(new three.SphereGeometry(0.045, 16, 12), new three.MeshStandardMaterial({ color: 0x9ca3af, roughness: 0.35, metalness: 0.7 }));
+ capsule.position.set(0, 0.42, 0);
+ capsule.name = 'mic-capsule';
+ base.add(capsule);
+ base.add(button(three, 'mic-rec', 0xdc2626, [0.07, 0.025, 0.05], [0.09, 0.042, 0.08]));
+ base.add(led(three, 'mic-led', 0x7f1d1d, [-0.09, 0.045, 0.08]));
+ base.add(plug(three, 'out', 'out', 0xfb923c, 0, 0, 0.17));
+ return base;
+}
+
+/** @param {any} api */
+function micSpec(api) {
+ return {
+ kind: 'mic',
+ label: 'Mic',
+ icon: '🎤',
+ group: 'Music Voice',
+ ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ /** 23-D1: the last take is a scene asset by hash @param {any} params */
+ assets: (params) => (params?.lastTake ? [{ hash: String(params.lastTake), name: String(params.lastTakeName || 'take') }] : []),
+ params: [
+ { key: 'level', label: 'Level', kind: 'range', min: 0, max: 2, step: 0.01, default: 1 },
+ { key: 'monitor', label: 'Monitor', kind: 'toggle', default: false },
+ { key: 'seconds', label: 'Take length', kind: 'range', min: 1, max: 60, step: 1, default: 8, unit: 's' }
+ // undeclared, part of the document: lastTake (hash), lastTakeName
+ ],
+ /**
+ * this peer's RAW mic -> level -> out (a cable picks it up there). `monitor` also sends
+ * it to the local instruments bus so you hear yourself without a speaker; off by default,
+ * because sound goes nowhere until cabled. The capture is asynchronous (a permission
+ * prompt on a peer that has not granted it): the graph exists at once, the source lands
+ * when the stream does. @param {any} ctx @param {any} node @param {any} params
+ */
+ build(ctx, node, params) {
+ const level = ctx.createGain();
+ level.gain.value = clamp(params.level, 0, 2, 1);
+ const out = ctx.createGain();
+ const monitor = ctx.createGain();
+ monitor.gain.value = params.monitor ? 1 : 0;
+ level.connect(out);
+ level.connect(monitor);
+ monitor.connect(api.audio.bus('instruments') ?? ctx.destination);
+ const h = { ctx, node, level, out, monitor, doc: { ...params }, source: null, stream: null, alive: true, error: '', taking: false };
+ mics.set(node.uuid, h);
+ Promise.resolve(api.audio.captureMic())
+ .then((/** @type {MediaStream} */ stream) => {
+ if (!h.alive || !stream) return;
+ h.stream = stream;
+ h.source = ctx.createMediaStreamSource(stream);
+ h.source.connect(level);
+ })
+ .catch((/** @type {any} */ error) => {
+ h.error = String(error?.message ?? error);
+ });
+ return {
+ output: out,
+ h,
+ dispose() {
+ h.alive = false;
+ try {
+ h.source?.disconnect();
+ } catch {
+ /* never connected */
+ }
+ level.disconnect();
+ out.disconnect();
+ monitor.disconnect();
+ mics.delete(node.uuid);
+ }
+ };
+ },
+ /** @param {any} built @param {string} key @param {any} value */
+ onParam(built, key, value) {
+ const h = built.h;
+ if (!h) return;
+ h.doc[key] = value;
+ if (key === 'level') glide(h.level.gain, clamp(value, 0, 2, 1), h.ctx);
+ else if (key === 'monitor') glide(h.monitor.gain, value ? 1 : 0, h.ctx);
+ },
+ mesh: (/** @type {any} */ three) => micMesh(three)
+ };
+}
+
+/** the face's Record button: a take through core's recorder; the item's HASH is written
+ * into the document so it replicates and enters the manifest. Pressed while a take runs, it
+ * ends the take early. @param {any} api @param {any} device @param {any} object */
+function clickMic(api, device, object) {
+ if (String(object?.name ?? '') !== 'mic-rec') return false;
+ const h = mics.get(device.uuid);
+ if (recState.active) {
+ api.audio.stopRecording();
+ api.haptic(0.4, 30);
+ return true;
+ }
+ const doc = api.audio.device(device.uuid)?.params ?? h?.doc ?? {};
+ const seconds = clamp(doc.seconds, 1, MAX_RECORD_SECONDS, 8);
+ const name = 'mic-' + device.uuid.slice(0, 4) + '-' + new Date().toISOString().slice(11, 19).replace(/:/g, '');
+ if (h) h.taking = true;
+ Promise.resolve(api.audio.record({ maxSeconds: seconds, name }))
+ .then((/** @type {any} */ item) => {
+ if (h) h.taking = false;
+ if (!item || !item.hash) return;
+ api.audio.setParams(device.uuid, { lastTake: item.hash, lastTakeName: item.name });
+ })
+ .catch(() => {
+ if (h) h.taking = false;
+ });
+ api.haptic(0.6, 50);
+ return true;
+}
+
+// ---- the Looper -----------------------------------------------------------------------------
+
+/** the loop's length in beats: `bars` bars of four @param {any} doc */
+function loopBeatsOf(doc) {
+ return clamp(doc?.loopBars, 1, 8, 2) * 4;
+}
+/** the loop's length in seconds at the tempo it was recorded at @param {any} doc */
+function loopSecondsOf(doc) {
+ return (loopBeatsOf(doc) * 60) / clamp(doc?.loopBpm, 20, 400, 120);
+}
+
+/**
+ * Trim a decoded take to THE LOOP: find the sync pip's leading edge (the first sample over
+ * PIP_THRESHOLD), skip the pip, and take exactly `seconds` of audio from there - zero-padded
+ * if the take ended short. Deterministic in the bytes, so every peer cuts the same samples
+ * and the period is exact to the sample. @param {any} ctx @param {any} raw @param {number} seconds
+ */
+function trimLoop(ctx, raw, seconds) {
+ const sr = raw.sampleRate;
+ const channels = Math.max(1, raw.numberOfChannels);
+ let edge = -1;
+ for (let c = 0; c < channels && edge < 0; c++) {
+ const data = raw.getChannelData(c);
+ for (let i = 0; i < data.length; i++) {
+ if (Math.abs(data[i]) > PIP_THRESHOLD) {
+ edge = i;
+ break;
+ }
+ }
+ }
+ // no pip found (a take made some other way): the loop starts where the bytes do
+ const start = edge < 0 ? 0 : edge + Math.round(PIP_SECONDS * sr);
+ const length = Math.max(1, Math.round(seconds * sr));
+ const out = ctx.createBuffer(channels, length, sr);
+ for (let c = 0; c < channels; c++) {
+ const src = raw.getChannelData(c);
+ const dst = out.getChannelData(c);
+ const n = Math.max(0, Math.min(length, src.length - start));
+ if (n > 0) dst.set(src.subarray(start, start + n));
+ }
+ return { buffer: out, edge, start };
+}
+
+/** fetch and trim the document's loop; every peer pulls and decodes its own copy by hash
+ * @param {any} api @param {any} h */
+function loadLoop(api, h) {
+ const hash = String(h.doc.loop || '');
+ h.buffer = null;
+ h.trim = null;
+ stopLoopSource(h, h.ctx.currentTime);
+ if (!hash) {
+ syncLoop(api, h);
+ return;
+ }
+ const wanted = hash;
+ Promise.resolve(api.audio.sample(hash))
+ .then((/** @type {any} */ raw) => {
+ if (!raw || !h.alive || String(h.doc.loop || '') !== wanted) return;
+ const trimmed = trimLoop(h.ctx, raw, loopSecondsOf(h.doc));
+ h.buffer = trimmed.buffer;
+ h.trim = { edge: trimmed.edge, start: trimmed.start, rawSeconds: raw.duration };
+ syncLoop(api, h);
+ })
+ .catch(() => {});
+}
+
+/** @param {any} h @param {number} at */
+function stopLoopSource(h, at) {
+ const src = h.src;
+ h.src = null;
+ if (!src) return;
+ try {
+ src.stop(at);
+ } catch {
+ /* already stopped */
+ }
+}
+
+/** start one pass of the loop at audio time `at`, `offset` seconds in, at `rate`
+ * @param {any} h @param {number} at @param {number} offset @param {number} rate */
+function startLoopSource(h, at, offset, rate) {
+ const src = h.ctx.createBufferSource();
+ src.buffer = h.buffer;
+ src.playbackRate.value = rate;
+ src.connect(h.loopGain);
+ src.start(at, Math.max(0, Math.min(h.buffer.duration - 0.001, offset)));
+ h.src = src;
+ h.passes++;
+ src.onended = () => {
+ try {
+ src.disconnect();
+ } catch {
+ /* gone */
+ }
+ if (h.src === src) h.src = null;
+ };
+}
+
+/**
+ * The scheduler's callback at a loop boundary - PURE of (at, bpm): stop the running pass
+ * there and start the next from sample 0, at the tempo ratio. A boundary the scheduler hands
+ * over late (an `at` already gone) starts NOW at the offset that keeps it in phase rather
+ * than from the top. @param {any} h @param {number} at @param {number} bpm
+ */
+function restartLoop(h, at, bpm) {
+ if (!h.buffer || !h.doc.play) return;
+ const rate = bpm / clamp(h.doc.loopBpm, 20, 400, 120);
+ const now = h.ctx.currentTime;
+ if (at >= now) {
+ stopLoopSource(h, at);
+ startLoopSource(h, at, 0, rate);
+ } else {
+ stopLoopSource(h, now);
+ startLoopSource(h, now, (now - at) * rate, rate);
+ }
+}
+
+/** start MID-loop at the phase the transport says (a take that just landed, a late joiner, a
+ * transport restart): the same derivation on every peer @param {any} api @param {any} h */
+function catchUp(api, h) {
+ if (h.src || !h.buffer || !h.doc.play) return;
+ const t = api.audio.transport();
+ if (!t.playing) return;
+ const P = loopBeatsOf(h.doc);
+ const phase = ((((t.beat - (Number(h.doc.startedBeat) || 0)) % P) + P) % P);
+ const rate = t.bpm / clamp(h.doc.loopBpm, 20, 400, 120);
+ const offset = (phase * 60) / clamp(h.doc.loopBpm, 20, 400, 120);
+ if (offset >= h.buffer.duration - 0.02) return; // the boundary is about to restart it anyway
+ h.catchUps++;
+ startLoopSource(h, h.ctx.currentTime, offset, rate);
+}
+
+/** make playback match the document: a repeat at every loop boundary from the bar the window
+ * started on (`startedBeat mod P`, `every: P`), plus a mid-loop catch-up now
+ * @param {any} api @param {any} h */
+function syncLoop(api, h) {
+ h.syncs++;
+ stopLoopSource(h, h.ctx.currentTime);
+ h.cancel?.();
+ h.cancel = null;
+ if (!h.buffer || !h.doc.play) return;
+ const P = loopBeatsOf(h.doc);
+ const offset = ((((Number(h.doc.startedBeat) || 0) % P) + P) % P);
+ h.cancel = api.audio.schedule(
+ offset,
+ (/** @type {{beat: number, at: number, late: boolean, bpm: number}} */ e) => {
+ if (e.late) return; // a boundary already gone is not a restart to fire now
+ restartLoop(h, e.at, e.bpm);
+ h.boundaries.push({ beat: e.beat, at: e.at, wall: Date.now() + (e.at - h.ctx.currentTime) * 1000, lateBy: Math.max(0, h.ctx.currentTime - e.at) });
+ if (h.boundaries.length > 64) h.boundaries.splice(0, h.boundaries.length - 64);
+ },
+ { every: P, swing: false }
+ );
+ catchUp(api, h);
+}
+
+/** @param {any} three */
+function looperMesh(three) {
+ const body = new three.Mesh(
+ new three.BoxGeometry(0.44, 0.1, 0.34),
+ new three.MeshStandardMaterial({ color: 0x292524, roughness: 0.85, metalness: 0.15 })
+ );
+ body.name = 'Looper';
+ body.add(button(three, 'lp-rec', 0xdc2626, [0.09, 0.03, 0.07], [-0.13, 0.065, 0.09]));
+ body.add(button(three, 'lp-play', 0x22c55e, [0.09, 0.03, 0.07], [0, 0.065, 0.09]));
+ body.add(button(three, 'lp-clear', 0x64748b, [0.09, 0.03, 0.07], [0.13, 0.065, 0.09]));
+ body.add(led(three, 'lp-led', 0x3f3f46, [-0.13, 0.062, 0.02]));
+ // a ring that turns with the loop's position
+ const ring = new three.Mesh(
+ new three.TorusGeometry(0.06, 0.008, 8, 32),
+ new three.MeshStandardMaterial({ color: 0x475569, roughness: 0.5, metalness: 0.4 })
+ );
+ ring.rotation.x = Math.PI / 2;
+ ring.position.set(0.07, 0.055, -0.03);
+ ring.name = 'lp-ring';
+ const mark = new three.Mesh(new three.BoxGeometry(0.012, 0.012, 0.03), new three.MeshStandardMaterial({ color: 0xf8fafc, roughness: 0.6 }));
+ mark.position.set(0, -0.06, 0);
+ mark.name = 'lp-mark';
+ ring.add(mark);
+ body.add(ring);
+ body.add(plug(three, 'in', 'in', 0x38bdf8, -0.1, 0, 0.19));
+ body.add(plug(three, 'out', 'out', 0xfb923c, 0.1, 0, 0.19));
+ return body;
+}
+
+/** @param {any} api */
+function looperSpec(api) {
+ return {
+ kind: 'looper',
+ label: 'Looper',
+ icon: '🔁',
+ group: 'Music Voice',
+ ports: { in: [{ id: 'in', label: 'In', kind: 'audio' }], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ /** the loop is a scene asset by hash @param {any} params */
+ assets: (params) => (params?.loop ? [{ hash: String(params.loop), name: String(params.loopName || 'loop') }] : []),
+ params: [
+ { key: 'bars', label: 'Bars', kind: 'range', min: 1, max: 8, step: 1, default: 2 },
+ { key: 'level', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.9 },
+ { key: 'feedback', label: 'Feedback', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.9 },
+ { key: 'play', label: 'Play', kind: 'toggle', default: true },
+ { key: 'thru', label: 'Thru', kind: 'toggle', default: true }
+ // undeclared, part of the document: loop (hash), loopName, startedBeat, startedBar,
+ // startedAt, loopBars, loopBpm, takes, state (idle / armed / recording)
+ ],
+ /**
+ * in -> thru -> out, and the loop's own gain -> out. The record gate is made per take.
+ * @param {any} ctx @param {any} node @param {any} params
+ */
+ build(ctx, node, params) {
+ const input = ctx.createGain();
+ const thru = ctx.createGain();
+ thru.gain.value = params.thru === false ? 0 : 1;
+ const out = ctx.createGain();
+ out.gain.value = clamp(params.level, 0, 1, 0.9);
+ const loopGain = ctx.createGain();
+ input.connect(thru);
+ thru.connect(out);
+ loopGain.connect(out);
+ const h = {
+ ctx,
+ node,
+ input,
+ thru,
+ out,
+ loopGain,
+ doc: { ...params },
+ buffer: null,
+ trim: null,
+ src: null,
+ cancel: null,
+ alive: true,
+ state: 'idle', // this peer's own take: idle / armed / recording
+ armed: null, // {cancel, before, at}
+ passes: 0,
+ catchUps: 0,
+ syncs: 0,
+ /** @type {{beat: number, at: number, wall: number, lateBy: number}[]} */ boundaries: []
+ };
+ loopers.set(node.uuid, h);
+ loadLoop(api, h);
+ return {
+ input,
+ output: out,
+ h,
+ dispose() {
+ h.alive = false;
+ h.armed?.cancel?.();
+ h.armed = null;
+ h.cancel?.();
+ h.cancel = null;
+ stopLoopSource(h, ctx.currentTime);
+ input.disconnect();
+ thru.disconnect();
+ out.disconnect();
+ loopGain.disconnect();
+ loopers.delete(node.uuid);
+ }
+ };
+ },
+ /** @param {any} built @param {string} key @param {any} value */
+ onParam(built, key, value) {
+ const h = built.h;
+ if (!h) return;
+ h.doc[key] = value;
+ if (key === 'level') glide(h.out.gain, clamp(value, 0, 1, 0.9), h.ctx);
+ else if (key === 'thru') glide(h.thru.gain, value === false ? 0 : 1, h.ctx);
+ else if (key === 'loop') loadLoop(api, h);
+ else if (key === 'play' || key === 'startedBeat' || key === 'loopBars' || key === 'loopBpm') syncLoop(api, h);
+ },
+ mesh: (/** @type {any} */ three) => looperMesh(three)
+ };
+}
+
+/**
+ * Arm the looper: the record window starts at the NEXT bar boundary with room for the lead,
+ * and lasts `bars` bars. A one-shot on the transport's scheduler, a beat ahead of the bar,
+ * starts the recorder; the gate and the pip are placed on the audio clock from the callback's
+ * exact `at`. The whole take is one gesture: `before` is captured HERE.
+ * @param {any} api @param {any} device @param {any} h
+ */
+function armLooper(api, device, h) {
+ const uuid = device.uuid;
+ if (h.state === 'armed') {
+ // a second press while armed disarms
+ h.armed?.cancel?.();
+ h.armed = null;
+ h.state = 'idle';
+ api.audio.previewParams(uuid, { state: 'idle' });
+ return;
+ }
+ if (h.state === 'recording') return;
+ let t = api.audio.transport();
+ if (!t.playing) {
+ api.audio.play();
+ t = api.audio.transport();
+ }
+ const bars = clamp(api.audio.device(uuid)?.params?.bars ?? h.doc.bars, 1, 8, 2);
+ const lead = Math.max(LOOP_LEAD_BEATS, (LOOP_LEAD_MIN_S * t.bpm) / 60);
+ let bar = Math.floor(t.beat / 4) + 1;
+ while (bar * 4 - lead - t.beat < 0.15) bar++;
+ const startBeat = bar * 4;
+ const before = api.audio.device(uuid);
+ h.state = 'armed';
+ const armedAt = Date.now();
+ const cancel = api.audio.schedule(startBeat - lead, (/** @type {any} */ e) => beginWindow(api, uuid, h, e, { startBeat, bars, lead, before, armedAt }), { swing: false });
+ h.armed = { cancel, before, at: armedAt, startBeat };
+ api.audio.previewParams(uuid, { state: 'armed' });
+}
+
+/**
+ * The lead callback: the recorder starts now (up to a beat and a bit before the bar), the
+ * GATE opens at the bar's exact audio time and closes `bars` bars later, the sync pip sits in
+ * the 3 ms before the gate. The old loop (scaled by `feedback`) is mixed into the window -
+ * that is the overdub. When the take lands, ONE setParams writes the hash and the window's
+ * bar so every peer plays the same bytes from the same bar.
+ * @param {any} api @param {string} uuid @param {any} h @param {{at: number, bpm: number, beat: number}} e
+ * @param {{startBeat: number, bars: number, lead: number, before: any, armedAt: number}} spec
+ */
+function beginWindow(api, uuid, h, e, spec) {
+ if (h.state !== 'armed' || !h.alive) return;
+ h.armed = null;
+ const ctx = h.ctx;
+ const secondsPerBeat = 60 / e.bpm;
+ const at = e.at + spec.lead * secondsPerBeat; // the bar boundary, on the audio clock
+ if (at < ctx.currentTime + 0.05) {
+ // the lead callback came too late to place the pip before the bar (a stalled main
+ // thread): a loop that cannot be aligned is not recorded at all
+ h.state = 'idle';
+ api.audio.previewParams(uuid, { state: 'idle' });
+ api.toast('Music Voice: missed the bar, press Record again');
+ return;
+ }
+ const seconds = spec.bars * 4 * secondsPerBeat;
+ const dest = ctx.createMediaStreamDestination();
+ const gate = ctx.createGain();
+ gate.gain.value = 0;
+ h.input.connect(gate);
+ gate.connect(dest);
+ const fb = ctx.createGain();
+ fb.gain.value = clamp(h.doc.feedback, 0, 1, 0.9);
+ h.loopGain.connect(fb);
+ fb.connect(gate);
+ gate.gain.setValueAtTime(0, Math.max(ctx.currentTime, at - 0.001));
+ gate.gain.setValueAtTime(1, at);
+ gate.gain.setValueAtTime(0, at + seconds);
+ const pip = ctx.createOscillator();
+ pip.frequency.value = PIP_HZ;
+ const pipGain = ctx.createGain();
+ pipGain.gain.value = PIP_GAIN;
+ pip.connect(pipGain);
+ pipGain.connect(dest);
+ const pipAt = Math.max(ctx.currentTime, at - PIP_SECONDS);
+ pip.start(pipAt);
+ pip.stop(at);
+ const startedAt = Date.now() + (at - ctx.currentTime) * 1000;
+ const maxSeconds = Math.min(MAX_RECORD_SECONDS, Math.ceil(spec.lead * secondsPerBeat + 0.3 + seconds + LOOP_TAIL_S));
+ h.state = 'recording';
+ h.window = { at, seconds, startBeat: spec.startBeat, startedAt };
+ api.audio.previewParams(uuid, { state: 'recording' });
+ const name = 'loop-' + uuid.slice(0, 4) + '-' + ((Number(h.doc.takes) || 0) + 1);
+ let stopTimer = null;
+ const cleanup = () => {
+ clearTimeout(stopTimer);
+ try {
+ h.input.disconnect(gate);
+ } catch {
+ /* gone */
+ }
+ try {
+ h.loopGain.disconnect(fb);
+ } catch {
+ /* gone */
+ }
+ fb.disconnect();
+ gate.disconnect();
+ stopNode(pip);
+ pipGain.disconnect();
+ };
+ const done = Promise.resolve(api.audio.record({ maxSeconds, stream: dest.stream, name }));
+ // end the take a little after the gate closes; the recorder's own cap is the backstop
+ stopTimer = setTimeout(() => api.audio.stopRecording(), Math.max(0, (at + seconds + LOOP_TAIL_S - ctx.currentTime) * 1000));
+ done
+ .then((/** @type {any} */ item) => {
+ cleanup();
+ h.state = 'idle';
+ h.window = null;
+ if (!h.alive) return;
+ if (!item || !item.hash) {
+ api.audio.previewParams(uuid, { state: 'idle' });
+ api.toast('Music Voice: the loop was not recorded');
+ return;
+ }
+ api.audio.setParams(
+ uuid,
+ {
+ loop: item.hash,
+ loopName: item.name,
+ startedBeat: spec.startBeat,
+ startedBar: spec.startBeat / 4,
+ startedAt,
+ loopBars: spec.bars,
+ loopBpm: e.bpm,
+ takes: (Number(h.doc.takes) || 0) + 1,
+ state: 'idle'
+ },
+ { before: spec.before }
+ );
+ })
+ .catch(() => {
+ cleanup();
+ h.state = 'idle';
+ h.window = null;
+ if (h.alive) api.audio.previewParams(uuid, { state: 'idle' });
+ });
+}
+
+/** @param {any} api @param {any} device @param {any} object */
+function clickLooper(api, device, object) {
+ const name = String(object?.name ?? '');
+ const h = loopers.get(device.uuid);
+ if (!h) return false;
+ if (name === 'lp-rec') {
+ armLooper(api, device, h);
+ api.haptic(0.6, 50);
+ return true;
+ }
+ if (name === 'lp-play') {
+ const doc = api.audio.device(device.uuid)?.params ?? h.doc;
+ api.audio.setParams(device.uuid, { play: doc.play === false });
+ api.haptic(0.4, 30);
+ return true;
+ }
+ if (name === 'lp-clear') {
+ if (h.state === 'armed') armLooper(api, device, h); // disarm first
+ api.audio.setParams(device.uuid, { loop: '', loopName: '', takes: 0, state: 'idle' });
+ api.haptic(0.4, 30);
+ return true;
+ }
+ return false;
+}
+
+// ---- the Synth ------------------------------------------------------------------------------
+
+/** the note a key mesh stands for, from userData or - when a replication path dropped that -
+ * from its name @param {any} object @returns {number|null} */
+function noteOf(object) {
+ const midi = object?.userData?.midi;
+ if (typeof midi === 'number') return midi;
+ const named = typeof object?.name === 'string' && object.name.startsWith('key-') ? Number(object.name.slice(4)) : NaN;
+ return Number.isFinite(named) ? named : null;
+}
+
+/** dip the key a note belongs to - from onNote, so every peer moves the same key
+ * @param {any} node @param {number} note */
+function dipKey(node, note) {
+ const key = node?.getObjectByName?.('key-' + note);
+ if (!key || dipped.has(key)) return;
+ dipped.add(key);
+ key.position.y -= KEY_DIP;
+ setTimeout(() => {
+ key.position.y += KEY_DIP;
+ dipped.delete(key);
+ }, KEY_DIP_MS);
+}
+
+/** @param {any} three */
+function synthMesh(three) {
+ const body = new three.Mesh(
+ new three.BoxGeometry(0.62, 0.1, 0.34),
+ new three.MeshStandardMaterial({ color: 0x1e293b, roughness: 0.8, metalness: 0.2 })
+ );
+ body.name = 'Synth';
+ const left = -((WHITE_MIDI.length - 1) * KEY_STEP) / 2;
+ const white = new three.MeshStandardMaterial({ color: 0xf5f5f0, roughness: 0.6 });
+ const black = new three.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.5 });
+ WHITE_MIDI.forEach((midi, index) => {
+ const key = new three.Mesh(new three.BoxGeometry(0.064, 0.03, 0.16), white);
+ key.position.set(left + index * KEY_STEP, 0.06, 0.08);
+ // the CLICK reads this (desktop and VR trigger are one path), and so does onNote; the
+ // NAME carries the same number because userData rides GLTF extras on the late-joiner path
+ key.userData.midi = midi;
+ key.name = 'key-' + midi;
+ body.add(key);
+ });
+ for (const [afterWhite, midi] of Object.entries(BLACK_MIDI)) {
+ const key = new three.Mesh(new three.BoxGeometry(0.04, 0.035, 0.1), black);
+ key.position.set(left + (Number(afterWhite) + 0.5) * KEY_STEP, 0.08, 0.05);
+ key.userData.midi = midi;
+ key.name = 'key-' + midi;
+ body.add(key);
+ }
+ // a panel of knobs (decor; the params live in the Inspector and the toolbox)
+ for (let i = 0; i < 4; i++) {
+ const knob = new three.Mesh(new three.CylinderGeometry(0.018, 0.018, 0.02, 12), new three.MeshStandardMaterial({ color: 0x94a3b8, roughness: 0.5, metalness: 0.5 }));
+ knob.position.set(-0.21 + i * 0.14, 0.06, -0.1);
+ knob.name = 'sy-knob-' + i;
+ body.add(knob);
+ }
+ body.add(plug(three, 'out', 'out', 0xfb923c, 0, 0, 0.19));
+ return body;
+}
+
+/** the release segment of the envelope at `t`, from wherever the envelope is then: a hold
+ * at `t`, a ramp to zero over `release`, the oscillator stopped after it
+ * @param {any} voice @param {number} t @param {number} release */
+function envelopeRelease(voice, t, release) {
+ const g = voice.amp.gain;
+ if (typeof g.cancelAndHoldAtTime === 'function') g.cancelAndHoldAtTime(t);
+ else {
+ g.cancelScheduledValues(t);
+ g.setValueAtTime(Math.max(0.0001, g.value), t);
+ }
+ g.linearRampToValueAtTime(0, t + release);
+ try {
+ voice.osc.stop(t + release + 0.05);
+ } catch {
+ /* stop() may only be called once: the earlier stop stands, the envelope is at zero anyway */
+ }
+}
+
+/** release a voice NOW-ish (an `off` note, a steal): it no longer counts against polyphony
+ * @param {any} h @param {any} voice @param {number} t @param {number} release */
+function releaseVoice(h, voice, t, release) {
+ if (voice.released) return;
+ voice.released = true;
+ clearTimeout(voice.gateTimer);
+ envelopeRelease(voice, t, release);
+}
+
+/** @param {any} h @param {any} voice */
+function dropVoice(h, voice) {
+ const index = h.voices.indexOf(voice);
+ if (index >= 0) h.voices.splice(index, 1);
+ try {
+ voice.osc.disconnect();
+ voice.filter.disconnect();
+ voice.amp.disconnect();
+ } catch {
+ /* gone */
+ }
+}
+
+/** @param {any} api */
+function synthSpec(api) {
+ return {
+ kind: 'synth',
+ label: 'Synth',
+ icon: '🎛️',
+ group: 'Music Voice',
+ ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ {
+ key: 'wave',
+ label: 'Wave',
+ kind: 'select',
+ default: 'sawtooth',
+ options: [
+ { value: 'sine', label: 'Sine' },
+ { value: 'triangle', label: 'Triangle' },
+ { value: 'sawtooth', label: 'Saw' },
+ { value: 'square', label: 'Square' }
+ ]
+ },
+ { key: 'attack', label: 'Attack', kind: 'range', min: 0.001, max: 2, step: 0.001, default: 0.01, unit: 's' },
+ { key: 'decay', label: 'Decay', kind: 'range', min: 0.01, max: 2, step: 0.01, default: 0.15, unit: 's' },
+ { key: 'sustain', label: 'Sustain', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.6 },
+ { key: 'release', label: 'Release', kind: 'range', min: 0.02, max: 4, step: 0.01, default: 0.4, unit: 's' },
+ { key: 'cutoff', label: 'Cutoff', kind: 'range', min: 40, max: 16000, step: 1, default: 2500, unit: 'Hz' },
+ { key: 'resonance', label: 'Resonance', kind: 'range', min: 0.1, max: 20, step: 0.1, default: 1 },
+ { key: 'level', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0.5 },
+ { key: 'polyphony', label: 'Polyphony', kind: 'range', min: 1, max: 16, step: 1, default: 8 },
+ { key: 'gate', label: 'Gate', kind: 'range', min: 0.05, max: 4, step: 0.01, default: 0.5, unit: 's' }
+ ],
+ /** one gain, connected to NOTHING; every voice lands here and a cable picks it up
+ * @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const out = ctx.createGain();
+ out.gain.value = clamp(params.level, 0, 1, 0.5);
+ const h = { ctx, node, out, doc: { ...params }, /** @type {any[]} */ voices: [], alive: true };
+ synths.set(node.uuid, h);
+ return {
+ output: out,
+ h,
+ dispose() {
+ h.alive = false;
+ for (const voice of [...h.voices]) {
+ clearTimeout(voice.gateTimer);
+ stopNode(voice.osc);
+ dropVoice(h, voice);
+ }
+ out.disconnect();
+ synths.delete(node.uuid);
+ }
+ };
+ },
+ /** every param is live: the filter and the level glide on the running voices too
+ * @param {any} built @param {string} key @param {any} value */
+ onParam(built, key, value) {
+ const h = built.h;
+ if (!h) return;
+ h.doc[key] = value;
+ if (key === 'level') glide(h.out.gain, clamp(value, 0, 1, 0.5), h.ctx);
+ else if (key === 'cutoff') for (const v of h.voices) glide(v.filter.frequency, clamp(value, 40, 16000, 2500), h.ctx, 0.02);
+ else if (key === 'resonance') for (const v of h.voices) glide(v.filter.Q, clamp(value, 0.1, 20, 1), h.ctx, 0.02);
+ else if (key === 'wave') for (const v of h.voices) v.osc.type = String(value);
+ },
+ /**
+ * A note on every peer from the same stamp: `at` is WALL CLOCK off the wire, and
+ * `api.audio.timeFor` is the only correct way to place it on this context. `gate`
+ * (seconds) overrides the document's; 0 holds the note until an `off` note for the
+ * same pitch (the VR trigger release) or HOLD_LIMIT_S. Past `polyphony`, the oldest
+ * voice is stolen. @param {any} built @param {any} event @param {any} node
+ */
+ onNote(built, { note, velocity, at, gate, off }, node) {
+ const h = built.h;
+ if (!h) return;
+ const n = Math.round(Number(note));
+ const t = api.audio.timeFor(typeof at === 'number' ? at : Date.now());
+ const doc = h.doc;
+ const release = clamp(doc.release, 0.02, 4, 0.4);
+ if (off) {
+ for (const v of h.voices) if (v.note === n && !v.released) releaseVoice(h, v, Math.max(t, h.ctx.currentTime), release);
+ return;
+ }
+ const polyphony = Math.round(clamp(doc.polyphony, 1, 16, 8));
+ while (h.voices.filter((v) => !v.released).length >= polyphony) {
+ const oldest = h.voices.find((v) => !v.released);
+ if (!oldest) break;
+ releaseVoice(h, oldest, h.ctx.currentTime, STEAL_RELEASE_S);
+ oldest.stolen = true;
+ }
+ const ctx = h.ctx;
+ const osc = ctx.createOscillator();
+ osc.type = String(doc.wave || 'sawtooth');
+ osc.frequency.value = 440 * Math.pow(2, (n - 69) / 12);
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.value = clamp(doc.cutoff, 40, 16000, 2500);
+ filter.Q.value = clamp(doc.resonance, 0.1, 20, 1);
+ const amp = ctx.createGain();
+ const peak = Math.max(0.001, Math.min(1, Number(velocity ?? 0.9)));
+ const attack = clamp(doc.attack, 0.001, 2, 0.01);
+ const decay = clamp(doc.decay, 0.01, 2, 0.15);
+ const sustain = clamp(doc.sustain, 0, 1, 0.6);
+ amp.gain.setValueAtTime(0, t);
+ amp.gain.linearRampToValueAtTime(peak, t + attack);
+ amp.gain.linearRampToValueAtTime(peak * sustain, t + attack + decay);
+ osc.connect(filter);
+ filter.connect(amp);
+ amp.connect(h.out);
+ osc.start(t);
+ const hold = typeof gate === 'number' ? gate : clamp(doc.gate, 0.05, 4, 0.5);
+ const gateAt = t + (hold > 0 ? hold : HOLD_LIMIT_S);
+ const voice = { note: n, osc, filter, amp, at: t, gateAt, released: false, stolen: false, gateTimer: null };
+ h.voices.push(voice);
+ osc.onended = () => dropVoice(h, voice);
+ // the gate's release is SCHEDULED on the audio clock (sample-accurate, the same on
+ // every peer); an `off` note or a steal may still cut in earlier. The voice stops
+ // counting against polyphony once its gate has passed.
+ envelopeRelease(voice, gateAt, release);
+ voice.gateTimer = setTimeout(() => {
+ voice.released = true;
+ }, Math.max(0, (gateAt - ctx.currentTime) * 1000));
+ dipKey(node, n);
+ },
+ mesh: (/** @type {any} */ three) => synthMesh(three)
+ };
+}
+
+/** notes held by a VR trigger: released when that hand's trigger lets go
+ * @type {{uuid: string, note: number, hand: string}[]} */
+let held = [];
+
+/** a key: a replicated note. In VR, the pressing hand's trigger holds the note (gate 0) and
+ * its release sends the `off`; on desktop the document's gate releases it.
+ * @param {any} api @param {any} device @param {any} object */
+function clickSynth(api, device, object) {
+ const midi = noteOf(object);
+ if (midi === null) return false;
+ const inVR = typeof api.isVR === 'function' && api.isVR();
+ const hand = inVR ? ['right', 'left'].find((hd) => api.vrHand?.(hd)?.trigger) ?? null : null;
+ if (hand) {
+ api.audio.note(device.uuid, { note: midi, velocity: 0.9, gate: 0 });
+ held.push({ uuid: device.uuid, note: midi, hand });
+ } else api.audio.note(device.uuid, { note: midi, velocity: 0.9 });
+ api.haptic(0.6, 60);
+ return true;
+}
+
+/** @param {any} api */
+function updateHeld(api) {
+ if (!held.length) return;
+ held = held.filter((entry) => {
+ const hand = api.vrHand?.(entry.hand);
+ if (hand && hand.trigger) return true;
+ api.audio.note(entry.uuid, { note: entry.note, velocity: 0, off: true });
+ return false;
+ });
+}
+
+// ---- the Theremin ---------------------------------------------------------------------------
+
+/** @param {any} three */
+function thereminMesh(three) {
+ const body = new three.Mesh(
+ new three.BoxGeometry(0.36, 0.12, 0.24),
+ new three.MeshStandardMaterial({ color: 0x3f2d1e, roughness: 0.8, metalness: 0.1 })
+ );
+ body.name = 'Theremin';
+ const antenna = new three.Mesh(new three.CylinderGeometry(0.008, 0.008, 0.6, 8), new three.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.3, metalness: 0.8 }));
+ antenna.position.set(0.15, 0.36, 0);
+ antenna.name = 'th-pitch-antenna';
+ body.add(antenna);
+ const loop = new three.Mesh(new three.TorusGeometry(0.07, 0.006, 8, 24), new three.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.3, metalness: 0.8 }));
+ loop.position.set(-0.25, 0.1, 0);
+ loop.rotation.y = Math.PI / 2;
+ loop.name = 'th-volume-loop';
+ body.add(loop);
+ body.add(led(three, 'th-led', 0x1e3a8a, [0, 0.065, 0.08]));
+ body.add(plug(three, 'out', 'out', 0xfb923c, 0, 0, 0.14));
+ return body;
+}
+
+/** @param {any} api */
+function thereminSpec(api) {
+ return {
+ kind: 'theremin',
+ label: 'Theremin',
+ icon: '👋',
+ group: 'Music Voice',
+ ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] },
+ params: [
+ { key: 'pitch', label: 'Pitch', kind: 'range', min: 55, max: 1760, step: 0.1, default: 440, unit: 'Hz' },
+ { key: 'volume', label: 'Volume', kind: 'range', min: 0, max: 1, step: 0.01, default: 0 },
+ {
+ key: 'wave',
+ label: 'Wave',
+ kind: 'select',
+ default: 'sine',
+ options: [
+ { value: 'sine', label: 'Sine' },
+ { value: 'triangle', label: 'Triangle' },
+ { value: 'sawtooth', label: 'Saw' }
+ ]
+ },
+ { key: 'hand', label: 'Hand', kind: 'select', default: 'right', options: [{ value: 'right', label: 'Right' }, { value: 'left', label: 'Left' }] }
+ ],
+ /** one oscillator that never stops, a volume gain, the output. Everything audible
+ * derives from the DOCUMENT - the hand only writes it. @param {any} ctx @param {any} node @param {any} params */
+ build(ctx, node, params) {
+ const osc = ctx.createOscillator();
+ osc.type = String(params.wave || 'sine');
+ osc.frequency.value = clamp(params.pitch, 55, 1760, 440);
+ const vol = ctx.createGain();
+ vol.gain.value = clamp(params.volume, 0, 1, 0);
+ const out = ctx.createGain();
+ osc.connect(vol);
+ vol.connect(out);
+ osc.start();
+ const h = { ctx, node, osc, vol, out, doc: { ...params }, local: { inReach: false, lastSent: 0 } };
+ theremins.set(node.uuid, h);
+ return {
+ output: out,
+ h,
+ dispose() {
+ stopNode(osc);
+ vol.disconnect();
+ out.disconnect();
+ theremins.delete(node.uuid);
+ }
+ };
+ },
+ /** glides: a hand moves continuously, the sound must too @param {any} built @param {string} key @param {any} value */
+ onParam(built, key, value) {
+ const h = built.h;
+ if (!h) return;
+ h.doc[key] = value;
+ if (key === 'pitch') glide(h.osc.frequency, clamp(value, 55, 1760, 440), h.ctx, 0.03);
+ else if (key === 'volume') glide(h.vol.gain, clamp(value, 0, 1, 0), h.ctx, 0.03);
+ else if (key === 'wave') h.osc.type = String(value);
+ },
+ mesh: (/** @type {any} */ three) => thereminMesh(three)
+ };
+}
+
+/** the VR hand -> the document, at THEREMIN_HZ: hand height above the body is pitch, the
+ * hand's distance from the body is volume. Written as previews (replicated, history-free);
+ * a hand that leaves reach writes volume 0 once. Nothing here is heard directly - every
+ * peer, this one included, synthesizes from the document. @param {any} api @param {number} nowMs */
+function updateTheremins(api, nowMs) {
+ if (typeof api.isVR !== 'function' || !api.isVR()) return;
+ for (const [uuid, h] of theremins) {
+ const hand = api.vrHand?.(String(h.doc.hand || 'right'));
+ if (!hand || !hand.connected || !h.node?.getWorldPosition) continue;
+ const at = h.node.getWorldPosition(new api.THREE.Vector3());
+ const dx = hand.position[0] - at.x;
+ const dy = hand.position[1] - at.y;
+ const dz = hand.position[2] - at.z;
+ const d = Math.hypot(dx, dz);
+ if (d > REACH || dy < -0.2) {
+ if (h.local.inReach) {
+ h.local.inReach = false;
+ api.audio.previewParams(uuid, { volume: 0 });
+ }
+ continue;
+ }
+ h.local.inReach = true;
+ if (nowMs - h.local.lastSent < PREVIEW_MS) continue;
+ const height = Math.max(0, Math.min(1, (dy - 0.05) / HEIGHT_SPAN));
+ const pitch = Math.round(PITCH_LO * Math.pow(2, height * PITCH_OCTAVES) * 10) / 10;
+ const volume = Math.round(Math.max(0, Math.min(1, 1 - (d - VOLUME_NEAR) / (REACH - VOLUME_NEAR))) * 100) / 100;
+ const pitchNow = Number(h.doc.pitch) || 440;
+ if (Math.abs(pitch - pitchNow) / pitchNow < 0.004 && Math.abs(volume - (Number(h.doc.volume) || 0)) < 0.02) continue;
+ h.local.lastSent = nowMs;
+ api.audio.previewParams(uuid, { pitch, volume });
+ }
+}
+
+// ---- the frame task ----------------------------------------------------------------------------
+
+let nextDisplay = 0;
+let nextLooper = 0;
+let nextTheremin = 0;
+
+/** @param {any} api @param {number} time seconds */
+function voiceFrame(api, time) {
+ updateHeld(api);
+ const nowMs = performance.now();
+ if (time >= nextTheremin) {
+ nextTheremin = time + 1 / THEREMIN_HZ;
+ updateTheremins(api, nowMs);
+ }
+ if (time >= nextLooper) {
+ nextLooper = time + 1 / LOOPER_HZ;
+ const t = api.audio.transport();
+ for (const h of loopers.values()) {
+ if (!t.playing) {
+ if (h.src) stopLoopSource(h, h.ctx.currentTime);
+ continue;
+ }
+ if (h.src) {
+ // a tempo change mid-pass: keep the running pass at the new ratio
+ const rate = t.bpm / clamp(h.doc.loopBpm, 20, 400, 120);
+ if (Math.abs(h.src.playbackRate.value - rate) > 1e-6) h.src.playbackRate.setTargetAtTime(rate, h.ctx.currentTime, 0.01);
+ } else catchUp(api, h);
+ }
+ }
+ if (time < nextDisplay) return;
+ nextDisplay = time + 1 / DISPLAY_HZ;
+ for (const h of mics.values()) {
+ const lamp = h.node?.getObjectByName?.('mic-led');
+ if (lamp) light(lamp, recState.active ? 0xef4444 : h.source ? 0x16a34a : 0x7f1d1d, recState.active ? 0.9 : 0.2);
+ }
+ const t = api.audio.transport();
+ for (const h of loopers.values()) {
+ const lamp = h.node?.getObjectByName?.('lp-led');
+ const state = String(h.doc.state || 'idle');
+ if (lamp) light(lamp, state === 'recording' ? 0xef4444 : state === 'armed' ? 0xf59e0b : h.buffer ? 0x22c55e : 0x3f3f46, state === 'idle' ? 0.2 : 0.9);
+ const play = h.node?.getObjectByName?.('lp-play');
+ if (play) light(play, h.doc.play === false ? 0x166534 : 0x22c55e, 0);
+ const ring = h.node?.getObjectByName?.('lp-ring');
+ if (ring && h.buffer && t.playing) {
+ const P = loopBeatsOf(h.doc);
+ const phase = ((((t.beat - (Number(h.doc.startedBeat) || 0)) % P) + P) % P) / P;
+ ring.rotation.z = -phase * Math.PI * 2;
+ }
+ }
+ for (const h of theremins.values()) {
+ const lamp = h.node?.getObjectByName?.('th-led');
+ const volume = clamp(h.doc.volume, 0, 1, 0);
+ if (lamp) light(lamp, volume > 0.01 ? 0x60a5fa : 0x1e3a8a, volume);
+ }
+}
+
+// ---- the module -----------------------------------------------------------------------------
+
+export default {
+ id: 'music-voice',
+ name: 'Music Voice',
+ version: '0.1.0',
+ description: 'A Mic, a Looper, a Synth and a Theremin on the engine: sing into the graph, loop what is patched in on the bar, play chords, wave a hand.',
+ /** @param {any} api */
+ register(api) {
+ void api.THREE; // handed to mesh(three) by the registry; never imported
+ const mine = ++generation;
+ api.registerAudioDevice(micSpec(api)).then((/** @type {string} */ kind) => (KINDS.mic = kind));
+ api.registerAudioDevice(looperSpec(api)).then((/** @type {string} */ kind) => (KINDS.looper = kind));
+ api.registerAudioDevice(synthSpec(api)).then((/** @type {string} */ kind) => (KINDS.synth = kind));
+ api.registerAudioDevice(thereminSpec(api)).then((/** @type {string} */ kind) => (KINDS.theremin = kind));
+
+ // the local recorder's state, for the mic faces (a store behind a promise)
+ Promise.resolve(api.audio.recording?.())
+ .then((/** @type {any} */ store) => {
+ if (!store?.subscribe) return;
+ store.subscribe((/** @type {any} */ value) => {
+ if (mine === generation && value) recState = value;
+ });
+ })
+ .catch(() => {});
+
+ api.registerClickHandler((/** @type {any} */ object) => {
+ const device = deviceRootOf(object);
+ const kind = device?.userData?.device?.kind;
+ if (!kind) return false;
+ if (kind === KINDS.mic) return clickMic(api, device, object);
+ if (kind === KINDS.looper) return clickLooper(api, device, object);
+ if (kind === KINDS.synth) return clickSynth(api, device, object);
+ return false;
+ });
+ api.registerFrameTask((/** @type {number} */ time) => voiceFrame(api, time));
+
+ api.registerMenu('Music Voice: demo', () => {
+ const synth = api.audio.addDevice('synth', { position: [-1.2, 0.8, -2] });
+ const looper = api.audio.addDevice('looper', { position: [-0.2, 0.8, -2] });
+ const mic = api.audio.addDevice('mic', { position: [0.7, 0.8, -2] });
+ const theremin = api.audio.addDevice('theremin', { position: [1.5, 0.8, -2] });
+ if (!synth || !looper || !mic || !theremin) return api.toast('Music Voice: could not add the devices');
+ api.audio.cable({ from: { uuid: synth.uuid, port: 'out' }, to: { uuid: looper.uuid, port: 'in' } });
+ // a Music Lab speaker if that module is here: the rig reaches the room through it
+ const speaker = api.audio.addDevice('mod-music-lab-speaker', { position: [0, 0, -3.2] });
+ const spec = speaker && api.audio.device(speaker.uuid);
+ if (speaker && spec) {
+ for (const from of [looper, mic, theremin]) api.audio.cable({ from: { uuid: from.uuid, port: 'out' }, to: { uuid: speaker.uuid, port: 'in' } });
+ }
+ api.toast('Music Voice: play the synth, press Record on the looper' + (speaker ? '' : ', then cable the looper, the mic and the theremin to a speaker'));
+ });
+
+ api.onSceneClear(() => {
+ mics.clear();
+ loopers.clear();
+ synths.clear();
+ theremins.clear();
+ held = [];
+ });
+ }
+};
diff --git a/modules/untangle/README.md b/modules/untangle/README.md
new file mode 100644
index 0000000..9743881
--- /dev/null
+++ b/modules/untangle/README.md
@@ -0,0 +1,60 @@
+# untangle — drag the dots until no edges cross
+
+The SDK's original test-flight game, now a **template game** (21-C C7): the board's
+pose and the starting level are node data, the desktop HUD is core HUD elements the
+template authors, and the puzzle itself stays imperative because it cannot be nodes
+(no arrays in the socket type system, no pointer-ray node). Everything comes from `api`.
+
+```
+modules/untangle/
+ src/puzzle.js the PURE puzzle: RNG, generator, crossing test (node-tested)
+ src/index.js the module: board, drag, audio, nodes, replication
+ src/def.js the games/untangle template def (room, HUD, graph)
+ test/ puzzle.test.mjs — determinism, solvability over 40 levels
+ module.js the bundled, self-contained entry (committed)
+```
+
+```bash
+npm run build:untangle # src/ -> module.js (esbuild) + untangle.def.json
+npm run test:untangle # the pure puzzle
+npm run pack -- untangle # -> untangle.zip
+APP_URL=https://localhost:5216/ npm test -- untangle.test # the test-flight
+```
+
+## Nodes (group "Untangle")
+
+| Node | What | Params |
+|---|---|---|
+| **Untangle Board** | owns the board while alive: pose, radius, starting level, autoAdvance | level · radius · boardY · x · z · yaw · autoAdvance · apply |
+| **Untangle Value** | a number OUT for a HUD Text / Compare | read: level · crossings · solved · dots · edges · count |
+| **Untangle Event** | an event OUT for a Counter / Set Game State / Sound | event: solved · level |
+
+The board node's `level` is the **starting** level — applied when the node's value
+changes, so `autoAdvance` can move on without the node putting the level back, and
+never on first sight over a state sync that already landed (a late joiner keeps the
+level the room is on). Remove the node and the default pose returns within a second.
+
+**The install gate.** With no Untangle Board node in any graph the module waits ~40
+frames and then falls back to today's behaviour (a level-1 board appears on its own);
+with a node, the node decides. A scene clear (`applySession` runs `/clear all` first)
+resets to level 1 and the node re-applies its level on the next tick — the template
+suite asserts that ordering, because its failure mode is "every game template silently
+starts at level 1".
+
+## The VR HUD
+
+The 512×96 canvas sprite over the board is the **VR-only** path (gated on
+`api.isVR()`): DOM is invisible in a headset. On desktop there is no sprite; the
+template's HUD Text elements read the value nodes instead.
+
+## Replication
+
+- the puzzle is a PURE function of the level (seeded RNG) — every peer generates the
+ identical graph; a saved scene carries the LEVEL (seed input), never the positions
+- positions are board units (the unit disc), so a radius change re-renders without
+ touching the model; drags stream as throttled previews, the DROP is the authoritative
+ `move`; win = zero crossings, checked on every peer from the same positions — all
+ peers advance in lockstep without a "win" message
+- `utevent solved` is pulsed with `api.fireNodeTrigger` on the peer that dropped the
+ solving dot (it replicates, so a Counter counts it once)
+- late joiners get `{level, positions}` via `registerStateSync`
diff --git a/modules/untangle/emit-def.mjs b/modules/untangle/emit-def.mjs
new file mode 100644
index 0000000..373d3e3
--- /dev/null
+++ b/modules/untangle/emit-def.mjs
@@ -0,0 +1,10 @@
+// emit untangle.def.json beside the module — the orchestrator's input for the scenes def
+// (run by `npm run build:untangle`)
+import { writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { untangleDef } from './src/def.js';
+
+const here = dirname(fileURLToPath(import.meta.url));
+writeFileSync(join(here, 'untangle.def.json'), JSON.stringify(untangleDef(), null, '\t') + '\n');
+console.log('untangle.def.json written');
diff --git a/modules/untangle/manifest.json b/modules/untangle/manifest.json
index ee40600..f4a447c 100644
--- a/modules/untangle/manifest.json
+++ b/modules/untangle/manifest.json
@@ -1,7 +1,9 @@
{
"id": "untangle",
"name": "Untangle",
- "version": "1.0.0",
- "description": "Untangle the graph: drag the dots until no edges cross. Procedural, replicated, with generative audio - built purely on the module SDK.",
- "entry": "module.js"
+ "version": "2.0.0",
+ "format": 1,
+ "description": "Untangle the graph: drag the dots until no edges cross. Procedural, replicated, with generative audio - board pose, level and readouts as flow nodes.",
+ "entry": "module.js",
+ "files": ["module.js"]
}
diff --git a/modules/untangle/module.js b/modules/untangle/module.js
index 4cc9f7b..a4bc59b 100644
--- a/modules/untangle/module.js
+++ b/modules/untangle/module.js
@@ -1,350 +1,484 @@
-// Untangle — the 190 test-flight game module. Drag the dots until no edges
-// cross. Proves a self-contained module (NO imports, everything from `api`) can
-// ship a real game: procedural guaranteed-solvable puzzles, desktop+VR drag via
-// api.pointerRay(), replicated moves, deterministic win detection and
-// generative WebAudio (ambient pad reacting to progress + SFX), all on the SDK.
-//
-// Replication model (golden rules):
-// - the puzzle is a PURE function of the level number (seeded RNG) — every peer
-// generates the identical graph (determinism IS the netcode)
-// - drags stream as throttled previews; the DROP is the authoritative 'move'
-// - win = zero crossings, checked after every authoritative move on every peer
-// from the same positions — all peers advance to the next level in lockstep
-// without a "win" message
-// - late joiners get {level, positions} via registerStateSync
+// modules/untangle/src/puzzle.js
+function mulberry32(seed) {
+ let t = seed >>> 0;
+ return function() {
+ t = t + 1831565813 >>> 0;
+ let r = Math.imul(t ^ t >>> 15, t | 1);
+ r ^= r + Math.imul(r ^ r >>> 7, r | 61);
+ return ((r ^ r >>> 14) >>> 0) / 4294967296;
+ };
+}
+function chordsCross(a, b, c, d, n) {
+ const between = (x, lo, hi) => {
+ const span = (hi - lo + n) % n;
+ const off = (x - lo + n) % n;
+ return off > 0 && off < span;
+ };
+ if (a === c || a === d || b === c || b === d) return false;
+ return between(c, a, b) !== between(d, a, b);
+}
+function generate(lvl) {
+ const rand = mulberry32(2654435769 ^ lvl * 2654435761);
+ const n = Math.min(5 + lvl, 16);
+ const edges = [];
+ for (let i = 0; i < n; i++) edges.push([i, (i + 1) % n]);
+ const chords = [];
+ for (let i = 0; i < n; i++) for (let j = i + 2; j < n; j++) if (!(i === 0 && j === n - 1)) chords.push([i, j]);
+ for (let i = chords.length - 1; i > 0; i--) {
+ const k = Math.floor(rand() * (i + 1));
+ const swap = chords[i];
+ chords[i] = chords[k];
+ chords[k] = swap;
+ }
+ const wanted = Math.floor(n * 0.8);
+ let added = 0;
+ for (const [a, b] of chords) {
+ if (added >= wanted) break;
+ if (edges.some(([c, d]) => chordsCross(a, b, c, d, n))) continue;
+ edges.push([a, b]);
+ added++;
+ }
+ const positions = [];
+ for (let i = 0; i < n; i++) {
+ const angle = rand() * Math.PI * 2;
+ const radius = Math.sqrt(rand()) * 0.9;
+ positions.push([Math.cos(angle) * radius, Math.sin(angle) * radius]);
+ }
+ return { n, edges, positions };
+}
+function segsCross(p1, p2, p3, p4) {
+ const d = (a, b, c) => (c[0] - a[0]) * (b[1] - a[1]) - (b[0] - a[0]) * (c[1] - a[1]);
+ const d1 = d(p3, p4, p1);
+ const d2 = d(p3, p4, p2);
+ const d3 = d(p1, p2, p3);
+ const d4 = d(p1, p2, p4);
+ return (d1 > 0 && d2 < 0 || d1 < 0 && d2 > 0) && (d3 > 0 && d4 < 0 || d3 < 0 && d4 > 0);
+}
+function edgeCrossings(positions, edges) {
+ const counts = edges.map(() => 0);
+ for (let i = 0; i < edges.length; i++) {
+ for (let j = i + 1; j < edges.length; j++) {
+ const [a, b] = edges[i];
+ const [c, d] = edges[j];
+ if (a === c || a === d || b === c || b === d) continue;
+ if (segsCross(positions[a], positions[b], positions[c], positions[d])) {
+ counts[i]++;
+ counts[j]++;
+ }
+ }
+ }
+ return counts;
+}
+function totalCrossings(counts) {
+ return counts.reduce((sum, c) => sum + c, 0) / 2;
+}
+function clampToBoard(p) {
+ return [Math.max(-1, Math.min(1, p[0])), Math.max(-1, Math.min(1, p[1]))];
+}
+var DEFAULT_BOARD = { level: 1, radius: 1.1, boardY: 1.6, x: 0, z: 0, yaw: 0, autoAdvance: true };
-export default {
- id: 'untangle',
- name: 'Untangle',
- version: '1.0.0',
- description: 'Drag the dots until no edges cross — procedural puzzle game (SDK test-flight).',
- /** @param {any} api */
- register(api) {
- const THREE = api.THREE;
- const GROUP = 'untangle-module';
- const BOARD_Y = 1.6; // board centre height (VR eye-ish); vertical x/y plane at z=0
- const RADIUS = 1.1;
- const DOT_R = 0.055;
-
- // ---------- state ----------
- let level = 1;
- /** @type {number[][]} 2D board coords per node: [x, y] */
- let positions = [];
- /** @type {number[][]} node index pairs */
- let edges = [];
- let group = null;
- /** @type {any[]} */ let dots = [];
- /** @type {any[]} */ let lineMeshes = [];
- let hud = null;
- let carried = -1;
- let won = false;
- let lastDragSent = 0;
-
- // ---------- deterministic RNG ----------
- function mulberry32(seed) {
- let t = seed >>> 0;
- return function () {
- t = (t + 0x6d2b79f5) >>> 0;
- let r = Math.imul(t ^ (t >>> 15), t | 1);
- r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
- return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
- };
- }
-
- // ---------- puzzle generation (guaranteed solvable) ----------
- // Solution layout: n nodes on a circle. Candidate edges are chords; two
- // chords of a circle cross iff exactly one endpoint of one lies between
- // the endpoints of the other (walking the circle). We keep the ring
- // (connectivity) + greedily add non-crossing chords — so the circle
- // layout IS a planar embedding and the puzzle is solvable by construction.
- // Then node positions are scrambled (seeded) so edges cross.
- function chordsCross(a, b, c, d, n) {
- const between = (x, lo, hi) => {
- // does x lie strictly between lo and hi walking the circle lo -> hi?
- const span = (hi - lo + n) % n;
- const off = (x - lo + n) % n;
- return off > 0 && off < span;
- };
- if (a === c || a === d || b === c || b === d) return false; // shared endpoint
- return between(c, a, b) !== between(d, a, b);
- }
-
- function generate(lvl) {
- const rand = mulberry32(0x9e3779b9 ^ (lvl * 2654435761));
- const n = Math.min(5 + lvl, 16); // difficulty scales, capped
- // ring edges first (connected planar base)
- edges = [];
- for (let i = 0; i < n; i++) edges.push([i, (i + 1) % n]);
- // candidate chords, deterministic shuffle, greedy non-crossing add
- const chords = [];
- for (let i = 0; i < n; i++)
- for (let j = i + 2; j < n; j++) if (!(i === 0 && j === n - 1)) chords.push([i, j]);
- for (let i = chords.length - 1; i > 0; i--) {
- const k = Math.floor(rand() * (i + 1));
- const swap = chords[i];
- chords[i] = chords[k];
- chords[k] = swap;
- }
- const wanted = Math.floor(n * 0.8);
- let added = 0;
- for (const [a, b] of chords) {
- if (added >= wanted) break;
- if (edges.some(([c, d]) => chordsCross(a, b, c, d, n))) continue;
- edges.push([a, b]);
- added++;
- }
- // scramble positions in the disc until at least one crossing exists
- positions = [];
- for (let i = 0; i < n; i++) {
- const angle = rand() * Math.PI * 2;
- const radius = Math.sqrt(rand()) * RADIUS * 0.9;
- positions.push([Math.cos(angle) * radius, Math.sin(angle) * radius]);
- }
- won = false;
- }
-
- // ---------- crossings on CURRENT positions ----------
- function segsCross(p1, p2, p3, p4) {
- const d = (a, b, c) => (c[0] - a[0]) * (b[1] - a[1]) - (b[0] - a[0]) * (c[1] - a[1]);
- const d1 = d(p3, p4, p1);
- const d2 = d(p3, p4, p2);
- const d3 = d(p1, p2, p3);
- const d4 = d(p1, p2, p4);
- return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
- }
- function edgeCrossings() {
- const counts = edges.map(() => 0);
- for (let i = 0; i < edges.length; i++) {
- for (let j = i + 1; j < edges.length; j++) {
- const [a, b] = edges[i];
- const [c, d] = edges[j];
- if (a === c || a === d || b === c || b === d) continue;
- if (segsCross(positions[a], positions[b], positions[c], positions[d])) {
- counts[i]++;
- counts[j]++;
- }
- }
- }
- return counts;
- }
-
- // ---------- render ----------
- function world(p) {
- return new THREE.Vector3(p[0], BOARD_Y + p[1], 0);
- }
- function build() {
- const scene = api.scene();
- if (!scene) return;
- if (group) scene.remove(group);
- group = new THREE.Group();
- group.name = GROUP;
- dots = [];
- lineMeshes = [];
- positions.forEach((p, i) => {
- const dot = new THREE.Mesh(
- new THREE.SphereGeometry(DOT_R, 20, 14),
- new THREE.MeshStandardMaterial({ color: 0xf1f5f9, roughness: 0.4 })
- );
- dot.name = 'untangle-dot-' + i;
- dot.position.copy(world(p));
- group.add(dot);
- dots.push(dot);
- });
- edges.forEach(([a, b], i) => {
- const geometry = new THREE.BufferGeometry().setFromPoints([world(positions[a]), world(positions[b])]);
- const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xf87171 }));
- line.name = 'untangle-edge-' + i;
- group.add(line);
- lineMeshes.push(line);
- });
- hud = makeHud();
- group.add(hud);
- scene.add(group);
- refresh();
- }
- function makeHud() {
- const canvas = document.createElement('canvas');
- canvas.width = 512;
- canvas.height = 96;
- const sprite = new THREE.Sprite(
- new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true })
- );
- sprite.name = 'untangle-hud';
- sprite.scale.set(1.6, 0.3, 1);
- sprite.position.set(0, BOARD_Y + RADIUS + 0.45, 0);
- sprite.userData.canvas = canvas;
- return sprite;
- }
- function drawHud(text, color) {
- if (!hud) return;
- const canvas = hud.userData.canvas;
- const g = canvas.getContext('2d');
- g.clearRect(0, 0, canvas.width, canvas.height);
- g.font = 'bold 44px monospace';
- g.textAlign = 'center';
- g.fillStyle = color;
- g.fillText(text, canvas.width / 2, 62);
- hud.material.map.needsUpdate = true;
- }
- function refresh() {
- if (!group) return;
- positions.forEach((p, i) => dots[i]?.position.copy(world(p)));
- const counts = edgeCrossings();
- let total = 0;
- edges.forEach(([a, b], i) => {
- const line = lineMeshes[i];
- if (!line) return;
- line.geometry.setFromPoints([world(positions[a]), world(positions[b])]);
- line.material.color.set(counts[i] > 0 ? 0xf87171 : 0x4ade80); // tangled warm, clear cool
- total += counts[i];
- });
- total = total / 2;
- drawHud('Level ' + level + ' · ' + (total === 0 ? 'solved!' : total + ' crossing' + (total === 1 ? '' : 's')), total === 0 ? '#4ade80' : '#e2e8f0');
- ambientSetTension(total);
- return total;
- }
-
- // ---------- generative audio (lazy — browsers gate audio on a gesture) ----------
- let ac = null;
- let padGain = null;
- let padFilter = null;
- function audio() {
- if (ac) return ac;
- ac = new (window.AudioContext || window.webkitAudioContext)();
- // ambient "thinking" pad: two detuned triangles through a lowpass, slow LFO
- padGain = ac.createGain();
- padGain.gain.value = 0.03;
- padFilter = ac.createBiquadFilter();
- padFilter.type = 'lowpass';
- padFilter.frequency.value = 400;
- const lfo = ac.createOscillator();
- const lfoGain = ac.createGain();
- lfo.frequency.value = 0.13;
- lfoGain.gain.value = 0.012;
- lfo.connect(lfoGain).connect(padGain.gain);
- for (const [type, freq] of [['triangle', 110], ['triangle', 110.7], ['sine', 220.3]]) {
- const osc = ac.createOscillator();
- osc.type = type;
- osc.frequency.value = freq;
- osc.connect(padFilter);
- osc.start();
- }
- padFilter.connect(padGain).connect(ac.destination);
- lfo.start();
- return ac;
- }
- function ambientSetTension(crossings) {
- if (!ac) return; // starts on the first interaction
- // more tangles = darker/closer pad; solving opens the filter up
- padFilter.frequency.linearRampToValueAtTime(
- 320 + Math.max(0, 24 - crossings) * 60,
- ac.currentTime + 0.6
- );
- }
- function blip(freq, duration = 0.07, gain = 0.12) {
- const ctx = audio();
- const osc = ctx.createOscillator();
- const g = ctx.createGain();
- osc.frequency.value = freq;
- g.gain.setValueAtTime(gain, ctx.currentTime);
- g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
- osc.connect(g).connect(ctx.destination);
- osc.start();
- osc.stop(ctx.currentTime + duration);
- }
- function winSting() {
- [523.25, 659.25, 783.99, 1046.5].forEach((f, i) =>
- setTimeout(() => blip(f, 0.22, 0.14), i * 110)
- );
- }
-
- // ---------- interaction: click to pick, pointerRay to carry, click to drop ----
- const dragPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); // the board plane z=0
- const hitPoint = new THREE.Vector3();
- function applyMove(i, p, authoritative) {
- if (!positions[i]) return;
- positions[i] = [p[0], p[1]];
- const total = refresh();
- if (authoritative && total === 0 && !won) {
- won = true;
- winSting();
- api.toast('Untangled! Level ' + (level + 1) + '…');
- setTimeout(() => {
- level += 1;
- generate(level);
- build();
- }, 1200);
- }
- }
- api.registerClickHandler((object) => {
- // while carrying, ANY click drops the dot where it is (clicking the tiny
- // dot exactly is fiddly, especially at the board-edge clamp)
- if (carried !== -1) {
- const i = carried;
- dots[i].material.color.set(0xf1f5f9);
- carried = -1;
- blip(440);
- api.send({ op: 'move', i, p: positions[i] });
- applyMove(i, positions[i], true);
- return true;
- }
- if (!object?.name?.startsWith('untangle-dot-')) return false;
- carried = +object.name.slice('untangle-dot-'.length);
- dots[carried].material.color.set(0xfbbf24);
- blip(660);
- return true; // consume — never selects the dot
- });
- api.registerFrameTask(() => {
- if (carried === -1 || !group) return;
- const ray = api.pointerRay();
- if (!ray) return;
- if (!ray.ray.intersectPlane(dragPlane, hitPoint)) return;
- const x = Math.max(-RADIUS, Math.min(RADIUS, hitPoint.x));
- const y = Math.max(-RADIUS, Math.min(RADIUS, hitPoint.y - BOARD_Y));
- positions[carried] = [x, y];
- refresh();
- const now = performance.now();
- if (now - lastDragSent > 100) {
- lastDragSent = now;
- api.send({ op: 'drag', i: carried, p: positions[carried] });
- }
- });
-
- // ---------- replication ----------
- api.onMessage((data) => {
- if (data.op === 'drag') applyMove(data.i, data.p, false);
- else if (data.op === 'move') applyMove(data.i, data.p, true);
- else if (data.op === 'restart') {
- level = data.level ?? 1;
- generate(level);
- build();
- }
- });
- api.registerStateSync({
- getState: () => ({ level, positions }),
- applyState: (state) => {
- if (!state) return;
- level = state.level ?? 1;
- generate(level); // identical graph from the seed
- if (Array.isArray(state.positions) && state.positions.length === positions.length)
- positions = state.positions.map((p) => [p[0], p[1]]);
- build();
- }
- });
- api.registerMenu('Restart level', () => {
- api.send({ op: 'restart', level });
- generate(level);
- build();
- });
- api.onSceneClear?.(() => {
- level = 1;
- generate(level);
- build();
- });
-
- // ---------- go ----------
- generate(level);
- build();
- api.registerInteractiveGroup(GROUP);
- }
+// modules/untangle/src/index.js
+var GROUP = "untangle-module";
+var DOT_R = 0.055;
+var EXPIRE_FRAMES = 40;
+var index_default = {
+ id: "untangle",
+ name: "Untangle",
+ version: "2.0.0",
+ description: "Drag the dots until no edges cross \u2014 procedural puzzle game; board pose, level and readouts as flow nodes.",
+ /** @param {any} api */
+ register(api) {
+ const THREE = api.THREE;
+ let level = 1;
+ let positions = [];
+ let edges = [];
+ const board = { ...DEFAULT_BOARD };
+ let group = null;
+ let dots = [];
+ let lineMeshes = [];
+ let sprite = null;
+ let carried = -1;
+ let won = false;
+ let solvedCount = 0;
+ let lastDragSent = 0;
+ let crossings = 0;
+ let built = false;
+ let frame = 0;
+ let nodeSeen = -1;
+ let nodeLevel = (
+ /** @type {number | null} */
+ null
+ );
+ let remoteApplied = false;
+ let sceneClears = 0;
+ let touched = false;
+ const local = (p) => new THREE.Vector3(p[0] * board.radius, p[1] * board.radius, 0);
+ function placeGroup() {
+ if (!group) return;
+ group.position.set(board.x, board.boardY, board.z);
+ group.rotation.set(0, board.yaw, 0);
+ group.updateMatrixWorld(true);
+ }
+ function build() {
+ const scene = api.scene();
+ if (!scene) return;
+ if (group) scene.remove(group);
+ group = new THREE.Group();
+ group.name = GROUP;
+ dots = [];
+ lineMeshes = [];
+ sprite = null;
+ positions.forEach((p, i) => {
+ const dot = new THREE.Mesh(
+ new THREE.SphereGeometry(DOT_R, 20, 14),
+ new THREE.MeshStandardMaterial({ color: 15857145, roughness: 0.4 })
+ );
+ dot.name = "untangle-dot-" + i;
+ dot.position.copy(local(p));
+ group.add(dot);
+ dots.push(dot);
+ });
+ edges.forEach(([a, b], i) => {
+ const geometry = new THREE.BufferGeometry().setFromPoints([local(positions[a]), local(positions[b])]);
+ const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 16281969 }));
+ line.name = "untangle-edge-" + i;
+ group.add(line);
+ lineMeshes.push(line);
+ });
+ scene.add(group);
+ placeGroup();
+ group.userData._ut = {
+ state: () => ({ level, positions, edges, board: { ...board }, won, crossings, solvedCount, carried, sprite: !!sprite }),
+ move: (i, p) => dropAt(i, p),
+ solve: () => solveNow(),
+ setLevel: (lvl) => setLevel(lvl, true)
+ };
+ built = true;
+ refresh();
+ }
+ function ensureSprite() {
+ const wantVR = typeof api.isVR === "function" && api.isVR();
+ if (!wantVR) {
+ if (sprite) {
+ group?.remove(sprite);
+ sprite.material.map?.dispose?.();
+ sprite.material.dispose?.();
+ sprite = null;
+ }
+ return;
+ }
+ if (sprite || !group || typeof document === "undefined") return;
+ const canvas = document.createElement("canvas");
+ canvas.width = 512;
+ canvas.height = 96;
+ sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false }));
+ sprite.name = "untangle-hud";
+ sprite.scale.set(1.6, 0.3, 1);
+ sprite.position.set(0, board.radius + 0.45, 0);
+ sprite.userData.canvas = canvas;
+ group.add(sprite);
+ }
+ function drawSprite(text, color) {
+ if (!sprite) return;
+ const canvas = sprite.userData.canvas;
+ const g = canvas.getContext("2d");
+ g.clearRect(0, 0, canvas.width, canvas.height);
+ g.font = "bold 44px monospace";
+ g.textAlign = "center";
+ g.fillStyle = color;
+ g.fillText(text, canvas.width / 2, 62);
+ sprite.material.map.needsUpdate = true;
+ }
+ function refresh() {
+ if (!group) return 0;
+ positions.forEach((p, i) => dots[i]?.position.copy(local(p)));
+ const counts = edgeCrossings(positions, edges);
+ edges.forEach(([a, b], i) => {
+ const line = lineMeshes[i];
+ if (!line) return;
+ line.geometry.setFromPoints([local(positions[a]), local(positions[b])]);
+ line.material.color.set(counts[i] > 0 ? 16281969 : 4906624);
+ });
+ crossings = totalCrossings(counts);
+ ensureSprite();
+ drawSprite("Level " + level + " \xB7 " + (crossings === 0 ? "solved!" : crossings + " crossing" + (crossings === 1 ? "" : "s")), crossings === 0 ? "#4ade80" : "#e2e8f0");
+ ambientSetTension(crossings);
+ return crossings;
+ }
+ function setLevel(lvl, announce = false) {
+ level = Math.max(1, Math.round(Number(lvl) || 1));
+ const g = generate(level);
+ edges = g.edges;
+ positions = g.positions;
+ won = false;
+ carried = -1;
+ build();
+ if (announce) fire("level");
+ }
+ let ac = null;
+ let padGain = null;
+ let padFilter = null;
+ function audio() {
+ if (ac) return ac;
+ ac = new (window.AudioContext || window.webkitAudioContext)();
+ padGain = ac.createGain();
+ padGain.gain.value = 0.03;
+ padFilter = ac.createBiquadFilter();
+ padFilter.type = "lowpass";
+ padFilter.frequency.value = 400;
+ const lfo = ac.createOscillator();
+ const lfoGain = ac.createGain();
+ lfo.frequency.value = 0.13;
+ lfoGain.gain.value = 0.012;
+ lfo.connect(lfoGain).connect(padGain.gain);
+ for (const [type, freq] of [["triangle", 110], ["triangle", 110.7], ["sine", 220.3]]) {
+ const osc = ac.createOscillator();
+ osc.type = type;
+ osc.frequency.value = freq;
+ osc.connect(padFilter);
+ osc.start();
+ }
+ padFilter.connect(padGain).connect(ac.destination);
+ lfo.start();
+ return ac;
+ }
+ function ambientSetTension(count) {
+ if (!ac) return;
+ padFilter.frequency.linearRampToValueAtTime(320 + Math.max(0, 24 - count) * 60, ac.currentTime + 0.6);
+ }
+ function blip(freq, duration = 0.07, gain = 0.12) {
+ const ctx = audio();
+ const osc = ctx.createOscillator();
+ const g = ctx.createGain();
+ osc.frequency.value = freq;
+ g.gain.setValueAtTime(gain, ctx.currentTime);
+ g.gain.exponentialRampToValueAtTime(1e-3, ctx.currentTime + duration);
+ osc.connect(g).connect(ctx.destination);
+ osc.start();
+ osc.stop(ctx.currentTime + duration);
+ }
+ function winSting() {
+ [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => setTimeout(() => blip(f, 0.22, 0.14), i * 110));
+ }
+ function fire(event) {
+ if (typeof api.fireNodeTrigger === "function") api.fireNodeTrigger("utevent", (data) => (data?.event ?? "solved") === event);
+ }
+ function applyMove(i, p, authoritative, fromMe = false) {
+ if (!positions[i]) return;
+ if (authoritative) touched = true;
+ positions[i] = clampToBoard([p[0], p[1]]);
+ const total = refresh();
+ if (authoritative && total === 0 && !won) {
+ won = true;
+ solvedCount++;
+ winSting();
+ if (fromMe) fire("solved");
+ if (board.autoAdvance) {
+ api.toast("Untangled! Level " + (level + 1) + "\u2026");
+ setTimeout(() => {
+ if (!won) return;
+ setLevel(level + 1, fromMe);
+ }, 1200);
+ } else api.toast("Untangled!");
+ }
+ }
+ function dropAt(i, p) {
+ if (!positions[i]) return false;
+ positions[i] = clampToBoard([p[0], p[1]]);
+ api.send({ op: "move", i, p: positions[i] });
+ applyMove(i, positions[i], true, true);
+ return true;
+ }
+ function solveNow() {
+ const n = positions.length;
+ for (let i = 0; i < n; i++) {
+ const angle = i / n * Math.PI * 2;
+ dropAt(i, [Math.cos(angle) * 0.85, Math.sin(angle) * 0.85]);
+ }
+ return crossings === 0;
+ }
+ const dragPlane = new THREE.Plane();
+ const planeNormal = new THREE.Vector3();
+ const hitPoint = new THREE.Vector3();
+ const localHit = new THREE.Vector3();
+ api.registerClickHandler((object) => {
+ if (carried !== -1) {
+ const i = carried;
+ dots[i]?.material.color.set(15857145);
+ carried = -1;
+ blip(440);
+ dropAt(i, positions[i]);
+ return true;
+ }
+ if (!object?.name?.startsWith("untangle-dot-")) return false;
+ carried = +object.name.slice("untangle-dot-".length);
+ dots[carried]?.material.color.set(16498468);
+ blip(660);
+ return true;
+ });
+ api.registerFrameTask(() => {
+ frame++;
+ if (!built && nodeSeen < 0 && frame > EXPIRE_FRAMES) setLevel(level);
+ if (nodeSeen >= 0 && frame - nodeSeen > EXPIRE_FRAMES) {
+ nodeSeen = -1;
+ nodeLevel = null;
+ Object.assign(board, { radius: DEFAULT_BOARD.radius, boardY: DEFAULT_BOARD.boardY, x: DEFAULT_BOARD.x, z: DEFAULT_BOARD.z, yaw: DEFAULT_BOARD.yaw, autoAdvance: DEFAULT_BOARD.autoAdvance });
+ placeGroup();
+ refresh();
+ }
+ if (carried === -1 || !group) return;
+ const ray = api.pointerRay();
+ if (!ray) return;
+ planeNormal.set(0, 0, 1).applyQuaternion(group.quaternion);
+ dragPlane.setFromNormalAndCoplanarPoint(planeNormal, group.position);
+ if (!ray.ray.intersectPlane(dragPlane, hitPoint)) return;
+ localHit.copy(hitPoint);
+ group.worldToLocal(localHit);
+ positions[carried] = clampToBoard([localHit.x / board.radius, localHit.y / board.radius]);
+ refresh();
+ const now = performance.now();
+ if (now - lastDragSent > 100) {
+ lastDragSent = now;
+ api.send({ op: "drag", i: carried, p: positions[carried] });
+ }
+ });
+ api.registerNodeGroup({
+ group: "Untangle",
+ items: [
+ {
+ type: "utboard",
+ label: "Untangle Board",
+ defaults: { ...DEFAULT_BOARD, apply: true },
+ params: [
+ { key: "level", kind: "range", min: 1, max: 40, step: 1 },
+ { key: "radius", kind: "range", min: 0.4, max: 3, step: 0.05 },
+ { key: "boardY", kind: "range", min: 0.3, max: 3, step: 0.05 },
+ { key: "x", kind: "range", min: -20, max: 20, step: 0.1 },
+ { key: "z", kind: "range", min: -20, max: 20, step: 0.1 },
+ { key: "yaw", kind: "range", min: -3.15, max: 3.15, step: 0.05 },
+ { key: "autoAdvance", kind: "toggle" },
+ { key: "apply", kind: "toggle" }
+ ]
+ },
+ {
+ type: "utvalue",
+ label: "Untangle Value",
+ defaults: { read: "level" },
+ params: [{ key: "read", kind: "select", options: ["level", "crossings", "solved", "dots", "edges", "count"] }]
+ },
+ {
+ type: "utevent",
+ label: "Untangle Event",
+ defaults: { event: "solved" },
+ params: [{ key: "event", kind: "select", options: ["solved", "level"] }]
+ }
+ ]
+ });
+ api.registerEffect("utboard", (object, base, data) => {
+ if (data.apply === false) return;
+ nodeSeen = frame;
+ const next = {
+ radius: Math.max(0.2, Number(data.radius ?? DEFAULT_BOARD.radius)),
+ boardY: Number(data.boardY ?? DEFAULT_BOARD.boardY),
+ x: Number(data.x ?? 0),
+ z: Number(data.z ?? 0),
+ yaw: Number(data.yaw ?? 0),
+ autoAdvance: !!(data.autoAdvance ?? true)
+ };
+ let poseChanged = false;
+ for (const key of Object.keys(next)) {
+ if (board[key] !== next[key]) {
+ board[key] = next[key];
+ poseChanged = true;
+ }
+ }
+ const wanted = Math.max(1, Math.round(Number(data.level) || 1));
+ if (nodeLevel !== wanted) {
+ const first = nodeLevel === null;
+ nodeLevel = wanted;
+ if (!(first && remoteApplied) && (wanted !== level || !built)) setLevel(wanted);
+ else if (!built) setLevel(level);
+ touched = true;
+ }
+ if (poseChanged && built) {
+ placeGroup();
+ refresh();
+ }
+ });
+ api.registerValueNode(
+ "utvalue",
+ (data) => {
+ switch (data?.read) {
+ case "crossings":
+ return crossings;
+ case "solved":
+ return won ? 1 : 0;
+ case "dots":
+ return positions.length;
+ case "edges":
+ return edges.length;
+ case "count":
+ return solvedCount;
+ default:
+ return level;
+ }
+ },
+ { vtype: "number" }
+ );
+ api.registerValueNode("utevent", () => 0, { vtype: "event" });
+ api.onMessage((data) => {
+ if (data.op === "drag") applyMove(data.i, data.p, false);
+ else if (data.op === "move") applyMove(data.i, data.p, true);
+ else if (data.op === "restart") {
+ touched = true;
+ setLevel(data.level ?? 1);
+ }
+ });
+ api.registerStateSync({
+ getState: () => touched ? { level, positions } : null,
+ applyState: (state) => {
+ if (!state) return;
+ remoteApplied = true;
+ touched = true;
+ setLevel(state.level ?? 1);
+ if (Array.isArray(state.positions) && state.positions.length === positions.length) {
+ positions = state.positions.map((p) => clampToBoard([p[0], p[1]]));
+ refresh();
+ }
+ }
+ });
+ api.registerMenu("Restart level", () => {
+ touched = true;
+ api.send({ op: "restart", level });
+ setLevel(level);
+ });
+ api.onSceneClear?.(() => {
+ sceneClears++;
+ level = 1;
+ nodeLevel = null;
+ remoteApplied = false;
+ won = false;
+ carried = -1;
+ if (group) {
+ api.scene()?.remove(group);
+ group = null;
+ }
+ built = false;
+ touched = false;
+ frame = 0;
+ });
+ api.registerInteractiveGroup(GROUP);
+ api.registerSystemGroup?.(GROUP);
+ if (typeof window !== "undefined") {
+ window.__untangle = {
+ state: () => ({ level, positions, edges, board: { ...board }, won, crossings, solvedCount, built, touched, nodeOwned: nodeSeen >= 0, sceneClears, sprite: !!sprite }),
+ move: (i, p) => dropAt(i, p),
+ solve: () => solveNow(),
+ setLevel: (lvl) => setLevel(lvl, true)
+ };
+ }
+ }
+};
+export {
+ index_default as default
};
diff --git a/modules/untangle/src/def.js b/modules/untangle/src/def.js
new file mode 100644
index 0000000..f67db27
--- /dev/null
+++ b/modules/untangle/src/def.js
@@ -0,0 +1,154 @@
+// untangle — THE DEF (21-C C7): the thin, honest template. A .tpscene can carry the
+// board's POSE and the starting LEVEL (seed INPUTS), never the positions (derived state —
+// a saved positions array loaded onto peers that regenerate from the seed is the
+// "deterministic animation that also receives corrective positions" failure). So the
+// template is: a room and a plinth so the board no longer hangs in the void, the
+// environment, the HUD document, the post stack, `modules: [{id:'untangle'}]` and the
+// control graph. Core reads `untangle.def.json` (emitted by `npm run build:untangle`).
+
+export const BOARD = { level: 2, radius: 1.1, boardY: 1.6, x: 0, z: 0, yaw: 0, autoAdvance: true, apply: true };
+export const NAMES = { floor: 'Floor', wall: 'Back wall', pedestal: 'Pedestal', frame: 'Board frame', lampL: 'Lamp left', lampR: 'Lamp right' };
+
+const PANEL = { bg: 'rgba(15, 18, 28, 0.9)', radius: 14, border: '1px solid rgba(251, 191, 36, 0.3)' };
+const BUTTON = (bg) => ({ size: 15, weight: '600', bg, color: '#ffffff', radius: 10 });
+
+/** the room: a floor, a back wall, the pedestal the node targets, a ring frame, two lamps */
+export function roomObjects() {
+ const y = BOARD.boardY;
+ return [
+ { type: 'box', name: NAMES.floor, color: 0x2b2f36, roughness: 0.95, size: [8, 0.2, 8], pos: [0, -0.1, 0] },
+ { type: 'box', name: NAMES.wall, color: 0x1f2430, roughness: 0.9, size: [8, 3.4, 0.2], pos: [0, 1.7, -1.6] },
+ { type: 'box', name: NAMES.pedestal, color: 0x3a4150, roughness: 0.8, size: [0.5, y - 0.3, 0.3], pos: [0, (y - 0.3) / 2, -0.12] },
+ { type: 'torus', name: NAMES.frame, color: 0xfbbf24, emissive: 0xfbbf24, emissiveIntensity: 0.35, roughness: 0.4, metalness: 0.6, r: BOARD.radius + 0.18, tube: 0.04, pos: [0, y, -0.06] },
+ { type: 'light', name: NAMES.lampL, color: 0xffd9a8, intensity: 8, distance: 10, pos: [-2.2, 2.6, 1.4] },
+ { type: 'light', name: NAMES.lampR, color: 0xa8d0ff, intensity: 6, distance: 10, pos: [2.2, 2.6, 1.4] }
+ ];
+}
+
+/** the control graph: the board node + HUD readouts + the game shell */
+export function untangleGraph() {
+ /** @type {any[]} */ const nodes = [];
+ /** @type {any[]} */ const edges = [];
+ const N = (id, type, label, x, y, data = {}) => nodes.push({ id, type, position: { x, y }, data: { label, ...data }, class: 'w-[150px]' });
+ const E = (source, target, handle) =>
+ edges.push({ id: 'e-' + source + '-' + target + (handle ? '.' + handle : ''), source, target, ...(handle ? { targetHandle: handle } : {}) });
+ let y = 40;
+ N('board', 'utboard', 'Untangle Board', 280, y, { ...BOARD });
+ N('selped', 'objectselector', 'Pedestal', 520, y, { selected: NAMES.pedestal });
+ E('board', 'selped');
+ y += 150;
+ const readout = (key, read, element, format) => {
+ N('v' + key, 'utvalue', 'Untangle: ' + read, 40, y, { read });
+ N('t' + key, 'hudtext', 'HUD ' + element, 280, y, { element, format, decimals: 0 });
+ E('v' + key, 't' + key, 'value');
+ y += 110;
+ };
+ readout('level', 'level', 'ut-level', 'LEVEL {v}');
+ readout('cross', 'crossings', 'ut-crossings', '{v} crossings');
+ readout('count', 'count', 'ut-solved', '{v} solved this session');
+ // the solved EVENT into a Counter (replicated once per solve) into a HUD Text
+ N('evsolved', 'utevent', 'On solved', 40, y, { event: 'solved' });
+ N('csolved', 'counter', 'Boards solved', 280, y, { op: 'up', step: 1 });
+ N('tcount', 'hudtext', 'HUD ut-counter', 520, y, { element: 'ut-counter', format: '{v} untangled', decimals: 0 });
+ E('evsolved', 'csolved', 'trigger');
+ E('csolved', 'tcount', 'value');
+ y += 150;
+ // the game shell: Start from the menu screen, P pauses, Quit resets
+ N('bstart', 'hudbutton', 'Start button', 40, y, { element: 'start-btn' });
+ N('gostart', 'setgamestate', 'Start', 280, y, { state: 'playing', outcome: '', reset: false });
+ E('bstart', 'gostart', 'trigger');
+ y += 110;
+ N('pkey', 'keypress', 'Press P', 40, y, { code: 'KeyP', edge: 'down', pulse: 0.3 });
+ N('pausetoggle', 'hudscreen', 'Toggle pause menu', 280, y, { screen: 'pause', action: 'toggle' });
+ E('pkey', 'pausetoggle', 'trigger');
+ y += 110;
+ N('bresume', 'hudbutton', 'Resume button', 40, y, { element: 'resume-btn' });
+ N('resumehide', 'hudscreen', 'Close pause menu', 280, y, { screen: 'pause', action: 'hide' });
+ E('bresume', 'resumehide', 'trigger');
+ y += 110;
+ N('bquit', 'hudbutton', 'Quit button', 40, y, { element: 'quit-btn' });
+ N('doquit', 'setgamestate', 'Quit to menu', 280, y, { state: 'menu', outcome: '', reset: true });
+ N('quithide', 'hudscreen', 'Close pause on quit', 520, y, { screen: 'pause', action: 'hide' });
+ E('bquit', 'doquit', 'trigger');
+ E('bquit', 'quithide', 'trigger');
+ return { nodes, edges };
+}
+
+export function untangleHud() {
+ return {
+ scene: {
+ active: '',
+ changedAt: 0,
+ screens: [
+ {
+ id: 'menu',
+ name: 'Menu',
+ showWhile: 'menu',
+ input: 'menu',
+ elements: [
+ { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 440, h: 300, z: 0, label: '', style: PANEL },
+ { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -100, w: 400, h: 48, z: 1, label: 'UNTANGLE', style: { size: 36, weight: '700', color: '#fbbf24', align: 'center' } },
+ { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -45, w: 400, h: 60, z: 1, label: 'Drag the dots until no edges cross. Click a dot to pick it up, click again to drop it. Every peer sees the same board.', style: { size: 13, color: '#d8dee9', align: 'center' }, wrap: true },
+ { id: 'start-btn', kind: 'button', anchor: 'center', x: 0, y: 40, w: 220, h: 46, z: 1, label: 'Start', enabled: true, style: BUTTON('#d97706') },
+ { id: 'menu-hint', kind: 'text', anchor: 'center', x: 0, y: 105, w: 400, h: 24, z: 1, label: 'Green edges are clear · red ones still cross · P pauses', style: { size: 12, color: '#8b97a8', align: 'center' } }
+ ]
+ },
+ {
+ id: 'hud',
+ name: 'HUD',
+ showWhile: 'playing',
+ input: 'game',
+ elements: [
+ { id: 'hud-panel', kind: 'panel', anchor: 'top-left', x: 16, y: 64, w: 240, h: 92, z: 0, label: '', style: PANEL },
+ { id: 'ut-level', kind: 'text', anchor: 'top-left', x: 28, y: 72, w: 220, h: 30, z: 1, label: 'LEVEL 1', style: { size: 22, weight: '700', color: '#fbbf24', align: 'left' } },
+ { id: 'ut-crossings', kind: 'text', anchor: 'top-left', x: 28, y: 104, w: 220, h: 22, z: 1, label: '0 crossings', style: { size: 14, color: '#e2e8f0', align: 'left' } },
+ { id: 'ut-counter', kind: 'text', anchor: 'top-left', x: 28, y: 128, w: 220, h: 20, z: 1, label: '0 untangled', style: { size: 12, color: '#94a3b8', align: 'left' } },
+ { id: 'ut-solved', kind: 'text', anchor: 'top-right', x: 16, y: 64, w: 220, h: 22, z: 1, label: '0 solved this session', style: { size: 12, color: '#94a3b8', align: 'right' } },
+ { id: 'play-hint', kind: 'text', anchor: 'bottom-center', x: 0, y: 12, w: 520, h: 20, z: 1, label: 'Click a dot, move, click to drop · P pauses', style: { size: 11, color: '#8b97a8', align: 'center' } }
+ ]
+ },
+ {
+ id: 'pause',
+ name: 'Pause',
+ input: 'menu',
+ elements: [
+ { id: 'pause-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 380, h: 250, z: 0, label: '', style: PANEL },
+ { id: 'pause-title', kind: 'text', anchor: 'center', x: 0, y: -75, w: 340, h: 36, z: 1, label: 'PAUSED', style: { size: 26, weight: '700', color: '#e5e9f0', align: 'center' } },
+ { id: 'resume-btn', kind: 'button', anchor: 'center', x: 0, y: -10, w: 240, h: 42, z: 1, label: 'Resume', enabled: true, style: BUTTON('#d97706') },
+ { id: 'quit-btn', kind: 'button', anchor: 'center', x: 0, y: 42, w: 240, h: 42, z: 1, label: 'Quit to menu', enabled: true, style: { size: 15, weight: '500', bg: '#3a4150', color: '#e5e9f0', radius: 10 } }
+ ]
+ }
+ ]
+ }
+ };
+}
+
+export function untangleDef() {
+ return {
+ kind: 'game',
+ slug: 'untangle',
+ title: 'Untangle',
+ description: 'A planar-graph puzzle on a lit board: drag the dots until no edges cross, then the next level appears. Every peer sees and solves the same board.',
+ license: 'CC0-1.0',
+ author: 'theprototype',
+ tags: ['puzzle', 'co-op', 'procedural'],
+ modules: [{ id: 'untangle', version: '2.0.0' }],
+ installModules: ['untangle'],
+ env: { preset: 'sunset', exposure: 1 },
+ physics: { play: { interaction: 'click', grounded: true, simOnPlay: false } },
+ post: {
+ enabled: true,
+ effects: [
+ { id: 'tone', kind: 'tonemapping', enabled: true, params: { mode: 'AGX' } },
+ { id: 'bloom', kind: 'bloom', enabled: true, params: { intensity: 0.45, luminanceThreshold: 0.8 } },
+ { id: 'aa', kind: 'smaa', enabled: true, params: {} }
+ ],
+ changedAt: 0
+ },
+ graphs: { scene: untangleGraph() },
+ hud: untangleHud(),
+ objects: roomObjects(),
+ view: { pos: [2.6, 2.4, 4.2], target: [0, BOARD.boardY, 0] },
+ thumb: { sceneGroups: ['untangle-module'] }
+ };
+}
diff --git a/modules/untangle/src/index.js b/modules/untangle/src/index.js
new file mode 100644
index 0000000..b5f2790
--- /dev/null
+++ b/modules/untangle/src/index.js
@@ -0,0 +1,463 @@
+// Untangle — drag the dots until no edges cross. Bundled to one self-contained module.js
+// by esbuild (npm run build:untangle); everything outside comes through `api`.
+//
+// 21-C C7 made it a TEMPLATE game: the board's POSE and the starting LEVEL are node
+// data (`utboard`, replicated with the graph), the desktop HUD is core HUD elements the
+// template authors (fed by `utvalue` / `utevent`), and the canvas SPRITE HUD stays as
+// the VR-only path (DOM is invisible in a headset). The RNG, the generator, the
+// crossing test, the drag and the audio stay imperative — there are no arrays in the
+// socket type system and no pointer-ray node, so they cannot be nodes. Do not try.
+//
+// Replication model (golden rules):
+// - the puzzle is a PURE function of the level number — every peer generates the
+// identical graph (determinism IS the netcode); a saved scene carries the level
+// (seed INPUT), never the positions (derived state)
+// - drags stream as throttled previews; the DROP is the authoritative 'move'
+// - win = zero crossings, checked after every authoritative move on every peer from the
+// same positions — all peers advance in lockstep without a "win" message
+// - late joiners get {level, positions} via registerStateSync
+
+import { generate, edgeCrossings, totalCrossings, clampToBoard, DEFAULT_BOARD } from './puzzle.js';
+
+const GROUP = 'untangle-module';
+const DOT_R = 0.055;
+const EXPIRE_FRAMES = 40; // a node gone from the graph -> the module's own defaults return
+
+export default {
+ id: 'untangle',
+ name: 'Untangle',
+ version: '2.0.0',
+ description: 'Drag the dots until no edges cross — procedural puzzle game; board pose, level and readouts as flow nodes.',
+ /** @param {any} api */
+ register(api) {
+ const THREE = api.THREE;
+
+ // ---------- state ----------
+ let level = 1;
+ /** @type {number[][]} board-unit coords per dot: [x, y] in [-1, 1] */
+ let positions = [];
+ /** @type {number[][]} dot index pairs */
+ let edges = [];
+ /** the board: pose + radius (node-owned while an utboard node is alive) */
+ const board = { ...DEFAULT_BOARD };
+ /** @type {any} */ let group = null;
+ /** @type {any[]} */ let dots = [];
+ /** @type {any[]} */ let lineMeshes = [];
+ /** @type {any} */ let sprite = null; // the VR-only canvas HUD
+ let carried = -1;
+ let won = false;
+ let solvedCount = 0;
+ let lastDragSent = 0;
+ let crossings = 0;
+ let built = false;
+ let frame = 0;
+ let nodeSeen = -1; // last frame an utboard node ran
+ let nodeLevel = /** @type {number | null} */ (null); // the node's level as last applied
+ let remoteApplied = false; // a state sync landed — the node's first sight must not undo it
+ let sceneClears = 0;
+ // golden rule 7: the module-state exchange on connect is SYMMETRIC (both ends ask
+ // getmodulestate), so a joiner whose fallback board nobody touched must answer
+ // with NOTHING, or its fresh level-1 scramble overwrites the room's game
+ let touched = false;
+
+ // ---------- the board in the world ----------
+ /** board units -> the group's local frame (metres) */
+ const local = (p) => new THREE.Vector3(p[0] * board.radius, p[1] * board.radius, 0);
+ function placeGroup() {
+ if (!group) return;
+ group.position.set(board.x, board.boardY, board.z);
+ group.rotation.set(0, board.yaw, 0);
+ group.updateMatrixWorld(true);
+ }
+
+ // ---------- render ----------
+ function build() {
+ const scene = api.scene();
+ if (!scene) return;
+ if (group) scene.remove(group);
+ group = new THREE.Group();
+ group.name = GROUP;
+ dots = [];
+ lineMeshes = [];
+ sprite = null;
+ positions.forEach((p, i) => {
+ const dot = new THREE.Mesh(
+ new THREE.SphereGeometry(DOT_R, 20, 14),
+ new THREE.MeshStandardMaterial({ color: 0xf1f5f9, roughness: 0.4 })
+ );
+ dot.name = 'untangle-dot-' + i;
+ dot.position.copy(local(p));
+ group.add(dot);
+ dots.push(dot);
+ });
+ edges.forEach(([a, b], i) => {
+ const geometry = new THREE.BufferGeometry().setFromPoints([local(positions[a]), local(positions[b])]);
+ const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xf87171 }));
+ line.name = 'untangle-edge-' + i;
+ group.add(line);
+ lineMeshes.push(line);
+ });
+ scene.add(group);
+ placeGroup();
+ // the test/debug hook (scene-root local, never serialized)
+ group.userData._ut = {
+ state: () => ({ level, positions, edges, board: { ...board }, won, crossings, solvedCount, carried, sprite: !!sprite }),
+ move: (i, p) => dropAt(i, p),
+ solve: () => solveNow(),
+ setLevel: (lvl) => setLevel(lvl, true)
+ };
+ built = true;
+ refresh();
+ }
+
+ // ---------- the VR sprite HUD (DOM is invisible in a headset) ----------
+ function ensureSprite() {
+ const wantVR = typeof api.isVR === 'function' && api.isVR();
+ if (!wantVR) {
+ if (sprite) {
+ group?.remove(sprite);
+ sprite.material.map?.dispose?.();
+ sprite.material.dispose?.();
+ sprite = null;
+ }
+ return;
+ }
+ if (sprite || !group || typeof document === 'undefined') return;
+ const canvas = document.createElement('canvas');
+ canvas.width = 512;
+ canvas.height = 96;
+ sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(canvas), transparent: true, depthWrite: false }));
+ sprite.name = 'untangle-hud';
+ sprite.scale.set(1.6, 0.3, 1);
+ sprite.position.set(0, board.radius + 0.45, 0);
+ sprite.userData.canvas = canvas;
+ group.add(sprite);
+ }
+ function drawSprite(text, color) {
+ if (!sprite) return;
+ const canvas = sprite.userData.canvas;
+ const g = canvas.getContext('2d');
+ g.clearRect(0, 0, canvas.width, canvas.height);
+ g.font = 'bold 44px monospace';
+ g.textAlign = 'center';
+ g.fillStyle = color;
+ g.fillText(text, canvas.width / 2, 62);
+ sprite.material.map.needsUpdate = true;
+ }
+
+ function refresh() {
+ if (!group) return 0;
+ positions.forEach((p, i) => dots[i]?.position.copy(local(p)));
+ const counts = edgeCrossings(positions, edges);
+ edges.forEach(([a, b], i) => {
+ const line = lineMeshes[i];
+ if (!line) return;
+ line.geometry.setFromPoints([local(positions[a]), local(positions[b])]);
+ line.material.color.set(counts[i] > 0 ? 0xf87171 : 0x4ade80); // tangled warm, clear cool
+ });
+ crossings = totalCrossings(counts);
+ ensureSprite();
+ drawSprite('Level ' + level + ' · ' + (crossings === 0 ? 'solved!' : crossings + ' crossing' + (crossings === 1 ? '' : 's')), crossings === 0 ? '#4ade80' : '#e2e8f0');
+ ambientSetTension(crossings);
+ return crossings;
+ }
+
+ /** (re)generate level `lvl` and rebuild; `announce` pulses the level event */
+ function setLevel(lvl, announce = false) {
+ level = Math.max(1, Math.round(Number(lvl) || 1));
+ const g = generate(level);
+ edges = g.edges;
+ positions = g.positions;
+ won = false;
+ carried = -1;
+ build();
+ if (announce) fire('level');
+ }
+
+ // ---------- generative audio (lazy — browsers gate audio on a gesture) ----------
+ let ac = null;
+ let padGain = null;
+ let padFilter = null;
+ function audio() {
+ if (ac) return ac;
+ ac = new (window.AudioContext || window.webkitAudioContext)();
+ padGain = ac.createGain();
+ padGain.gain.value = 0.03;
+ padFilter = ac.createBiquadFilter();
+ padFilter.type = 'lowpass';
+ padFilter.frequency.value = 400;
+ const lfo = ac.createOscillator();
+ const lfoGain = ac.createGain();
+ lfo.frequency.value = 0.13;
+ lfoGain.gain.value = 0.012;
+ lfo.connect(lfoGain).connect(padGain.gain);
+ for (const [type, freq] of [['triangle', 110], ['triangle', 110.7], ['sine', 220.3]]) {
+ const osc = ac.createOscillator();
+ osc.type = type;
+ osc.frequency.value = freq;
+ osc.connect(padFilter);
+ osc.start();
+ }
+ padFilter.connect(padGain).connect(ac.destination);
+ lfo.start();
+ return ac;
+ }
+ function ambientSetTension(count) {
+ if (!ac) return; // starts on the first interaction
+ padFilter.frequency.linearRampToValueAtTime(320 + Math.max(0, 24 - count) * 60, ac.currentTime + 0.6);
+ }
+ function blip(freq, duration = 0.07, gain = 0.12) {
+ const ctx = audio();
+ const osc = ctx.createOscillator();
+ const g = ctx.createGain();
+ osc.frequency.value = freq;
+ g.gain.setValueAtTime(gain, ctx.currentTime);
+ g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
+ osc.connect(g).connect(ctx.destination);
+ osc.start();
+ osc.stop(ctx.currentTime + duration);
+ }
+ function winSting() {
+ [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => setTimeout(() => blip(f, 0.22, 0.14), i * 110));
+ }
+
+ // ---------- events out (fireNodeTrigger replicates: pulse where it HAPPENED) ----------
+ /** @param {string} event */
+ function fire(event) {
+ if (typeof api.fireNodeTrigger === 'function') api.fireNodeTrigger('utevent', (data) => (data?.event ?? 'solved') === event);
+ }
+
+ // ---------- moves ----------
+ /** apply a position; an authoritative move checks the win on EVERY peer */
+ function applyMove(i, p, authoritative, fromMe = false) {
+ if (!positions[i]) return;
+ if (authoritative) touched = true;
+ positions[i] = clampToBoard([p[0], p[1]]);
+ const total = refresh();
+ if (authoritative && total === 0 && !won) {
+ won = true;
+ solvedCount++;
+ winSting();
+ if (fromMe) fire('solved');
+ if (board.autoAdvance) {
+ api.toast('Untangled! Level ' + (level + 1) + '…');
+ setTimeout(() => {
+ if (!won) return; // a restart got there first
+ setLevel(level + 1, fromMe);
+ }, 1200);
+ } else api.toast('Untangled!');
+ }
+ }
+ /** the authoritative drop of dot i at board point p (apply locally + send) */
+ function dropAt(i, p) {
+ if (!positions[i]) return false;
+ positions[i] = clampToBoard([p[0], p[1]]);
+ api.send({ op: 'move', i, p: positions[i] });
+ applyMove(i, positions[i], true, true);
+ return true;
+ }
+ /** debug/test: put every dot on the solution circle through authoritative drops */
+ function solveNow() {
+ const n = positions.length;
+ for (let i = 0; i < n; i++) {
+ const angle = (i / n) * Math.PI * 2;
+ dropAt(i, [Math.cos(angle) * 0.85, Math.sin(angle) * 0.85]);
+ }
+ return crossings === 0;
+ }
+
+ // ---------- interaction: click to pick, pointerRay to carry, click to drop ----------
+ const dragPlane = new THREE.Plane();
+ const planeNormal = new THREE.Vector3();
+ const hitPoint = new THREE.Vector3();
+ const localHit = new THREE.Vector3();
+ api.registerClickHandler((object) => {
+ // while carrying, ANY click drops the dot where it is (clicking the tiny dot
+ // exactly is fiddly, especially at the board-edge clamp)
+ if (carried !== -1) {
+ const i = carried;
+ dots[i]?.material.color.set(0xf1f5f9);
+ carried = -1;
+ blip(440);
+ dropAt(i, positions[i]);
+ return true;
+ }
+ if (!object?.name?.startsWith('untangle-dot-')) return false;
+ carried = +object.name.slice('untangle-dot-'.length);
+ dots[carried]?.material.color.set(0xfbbf24);
+ blip(660);
+ return true; // consume — never selects the dot
+ });
+ api.registerFrameTask(() => {
+ frame++;
+ // the node-or-fallback gate: no utboard node within EXPIRE_FRAMES of load (or of a
+ // scene clear) -> today's behaviour, a level-1 puzzle appears on its own
+ if (!built && nodeSeen < 0 && frame > EXPIRE_FRAMES) setLevel(level);
+ if (nodeSeen >= 0 && frame - nodeSeen > EXPIRE_FRAMES) {
+ nodeSeen = -1;
+ nodeLevel = null;
+ Object.assign(board, { radius: DEFAULT_BOARD.radius, boardY: DEFAULT_BOARD.boardY, x: DEFAULT_BOARD.x, z: DEFAULT_BOARD.z, yaw: DEFAULT_BOARD.yaw, autoAdvance: DEFAULT_BOARD.autoAdvance });
+ placeGroup();
+ refresh();
+ }
+ if (carried === -1 || !group) return;
+ const ray = api.pointerRay();
+ if (!ray) return;
+ // the board plane in WORLD space: the group's +Z through its origin
+ planeNormal.set(0, 0, 1).applyQuaternion(group.quaternion);
+ dragPlane.setFromNormalAndCoplanarPoint(planeNormal, group.position);
+ if (!ray.ray.intersectPlane(dragPlane, hitPoint)) return;
+ localHit.copy(hitPoint);
+ group.worldToLocal(localHit);
+ positions[carried] = clampToBoard([localHit.x / board.radius, localHit.y / board.radius]);
+ refresh();
+ const now = performance.now();
+ if (now - lastDragSent > 100) {
+ lastDragSent = now;
+ api.send({ op: 'drag', i: carried, p: positions[carried] });
+ }
+ });
+
+ // ---------- the node family (group "Untangle") ----------
+ api.registerNodeGroup({
+ group: 'Untangle',
+ items: [
+ {
+ type: 'utboard',
+ label: 'Untangle Board',
+ defaults: { ...DEFAULT_BOARD, apply: true },
+ params: [
+ { key: 'level', kind: 'range', min: 1, max: 40, step: 1 },
+ { key: 'radius', kind: 'range', min: 0.4, max: 3, step: 0.05 },
+ { key: 'boardY', kind: 'range', min: 0.3, max: 3, step: 0.05 },
+ { key: 'x', kind: 'range', min: -20, max: 20, step: 0.1 },
+ { key: 'z', kind: 'range', min: -20, max: 20, step: 0.1 },
+ { key: 'yaw', kind: 'range', min: -3.15, max: 3.15, step: 0.05 },
+ { key: 'autoAdvance', kind: 'toggle' },
+ { key: 'apply', kind: 'toggle' }
+ ]
+ },
+ {
+ type: 'utvalue',
+ label: 'Untangle Value',
+ defaults: { read: 'level' },
+ params: [{ key: 'read', kind: 'select', options: ['level', 'crossings', 'solved', 'dots', 'edges', 'count'] }]
+ },
+ {
+ type: 'utevent',
+ label: 'Untangle Event',
+ defaults: { event: 'solved' },
+ params: [{ key: 'event', kind: 'select', options: ['solved', 'level'] }]
+ }
+ ]
+ });
+ // the board node OWNS pose + radius + autoAdvance while alive; its `level` is the
+ // STARTING level — applied when the node's value changes (not every frame), so
+ // autoAdvance can move on, and never on first sight over a landed state sync
+ api.registerEffect('utboard', (object, base, data) => {
+ if (data.apply === false) return;
+ nodeSeen = frame;
+ const next = {
+ radius: Math.max(0.2, Number(data.radius ?? DEFAULT_BOARD.radius)),
+ boardY: Number(data.boardY ?? DEFAULT_BOARD.boardY),
+ x: Number(data.x ?? 0),
+ z: Number(data.z ?? 0),
+ yaw: Number(data.yaw ?? 0),
+ autoAdvance: !!(data.autoAdvance ?? true)
+ };
+ let poseChanged = false;
+ for (const key of Object.keys(next)) {
+ if (board[key] !== next[key]) {
+ board[key] = next[key];
+ poseChanged = true;
+ }
+ }
+ const wanted = Math.max(1, Math.round(Number(data.level) || 1));
+ if (nodeLevel !== wanted) {
+ const first = nodeLevel === null;
+ nodeLevel = wanted;
+ if (!(first && remoteApplied) && (wanted !== level || !built)) setLevel(wanted);
+ else if (!built) setLevel(level);
+ touched = true;
+ }
+ if (poseChanged && built) {
+ placeGroup();
+ refresh();
+ }
+ });
+ api.registerValueNode(
+ 'utvalue',
+ (data) => {
+ switch (data?.read) {
+ case 'crossings': return crossings;
+ case 'solved': return won ? 1 : 0;
+ case 'dots': return positions.length;
+ case 'edges': return edges.length;
+ case 'count': return solvedCount;
+ default: return level;
+ }
+ },
+ { vtype: 'number' }
+ );
+ api.registerValueNode('utevent', () => 0, { vtype: 'event' });
+
+ // ---------- replication ----------
+ api.onMessage((data) => {
+ if (data.op === 'drag') applyMove(data.i, data.p, false);
+ else if (data.op === 'move') applyMove(data.i, data.p, true);
+ else if (data.op === 'restart') {
+ touched = true;
+ setLevel(data.level ?? 1);
+ }
+ });
+ api.registerStateSync({
+ getState: () => (touched ? { level, positions } : null),
+ applyState: (state) => {
+ if (!state) return;
+ remoteApplied = true;
+ touched = true;
+ setLevel(state.level ?? 1);
+ if (Array.isArray(state.positions) && state.positions.length === positions.length) {
+ positions = state.positions.map((p) => clampToBoard([p[0], p[1]]));
+ refresh();
+ }
+ }
+ });
+ api.registerMenu('Restart level', () => {
+ touched = true;
+ api.send({ op: 'restart', level });
+ setLevel(level);
+ });
+ // a scene clear (applySession runs `/clear all` FIRST) resets to level 1; when a
+ // board node owns the level it applies on the next flowRuntime tick (nodeLevel is
+ // forgotten so the node's value is applied again even when it did not change)
+ api.onSceneClear?.(() => {
+ sceneClears++;
+ level = 1;
+ nodeLevel = null;
+ remoteApplied = false;
+ won = false;
+ carried = -1;
+ if (group) {
+ api.scene()?.remove(group);
+ group = null;
+ }
+ built = false;
+ touched = false;
+ frame = 0; // the fallback window opens again
+ });
+
+ api.registerInteractiveGroup(GROUP);
+ api.registerSystemGroup?.(GROUP);
+
+ // test/debug hook (never serialized)
+ if (typeof window !== 'undefined') {
+ /** @type {any} */ (window).__untangle = {
+ state: () => ({ level, positions, edges, board: { ...board }, won, crossings, solvedCount, built, touched, nodeOwned: nodeSeen >= 0, sceneClears, sprite: !!sprite }),
+ move: (i, p) => dropAt(i, p),
+ solve: () => solveNow(),
+ setLevel: (lvl) => setLevel(lvl, true)
+ };
+ }
+ }
+};
diff --git a/modules/untangle/src/puzzle.js b/modules/untangle/src/puzzle.js
new file mode 100644
index 0000000..b44c1bd
--- /dev/null
+++ b/modules/untangle/src/puzzle.js
@@ -0,0 +1,117 @@
+// The PURE puzzle — no THREE, no DOM, no api (node-tested in test/puzzle.test.mjs).
+//
+// The puzzle is a pure function of the level number (seeded RNG), so every peer
+// generates the identical graph: determinism IS the netcode. Positions are in BOARD
+// units, the unit disc [-1, 1]² — the renderer scales them by the board radius, so a
+// radius change (the utboard node) re-renders without touching the replicated model.
+
+/** @param {number} seed */
+export function mulberry32(seed) {
+ let t = seed >>> 0;
+ return function () {
+ t = (t + 0x6d2b79f5) >>> 0;
+ let r = Math.imul(t ^ (t >>> 15), t | 1);
+ r ^= r + Math.imul(r ^ (r >>> 7), r | 61);
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Two chords of a circle cross iff exactly one endpoint of one lies strictly between
+ * the endpoints of the other, walking the circle. @param {number} n ring size
+ */
+export function chordsCross(a, b, c, d, n) {
+ const between = (x, lo, hi) => {
+ const span = (hi - lo + n) % n;
+ const off = (x - lo + n) % n;
+ return off > 0 && off < span;
+ };
+ if (a === c || a === d || b === c || b === d) return false; // shared endpoint
+ return between(c, a, b) !== between(d, a, b);
+}
+
+/**
+ * Generate level `lvl`: a guaranteed-solvable graph (the ring plus greedily added
+ * non-crossing chords — the circle layout IS a planar embedding) with SCRAMBLED
+ * positions in the unit disc. @param {number} lvl
+ * @returns {{n: number, edges: number[][], positions: number[][]}}
+ */
+export function generate(lvl) {
+ const rand = mulberry32(0x9e3779b9 ^ (lvl * 2654435761));
+ const n = Math.min(5 + lvl, 16); // difficulty scales, capped
+ const edges = [];
+ for (let i = 0; i < n; i++) edges.push([i, (i + 1) % n]);
+ const chords = [];
+ for (let i = 0; i < n; i++) for (let j = i + 2; j < n; j++) if (!(i === 0 && j === n - 1)) chords.push([i, j]);
+ for (let i = chords.length - 1; i > 0; i--) {
+ const k = Math.floor(rand() * (i + 1));
+ const swap = chords[i];
+ chords[i] = chords[k];
+ chords[k] = swap;
+ }
+ const wanted = Math.floor(n * 0.8);
+ let added = 0;
+ for (const [a, b] of chords) {
+ if (added >= wanted) break;
+ if (edges.some(([c, d]) => chordsCross(a, b, c, d, n))) continue;
+ edges.push([a, b]);
+ added++;
+ }
+ // scramble positions in the disc (0.9 keeps the dots clear of the rim)
+ const positions = [];
+ for (let i = 0; i < n; i++) {
+ const angle = rand() * Math.PI * 2;
+ const radius = Math.sqrt(rand()) * 0.9;
+ positions.push([Math.cos(angle) * radius, Math.sin(angle) * radius]);
+ }
+ return { n, edges, positions };
+}
+
+/** proper segment intersection (shared endpoints excluded by the caller) */
+export function segsCross(p1, p2, p3, p4) {
+ const d = (a, b, c) => (c[0] - a[0]) * (b[1] - a[1]) - (b[0] - a[0]) * (c[1] - a[1]);
+ const d1 = d(p3, p4, p1);
+ const d2 = d(p3, p4, p2);
+ const d3 = d(p1, p2, p3);
+ const d4 = d(p1, p2, p4);
+ return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
+}
+
+/** per-edge crossing counts on CURRENT positions @param {number[][]} positions @param {number[][]} edges */
+export function edgeCrossings(positions, edges) {
+ const counts = edges.map(() => 0);
+ for (let i = 0; i < edges.length; i++) {
+ for (let j = i + 1; j < edges.length; j++) {
+ const [a, b] = edges[i];
+ const [c, d] = edges[j];
+ if (a === c || a === d || b === c || b === d) continue;
+ if (segsCross(positions[a], positions[b], positions[c], positions[d])) {
+ counts[i]++;
+ counts[j]++;
+ }
+ }
+ }
+ return counts;
+}
+
+/** total crossings (each pair once) @param {number[]} counts */
+export function totalCrossings(counts) {
+ return counts.reduce((sum, c) => sum + c, 0) / 2;
+}
+
+/** the solution layout: n dots on the unit circle (the planar embedding generate() built from) */
+export function solvedPositions(n, scale = 0.85) {
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const angle = (i / n) * Math.PI * 2;
+ out.push([Math.cos(angle) * scale, Math.sin(angle) * scale]);
+ }
+ return out;
+}
+
+/** clamp a board-unit position into the disc's bounding square @param {number[]} p */
+export function clampToBoard(p) {
+ return [Math.max(-1, Math.min(1, p[0])), Math.max(-1, Math.min(1, p[1]))];
+}
+
+export const DEFAULT_BOARD = { level: 1, radius: 1.1, boardY: 1.6, x: 0, z: 0, yaw: 0, autoAdvance: true };
diff --git a/modules/untangle/test/puzzle.test.mjs b/modules/untangle/test/puzzle.test.mjs
new file mode 100644
index 0000000..31c7263
--- /dev/null
+++ b/modules/untangle/test/puzzle.test.mjs
@@ -0,0 +1,32 @@
+// The pure puzzle — run WITHOUT the app.
+import { generate, edgeCrossings, totalCrossings, solvedPositions, chordsCross, segsCross, clampToBoard, mulberry32 } from '../src/puzzle.js';
+
+export function run(check) {
+ // determinism: the same level twice is the same graph and the same scramble
+ const a = generate(3), b = generate(3);
+ check(JSON.stringify(a) === JSON.stringify(b), 'generate(3) is deterministic (edges + positions)');
+ check(generate(4).n === 9 && generate(20).n === 16, 'n = 5 + level, capped at 16');
+ // every level's graph is SOLVABLE: the circle layout has zero crossings
+ let solvable = 0, scrambled = 0;
+ for (let lvl = 1; lvl <= 40; lvl++) {
+ const g = generate(lvl);
+ if (totalCrossings(edgeCrossings(solvedPositions(g.n), g.edges)) === 0) solvable++;
+ if (totalCrossings(edgeCrossings(g.positions, g.edges)) > 0) scrambled++;
+ }
+ check(solvable === 40, 'levels 1-40: the ring layout solves every graph (' + solvable + '/40)');
+ check(scrambled >= 38, 'levels 1-40: the scramble tangles almost every start (' + scrambled + '/40 start with crossings)');
+ // the ring is always there (connectivity) and chords never share an endpoint pair twice
+ const g = generate(7);
+ const ring = g.edges.slice(0, g.n).every(([u, v], i) => u === i && v === (i + 1) % g.n);
+ const keys = new Set(g.edges.map(([u, v]) => (u < v ? u + ':' + v : v + ':' + u)));
+ check(ring && keys.size === g.edges.length, 'level 7: ring first, no duplicate edges (' + g.edges.length + ' edges)');
+ // chord + segment crossing primitives
+ check(chordsCross(0, 2, 1, 3, 6) === true && chordsCross(0, 2, 3, 5, 6) === false && chordsCross(0, 2, 2, 4, 6) === false, 'chordsCross: crossing, disjoint, shared endpoint');
+ check(segsCross([0, 0], [1, 1], [0, 1], [1, 0]) === true && segsCross([0, 0], [1, 0], [0, 1], [1, 1]) === false, 'segsCross: an X crosses, parallels do not');
+ // positions live in board units: the scramble stays inside the disc
+ check(g.positions.every(([x, y]) => Math.hypot(x, y) <= 0.9 + 1e-9), 'scrambled positions stay within 0.9 of the unit disc');
+ check(JSON.stringify(clampToBoard([3, -2])) === '[1,-1]' && JSON.stringify(clampToBoard([0.2, 0.3])) === '[0.2,0.3]', 'clampToBoard clamps to the unit square');
+ // the RNG is the same mulberry32 the old module used (no Math.random anywhere)
+ const r = mulberry32(42);
+ check(Math.abs(r() - mulberry32(42)()) < 1e-12, 'mulberry32 is seed-deterministic');
+}
diff --git a/modules/untangle/test/run.mjs b/modules/untangle/test/run.mjs
new file mode 100644
index 0000000..f096def
--- /dev/null
+++ b/modules/untangle/test/run.mjs
@@ -0,0 +1,13 @@
+// Pure puzzle tests — run WITHOUT the app:
+// node modules/untangle/test/run.mjs (npm run test:untangle)
+import { run as puzzleTests } from './puzzle.test.mjs';
+
+let failures = 0;
+function check(ok, label) {
+ console.log((ok ? 'PASS ' : 'FAIL ') + label);
+ if (!ok) failures++;
+}
+console.log('=== puzzle.test.mjs ===');
+puzzleTests(check);
+console.log('\n' + (failures === 0 ? 'ALL PASS' : failures + ' FAILURES'));
+process.exit(failures === 0 ? 0 : 1);
diff --git a/modules/untangle/untangle.def.json b/modules/untangle/untangle.def.json
new file mode 100644
index 0000000..df68fb4
--- /dev/null
+++ b/modules/untangle/untangle.def.json
@@ -0,0 +1,811 @@
+{
+ "kind": "game",
+ "slug": "untangle",
+ "title": "Untangle",
+ "description": "A planar-graph puzzle on a lit board: drag the dots until no edges cross, then the next level appears. Every peer sees and solves the same board.",
+ "license": "CC0-1.0",
+ "author": "theprototype",
+ "tags": [
+ "puzzle",
+ "co-op",
+ "procedural"
+ ],
+ "modules": [
+ {
+ "id": "untangle",
+ "version": "2.0.0"
+ }
+ ],
+ "installModules": [
+ "untangle"
+ ],
+ "env": {
+ "preset": "sunset",
+ "exposure": 1
+ },
+ "physics": {
+ "play": {
+ "interaction": "click",
+ "grounded": true,
+ "simOnPlay": false
+ }
+ },
+ "post": {
+ "enabled": true,
+ "effects": [
+ {
+ "id": "tone",
+ "kind": "tonemapping",
+ "enabled": true,
+ "params": {
+ "mode": "AGX"
+ }
+ },
+ {
+ "id": "bloom",
+ "kind": "bloom",
+ "enabled": true,
+ "params": {
+ "intensity": 0.45,
+ "luminanceThreshold": 0.8
+ }
+ },
+ {
+ "id": "aa",
+ "kind": "smaa",
+ "enabled": true,
+ "params": {}
+ }
+ ],
+ "changedAt": 0
+ },
+ "graphs": {
+ "scene": {
+ "nodes": [
+ {
+ "id": "board",
+ "type": "utboard",
+ "position": {
+ "x": 280,
+ "y": 40
+ },
+ "data": {
+ "label": "Untangle Board",
+ "level": 2,
+ "radius": 1.1,
+ "boardY": 1.6,
+ "x": 0,
+ "z": 0,
+ "yaw": 0,
+ "autoAdvance": true,
+ "apply": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "selped",
+ "type": "objectselector",
+ "position": {
+ "x": 520,
+ "y": 40
+ },
+ "data": {
+ "label": "Pedestal",
+ "selected": "Pedestal"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "vlevel",
+ "type": "utvalue",
+ "position": {
+ "x": 40,
+ "y": 190
+ },
+ "data": {
+ "label": "Untangle: level",
+ "read": "level"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tlevel",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 190
+ },
+ "data": {
+ "label": "HUD ut-level",
+ "element": "ut-level",
+ "format": "LEVEL {v}",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "vcross",
+ "type": "utvalue",
+ "position": {
+ "x": 40,
+ "y": 300
+ },
+ "data": {
+ "label": "Untangle: crossings",
+ "read": "crossings"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tcross",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 300
+ },
+ "data": {
+ "label": "HUD ut-crossings",
+ "element": "ut-crossings",
+ "format": "{v} crossings",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "vcount",
+ "type": "utvalue",
+ "position": {
+ "x": 40,
+ "y": 410
+ },
+ "data": {
+ "label": "Untangle: count",
+ "read": "count"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tcount",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 410
+ },
+ "data": {
+ "label": "HUD ut-solved",
+ "element": "ut-solved",
+ "format": "{v} solved this session",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "evsolved",
+ "type": "utevent",
+ "position": {
+ "x": 40,
+ "y": 520
+ },
+ "data": {
+ "label": "On solved",
+ "event": "solved"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "csolved",
+ "type": "counter",
+ "position": {
+ "x": 280,
+ "y": 520
+ },
+ "data": {
+ "label": "Boards solved",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "tcount",
+ "type": "hudtext",
+ "position": {
+ "x": 520,
+ "y": 520
+ },
+ "data": {
+ "label": "HUD ut-counter",
+ "element": "ut-counter",
+ "format": "{v} untangled",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bstart",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 670
+ },
+ "data": {
+ "label": "Start button",
+ "element": "start-btn"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "gostart",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 670
+ },
+ "data": {
+ "label": "Start",
+ "state": "playing",
+ "outcome": "",
+ "reset": false
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "pkey",
+ "type": "keypress",
+ "position": {
+ "x": 40,
+ "y": 780
+ },
+ "data": {
+ "label": "Press P",
+ "code": "KeyP",
+ "edge": "down",
+ "pulse": 0.3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "pausetoggle",
+ "type": "hudscreen",
+ "position": {
+ "x": 280,
+ "y": 780
+ },
+ "data": {
+ "label": "Toggle pause menu",
+ "screen": "pause",
+ "action": "toggle"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bresume",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 890
+ },
+ "data": {
+ "label": "Resume button",
+ "element": "resume-btn"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "resumehide",
+ "type": "hudscreen",
+ "position": {
+ "x": 280,
+ "y": 890
+ },
+ "data": {
+ "label": "Close pause menu",
+ "screen": "pause",
+ "action": "hide"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "bquit",
+ "type": "hudbutton",
+ "position": {
+ "x": 40,
+ "y": 1000
+ },
+ "data": {
+ "label": "Quit button",
+ "element": "quit-btn"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "doquit",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 1000
+ },
+ "data": {
+ "label": "Quit to menu",
+ "state": "menu",
+ "outcome": "",
+ "reset": true
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "quithide",
+ "type": "hudscreen",
+ "position": {
+ "x": 520,
+ "y": 1000
+ },
+ "data": {
+ "label": "Close pause on quit",
+ "screen": "pause",
+ "action": "hide"
+ },
+ "class": "w-[150px]"
+ }
+ ],
+ "edges": [
+ {
+ "id": "e-board-selped",
+ "source": "board",
+ "target": "selped"
+ },
+ {
+ "id": "e-vlevel-tlevel.value",
+ "source": "vlevel",
+ "target": "tlevel",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-vcross-tcross.value",
+ "source": "vcross",
+ "target": "tcross",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-vcount-tcount.value",
+ "source": "vcount",
+ "target": "tcount",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-evsolved-csolved.trigger",
+ "source": "evsolved",
+ "target": "csolved",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-csolved-tcount.value",
+ "source": "csolved",
+ "target": "tcount",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-bstart-gostart.trigger",
+ "source": "bstart",
+ "target": "gostart",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-pkey-pausetoggle.trigger",
+ "source": "pkey",
+ "target": "pausetoggle",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bresume-resumehide.trigger",
+ "source": "bresume",
+ "target": "resumehide",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bquit-doquit.trigger",
+ "source": "bquit",
+ "target": "doquit",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-bquit-quithide.trigger",
+ "source": "bquit",
+ "target": "quithide",
+ "targetHandle": "trigger"
+ }
+ ]
+ }
+ },
+ "hud": {
+ "scene": {
+ "active": "",
+ "changedAt": 0,
+ "screens": [
+ {
+ "id": "menu",
+ "name": "Menu",
+ "showWhile": "menu",
+ "input": "menu",
+ "elements": [
+ {
+ "id": "menu-panel",
+ "kind": "panel",
+ "anchor": "center",
+ "x": 0,
+ "y": 0,
+ "w": 440,
+ "h": 300,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(15, 18, 28, 0.9)",
+ "radius": 14,
+ "border": "1px solid rgba(251, 191, 36, 0.3)"
+ }
+ },
+ {
+ "id": "title",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -100,
+ "w": 400,
+ "h": 48,
+ "z": 1,
+ "label": "UNTANGLE",
+ "style": {
+ "size": 36,
+ "weight": "700",
+ "color": "#fbbf24",
+ "align": "center"
+ }
+ },
+ {
+ "id": "subtitle",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -45,
+ "w": 400,
+ "h": 60,
+ "z": 1,
+ "label": "Drag the dots until no edges cross. Click a dot to pick it up, click again to drop it. Every peer sees the same board.",
+ "style": {
+ "size": 13,
+ "color": "#d8dee9",
+ "align": "center"
+ },
+ "wrap": true
+ },
+ {
+ "id": "start-btn",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 40,
+ "w": 220,
+ "h": 46,
+ "z": 1,
+ "label": "Start",
+ "enabled": true,
+ "style": {
+ "size": 15,
+ "weight": "600",
+ "bg": "#d97706",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "menu-hint",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": 105,
+ "w": 400,
+ "h": 24,
+ "z": 1,
+ "label": "Green edges are clear · red ones still cross · P pauses",
+ "style": {
+ "size": 12,
+ "color": "#8b97a8",
+ "align": "center"
+ }
+ }
+ ]
+ },
+ {
+ "id": "hud",
+ "name": "HUD",
+ "showWhile": "playing",
+ "input": "game",
+ "elements": [
+ {
+ "id": "hud-panel",
+ "kind": "panel",
+ "anchor": "top-left",
+ "x": 16,
+ "y": 64,
+ "w": 240,
+ "h": 92,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(15, 18, 28, 0.9)",
+ "radius": 14,
+ "border": "1px solid rgba(251, 191, 36, 0.3)"
+ }
+ },
+ {
+ "id": "ut-level",
+ "kind": "text",
+ "anchor": "top-left",
+ "x": 28,
+ "y": 72,
+ "w": 220,
+ "h": 30,
+ "z": 1,
+ "label": "LEVEL 1",
+ "style": {
+ "size": 22,
+ "weight": "700",
+ "color": "#fbbf24",
+ "align": "left"
+ }
+ },
+ {
+ "id": "ut-crossings",
+ "kind": "text",
+ "anchor": "top-left",
+ "x": 28,
+ "y": 104,
+ "w": 220,
+ "h": 22,
+ "z": 1,
+ "label": "0 crossings",
+ "style": {
+ "size": 14,
+ "color": "#e2e8f0",
+ "align": "left"
+ }
+ },
+ {
+ "id": "ut-counter",
+ "kind": "text",
+ "anchor": "top-left",
+ "x": 28,
+ "y": 128,
+ "w": 220,
+ "h": 20,
+ "z": 1,
+ "label": "0 untangled",
+ "style": {
+ "size": 12,
+ "color": "#94a3b8",
+ "align": "left"
+ }
+ },
+ {
+ "id": "ut-solved",
+ "kind": "text",
+ "anchor": "top-right",
+ "x": 16,
+ "y": 64,
+ "w": 220,
+ "h": 22,
+ "z": 1,
+ "label": "0 solved this session",
+ "style": {
+ "size": 12,
+ "color": "#94a3b8",
+ "align": "right"
+ }
+ },
+ {
+ "id": "play-hint",
+ "kind": "text",
+ "anchor": "bottom-center",
+ "x": 0,
+ "y": 12,
+ "w": 520,
+ "h": 20,
+ "z": 1,
+ "label": "Click a dot, move, click to drop · P pauses",
+ "style": {
+ "size": 11,
+ "color": "#8b97a8",
+ "align": "center"
+ }
+ }
+ ]
+ },
+ {
+ "id": "pause",
+ "name": "Pause",
+ "input": "menu",
+ "elements": [
+ {
+ "id": "pause-panel",
+ "kind": "panel",
+ "anchor": "center",
+ "x": 0,
+ "y": 0,
+ "w": 380,
+ "h": 250,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(15, 18, 28, 0.9)",
+ "radius": 14,
+ "border": "1px solid rgba(251, 191, 36, 0.3)"
+ }
+ },
+ {
+ "id": "pause-title",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -75,
+ "w": 340,
+ "h": 36,
+ "z": 1,
+ "label": "PAUSED",
+ "style": {
+ "size": 26,
+ "weight": "700",
+ "color": "#e5e9f0",
+ "align": "center"
+ }
+ },
+ {
+ "id": "resume-btn",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": -10,
+ "w": 240,
+ "h": 42,
+ "z": 1,
+ "label": "Resume",
+ "enabled": true,
+ "style": {
+ "size": 15,
+ "weight": "600",
+ "bg": "#d97706",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "quit-btn",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 42,
+ "w": 240,
+ "h": 42,
+ "z": 1,
+ "label": "Quit to menu",
+ "enabled": true,
+ "style": {
+ "size": 15,
+ "weight": "500",
+ "bg": "#3a4150",
+ "color": "#e5e9f0",
+ "radius": 10
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "objects": [
+ {
+ "type": "box",
+ "name": "Floor",
+ "color": 2830134,
+ "roughness": 0.95,
+ "size": [
+ 8,
+ 0.2,
+ 8
+ ],
+ "pos": [
+ 0,
+ -0.1,
+ 0
+ ]
+ },
+ {
+ "type": "box",
+ "name": "Back wall",
+ "color": 2040880,
+ "roughness": 0.9,
+ "size": [
+ 8,
+ 3.4,
+ 0.2
+ ],
+ "pos": [
+ 0,
+ 1.7,
+ -1.6
+ ]
+ },
+ {
+ "type": "box",
+ "name": "Pedestal",
+ "color": 3817808,
+ "roughness": 0.8,
+ "size": [
+ 0.5,
+ 1.3,
+ 0.3
+ ],
+ "pos": [
+ 0,
+ 0.65,
+ -0.12
+ ]
+ },
+ {
+ "type": "torus",
+ "name": "Board frame",
+ "color": 16498468,
+ "emissive": 16498468,
+ "emissiveIntensity": 0.35,
+ "roughness": 0.4,
+ "metalness": 0.6,
+ "r": 1.28,
+ "tube": 0.04,
+ "pos": [
+ 0,
+ 1.6,
+ -0.06
+ ]
+ },
+ {
+ "type": "light",
+ "name": "Lamp left",
+ "color": 16767400,
+ "intensity": 8,
+ "distance": 10,
+ "pos": [
+ -2.2,
+ 2.6,
+ 1.4
+ ]
+ },
+ {
+ "type": "light",
+ "name": "Lamp right",
+ "color": 11063551,
+ "intensity": 6,
+ "distance": 10,
+ "pos": [
+ 2.2,
+ 2.6,
+ 1.4
+ ]
+ }
+ ],
+ "view": {
+ "pos": [
+ 2.6,
+ 2.4,
+ 4.2
+ ],
+ "target": [
+ 0,
+ 1.6,
+ 0
+ ]
+ },
+ "thumb": {
+ "sceneGroups": [
+ "untangle-module"
+ ]
+ }
+}
diff --git a/modules/waves/README.md b/modules/waves/README.md
new file mode 100644
index 0000000..040917e
--- /dev/null
+++ b/modules/waves/README.md
@@ -0,0 +1,105 @@
+# waves — wave survival on the health module, derived on every peer
+
+Pre-placed enemies carry the [health](../health/) module's chains. A `waves` node reads
+their hit counters and derives the wave; each wave heals the survivors back with local
+pulses, walks the living enemies from their spawn points to a goal on the synced clock,
+and when the last wave's last enemy falls every peer fires `over` and appends the same
+run to `gameState.vars`. Kills sit on the killer's own peer row. Nobody is an authority,
+nothing is sent on a timer, and the module has no `registerStateSync`. **Needs the health
+module installed** (it authors the health module's node types as ordinary graph data).
+
+```
+modules/waves/
+ src/curve.js PURE: the size curve, kills needed, the wave from the counters, the walk
+ src/engine.js the 10 Hz sweep: derive, heal into the next wave, walk, events, the log
+ src/nodes.js the "Waves" node family
+ src/toolbox.js the arena builder (the recipe is pure and node-tested)
+ src/hud.js the DOM HUD as data + the node graph that drives it
+ src/def.js the template def (waves.def.json, emitted by the build)
+ test/ node tests, no app (npm run test:waves)
+ module.js the bundled entry (committed)
+```
+
+```bash
+npm run build:waves # src/ -> module.js (esbuild) + waves.def.json
+npm run test:waves # curve + recipe + def, no app
+npm run pack -- health waves # both zips (the flight installs both)
+flock -w 5400 /tmp/tp-e2e.lock env APP_URL=https://theprototype.app:5215/ npm test -- waves
+```
+
+## The ledger trick (why a joiner lands on the right wave)
+
+An enemy's hits never reset between waves. At the start of each wave every peer fires
+**local** heal pulses for the enemies that wave uses — `healsBefore(i, wave) × hp` minus
+what the heal counter already shows — so an enemy's hit counter reads `hp × kills` and
+its heal counter `hp × (appearances − 1)`. Wave `n` is complete when every enemy it uses
+has been killed once per wave it appeared in (`killsNeeded`). That is a pure function of
+the hit counters, which travel with their stamps in the trigger-log handshake (DEVX #18):
+a late joiner reads the same counters, derives the same wave, and fires no heals (its
+heal counters arrived healed). The health module's overkill guard is what keeps a counter
+an exact multiple of `hp`.
+
+The wave's **start** is a replicated stamp too: the round's `startedAt` for wave 1, the
+previous wave's last killing pulse plus `interval` after that. Enemy `i` of a wave leaves
+its spawn point `stagger × i` seconds after the start and walks straight to the goal at
+`speed` — `enemyPosition()` is pure in `(data, time)`, so every peer places it identically
+and nothing is sent. Positions are written **locally** to each peer's copy; when no run is
+on, the enemies stand where the scene put them.
+
+**No spawner.** The spike (roadmap 29 fork 3) proved a module can author and pulse core's
+`spawn` node — but a spawned copy is a transient clone with no nodes and a uuid minted by
+the initiator, pulses carry no payload, and copies exist only while the physics sim runs.
+Per-copy health cannot ride the trigger log, so the enemies are pre-placed (the Towers B8
+ruling) and the recipe gives each one its chains.
+
+## Nodes (group "Waves")
+
+| Node | Kind | Params / inputs |
+|---|---|---|
+| **Waves** `waves` | value (the current wave) | `name` (the enemies' health name) · `waves` · `sizeStart` / `sizeStep` (enemies in wave 1, more per wave) · `interval` (seconds between waves) · `speed` / `stagger` (the walk) · `reach` · `spawnPrefix` (objects named `Spawn…` are the spawn points, cycled) · input `goal` (an object; without one the enemies hold their spawn points) |
+| **Waves Value** `wavesvalue` | value | `name` · `read: wave\|left\|size\|waves\|done` |
+| **Waves Event** `wavesevent` | event out | `name` · `event: start\|wave\|over`, fired locally on every peer from the same derived edge — wire `over` into Set Game State (over) |
+
+## The arena recipe (**Waves ▸ Enemies from selection** / **Add boxes as enemies**)
+
+Select the enemies and, last, the goal. Per enemy the recipe authors:
+
+```
+damage(source) ──▶ counter.pulse ──▶ health.damage ◀──target── objectselector(enemy)
+healthreset ──────▶ counter.reset (both counters)
+heal ──▶ counter.pulse ──▶ health.heal
+objectselector(enemy) ──zone──▶ damage(zone, reach, enemy dmg / s) ──▶ health(player).damage
+```
+
+plus, once: the `waves` node with its goal selector, `wavesevent(over) → setgamestate(over)`,
+and the player's health chain (`scope: player`, respawn) if none carries the name. `source`
+is how an enemy is killed: `click` (desktop), `hit` (a knock, speed-scaled) or `touch`.
+Everything is ordinary replicated graph through `api.flow.addNodes`, one undo entry.
+
+## Scoring and the run log
+
+- **Kills** — the health module credits the peer that dealt the killing blow (its click,
+ its own hand's knock) on its own `kills` peerVars row: one writer per row, a Leaderboard
+ node renders them.
+- **The run** — at `over` every peer appends `{at, waves, reached, cleared, players:
+ [{name, kills}]}` to `gameState.vars['waves:'].runs` (capped at 50). The inputs are
+ replicated and the entry is keyed by the last kill's stamp, so every peer appends the
+ same object and the append is idempotent — the flight asserts A, B and the joiner hold
+ byte-identical entries. A saved `.tpscene` carries the log (the football precedent).
+
+## The HUD and the template
+
+`hud.js` is the Towers / Stars Room / football shape: **menu** (Start) · **hud** (wave,
+enemies left, the player's bar, the kills list) · **over** (the sheet and the log) with
+`showWhile`, and `hudGraph()` the eleven nodes that drive it (a HUD Button pulses Set Game
+State directly; Waves Value → HUD Text; Health Value → HUD Bar; Leaderboard on `kills`).
+`waves.def.json` is the template def for the scenes checkout: a ground, a goal, three spawn
+pads, four enemies (`hit` to kill) and the graph above — the integrator stages it.
+
+## Owed on device
+
+The feel of a wave arriving in VR (a sound on `wavesevent wave` is one node away), knocking
+an enemy down with a hand at a real reach, a 3+ player run, and the arena toolbox in
+non-dark themes. Object regen is not offered (see the health README); wave hp scaling
+(`hpScale` in the plan) is not: it would need per-wave `max` writes (replicated node data
+from every peer) — the size curve is the difficulty curve.
diff --git a/modules/waves/emit-def.mjs b/modules/waves/emit-def.mjs
new file mode 100644
index 0000000..5c9ec26
--- /dev/null
+++ b/modules/waves/emit-def.mjs
@@ -0,0 +1,10 @@
+// emit waves.def.json beside the module — the orchestrator's input for the scenes def
+// (run by `npm run build:waves`)
+import { writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { wavesDef } from './src/def.js';
+
+const here = dirname(fileURLToPath(import.meta.url));
+writeFileSync(join(here, 'waves.def.json'), JSON.stringify(wavesDef(), null, '\t') + '\n');
+console.log('waves.def.json written');
diff --git a/modules/waves/manifest.json b/modules/waves/manifest.json
new file mode 100644
index 0000000..d305ba1
--- /dev/null
+++ b/modules/waves/manifest.json
@@ -0,0 +1,9 @@
+{
+ "id": "waves",
+ "name": "Waves",
+ "version": "1.0.0",
+ "format": 1,
+ "description": "Wave survival on the health module: enemies walk from spawn points to a goal, a wave ends when its last enemy dies, the run when the last wave does - derived on every peer, no authority.",
+ "entry": "module.js",
+ "files": ["module.js"]
+}
diff --git a/modules/waves/module.js b/modules/waves/module.js
new file mode 100644
index 0000000..9028375
--- /dev/null
+++ b/modules/waves/module.js
@@ -0,0 +1,842 @@
+// modules/waves/src/curve.js
+var DEFAULTS = Object.freeze({
+ name: "enemy",
+ waves: 3,
+ sizeStart: 2,
+ sizeStep: 1,
+ interval: 3,
+ speed: 1.5,
+ stagger: 0.6,
+ reach: 1.5
+});
+function clamp(n, lo, hi, fallback = lo) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return fallback;
+ return Math.min(hi, Math.max(lo, v));
+}
+function curveOf(data, enemies) {
+ return {
+ waves: Math.round(clamp(data?.waves, 1, 50, DEFAULTS.waves)),
+ sizeStart: Math.round(clamp(data?.sizeStart, 1, 200, DEFAULTS.sizeStart)),
+ sizeStep: Math.round(clamp(data?.sizeStep, 0, 50, DEFAULTS.sizeStep)),
+ enemies: Math.max(0, Math.round(Number(enemies) || 0))
+ };
+}
+function sizeOf(n, c) {
+ if (n < 1) return 0;
+ return Math.min(c.enemies, c.sizeStart + c.sizeStep * (n - 1));
+}
+function firstWaveOf(i, c) {
+ for (let n = 1; n <= c.waves; n++) if (i < sizeOf(n, c)) return n;
+ return null;
+}
+function killsNeeded(i, n, c) {
+ const first = firstWaveOf(i, c);
+ if (first === null || n < first) return 0;
+ return n - first + 1;
+}
+function killsOf(hits, hp) {
+ const max = clamp(hp, 1, 1e6, 1);
+ return Math.floor(Math.max(0, Number(hits) || 0) / max);
+}
+function waveComplete(n, kills, c) {
+ const size = sizeOf(n, c);
+ if (size <= 0) return false;
+ for (let i = 0; i < size; i++) if ((kills[i] ?? 0) < killsNeeded(i, n, c)) return false;
+ return true;
+}
+function waveOf(kills, c) {
+ let completed = 0;
+ while (completed < c.waves && waveComplete(completed + 1, kills, c)) completed++;
+ const done = completed >= c.waves;
+ return { completed, wave: done ? c.waves : completed + 1, done };
+}
+function aliveIn(n, kills, c) {
+ const out = [];
+ for (let i = 0; i < sizeOf(n, c); i++) if ((kills[i] ?? 0) < killsNeeded(i, n, c)) out.push(i);
+ return out;
+}
+function usedIn(n, c) {
+ const out = [];
+ for (let i = 0; i < sizeOf(n, c); i++) out.push(i);
+ return out;
+}
+function healsBefore(i, n, c) {
+ const first = firstWaveOf(i, c);
+ if (first === null || n <= first) return 0;
+ return n - first;
+}
+function enemyPosition(p) {
+ const speed = clamp(p.speed, 0.01, 100, DEFAULTS.speed);
+ const stagger = clamp(p.stagger, 0, 60, DEFAULTS.stagger);
+ const t = p.now - p.waveStart - stagger * p.index;
+ if (!(t > 0)) return p.start.slice();
+ const dx = p.goal[0] - p.start[0];
+ const dz = p.goal[2] - p.start[2];
+ const dist = Math.hypot(dx, dz);
+ if (dist < 1e-6) return p.start.slice();
+ const f = Math.min(1, t * speed / dist);
+ return [p.start[0] + dx * f, p.start[1], p.start[2] + dz * f];
+}
+function spawnFor(i, points, fallback) {
+ if (!points.length) return fallback.slice();
+ return points[i % points.length].slice();
+}
+function runEntry(r) {
+ return {
+ at: r.at,
+ waves: r.waves,
+ reached: r.reached,
+ cleared: !!r.cleared,
+ players: r.rows.map((x) => ({ name: String(x.name ?? ""), kills: Number(x.kills) || 0 })).sort((a, b) => b.kills - a.kills || a.name.localeCompare(b.name))
+ };
+}
+function appendRun(log, entry, cap = 50) {
+ const list = Array.isArray(log) ? log.filter((e) => e && e.at !== entry.at) : [];
+ list.push(entry);
+ return list.slice(-cap);
+}
+
+// modules/waves/src/engine.js
+var SWEEP = 0.1;
+var LOG_PREFIX = "waves:";
+var toSeconds = (ms) => ms / 1e3 % 86400;
+var KILLS_ROW = "kills";
+function createWavesEngine(api) {
+ const state = /* @__PURE__ */ new Map();
+ const waveSeen = /* @__PURE__ */ new Map();
+ const doneSeen = /* @__PURE__ */ new Map();
+ const runSeen = /* @__PURE__ */ new Map();
+ const parked = /* @__PURE__ */ new Map();
+ const healExpected = /* @__PURE__ */ new Map();
+ const listeners = /* @__PURE__ */ new Set();
+ const now = () => api.now();
+ function objectOf(uuid) {
+ if (!uuid) return null;
+ return api.objectsGroup()?.getObjectByProperty("uuid", uuid) ?? null;
+ }
+ const worldPos = (object) => object.getWorldPosition(new api.THREE.Vector3()).toArray();
+ function graphView() {
+ const nodes = api.flow.nodes();
+ const edges2 = api.flow.edges();
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const byTarget = /* @__PURE__ */ new Map();
+ for (const e of edges2) {
+ if (!byTarget.has(e.target)) byTarget.set(e.target, []);
+ byTarget.get(e.target)?.push(e);
+ }
+ return { nodes, edges: edges2, byId, byTarget };
+ }
+ const into = (g, nodeId, handle) => (g.byTarget.get(nodeId) ?? []).filter((e) => (e.targetHandle ?? null) === handle).map((e) => e.source);
+ function selectorInto(g, node, handle) {
+ for (const id of into(g, node.id, handle)) {
+ const src = g.byId.get(id);
+ if (src?.type !== "objectselector") continue;
+ const selected = String(src.data?.selected ?? "");
+ if (selected && selected !== "-None-") return selected;
+ }
+ if (handle === "target" && node.graphId && node.graphId !== "scene") return node.graphId;
+ return null;
+ }
+ const nameOf = (node) => String(node?.data?.name ?? "").trim() || DEFAULTS.name;
+ function enemiesOf(g, name) {
+ const out = [];
+ for (const node of g.nodes) {
+ if (node.type !== "health" || node.data?.scope === "player") continue;
+ if (nameOf(node) !== name) continue;
+ const uuid = selectorInto(g, node, "target");
+ const object = objectOf(uuid);
+ const damageCounters = into(g, node.id, "damage").filter((id) => g.byId.get(id)?.type === "counter");
+ const healCounters = into(g, node.id, "heal").filter((id) => g.byId.get(id)?.type === "counter");
+ let hits = 0;
+ let lastHit = null;
+ for (const id of damageCounters) {
+ hits += Number(api.flow.nodeValue(id)) || 0;
+ const stamp = api.flow.triggerStamp(id);
+ if (stamp && (lastHit === null || stamp.stamp > lastHit)) lastHit = stamp.stamp;
+ }
+ let heals = 0;
+ for (const id of healCounters) heals += Number(api.flow.nodeValue(id)) || 0;
+ const max = clamp(node.data?.max, 1, 1e6, 5);
+ out.push({
+ healthId: node.id,
+ uuid,
+ label: object?.name || (uuid ? uuid.slice(0, 8) + "\u2026" : "(no target)"),
+ max,
+ hits,
+ heals,
+ lastHit,
+ kills: killsOf(hits, max),
+ hp: Math.max(0, Math.min(max, max - (hits - heals))),
+ healNodes: g.nodes.filter((n) => n.type === "heal" && healCounters.some((c) => (g.byTarget.get(c) ?? []).some((e) => e.source === n.id))).map((n) => n.id)
+ });
+ }
+ out.sort((a, b) => a.label.localeCompare(b.label) || a.healthId.localeCompare(b.healthId));
+ return out;
+ }
+ function spawnPoints(prefix) {
+ const p = String(prefix ?? "").trim();
+ if (!p) return [];
+ const root = api.objectsGroup();
+ const found = (root?.children ?? []).filter((c) => String(c.name ?? "").startsWith(p));
+ found.sort((a, b) => String(a.name).localeCompare(String(b.name)));
+ return found.map((c) => worldPos(c));
+ }
+ function derive(node, g) {
+ const d = node.data ?? {};
+ const name = nameOf(node);
+ const enemies = enemiesOf(g, name);
+ const curve = curveOf(d, enemies.length);
+ const kills = enemies.map((e) => e.kills);
+ const { completed, wave, done } = waveOf(kills, curve);
+ const cutoff = api.game.roundCutoff();
+ const underway = api.game.roundUnderway();
+ const running = underway && typeof cutoff === "number" && Number.isFinite(cutoff) && enemies.length > 0 && !done;
+ const interval = clamp(d.interval, 0, 600, DEFAULTS.interval);
+ let waveStart = typeof cutoff === "number" && Number.isFinite(cutoff) ? toSeconds(cutoff) : null;
+ let clearedAt = null;
+ if (completed > 0) {
+ let last = null;
+ for (const i of usedIn(completed, curve)) {
+ const e = enemies[i];
+ if (e?.lastHit !== null && e?.lastHit !== void 0 && (last === null || e.lastHit > last)) last = e.lastHit;
+ }
+ clearedAt = last;
+ if (last !== null && !done) waveStart = last + interval;
+ }
+ const started = running && waveStart !== null && now() >= waveStart;
+ const goalUuid = selectorInto(g, node, "goal");
+ const goalObject = objectOf(goalUuid);
+ return {
+ id: node.id,
+ name,
+ curve,
+ enemies,
+ wave,
+ completed,
+ done,
+ running,
+ started,
+ waveStart,
+ clearedAt,
+ interval,
+ alive: running || done ? aliveIn(wave, kills, curve).length : sizeOf(wave, curve),
+ size: sizeOf(wave, curve),
+ goal: goalObject ? worldPos(goalObject) : null,
+ spawns: spawnPoints(d.spawnPrefix ?? "Spawn"),
+ speed: clamp(d.speed, 0.01, 100, DEFAULTS.speed),
+ stagger: clamp(d.stagger, 0, 60, DEFAULTS.stagger),
+ reach: clamp(d.reach, 0.1, 100, DEFAULTS.reach)
+ };
+ }
+ function healSweep(s) {
+ if (!s.started) return;
+ for (const i of usedIn(s.wave, s.curve)) {
+ const e = s.enemies[i];
+ if (!e || !e.healNodes.length) continue;
+ const expected = healExpected.get(e.healthId) ?? 0;
+ const heals = Math.max(e.heals, expected);
+ const owed = healsBefore(i, s.wave, s.curve) * e.max - heals;
+ if (owed <= 0) continue;
+ const target = e.healNodes[0];
+ const fire = Math.min(owed, 100);
+ for (let k = 0; k < fire; k++)
+ api.fireNodeTrigger("heal", (_d, id) => id === target, { replicate: false });
+ healExpected.set(e.healthId, heals + fire);
+ }
+ }
+ function moveSweep(s) {
+ const used = usedIn(s.wave, s.curve);
+ const alive = new Set(aliveIn(s.wave, s.enemies.map((e) => e.kills), s.curve));
+ for (let k = 0; k < s.enemies.length; k++) {
+ const e = s.enemies[k];
+ const object = objectOf(e.uuid);
+ if (!object) continue;
+ if (!parked.has(e.uuid)) parked.set(e.uuid, object.position.toArray());
+ const home = parked.get(e.uuid) ?? object.position.toArray();
+ const index = used.indexOf(k);
+ const walking = s.running && s.started && index >= 0 && alive.has(k) && s.goal;
+ if (walking && s.waveStart !== null) {
+ const start = spawnFor(index, s.spawns, home);
+ object.position.fromArray(enemyPosition({ start, goal: (
+ /** @type {number[]} */
+ s.goal
+ ), waveStart: s.waveStart, index, now: now(), speed: s.speed, stagger: s.stagger }));
+ } else if (!s.running) {
+ object.position.fromArray(home);
+ } else if (index >= 0 && !alive.has(k) === false) {
+ object.position.fromArray(spawnFor(index, s.spawns, home));
+ }
+ object.updateMatrixWorld?.();
+ }
+ if (!s.running) for (const e of s.enemies) parked.delete(e.uuid);
+ }
+ function emit(name, kind) {
+ api.fireNodeTrigger("wavesevent", (d) => nameOf({ data: d }) === name && String(d?.event ?? "wave") === kind, { replicate: false });
+ }
+ function edges(s, firstSight) {
+ const prevWave = waveSeen.get(s.id);
+ const prevDone = doneSeen.get(s.id);
+ const prevRun = runSeen.get(s.id);
+ waveSeen.set(s.id, s.wave);
+ doneSeen.set(s.id, s.done);
+ runSeen.set(s.id, s.running);
+ if (firstSight) return;
+ if (s.running && prevRun === false) emit(s.name, "start");
+ if (typeof prevWave === "number" && s.wave > prevWave && !s.done) emit(s.name, "wave");
+ if (s.done && prevDone === false) {
+ emit(s.name, "over");
+ logRun(s);
+ }
+ }
+ function logRun(s) {
+ if (typeof s.clearedAt !== "number") return;
+ const names = new Map((api.peerNames?.() ?? []).map((p) => [p.id, p.label ?? p.name]));
+ const rows = (api.peerVars.all(KILLS_ROW) ?? []).map((r) => ({ name: names.get(r.id) ?? "peer " + String(r.id).slice(0, 4), kills: r.value }));
+ const entry = runEntry({ at: s.clearedAt, waves: s.curve.waves, reached: s.wave, cleared: true, rows });
+ const key = LOG_PREFIX + s.name;
+ const held = api.game.getVar(key, null);
+ api.game.setVar(key, { runs: appendRun(held && typeof held === "object" ? held.runs : [], entry) });
+ }
+ function runLog(name) {
+ const v = api.game.getVar(LOG_PREFIX + name, null);
+ return v && typeof v === "object" && Array.isArray(v.runs) ? v.runs : [];
+ }
+ function sweep() {
+ const g = graphView();
+ const live = /* @__PURE__ */ new Set();
+ for (const node of g.nodes) {
+ if (node.type !== "waves") continue;
+ live.add(node.id);
+ const firstSight = !state.has(node.id);
+ const s = derive(node, g);
+ state.set(node.id, s);
+ edges(s, firstSight);
+ healSweep(s);
+ moveSweep(s);
+ }
+ for (const id of [...state.keys()])
+ if (!live.has(id)) {
+ state.delete(id);
+ waveSeen.delete(id);
+ doneSeen.delete(id);
+ runSeen.delete(id);
+ }
+ for (const s of state.values())
+ for (const e of s.enemies) {
+ const expected = healExpected.get(e.healthId);
+ if (expected !== void 0 && (e.heals >= expected || e.heals === 0 && e.hits === 0)) healExpected.delete(e.healthId);
+ }
+ for (const fn of listeners) fn();
+ }
+ let lastSweep = -1;
+ api.registerFrameTask(() => {
+ const t = performance.now() / 1e3;
+ if (t - lastSweep < SWEEP) return;
+ lastSweep = t;
+ try {
+ sweep();
+ } catch (error) {
+ console.warn("[waves] sweep failed", error);
+ }
+ });
+ function read(name) {
+ for (const s of state.values()) if (s.name === name) return s;
+ return null;
+ }
+ function clear() {
+ state.clear();
+ waveSeen.clear();
+ doneSeen.clear();
+ runSeen.clear();
+ parked.clear();
+ healExpected.clear();
+ }
+ return {
+ state,
+ stateOf: (id) => state.get(id) ?? null,
+ read,
+ all: () => [...state.values()],
+ graphView,
+ runLog,
+ sweep,
+ onChange: (fn) => {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ },
+ clear
+ };
+}
+
+// modules/waves/src/nodes.js
+var READS = ["wave", "left", "size", "waves", "done"];
+var EVENTS = ["wave", "over", "start"];
+function registerNodes(api, engine) {
+ api.registerNodeGroup({
+ group: "Waves",
+ items: [
+ {
+ type: "waves",
+ label: "Waves",
+ defaults: { ...DEFAULTS, spawnPrefix: "Spawn", goal: "" },
+ params: [
+ { key: "name", kind: "text", placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: "waves", kind: "range", min: 1, max: 50, step: 1 },
+ { key: "sizeStart", kind: "range", min: 1, max: 50, step: 1 },
+ { key: "sizeStep", kind: "range", min: 0, max: 20, step: 1 },
+ { key: "interval", kind: "range", min: 0, max: 60, step: 0.5 },
+ { key: "speed", kind: "range", min: 0.1, max: 20, step: 0.1 },
+ { key: "stagger", kind: "range", min: 0, max: 10, step: 0.1 },
+ { key: "reach", kind: "range", min: 0.5, max: 10, step: 0.5 },
+ { key: "spawnPrefix", kind: "text", placeholder: "Spawn", maxLength: 40 }
+ ]
+ },
+ {
+ type: "wavesvalue",
+ label: "Waves Value",
+ defaults: { name: DEFAULTS.name, read: "wave" },
+ params: [
+ { key: "name", kind: "text", placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: "read", kind: "select", options: READS }
+ ]
+ },
+ {
+ type: "wavesevent",
+ label: "Waves Event",
+ defaults: { name: DEFAULTS.name, event: "wave" },
+ params: [
+ { key: "name", kind: "text", placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: "event", kind: "select", options: EVENTS }
+ ]
+ }
+ ]
+ });
+ api.registerValueNode(
+ "waves",
+ (_data, _time, ctx) => engine.stateOf(ctx?.id)?.wave ?? 0,
+ { vtype: "number", inputs: { goal: "object" } }
+ );
+ api.registerValueNode(
+ "wavesvalue",
+ (data) => {
+ const s = engine.read(String(data?.name ?? "").trim() || DEFAULTS.name);
+ if (!s) return 0;
+ switch (String(data?.read ?? "wave")) {
+ case "left":
+ return s.alive;
+ case "size":
+ return s.size;
+ case "waves":
+ return s.curve.waves;
+ case "done":
+ return s.done ? 1 : 0;
+ default:
+ return s.wave;
+ }
+ },
+ { vtype: "number" }
+ );
+ api.registerValueNode("wavesevent", () => 0, { vtype: "event" });
+}
+
+// modules/waves/src/toolbox.js
+var SELECT_CSS = "background:rgba(0,0,0,0.25);color:inherit;border:1px solid rgba(255,255,255,0.15);border-radius:4px;font-size:11px;padding:1px 3px;";
+var INPUT_CSS = SELECT_CSS + "width:100%;";
+var COL = 220;
+var ROW = 200;
+var X0 = 60;
+var Y0 = 40;
+function elem(tag, props, css) {
+ const node = document.createElement(tag);
+ Object.assign(node, props ?? {});
+ if (css) node.setAttribute("style", css);
+ return node;
+}
+function select(options, value) {
+ const node = (
+ /** @type {HTMLSelectElement} */
+ elem("select", {}, SELECT_CSS)
+ );
+ for (const option of options) node.appendChild(elem("option", { value: option, textContent: option }));
+ node.value = value;
+ return node;
+}
+function arenaRecipe(spec) {
+ const o = spec.options;
+ const name = String(o.name ?? "").trim() || DEFAULTS.name;
+ const playerName = String(o.playerName ?? "").trim() || "me";
+ const nodes = [];
+ const edges = [];
+ let row = spec.row;
+ const y = () => Y0 + row * ROW;
+ const wavesIdx = nodes.push({ type: "waves", x: X0, y: y(), data: { name, waves: o.waves, sizeStart: o.sizeStart, sizeStep: o.sizeStep, interval: o.interval, speed: o.speed, stagger: DEFAULTS.stagger, reach: o.reach, spawnPrefix: o.spawnPrefix } }) - 1;
+ if (spec.goal) {
+ const sel = nodes.push({ type: "objectselector", x: X0 - 0, y: y() + 100, data: { selected: spec.goal } }) - 1;
+ edges.push({ from: sel, to: wavesIdx, handle: "goal" });
+ }
+ const over = nodes.push({ type: "wavesevent", x: X0 + COL, y: y(), data: { name, event: "over" } }) - 1;
+ const setOver = nodes.push({ type: "setgamestate", x: X0 + 2 * COL, y: y(), data: { state: "over", outcome: "won" } }) - 1;
+ edges.push({ from: over, to: setOver, handle: "trigger" });
+ let playerRef = (
+ /** @type {number|string|null} */
+ spec.playerHealthId
+ );
+ if (!playerRef) {
+ const pd = nodes.push({ type: "damage", x: X0 + 3 * COL, y: y(), data: { amount: 1, source: "wired" } }) - 1;
+ const pc = nodes.push({ type: "counter", x: X0 + 4 * COL, y: y(), data: { op: "up", step: 1 } }) - 1;
+ const ph = nodes.push({ type: "health", x: X0 + 5 * COL, y: y(), data: { name: playerName, scope: "player", max: o.playerHp, regen: o.playerRegen, deathAction: "respawn", respawnDelay: 3 } }) - 1;
+ const pr = nodes.push({ type: "healthreset", x: X0 + 3 * COL, y: y() + 100, data: { name: playerName } }) - 1;
+ edges.push({ from: pd, to: pc, handle: "pulse" }, { from: pc, to: ph, handle: "damage" }, { from: pr, to: pc, handle: "reset" });
+ playerRef = ph;
+ }
+ row++;
+ for (const uuid of spec.enemies) {
+ const d = nodes.push({ type: "damage", x: X0, y: y(), data: { amount: 1, source: o.source, scale: o.source === "hit" ? "speed" : "none", speedRef: 3 } }) - 1;
+ const c = nodes.push({ type: "counter", x: X0 + COL, y: y(), data: { op: "up", step: 1 } }) - 1;
+ const h = nodes.push({ type: "health", x: X0 + 2 * COL, y: y(), data: { name, scope: "object", max: o.hp, deathAction: "hide", respawnDelay: 3 } }) - 1;
+ const s = nodes.push({ type: "objectselector", x: X0 + 3 * COL, y: y(), data: { selected: uuid } }) - 1;
+ const r = nodes.push({ type: "healthreset", x: X0, y: y() + 100, data: { name } }) - 1;
+ const he = nodes.push({ type: "heal", x: X0 + COL, y: y() + 100, data: { amount: 1 } }) - 1;
+ const hc = nodes.push({ type: "counter", x: X0 + 2 * COL, y: y() + 100, data: { op: "up", step: 1 } }) - 1;
+ const z = nodes.push({ type: "damage", x: X0 + 4 * COL, y: y(), data: { amount: o.enemyDamage, source: "zone", perSecond: o.enemyRate, radius: o.reach } }) - 1;
+ const zs = nodes.push({ type: "objectselector", x: X0 + 4 * COL, y: y() + 100, data: { selected: uuid } }) - 1;
+ edges.push(
+ { from: d, to: c, handle: "pulse" },
+ { from: c, to: h, handle: "damage" },
+ { from: s, to: h, handle: "target" },
+ { from: r, to: c, handle: "reset" },
+ { from: r, to: hc, handle: "reset" },
+ { from: he, to: hc, handle: "pulse" },
+ { from: hc, to: h, handle: "heal" },
+ { from: zs, to: z, handle: "zone" },
+ { from: z, to: playerRef, handle: "damage" }
+ );
+ row++;
+ }
+ return { nodes, edges, rows: row - spec.row };
+}
+function registerToolbox(api, engine) {
+ function playerHealthId(playerName) {
+ for (const node of api.flow.nodes("health"))
+ if (node.data?.scope === "player" && (String(node.data?.name ?? "").trim() || "hp") === playerName) return node.id;
+ return null;
+ }
+ function build(options, enemies) {
+ if (!enemies.length) {
+ api.toast("Select the enemy objects first (or add boxes)");
+ return null;
+ }
+ const g = engine.graphView();
+ const row = g.nodes.filter((n) => n.type === "health" || n.type === "waves").length;
+ const goal = api.selectedUuids().find((u) => !enemies.includes(u)) ?? null;
+ const spec = arenaRecipe({ enemies, goal: options.goal ?? goal, options, row, playerHealthId: playerHealthId(String(options.playerName ?? "").trim() || "me") });
+ const ids = api.flow.addNodes({ nodes: spec.nodes, edges: spec.edges });
+ api.toast("Arena: " + enemies.length + " enemies over " + options.waves + " waves" + (spec.nodes.some((n) => n.type === "objectselector" && n.data.selected === (options.goal ?? goal)) ? ", walking to the goal" : " (no goal: they hold their spawn points)"));
+ return ids;
+ }
+ function mount(el) {
+ el.textContent = "";
+ el.classList.add("waves-manager");
+ el.appendChild(
+ elem("style", {
+ textContent: ".waves-manager{display:flex;flex-direction:column;gap:8px;min-width:240px;font-size:12px}.wm-form{display:grid;grid-template-columns:auto 1fr;gap:4px 6px;align-items:center}.wm-form label{opacity:0.75;font-size:11px}.wm-buttons{display:flex;gap:6px;flex-wrap:wrap}.wm-run{padding:4px 6px;border-radius:5px;background:rgba(255,255,255,0.05);margin-bottom:3px}.wm-title{font-weight:600}.wm-line{font-size:11px;opacity:0.8}.wm-empty{opacity:0.6;font-size:11px}"
+ })
+ );
+ el.appendChild(elem("div", { className: "tbx-label", textContent: "Build an arena" }));
+ const form = elem("div", { className: "wm-form" });
+ const name = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "text", value: DEFAULTS.name, maxLength: 40 }, INPUT_CSS)
+ );
+ const waves = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "1", max: "50", step: "1", value: String(DEFAULTS.waves) }, INPUT_CSS)
+ );
+ const sizeStart = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "1", max: "50", step: "1", value: String(DEFAULTS.sizeStart) }, INPUT_CSS)
+ );
+ const sizeStep = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "0", max: "20", step: "1", value: String(DEFAULTS.sizeStep) }, INPUT_CSS)
+ );
+ const interval = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "0", max: "60", step: "0.5", value: String(DEFAULTS.interval) }, INPUT_CSS)
+ );
+ const hp = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "1", max: "20", step: "1", value: "3" }, INPUT_CSS)
+ );
+ const source = select(["click", "hit", "touch"], "click");
+ const speed = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "0.1", max: "20", step: "0.1", value: String(DEFAULTS.speed) }, INPUT_CSS)
+ );
+ const reach = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "0.5", max: "10", step: "0.5", value: String(DEFAULTS.reach) }, INPUT_CSS)
+ );
+ const enemyDamage = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "1", max: "20", step: "1", value: "1" }, INPUT_CSS)
+ );
+ const enemyRate = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "0.5", max: "10", step: "0.5", value: "1" }, INPUT_CSS)
+ );
+ const playerName = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "text", value: "me", maxLength: 40 }, INPUT_CSS)
+ );
+ const playerHp = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "1", max: "100", step: "1", value: "10" }, INPUT_CSS)
+ );
+ const spawnPrefix = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "text", value: "Spawn", maxLength: 40 }, INPUT_CSS)
+ );
+ const count = (
+ /** @type {HTMLInputElement} */
+ elem("input", { type: "number", min: "1", max: "20", step: "1", value: "4" }, INPUT_CSS)
+ );
+ for (const [label, control] of [
+ ["Enemies named", name],
+ ["Waves", waves],
+ ["First wave", sizeStart],
+ ["+ per wave", sizeStep],
+ ["Between (s)", interval],
+ ["Enemy hp", hp],
+ ["Killed by", source],
+ ["Walk m/s", speed],
+ ["Reach", reach],
+ ["Enemy dmg", enemyDamage],
+ ["\u2026per second", enemyRate],
+ ["Player health", playerName],
+ ["Player hp", playerHp],
+ ["Spawn prefix", spawnPrefix],
+ ["Boxes to add", count]
+ ]) {
+ form.appendChild(elem("label", { textContent: label }));
+ form.appendChild(
+ /** @type {any} */
+ control
+ );
+ }
+ el.appendChild(form);
+ const options = () => ({
+ name: name.value.trim() || DEFAULTS.name,
+ waves: Math.max(1, Math.min(50, Number(waves.value) || DEFAULTS.waves)),
+ sizeStart: Math.max(1, Math.min(50, Number(sizeStart.value) || DEFAULTS.sizeStart)),
+ sizeStep: Math.max(0, Math.min(20, Number(sizeStep.value) || 0)),
+ interval: Math.max(0, Math.min(60, Number(interval.value) || 0)),
+ hp: Math.max(1, Math.min(20, Number(hp.value) || 3)),
+ source: source.value,
+ speed: Math.max(0.1, Math.min(20, Number(speed.value) || DEFAULTS.speed)),
+ reach: Math.max(0.5, Math.min(10, Number(reach.value) || DEFAULTS.reach)),
+ enemyDamage: Math.max(1, Math.min(20, Number(enemyDamage.value) || 1)),
+ enemyRate: Math.max(0.5, Math.min(10, Number(enemyRate.value) || 1)),
+ playerName: playerName.value.trim() || "me",
+ playerHp: Math.max(1, Math.min(100, Number(playerHp.value) || 10)),
+ playerRegen: 0,
+ spawnPrefix: spawnPrefix.value.trim() || "Spawn"
+ });
+ const buttons = elem("div", { className: "wm-buttons" });
+ const fromSelection = elem("button", { className: "tbx-btn tbx-primary", textContent: "Enemies from selection" });
+ fromSelection.title = "The selected objects become the enemies; select the goal object too and it becomes the goal";
+ fromSelection.addEventListener("click", () => {
+ const selected = api.selectedUuids();
+ const enemies = selected.length > 1 ? selected.slice(0, -1) : selected;
+ const goal = selected.length > 1 ? selected[selected.length - 1] : null;
+ build({ ...options(), goal }, enemies);
+ refresh();
+ });
+ const addBoxes = elem("button", { className: "tbx-btn", textContent: "Add boxes as enemies" });
+ addBoxes.title = "Creates N boxes (replicated), parks them in a row, and makes them the enemies";
+ addBoxes.addEventListener("click", async () => {
+ const n = Math.max(1, Math.min(20, Number(count.value) || 4));
+ const made = [];
+ for (let i = 0; i < n; i++) {
+ const ids = await api.create("/create box", { at: [-6 + i * 1.5, 0.5, -8] });
+ if (ids[0]) made.push(ids[0]);
+ }
+ const goal = api.selectedUuids()[0] ?? null;
+ build({ ...options(), goal }, made);
+ refresh();
+ });
+ buttons.appendChild(fromSelection);
+ buttons.appendChild(addBoxes);
+ el.appendChild(buttons);
+ const list = elem("div", {});
+ el.appendChild(list);
+ function refresh() {
+ list.textContent = "";
+ const runs = engine.all();
+ if (!runs.length) {
+ list.appendChild(elem("div", { className: "wm-empty", textContent: "No arena yet. Select the enemies (and a goal), or add boxes." }));
+ return;
+ }
+ for (const s of runs) {
+ const box = elem("div", { className: "wm-run" });
+ box.dataset.node = s.id;
+ box.appendChild(elem("div", { className: "wm-title", textContent: s.name + " \xB7 wave " + s.wave + " of " + s.curve.waves + (s.done ? " \xB7 cleared" : s.running ? s.started ? " \xB7 " + s.alive + " left" : " \xB7 next wave soon" : " \xB7 idle") }));
+ box.appendChild(elem("div", { className: "wm-line", textContent: s.enemies.length + " enemies \xB7 " + (s.goal ? "walking to the goal" : "no goal") + " \xB7 " + s.spawns.length + " spawn points \xB7 " + engine.runLog(s.name).length + " runs logged" }));
+ list.appendChild(box);
+ }
+ }
+ refresh();
+ const timer = setInterval(refresh, 500);
+ return () => clearInterval(timer);
+ }
+ const toolbox = api.registerToolbox({ id: "arena", title: "Waves", width: 280, minW: 250, sidebar: false, mount });
+ api.registerMenu("Open Waves", () => api.openToolbox(toolbox));
+ return { toolbox, build, arenaRecipe };
+}
+
+// modules/waves/src/hud.js
+var PANEL = { bg: "rgba(24, 20, 30, 0.92)", radius: 16, border: "1px solid rgba(220, 120, 120, 0.25)" };
+var BUTTON = (bg) => ({ size: 16, weight: "600", bg, color: "#ffffff", radius: 10 });
+function arenaHud() {
+ return {
+ scene: {
+ active: "",
+ changedAt: 0,
+ screens: [
+ {
+ id: "menu",
+ name: "Menu",
+ showWhile: "menu",
+ input: "menu",
+ elements: [
+ { id: "menu-panel", kind: "panel", anchor: "center", x: 0, y: 0, w: 460, h: 340, z: 0, label: "", style: PANEL },
+ { id: "title", kind: "text", anchor: "center", x: 0, y: -120, w: 400, h: 54, z: 1, label: "WAVES", style: { size: 40, weight: "700", color: "#ff9c6b", align: "center" } },
+ { id: "subtitle", kind: "text", anchor: "center", x: 0, y: -66, w: 420, h: 44, z: 1, label: "Hold the goal. Every wave brings more enemies; the round ends when the last one falls.", style: { size: 14, color: "#e6dede", align: "center" }, wrap: true },
+ { id: "wv-start", kind: "button", anchor: "center", x: 0, y: 10, w: 220, h: 48, z: 1, label: "Start", enabled: true, style: BUTTON("#c94a4a") },
+ { id: "wv-log", kind: "list", anchor: "center", x: 0, y: 90, w: 420, h: 80, z: 1, label: "", rows: [], style: { size: 12, color: "#d6c8c8", align: "center" } }
+ ]
+ },
+ {
+ id: "hud",
+ name: "HUD",
+ showWhile: "playing",
+ input: "game",
+ elements: [
+ { id: "wv-wave", kind: "text", anchor: "top-center", x: 0, y: 14, w: 320, h: 34, z: 1, label: "Wave 1", style: { size: 22, weight: "700", color: "#ff9c6b", align: "center" } },
+ { id: "wv-left", kind: "text", anchor: "top-center", x: 0, y: 48, w: 320, h: 24, z: 1, label: "", style: { size: 14, color: "#e6dede", align: "center" } },
+ { id: "wv-hp", kind: "bar", anchor: "bottom-center", x: 0, y: 24, w: 320, h: 18, z: 1, label: "", min: 0, max: 1, value: 1, orientation: "horizontal", showPercent: false, style: { color: "#6fcf7a", bg: "rgba(0,0,0,0.35)", radius: 9 } },
+ { id: "wv-kills", kind: "list", anchor: "top-right", x: 16, y: 14, w: 220, h: 100, z: 1, label: "", rows: [], style: { size: 12, color: "#d6c8c8", align: "right" } }
+ ]
+ },
+ {
+ id: "over",
+ name: "Round over",
+ showWhile: "over",
+ input: "menu",
+ elements: [
+ { id: "over-panel", kind: "panel", anchor: "center", x: 0, y: 0, w: 460, h: 340, z: 0, label: "", style: PANEL },
+ { id: "over-title", kind: "text", anchor: "center", x: 0, y: -120, w: 420, h: 40, z: 1, label: "ARENA CLEARED", style: { size: 30, weight: "700", color: "#ff9c6b", align: "center" } },
+ { id: "wv-kills-over", kind: "list", anchor: "center", x: 0, y: -50, w: 420, h: 90, z: 1, label: "", rows: [], style: { size: 14, color: "#e6dede", align: "center" } },
+ { id: "wv-log-over", kind: "list", anchor: "center", x: 0, y: 40, w: 420, h: 70, z: 1, label: "", rows: [], style: { size: 12, color: "#d6c8c8", align: "center" } },
+ { id: "wv-again", kind: "button", anchor: "center", x: 0, y: 115, w: 220, h: 44, z: 1, label: "Again", enabled: true, style: BUTTON("#3b7dd8") }
+ ]
+ }
+ ]
+ }
+ };
+}
+function hudGraph(o) {
+ const x = o.x ?? 60;
+ const y = o.y ?? 40;
+ const nodes = [
+ { type: "hudbutton", x, y, data: { element: "wv-start" } },
+ { type: "setgamestate", x: x + 220, y, data: { state: "playing", outcome: "" } },
+ { type: "hudbutton", x, y: y + 100, data: { element: "wv-again" } },
+ { type: "setgamestate", x: x + 220, y: y + 100, data: { state: "playing", outcome: "" } },
+ { type: "wavesvalue", x, y: y + 200, data: { name: o.name, read: "wave" } },
+ { type: "hudtext", x: x + 220, y: y + 200, data: { element: "wv-wave", format: "Wave {v}", decimals: 0 } },
+ { type: "wavesvalue", x, y: y + 300, data: { name: o.name, read: "left" } },
+ { type: "hudtext", x: x + 220, y: y + 300, data: { element: "wv-left", format: "{v} left", decimals: 0 } },
+ { type: "healthvalue", x, y: y + 400, data: { name: o.playerName, read: "fraction" } },
+ { type: "hudbar", x: x + 220, y: y + 400, data: { element: "wv-hp", min: 0, max: 1 } },
+ { type: "leaderboard", x, y: y + 500, data: { variable: "kills", element: "wv-kills,wv-kills-over", limit: 8 } }
+ ];
+ const edges = [
+ { from: 0, to: 1, handle: "trigger" },
+ { from: 2, to: 3, handle: "trigger" },
+ { from: 4, to: 5, handle: "value" },
+ { from: 6, to: 7, handle: "value" },
+ { from: 8, to: 9, handle: "value" }
+ ];
+ return { nodes, edges };
+}
+
+// modules/waves/src/index.js
+var index_default = {
+ id: "waves",
+ name: "Waves",
+ version: "1.0.0",
+ description: "Wave survival on the health module: enemies walk from spawn points to a goal, a wave ends when its last enemy dies, the run is over when the last wave does \u2014 derived on every peer, no authority.",
+ /** @param {any} api the module SDK surface */
+ register(api) {
+ if (!api.flow?.addNodes || !api.game?.roundCutoff || !api.peerVars?.all || !api.registerValueNode) {
+ api.toast("Waves needs a newer app build (the game SDK seams are missing)");
+ return;
+ }
+ const engine = createWavesEngine(api);
+ registerNodes(api, engine);
+ const toolbox = registerToolbox(api, engine);
+ api.hud.registerDebugLine(() => {
+ const runs = engine.all();
+ if (!runs.length) return null;
+ return runs.map((s) => "waves (" + s.name + "): " + (s.done ? "cleared" : "wave " + s.wave + "/" + s.curve.waves + (s.running ? ", " + s.alive + " left" : ", idle"))).join(" \xB7 ");
+ });
+ api.hud.registerAction({
+ key: "showwave",
+ label: "Show the wave",
+ group: "Data",
+ role: "drives",
+ node: "",
+ via: { node: "wavesvalue", data: { name: DEFAULTS.name, read: "wave" }, handle: "value" },
+ hint: "The current wave, derived from the enemies\u2019 hit counters on every peer."
+ });
+ api.hud.registerAction({
+ key: "showleft",
+ label: "Show enemies left",
+ group: "Data",
+ role: "drives",
+ node: "",
+ via: { node: "wavesvalue", data: { name: DEFAULTS.name, read: "left" }, handle: "value" },
+ hint: "How many of this wave are still standing."
+ });
+ api.onSceneClear(() => engine.clear());
+ if (typeof window !== "undefined")
+ window.__waves = {
+ api,
+ engine,
+ toolbox,
+ hud: arenaHud,
+ hudGraph,
+ snapshot: () => engine.all().map((s) => ({
+ id: s.id,
+ name: s.name,
+ wave: s.wave,
+ completed: s.completed,
+ done: s.done,
+ running: s.running,
+ started: s.started,
+ alive: s.alive,
+ size: s.size,
+ waves: s.curve.waves,
+ waveStart: s.waveStart,
+ goal: s.goal,
+ spawns: s.spawns.length,
+ enemies: s.enemies.map((e) => ({ uuid: e.uuid, label: e.label, hits: e.hits, heals: e.heals, kills: e.kills, hp: e.hp })),
+ log: engine.runLog(s.name)
+ }))
+ };
+ }
+};
+export {
+ index_default as default
+};
diff --git a/modules/waves/src/curve.js b/modules/waves/src/curve.js
new file mode 100644
index 0000000..ab98e85
--- /dev/null
+++ b/modules/waves/src/curve.js
@@ -0,0 +1,163 @@
+// waves — THE CURVE, pure. Which enemies a wave uses, how many kills each needs, which
+// wave the counters say we are in, and where an enemy stands at a given time. Every peer
+// holds the same counters and the same clock, so every peer derives the same wave and
+// the same positions — nothing about a wave is ever sent.
+//
+// THE LEDGER TRICK. An enemy is a pre-placed object with a health chain (the health
+// module's Counter of hits). Its hits never reset between waves: at the start of each
+// wave every peer heals it back to full with LOCAL heal pulses, so its counter reads
+// `hp × kills`. Wave n is complete when every enemy it uses has been killed once per wave
+// it appeared in. That is a pure function of the hit counters, which travel with their
+// stamps in the trigger-log handshake — so a late joiner lands on the same wave.
+
+export const DEFAULTS = Object.freeze({
+ name: 'enemy',
+ waves: 3,
+ sizeStart: 2,
+ sizeStep: 1,
+ interval: 3,
+ speed: 1.5,
+ stagger: 0.6,
+ reach: 1.5
+});
+
+/** @param {any} n @param {number} lo @param {number} hi @param {number} fallback */
+export function clamp(n, lo, hi, fallback = lo) {
+ const v = Number(n);
+ if (!Number.isFinite(v)) return fallback;
+ return Math.min(hi, Math.max(lo, v));
+}
+
+/** @typedef {{waves: number, sizeStart: number, sizeStep: number, enemies: number}} Curve */
+
+/** the curve's numbers, clamped @param {any} data @param {number} enemies @returns {Curve} */
+export function curveOf(data, enemies) {
+ return {
+ waves: Math.round(clamp(data?.waves, 1, 50, DEFAULTS.waves)),
+ sizeStart: Math.round(clamp(data?.sizeStart, 1, 200, DEFAULTS.sizeStart)),
+ sizeStep: Math.round(clamp(data?.sizeStep, 0, 50, DEFAULTS.sizeStep)),
+ enemies: Math.max(0, Math.round(Number(enemies) || 0))
+ };
+}
+
+/** how many enemies wave `n` (1-based) uses — never more than there are @param {number} n @param {Curve} c */
+export function sizeOf(n, c) {
+ if (n < 1) return 0;
+ return Math.min(c.enemies, c.sizeStart + c.sizeStep * (n - 1));
+}
+
+/** the first wave enemy `i` (0-based) appears in, or null if it never does @param {number} i @param {Curve} c */
+export function firstWaveOf(i, c) {
+ for (let n = 1; n <= c.waves; n++) if (i < sizeOf(n, c)) return n;
+ return null;
+}
+
+/** kills enemy `i` must have taken for wave `n` to be complete @param {number} i @param {number} n @param {Curve} c */
+export function killsNeeded(i, n, c) {
+ const first = firstWaveOf(i, c);
+ if (first === null || n < first) return 0;
+ return n - first + 1;
+}
+
+/** kills from a hit counter: the overkill guard makes `hits` a multiple of `hp`, and the
+ * floor keeps a stray extra hit from counting as a life @param {number} hits @param {number} hp */
+export function killsOf(hits, hp) {
+ const max = clamp(hp, 1, 1e6, 1);
+ return Math.floor(Math.max(0, Number(hits) || 0) / max);
+}
+
+/** is wave `n` complete on these kills? @param {number} n @param {number[]} kills @param {Curve} c */
+export function waveComplete(n, kills, c) {
+ const size = sizeOf(n, c);
+ if (size <= 0) return false;
+ for (let i = 0; i < size; i++) if ((kills[i] ?? 0) < killsNeeded(i, n, c)) return false;
+ return true;
+}
+
+/**
+ * THE WAVE, derived: how many waves are complete, which one is on, and whether the
+ * run is over. `wave` stays at the last wave once everything is dead (`done`).
+ * @param {number[]} kills per enemy @param {Curve} c
+ * @returns {{completed: number, wave: number, done: boolean}}
+ */
+export function waveOf(kills, c) {
+ let completed = 0;
+ while (completed < c.waves && waveComplete(completed + 1, kills, c)) completed++;
+ const done = completed >= c.waves;
+ return { completed, wave: done ? c.waves : completed + 1, done };
+}
+
+/** the enemies alive in wave `n`: used by it and not yet killed for it @param {number} n @param {number[]} kills @param {Curve} c */
+export function aliveIn(n, kills, c) {
+ /** @type {number[]} */
+ const out = [];
+ for (let i = 0; i < sizeOf(n, c); i++) if ((kills[i] ?? 0) < killsNeeded(i, n, c)) out.push(i);
+ return out;
+}
+
+/** the enemies wave `n` uses (alive or not) @param {number} n @param {Curve} c */
+export function usedIn(n, c) {
+ /** @type {number[]} */
+ const out = [];
+ for (let i = 0; i < sizeOf(n, c); i++) out.push(i);
+ return out;
+}
+
+/** how many full heals enemy `i` should have received before wave `n` starts — one per
+ * earlier wave it was killed in @param {number} i @param {number} n @param {Curve} c */
+export function healsBefore(i, n, c) {
+ const first = firstWaveOf(i, c);
+ if (first === null || n <= first) return 0;
+ return n - first;
+}
+
+/**
+ * Where enemy `index` of a wave stands: it leaves `start` `stagger × index` seconds after
+ * the wave starts and walks straight to `goal` at `speed`, then stays. Pure in (data,
+ * time), so every peer places it identically with nothing sent.
+ * @param {{start: number[], goal: number[], waveStart: number, index: number, now: number, speed: any, stagger: any}} p
+ * @returns {number[]}
+ */
+export function enemyPosition(p) {
+ const speed = clamp(p.speed, 0.01, 100, DEFAULTS.speed);
+ const stagger = clamp(p.stagger, 0, 60, DEFAULTS.stagger);
+ const t = p.now - p.waveStart - stagger * p.index;
+ if (!(t > 0)) return p.start.slice();
+ const dx = p.goal[0] - p.start[0];
+ const dz = p.goal[2] - p.start[2];
+ const dist = Math.hypot(dx, dz);
+ if (dist < 1e-6) return p.start.slice();
+ const f = Math.min(1, (t * speed) / dist);
+ return [p.start[0] + dx * f, p.start[1], p.start[2] + dz * f];
+}
+
+/** distance on the ground plane @param {number[]} a @param {number[]} b */
+export function groundDistance(a, b) {
+ return Math.hypot(a[0] - b[0], a[2] - b[2]);
+}
+
+/** the spawn point for enemy `i`: points cycle @param {number} i @param {number[][]} points @param {number[]} fallback */
+export function spawnFor(i, points, fallback) {
+ if (!points.length) return fallback.slice();
+ return points[i % points.length].slice();
+}
+
+/** the match-log entry for a finished (or abandoned) run — deterministic from replicated
+ * inputs so every peer appends the same object @param {{at: number, waves: number, reached: number, cleared: boolean, rows: {name: string, kills: number}[]}} r */
+export function runEntry(r) {
+ return {
+ at: r.at,
+ waves: r.waves,
+ reached: r.reached,
+ cleared: !!r.cleared,
+ players: r.rows.map((x) => ({ name: String(x.name ?? ''), kills: Number(x.kills) || 0 })).sort((a, b) => b.kills - a.kills || a.name.localeCompare(b.name))
+ };
+}
+
+/** append idempotently (an entry with the same `at` is the same run), capped, newest last
+ * @param {any} log @param {ReturnType} entry @param {number} cap */
+export function appendRun(log, entry, cap = 50) {
+ const list = Array.isArray(log) ? log.filter((e) => e && e.at !== entry.at) : [];
+ list.push(entry);
+ return list.slice(-cap);
+}
diff --git a/modules/waves/src/def.js b/modules/waves/src/def.js
new file mode 100644
index 0000000..0939fd5
--- /dev/null
+++ b/modules/waves/src/def.js
@@ -0,0 +1,75 @@
+// waves — THE DEF, the shape core's scripts/author-templates.cjs builds a game template
+// from (the football precedent): objects as `{type, name, color, size, pos, physics}`,
+// a graph of `{id, type, position, data}` nodes and canonical `e--[.handle]`
+// edges, with Object Selectors naming objects by NAME (the author script resolves them).
+// One source: the HUD is hud.js; the arena's graph is the toolbox's recipe run on a fixed
+// set of enemy boxes. The integrator pastes `waves.def.json` (emitted by
+// `npm run build:waves`) into the author script and stages the template.
+
+import { arenaHud, hudGraph } from './hud.js';
+import { arenaRecipe } from './toolbox.js';
+
+const ENEMIES = 4;
+const SPAWNS = [[-6, 0.4, -10], [0, 0.4, -12], [6, 0.4, -10]];
+const GOAL = [0, 0.5, 6];
+
+/** addNodes-shaped `{nodes, edges}` (indices) -> the author script's graph shape
+ * @param {{nodes: any[], edges: any[]}} spec @param {string} prefix */
+export function toGraph(spec, prefix) {
+ const nodes = spec.nodes.map((n, i) => ({
+ id: prefix + i,
+ type: n.type,
+ position: { x: n.x, y: n.y },
+ data: { label: n.type, ...(n.data ?? {}) },
+ class: 'w-[150px]'
+ }));
+ const edges = spec.edges.map((e) => {
+ const source = prefix + e.from;
+ const target = prefix + e.to;
+ return {
+ id: 'e-' + source + '-' + target + (e.handle ? '.' + e.handle : ''),
+ source,
+ target,
+ ...(e.handle ? { targetHandle: e.handle } : {})
+ };
+ });
+ return { nodes, edges };
+}
+
+export function wavesDef() {
+ const enemyNames = Array.from({ length: ENEMIES }, (_, i) => 'Enemy ' + (i + 1));
+ const objects = [
+ { type: 'box', name: 'Ground', color: 0x2b2f36, size: [30, 0.1, 40], pos: [0, -0.05, -2], roughness: 0.95, physics: { mode: 'static', friction: 0.7 } },
+ { type: 'box', name: 'Goal', color: 0x3b7dd8, size: [1.6, 1, 1.6], pos: GOAL, physics: { mode: 'static' } },
+ ...SPAWNS.map((p, i) => ({ type: 'box', name: 'Spawn ' + (i + 1), color: 0xc94a4a, size: [0.6, 0.1, 0.6], pos: p, physics: { mode: 'static' } })),
+ ...enemyNames.map((name, i) => ({ type: 'box', name, color: 0xff9c6b, size: [0.8, 0.8, 0.8], pos: [-6 + i * 1.5, 0.4, -8], physics: { mode: 'dynamic', mass: 1, friction: 0.6 } })),
+ { type: 'box', name: 'Home', color: 0x6fcf7a, size: [1, 0.1, 1], pos: [0, 0.05, 9], physics: { mode: 'static' } }
+ ];
+ // selectors name objects by NAME in a def; the recipe takes those in place of uuids
+ const arena = arenaRecipe({
+ enemies: enemyNames,
+ goal: 'Goal',
+ row: 0,
+ playerHealthId: null,
+ options: { name: 'enemy', waves: 3, sizeStart: 2, sizeStep: 1, interval: 3, hp: 3, source: 'hit', speed: 1.5, reach: 1.5, enemyDamage: 1, enemyRate: 1, playerName: 'me', playerHp: 10, playerRegen: 0.5, spawnPrefix: 'Spawn' }
+ });
+ const hud = hudGraph({ name: 'enemy', playerName: 'me', x: 60, y: 40 + (arena.rows + 1) * 200 });
+ const a = toGraph(arena, 'wa');
+ const h = toGraph(hud, 'wh');
+ return {
+ kind: 'game',
+ slug: 'waves',
+ title: 'Waves',
+ description: 'Wave survival: hold the goal against waves of enemies walking in from the spawn points. Knock them down; the round ends when the last wave falls.',
+ license: 'CC0-1.0',
+ author: 'theprototype',
+ tags: ['vr', 'co-op', 'survival'],
+ modules: [{ id: 'health', version: '1.0.0' }, { id: 'waves', version: '1.0.0' }],
+ installModules: ['health', 'waves'],
+ env: { preset: 'dusk', exposure: 0.9 },
+ physics: { knock: { enabled: true, gain: 1, maxSpeed: 10, spin: 0.8 }, play: { interaction: 'grab', grounded: true, simOnPlay: true } },
+ graphs: { scene: { nodes: [...a.nodes, ...h.nodes], edges: [...a.edges, ...h.edges] } },
+ hud: arenaHud(),
+ objects
+ };
+}
diff --git a/modules/waves/src/engine.js b/modules/waves/src/engine.js
new file mode 100644
index 0000000..5cdfd2e
--- /dev/null
+++ b/modules/waves/src/engine.js
@@ -0,0 +1,353 @@
+// waves — THE ENGINE: one 10 Hz sweep that derives the wave from the enemies' hit
+// counters, heals the survivors into the next wave with LOCAL pulses, walks the living
+// enemies along a line every peer computes, and fires the events a graph reacts to.
+//
+// Nothing here is sent. The enemies are pre-placed objects carrying the health module's
+// chains (their counters travel with their stamps in the trigger-log handshake), the
+// wave is a pure function of those counters (src/curve.js), and the enemy positions are
+// a pure function of (the wave's start stamp, the clock). A late joiner lands on the same
+// wave because it lands on the same counters.
+//
+// FIRST SIGHT NEVER FIRES: a run that was already under way when this peer first saw the
+// waves node fires no `wave`/`over` event and appends no log entry here — those moments
+// were somebody else's to witness.
+
+import { curveOf, sizeOf, killsOf, waveOf, aliveIn, usedIn, healsBefore, enemyPosition, spawnFor, runEntry, appendRun, clamp, DEFAULTS } from './curve.js';
+
+const SWEEP = 0.1;
+const LOG_PREFIX = 'waves:';
+/** the game shell stamps its round in session milliseconds; the trigger log and
+ * api.now() are seconds of day — one conversion, here @param {number} ms */
+const toSeconds = (ms) => (ms / 1000) % 86400;
+const KILLS_ROW = 'kills';
+
+/** @param {any} api */
+export function createWavesEngine(api) {
+ /** @type {Map} waves node id -> derived state */
+ const state = new Map();
+ /** waves node id -> the wave we last saw (for the `wave` event edge) */
+ const waveSeen = new Map();
+ /** waves node id -> done we last saw */
+ const doneSeen = new Map();
+ /** waves node id -> was the run active last sweep */
+ const runSeen = new Map();
+ /** enemy uuid -> its parked pose (where it stands when no run is on) */
+ const parked = new Map();
+ /** health id -> the heal count we expect the counter to show once our local pulses
+ * land — flowValues republishes ~6/s, and firing again before it does would heal
+ * twice */
+ const healExpected = new Map();
+ /** the listeners the toolbox uses @type {Set<() => void>} */
+ const listeners = new Set();
+
+ const now = () => api.now();
+ /** @param {string|null} uuid */
+ function objectOf(uuid) {
+ if (!uuid) return null;
+ return api.objectsGroup()?.getObjectByProperty('uuid', uuid) ?? null;
+ }
+ /** @param {any} object @returns {number[]} */
+ const worldPos = (object) => object.getWorldPosition(new api.THREE.Vector3()).toArray();
+
+ // ---- graph reads ------------------------------------------------------------------
+ function graphView() {
+ const nodes = api.flow.nodes();
+ const edges = api.flow.edges();
+ /** @type {Map} */
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ /** @type {Map} */
+ const byTarget = new Map();
+ for (const e of edges) {
+ if (!byTarget.has(e.target)) byTarget.set(e.target, []);
+ byTarget.get(e.target)?.push(e);
+ }
+ return { nodes, edges, byId, byTarget };
+ }
+
+ /** the ids wired into `nodeId` on `handle` @param {any} g @param {string} nodeId @param {string} handle */
+ const into = (g, nodeId, handle) => (g.byTarget.get(nodeId) ?? []).filter((/** @type {any} */ e) => (e.targetHandle ?? null) === handle).map((/** @type {any} */ e) => e.source);
+
+ /** the object an Object Selector wired into `handle` names @param {any} g @param {any} node @param {string} handle */
+ function selectorInto(g, node, handle) {
+ for (const id of into(g, node.id, handle)) {
+ const src = g.byId.get(id);
+ if (src?.type !== 'objectselector') continue;
+ const selected = String(src.data?.selected ?? '');
+ if (selected && selected !== '-None-') return selected;
+ }
+ if (handle === 'target' && node.graphId && node.graphId !== 'scene') return node.graphId;
+ return null;
+ }
+
+ /** @param {any} node @returns {string} */
+ const nameOf = (node) => String(node?.data?.name ?? '').trim() || DEFAULTS.name;
+
+ /**
+ * The enemies of a run: every object-scoped health node carrying the run's name, in a
+ * DETERMINISTIC order (object name, then node id) so enemy `i` is the same enemy on
+ * every peer. Each brings its hit counter (the ledger), its heal counter and its max.
+ * @param {any} g @param {string} name
+ */
+ function enemiesOf(g, name) {
+ /** @type {any[]} */
+ const out = [];
+ for (const node of g.nodes) {
+ if (node.type !== 'health' || node.data?.scope === 'player') continue;
+ if (nameOf(node) !== name) continue;
+ const uuid = selectorInto(g, node, 'target');
+ const object = objectOf(uuid);
+ const damageCounters = into(g, node.id, 'damage').filter((id) => g.byId.get(id)?.type === 'counter');
+ const healCounters = into(g, node.id, 'heal').filter((id) => g.byId.get(id)?.type === 'counter');
+ let hits = 0;
+ let lastHit = null;
+ for (const id of damageCounters) {
+ hits += Number(api.flow.nodeValue(id)) || 0;
+ const stamp = api.flow.triggerStamp(id);
+ if (stamp && (lastHit === null || stamp.stamp > lastHit)) lastHit = stamp.stamp;
+ }
+ let heals = 0;
+ for (const id of healCounters) heals += Number(api.flow.nodeValue(id)) || 0;
+ const max = clamp(node.data?.max, 1, 1e6, 5);
+ out.push({
+ healthId: node.id,
+ uuid,
+ label: object?.name || (uuid ? uuid.slice(0, 8) + '…' : '(no target)'),
+ max,
+ hits,
+ heals,
+ lastHit,
+ kills: killsOf(hits, max),
+ hp: Math.max(0, Math.min(max, max - (hits - heals))),
+ healNodes: g.nodes.filter((n) => n.type === 'heal' && healCounters.some((c) => (g.byTarget.get(c) ?? []).some((/** @type {any} */ e) => e.source === n.id))).map((n) => n.id)
+ });
+ }
+ out.sort((a, b) => a.label.localeCompare(b.label) || a.healthId.localeCompare(b.healthId));
+ return out;
+ }
+
+ /** spawn points: objects whose name starts with the prefix, sorted by name @param {string} prefix */
+ function spawnPoints(prefix) {
+ const p = String(prefix ?? '').trim();
+ if (!p) return [];
+ const root = api.objectsGroup();
+ const found = (root?.children ?? []).filter((/** @type {any} */ c) => String(c.name ?? '').startsWith(p));
+ found.sort((/** @type {any} */ a, /** @type {any} */ b) => String(a.name).localeCompare(String(b.name)));
+ return found.map((/** @type {any} */ c) => worldPos(c));
+ }
+
+ // ---- the derivation ---------------------------------------------------------------
+ /** @param {any} node @param {any} g */
+ function derive(node, g) {
+ const d = node.data ?? {};
+ const name = nameOf(node);
+ const enemies = enemiesOf(g, name);
+ const curve = curveOf(d, enemies.length);
+ const kills = enemies.map((e) => e.kills);
+ const { completed, wave, done } = waveOf(kills, curve);
+ const cutoff = api.game.roundCutoff();
+ const underway = api.game.roundUnderway();
+ const running = underway && typeof cutoff === 'number' && Number.isFinite(cutoff) && enemies.length > 0 && !done;
+ const interval = clamp(d.interval, 0, 600, DEFAULTS.interval);
+ // when the current wave started: the round for wave 1, the previous wave's last
+ // kill plus the interval after that — both replicated stamps. The round's stamp is
+ // session MILLISECONDS; the trigger log and api.now() are seconds of day.
+ let waveStart = typeof cutoff === 'number' && Number.isFinite(cutoff) ? toSeconds(cutoff) : null;
+ let clearedAt = null;
+ if (completed > 0) {
+ let last = null;
+ for (const i of usedIn(completed, curve)) {
+ const e = enemies[i];
+ if (e?.lastHit !== null && e?.lastHit !== undefined && (last === null || e.lastHit > last)) last = e.lastHit;
+ }
+ clearedAt = last;
+ if (last !== null && !done) waveStart = last + interval;
+ }
+ const started = running && waveStart !== null && now() >= waveStart;
+ const goalUuid = selectorInto(g, node, 'goal');
+ const goalObject = objectOf(goalUuid);
+ return {
+ id: node.id,
+ name,
+ curve,
+ enemies,
+ wave,
+ completed,
+ done,
+ running,
+ started,
+ waveStart,
+ clearedAt,
+ interval,
+ alive: running || done ? aliveIn(wave, kills, curve).length : sizeOf(wave, curve),
+ size: sizeOf(wave, curve),
+ goal: goalObject ? worldPos(goalObject) : null,
+ spawns: spawnPoints(d.spawnPrefix ?? 'Spawn'),
+ speed: clamp(d.speed, 0.01, 100, DEFAULTS.speed),
+ stagger: clamp(d.stagger, 0, 60, DEFAULTS.stagger),
+ reach: clamp(d.reach, 0.1, 100, DEFAULTS.reach)
+ };
+ }
+
+ // ---- heals into the next wave ----------------------------------------------------------
+ /** every peer fires the LOCAL heal pulses the current wave owes each enemy it uses —
+ * idempotent against the heal counter, so a joiner (whose counter arrived healed)
+ * fires nothing @param {ReturnType} s */
+ function healSweep(s) {
+ if (!s.started) return;
+ for (const i of usedIn(s.wave, s.curve)) {
+ const e = s.enemies[i];
+ if (!e || !e.healNodes.length) continue;
+ const expected = healExpected.get(e.healthId) ?? 0;
+ const heals = Math.max(e.heals, expected);
+ const owed = healsBefore(i, s.wave, s.curve) * e.max - heals;
+ if (owed <= 0) continue;
+ const target = e.healNodes[0];
+ const fire = Math.min(owed, 100);
+ for (let k = 0; k < fire; k++)
+ api.fireNodeTrigger('heal', (/** @type {any} */ _d, /** @type {string} */ id) => id === target, { replicate: false });
+ healExpected.set(e.healthId, heals + fire);
+ }
+ }
+
+ // ---- movement --------------------------------------------------------------------------
+ /** @param {ReturnType} s */
+ function moveSweep(s) {
+ const used = usedIn(s.wave, s.curve);
+ const alive = new Set(aliveIn(s.wave, s.enemies.map((e) => e.kills), s.curve));
+ for (let k = 0; k < s.enemies.length; k++) {
+ const e = s.enemies[k];
+ const object = objectOf(e.uuid);
+ if (!object) continue;
+ if (!parked.has(e.uuid)) parked.set(e.uuid, object.position.toArray());
+ const home = parked.get(e.uuid) ?? object.position.toArray();
+ const index = used.indexOf(k);
+ const walking = s.running && s.started && index >= 0 && alive.has(k) && s.goal;
+ if (walking && s.waveStart !== null) {
+ const start = spawnFor(index, s.spawns, home);
+ object.position.fromArray(enemyPosition({ start, goal: /** @type {number[]} */ (s.goal), waveStart: s.waveStart, index, now: now(), speed: s.speed, stagger: s.stagger }));
+ } else if (!s.running) {
+ // no run: everyone stands where the scene put them
+ object.position.fromArray(home);
+ } else if (index >= 0 && !alive.has(k) === false) {
+ // used, alive, but no goal: at its spawn point
+ object.position.fromArray(spawnFor(index, s.spawns, home));
+ }
+ object.updateMatrixWorld?.();
+ }
+ // a run that ended (or was abandoned) lets go of the parked poses once restored
+ if (!s.running) for (const e of s.enemies) parked.delete(e.uuid);
+ }
+
+ // ---- events + the run log --------------------------------------------------------------
+ /** @param {string} name @param {string} kind */
+ function emit(name, kind) {
+ api.fireNodeTrigger('wavesevent', (/** @type {any} */ d) => nameOf({ data: d }) === name && String(d?.event ?? 'wave') === kind, { replicate: false });
+ }
+
+ /** @param {ReturnType} s @param {boolean} firstSight */
+ function edges(s, firstSight) {
+ const prevWave = waveSeen.get(s.id);
+ const prevDone = doneSeen.get(s.id);
+ const prevRun = runSeen.get(s.id);
+ waveSeen.set(s.id, s.wave);
+ doneSeen.set(s.id, s.done);
+ runSeen.set(s.id, s.running);
+ if (firstSight) return;
+ if (s.running && prevRun === false) emit(s.name, 'start');
+ if (typeof prevWave === 'number' && s.wave > prevWave && !s.done) emit(s.name, 'wave');
+ if (s.done && prevDone === false) {
+ emit(s.name, 'over');
+ logRun(s);
+ }
+ }
+
+ /** the run into gameState.vars — the same entry from every peer (deterministic inputs),
+ * appended idempotently by its `at` @param {ReturnType} s */
+ function logRun(s) {
+ if (typeof s.clearedAt !== 'number') return;
+ const names = new Map((api.peerNames?.() ?? []).map((/** @type {any} */ p) => [p.id, p.label ?? p.name]));
+ const rows = (api.peerVars.all(KILLS_ROW) ?? []).map((/** @type {any} */ r) => ({ name: names.get(r.id) ?? 'peer ' + String(r.id).slice(0, 4), kills: r.value }));
+ const entry = runEntry({ at: s.clearedAt, waves: s.curve.waves, reached: s.wave, cleared: true, rows });
+ const key = LOG_PREFIX + s.name;
+ const held = api.game.getVar(key, null);
+ api.game.setVar(key, { runs: appendRun(held && typeof held === 'object' ? held.runs : [], entry) });
+ }
+
+ /** @param {string} name */
+ function runLog(name) {
+ const v = api.game.getVar(LOG_PREFIX + name, null);
+ return v && typeof v === 'object' && Array.isArray(v.runs) ? v.runs : [];
+ }
+
+ // ---- the sweep -------------------------------------------------------------------------
+ function sweep() {
+ const g = graphView();
+ const live = new Set();
+ for (const node of g.nodes) {
+ if (node.type !== 'waves') continue;
+ live.add(node.id);
+ const firstSight = !state.has(node.id);
+ const s = derive(node, g);
+ state.set(node.id, s);
+ edges(s, firstSight);
+ healSweep(s);
+ moveSweep(s);
+ }
+ for (const id of [...state.keys()])
+ if (!live.has(id)) {
+ state.delete(id);
+ waveSeen.delete(id);
+ doneSeen.delete(id);
+ runSeen.delete(id);
+ }
+ // once the counter shows what we fired (or a reset took it below), forget the expectation
+ for (const s of state.values())
+ for (const e of s.enemies) {
+ const expected = healExpected.get(e.healthId);
+ if (expected !== undefined && (e.heals >= expected || (e.heals === 0 && e.hits === 0))) healExpected.delete(e.healthId);
+ }
+ for (const fn of listeners) fn();
+ }
+
+ let lastSweep = -1;
+ api.registerFrameTask(() => {
+ const t = performance.now() / 1000;
+ if (t - lastSweep < SWEEP) return;
+ lastSweep = t;
+ try {
+ sweep();
+ } catch (error) {
+ console.warn('[waves] sweep failed', error);
+ }
+ });
+
+ /** @param {string} name */
+ function read(name) {
+ for (const s of state.values()) if (s.name === name) return s;
+ return null;
+ }
+
+ function clear() {
+ state.clear();
+ waveSeen.clear();
+ doneSeen.clear();
+ runSeen.clear();
+ parked.clear();
+ healExpected.clear();
+ }
+
+ return {
+ state,
+ stateOf: (/** @type {string} */ id) => state.get(id) ?? null,
+ read,
+ all: () => [...state.values()],
+ graphView,
+ runLog,
+ sweep,
+ onChange: (/** @type {() => void} */ fn) => {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ },
+ clear
+ };
+}
diff --git a/modules/waves/src/hud.js b/modules/waves/src/hud.js
new file mode 100644
index 0000000..840888d
--- /dev/null
+++ b/modules/waves/src/hud.js
@@ -0,0 +1,91 @@
+// waves — THE DOM HUD, as data (the Towers / Stars Room / football shape): a menu that
+// starts the round, a play screen with the wave, the enemies left and the player's
+// health bar, and an over screen with the run log. The screens follow the game shell
+// (`showWhile`), so a late joiner lands on the right one with no wiring. The lists and the
+// bar are driven by ordinary nodes (see `hudGraph`): Waves Value -> HUD Text, Health
+// Value -> HUD Bar, Leaderboard on the `kills` rows.
+
+const PANEL = { bg: 'rgba(24, 20, 30, 0.92)', radius: 16, border: '1px solid rgba(220, 120, 120, 0.25)' };
+const BUTTON = (bg) => ({ size: 16, weight: '600', bg, color: '#ffffff', radius: 10 });
+
+/** the def's `hud` field */
+export function arenaHud() {
+ return {
+ scene: {
+ active: '',
+ changedAt: 0,
+ screens: [
+ {
+ id: 'menu',
+ name: 'Menu',
+ showWhile: 'menu',
+ input: 'menu',
+ elements: [
+ { id: 'menu-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 460, h: 340, z: 0, label: '', style: PANEL },
+ { id: 'title', kind: 'text', anchor: 'center', x: 0, y: -120, w: 400, h: 54, z: 1, label: 'WAVES', style: { size: 40, weight: '700', color: '#ff9c6b', align: 'center' } },
+ { id: 'subtitle', kind: 'text', anchor: 'center', x: 0, y: -66, w: 420, h: 44, z: 1, label: 'Hold the goal. Every wave brings more enemies; the round ends when the last one falls.', style: { size: 14, color: '#e6dede', align: 'center' }, wrap: true },
+ { id: 'wv-start', kind: 'button', anchor: 'center', x: 0, y: 10, w: 220, h: 48, z: 1, label: 'Start', enabled: true, style: BUTTON('#c94a4a') },
+ { id: 'wv-log', kind: 'list', anchor: 'center', x: 0, y: 90, w: 420, h: 80, z: 1, label: '', rows: [], style: { size: 12, color: '#d6c8c8', align: 'center' } }
+ ]
+ },
+ {
+ id: 'hud',
+ name: 'HUD',
+ showWhile: 'playing',
+ input: 'game',
+ elements: [
+ { id: 'wv-wave', kind: 'text', anchor: 'top-center', x: 0, y: 14, w: 320, h: 34, z: 1, label: 'Wave 1', style: { size: 22, weight: '700', color: '#ff9c6b', align: 'center' } },
+ { id: 'wv-left', kind: 'text', anchor: 'top-center', x: 0, y: 48, w: 320, h: 24, z: 1, label: '', style: { size: 14, color: '#e6dede', align: 'center' } },
+ { id: 'wv-hp', kind: 'bar', anchor: 'bottom-center', x: 0, y: 24, w: 320, h: 18, z: 1, label: '', min: 0, max: 1, value: 1, orientation: 'horizontal', showPercent: false, style: { color: '#6fcf7a', bg: 'rgba(0,0,0,0.35)', radius: 9 } },
+ { id: 'wv-kills', kind: 'list', anchor: 'top-right', x: 16, y: 14, w: 220, h: 100, z: 1, label: '', rows: [], style: { size: 12, color: '#d6c8c8', align: 'right' } }
+ ]
+ },
+ {
+ id: 'over',
+ name: 'Round over',
+ showWhile: 'over',
+ input: 'menu',
+ elements: [
+ { id: 'over-panel', kind: 'panel', anchor: 'center', x: 0, y: 0, w: 460, h: 340, z: 0, label: '', style: PANEL },
+ { id: 'over-title', kind: 'text', anchor: 'center', x: 0, y: -120, w: 420, h: 40, z: 1, label: 'ARENA CLEARED', style: { size: 30, weight: '700', color: '#ff9c6b', align: 'center' } },
+ { id: 'wv-kills-over', kind: 'list', anchor: 'center', x: 0, y: -50, w: 420, h: 90, z: 1, label: '', rows: [], style: { size: 14, color: '#e6dede', align: 'center' } },
+ { id: 'wv-log-over', kind: 'list', anchor: 'center', x: 0, y: 40, w: 420, h: 70, z: 1, label: '', rows: [], style: { size: 12, color: '#d6c8c8', align: 'center' } },
+ { id: 'wv-again', kind: 'button', anchor: 'center', x: 0, y: 115, w: 220, h: 44, z: 1, label: 'Again', enabled: true, style: BUTTON('#3b7dd8') }
+ ]
+ }
+ ]
+ }
+ };
+}
+
+/**
+ * The nodes that drive the HUD, in addNodes shape (indices in `edges` refer to `nodes`).
+ * A HUD Button pulses through a Delay (DEVX #22: a hudbutton stamp cannot reach a module
+ * input, and Set Game State reads it directly).
+ * @param {{name: string, playerName: string, x?: number, y?: number}} o
+ */
+export function hudGraph(o) {
+ const x = o.x ?? 60;
+ const y = o.y ?? 40;
+ const nodes = [
+ { type: 'hudbutton', x, y, data: { element: 'wv-start' } },
+ { type: 'setgamestate', x: x + 220, y, data: { state: 'playing', outcome: '' } },
+ { type: 'hudbutton', x, y: y + 100, data: { element: 'wv-again' } },
+ { type: 'setgamestate', x: x + 220, y: y + 100, data: { state: 'playing', outcome: '' } },
+ { type: 'wavesvalue', x, y: y + 200, data: { name: o.name, read: 'wave' } },
+ { type: 'hudtext', x: x + 220, y: y + 200, data: { element: 'wv-wave', format: 'Wave {v}', decimals: 0 } },
+ { type: 'wavesvalue', x, y: y + 300, data: { name: o.name, read: 'left' } },
+ { type: 'hudtext', x: x + 220, y: y + 300, data: { element: 'wv-left', format: '{v} left', decimals: 0 } },
+ { type: 'healthvalue', x, y: y + 400, data: { name: o.playerName, read: 'fraction' } },
+ { type: 'hudbar', x: x + 220, y: y + 400, data: { element: 'wv-hp', min: 0, max: 1 } },
+ { type: 'leaderboard', x, y: y + 500, data: { variable: 'kills', element: 'wv-kills,wv-kills-over', limit: 8 } }
+ ];
+ const edges = [
+ { from: 0, to: 1, handle: 'trigger' },
+ { from: 2, to: 3, handle: 'trigger' },
+ { from: 4, to: 5, handle: 'value' },
+ { from: 6, to: 7, handle: 'value' },
+ { from: 8, to: 9, handle: 'value' }
+ ];
+ return { nodes, edges };
+}
diff --git a/modules/waves/src/index.js b/modules/waves/src/index.js
new file mode 100644
index 0000000..936866b
--- /dev/null
+++ b/modules/waves/src/index.js
@@ -0,0 +1,89 @@
+// waves — the module entry. Bundled to one self-contained module.js by esbuild
+// (npm run build:waves); everything outside comes through `api`.
+//
+// The game in one breath: pre-placed enemies carry the health module's chains; a Waves
+// node derives the wave from their hit counters (each wave heals them back with local
+// pulses, so a counter reads hp × kills), walks the living ones from their spawn points
+// to the goal on the synced clock, and when the last wave's last enemy falls every peer
+// fires `over` and appends the same run to gameState.vars. Kills sit on the killer's
+// own peerVars row (the health module credits them). Needs the health module.
+
+import { createWavesEngine } from './engine.js';
+import { registerNodes } from './nodes.js';
+import { registerToolbox } from './toolbox.js';
+import { arenaHud, hudGraph } from './hud.js';
+import { DEFAULTS } from './curve.js';
+
+export default {
+ id: 'waves',
+ name: 'Waves',
+ version: '1.0.0',
+ description:
+ 'Wave survival on the health module: enemies walk from spawn points to a goal, a wave ends when its last enemy dies, the run is over when the last wave does — derived on every peer, no authority.',
+
+ /** @param {any} api the module SDK surface */
+ register(api) {
+ if (!api.flow?.addNodes || !api.game?.roundCutoff || !api.peerVars?.all || !api.registerValueNode) {
+ api.toast('Waves needs a newer app build (the game SDK seams are missing)');
+ return;
+ }
+ const engine = createWavesEngine(api);
+ registerNodes(api, engine);
+ const toolbox = registerToolbox(api, engine);
+
+ api.hud.registerDebugLine(() => {
+ const runs = engine.all();
+ if (!runs.length) return null;
+ return runs
+ .map((s) => 'waves (' + s.name + '): ' + (s.done ? 'cleared' : 'wave ' + s.wave + '/' + s.curve.waves + (s.running ? ', ' + s.alive + ' left' : ', idle')))
+ .join(' · ');
+ });
+ api.hud.registerAction({
+ key: 'showwave',
+ label: 'Show the wave',
+ group: 'Data',
+ role: 'drives',
+ node: '',
+ via: { node: 'wavesvalue', data: { name: DEFAULTS.name, read: 'wave' }, handle: 'value' },
+ hint: 'The current wave, derived from the enemies’ hit counters on every peer.'
+ });
+ api.hud.registerAction({
+ key: 'showleft',
+ label: 'Show enemies left',
+ group: 'Data',
+ role: 'drives',
+ node: '',
+ via: { node: 'wavesvalue', data: { name: DEFAULTS.name, read: 'left' }, handle: 'value' },
+ hint: 'How many of this wave are still standing.'
+ });
+
+ api.onSceneClear(() => engine.clear());
+
+ if (typeof window !== 'undefined')
+ /** @type {any} */ (window).__waves = {
+ api,
+ engine,
+ toolbox,
+ hud: arenaHud,
+ hudGraph,
+ snapshot: () =>
+ engine.all().map((s) => ({
+ id: s.id,
+ name: s.name,
+ wave: s.wave,
+ completed: s.completed,
+ done: s.done,
+ running: s.running,
+ started: s.started,
+ alive: s.alive,
+ size: s.size,
+ waves: s.curve.waves,
+ waveStart: s.waveStart,
+ goal: s.goal,
+ spawns: s.spawns.length,
+ enemies: s.enemies.map((e) => ({ uuid: e.uuid, label: e.label, hits: e.hits, heals: e.heals, kills: e.kills, hp: e.hp })),
+ log: engine.runLog(s.name)
+ }))
+ };
+ }
+};
diff --git a/modules/waves/src/nodes.js b/modules/waves/src/nodes.js
new file mode 100644
index 0000000..ff6b7db
--- /dev/null
+++ b/modules/waves/src/nodes.js
@@ -0,0 +1,86 @@
+// waves — THE NODE FAMILY (group "Waves").
+//
+// waves VALUE (the current wave). Owns the run: `name` (the enemies' health
+// name), `waves`, `sizeStart`/`sizeStep` (the size curve), `interval`
+// (seconds between waves), `speed`/`stagger` (the walk), `reach`,
+// `spawnPrefix` (objects named Spawn… are the spawn points). Input `goal`
+// (an object the enemies walk to). Enemies = every health node carrying
+// the name; the wave is derived from their hit counters.
+// wavesvalue VALUE. `wave` / `left` (alive in this wave) / `size` / `waves` / `done`.
+// wavesevent EVENT out. `start` / `wave` / `over`, fired locally on every peer from
+// the same derived edge — wire `over` into Set Game State (over).
+
+import { DEFAULTS } from './curve.js';
+
+export const READS = ['wave', 'left', 'size', 'waves', 'done'];
+export const EVENTS = ['wave', 'over', 'start'];
+
+/** @param {any} api @param {ReturnType} engine */
+export function registerNodes(api, engine) {
+ api.registerNodeGroup({
+ group: 'Waves',
+ items: [
+ {
+ type: 'waves',
+ label: 'Waves',
+ defaults: { ...DEFAULTS, spawnPrefix: 'Spawn', goal: '' },
+ params: [
+ { key: 'name', kind: 'text', placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: 'waves', kind: 'range', min: 1, max: 50, step: 1 },
+ { key: 'sizeStart', kind: 'range', min: 1, max: 50, step: 1 },
+ { key: 'sizeStep', kind: 'range', min: 0, max: 20, step: 1 },
+ { key: 'interval', kind: 'range', min: 0, max: 60, step: 0.5 },
+ { key: 'speed', kind: 'range', min: 0.1, max: 20, step: 0.1 },
+ { key: 'stagger', kind: 'range', min: 0, max: 10, step: 0.1 },
+ { key: 'reach', kind: 'range', min: 0.5, max: 10, step: 0.5 },
+ { key: 'spawnPrefix', kind: 'text', placeholder: 'Spawn', maxLength: 40 }
+ ]
+ },
+ {
+ type: 'wavesvalue',
+ label: 'Waves Value',
+ defaults: { name: DEFAULTS.name, read: 'wave' },
+ params: [
+ { key: 'name', kind: 'text', placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: 'read', kind: 'select', options: READS }
+ ]
+ },
+ {
+ type: 'wavesevent',
+ label: 'Waves Event',
+ defaults: { name: DEFAULTS.name, event: 'wave' },
+ params: [
+ { key: 'name', kind: 'text', placeholder: DEFAULTS.name, maxLength: 40 },
+ { key: 'event', kind: 'select', options: EVENTS }
+ ]
+ }
+ ]
+ });
+
+ api.registerValueNode(
+ 'waves',
+ (/** @type {any} */ _data, /** @type {number} */ _time, /** @type {any} */ ctx) => engine.stateOf(ctx?.id)?.wave ?? 0,
+ { vtype: 'number', inputs: { goal: 'object' } }
+ );
+ api.registerValueNode(
+ 'wavesvalue',
+ (/** @type {any} */ data) => {
+ const s = engine.read(String(data?.name ?? '').trim() || DEFAULTS.name);
+ if (!s) return 0;
+ switch (String(data?.read ?? 'wave')) {
+ case 'left':
+ return s.alive;
+ case 'size':
+ return s.size;
+ case 'waves':
+ return s.curve.waves;
+ case 'done':
+ return s.done ? 1 : 0;
+ default:
+ return s.wave;
+ }
+ },
+ { vtype: 'number' }
+ );
+ api.registerValueNode('wavesevent', () => 0, { vtype: 'event' });
+}
diff --git a/modules/waves/src/toolbox.js b/modules/waves/src/toolbox.js
new file mode 100644
index 0000000..5a3d3a5
--- /dev/null
+++ b/modules/waves/src/toolbox.js
@@ -0,0 +1,248 @@
+// waves — THE ARENA BUILDER (a toolbox): one form makes the selected objects the
+// enemies — or adds N boxes — and authors, through api.flow.addNodes, every chain the
+// run stands on: the health module's chain per enemy, a heal chain per enemy (the wave
+// heals them back), a zone chain per enemy into the player's health (an enemy in reach
+// hurts the player), the Waves node with its goal, the player's health, and the game
+// shell wiring (`over` -> Set Game State). All of it is ordinary replicated graph.
+// LOCAL always; nothing here goes on the wire except through those paths.
+
+import { DEFAULTS } from './curve.js';
+
+const SELECT_CSS =
+ 'background:rgba(0,0,0,0.25);color:inherit;border:1px solid rgba(255,255,255,0.15);' +
+ 'border-radius:4px;font-size:11px;padding:1px 3px;';
+const INPUT_CSS = SELECT_CSS + 'width:100%;';
+const COL = 220;
+const ROW = 200;
+const X0 = 60;
+const Y0 = 40;
+
+/** @param {string} tag @param {Record=} props @param {string=} css */
+function elem(tag, props, css) {
+ const node = document.createElement(tag);
+ Object.assign(node, props ?? {});
+ if (css) node.setAttribute('style', css);
+ return node;
+}
+/** @param {string[]} options @param {string} value */
+function select(options, value) {
+ const node = /** @type {HTMLSelectElement} */ (elem('select', {}, SELECT_CSS));
+ for (const option of options) node.appendChild(elem('option', { value: option, textContent: option }));
+ node.value = value;
+ return node;
+}
+
+/**
+ * THE ARENA, as addNodes specs. Pure: given the enemy uuids and the options it returns
+ * the nodes and edges, row by row, so a node test can hold it to its shape.
+ * @param {{enemies: string[], goal: string|null, options: any, row: number, playerHealthId: string|null}} spec
+ */
+export function arenaRecipe(spec) {
+ const o = spec.options;
+ const name = String(o.name ?? '').trim() || DEFAULTS.name;
+ const playerName = String(o.playerName ?? '').trim() || 'me';
+ /** @type {any[]} */
+ const nodes = [];
+ /** @type {any[]} */
+ const edges = [];
+ let row = spec.row;
+ const y = () => Y0 + row * ROW;
+
+ // the run itself, its goal, the shell wiring, and the player's health (once)
+ const wavesIdx = nodes.push({ type: 'waves', x: X0, y: y(), data: { name, waves: o.waves, sizeStart: o.sizeStart, sizeStep: o.sizeStep, interval: o.interval, speed: o.speed, stagger: DEFAULTS.stagger, reach: o.reach, spawnPrefix: o.spawnPrefix } }) - 1;
+ if (spec.goal) {
+ const sel = nodes.push({ type: 'objectselector', x: X0 - 0, y: y() + 100, data: { selected: spec.goal } }) - 1;
+ edges.push({ from: sel, to: wavesIdx, handle: 'goal' });
+ }
+ const over = nodes.push({ type: 'wavesevent', x: X0 + COL, y: y(), data: { name, event: 'over' } }) - 1;
+ const setOver = nodes.push({ type: 'setgamestate', x: X0 + 2 * COL, y: y(), data: { state: 'over', outcome: 'won' } }) - 1;
+ edges.push({ from: over, to: setOver, handle: 'trigger' });
+ let playerRef = /** @type {number|string|null} */ (spec.playerHealthId);
+ if (!playerRef) {
+ const pd = nodes.push({ type: 'damage', x: X0 + 3 * COL, y: y(), data: { amount: 1, source: 'wired' } }) - 1;
+ const pc = nodes.push({ type: 'counter', x: X0 + 4 * COL, y: y(), data: { op: 'up', step: 1 } }) - 1;
+ const ph = nodes.push({ type: 'health', x: X0 + 5 * COL, y: y(), data: { name: playerName, scope: 'player', max: o.playerHp, regen: o.playerRegen, deathAction: 'respawn', respawnDelay: 3 } }) - 1;
+ const pr = nodes.push({ type: 'healthreset', x: X0 + 3 * COL, y: y() + 100, data: { name: playerName } }) - 1;
+ edges.push({ from: pd, to: pc, handle: 'pulse' }, { from: pc, to: ph, handle: 'damage' }, { from: pr, to: pc, handle: 'reset' });
+ playerRef = ph;
+ }
+ row++;
+
+ // per enemy: the health chain, the heal chain, the zone chain into the player
+ for (const uuid of spec.enemies) {
+ const d = nodes.push({ type: 'damage', x: X0, y: y(), data: { amount: 1, source: o.source, scale: o.source === 'hit' ? 'speed' : 'none', speedRef: 3 } }) - 1;
+ const c = nodes.push({ type: 'counter', x: X0 + COL, y: y(), data: { op: 'up', step: 1 } }) - 1;
+ const h = nodes.push({ type: 'health', x: X0 + 2 * COL, y: y(), data: { name, scope: 'object', max: o.hp, deathAction: 'hide', respawnDelay: 3 } }) - 1;
+ const s = nodes.push({ type: 'objectselector', x: X0 + 3 * COL, y: y(), data: { selected: uuid } }) - 1;
+ const r = nodes.push({ type: 'healthreset', x: X0, y: y() + 100, data: { name } }) - 1;
+ const he = nodes.push({ type: 'heal', x: X0 + COL, y: y() + 100, data: { amount: 1 } }) - 1;
+ const hc = nodes.push({ type: 'counter', x: X0 + 2 * COL, y: y() + 100, data: { op: 'up', step: 1 } }) - 1;
+ const z = nodes.push({ type: 'damage', x: X0 + 4 * COL, y: y(), data: { amount: o.enemyDamage, source: 'zone', perSecond: o.enemyRate, radius: o.reach } }) - 1;
+ const zs = nodes.push({ type: 'objectselector', x: X0 + 4 * COL, y: y() + 100, data: { selected: uuid } }) - 1;
+ edges.push(
+ { from: d, to: c, handle: 'pulse' },
+ { from: c, to: h, handle: 'damage' },
+ { from: s, to: h, handle: 'target' },
+ { from: r, to: c, handle: 'reset' },
+ { from: r, to: hc, handle: 'reset' },
+ { from: he, to: hc, handle: 'pulse' },
+ { from: hc, to: h, handle: 'heal' },
+ { from: zs, to: z, handle: 'zone' },
+ { from: z, to: playerRef, handle: 'damage' }
+ );
+ row++;
+ }
+ return { nodes, edges, rows: row - spec.row };
+}
+
+/** @param {any} api @param {ReturnType} engine */
+export function registerToolbox(api, engine) {
+ /** the player's existing health node for a name, if any @param {string} playerName */
+ function playerHealthId(playerName) {
+ for (const node of api.flow.nodes('health'))
+ if (node.data?.scope === 'player' && (String(node.data?.name ?? '').trim() || 'hp') === playerName) return node.id;
+ return null;
+ }
+
+ /** @param {any} options @param {string[]} enemies */
+ function build(options, enemies) {
+ if (!enemies.length) {
+ api.toast('Select the enemy objects first (or add boxes)');
+ return null;
+ }
+ const g = engine.graphView();
+ const row = g.nodes.filter((n) => n.type === 'health' || n.type === 'waves').length;
+ const goal = api.selectedUuids().find((u) => !enemies.includes(u)) ?? null;
+ const spec = arenaRecipe({ enemies, goal: options.goal ?? goal, options, row, playerHealthId: playerHealthId(String(options.playerName ?? '').trim() || 'me') });
+ const ids = api.flow.addNodes({ nodes: spec.nodes, edges: spec.edges });
+ api.toast('Arena: ' + enemies.length + ' enemies over ' + options.waves + ' waves' + (spec.nodes.some((n) => n.type === 'objectselector' && n.data.selected === (options.goal ?? goal)) ? ', walking to the goal' : ' (no goal: they hold their spawn points)'));
+ return ids;
+ }
+
+ /** @param {HTMLElement} el */
+ function mount(el) {
+ el.textContent = '';
+ el.classList.add('waves-manager');
+ el.appendChild(
+ elem('style', {
+ textContent:
+ '.waves-manager{display:flex;flex-direction:column;gap:8px;min-width:240px;font-size:12px}' +
+ '.wm-form{display:grid;grid-template-columns:auto 1fr;gap:4px 6px;align-items:center}' +
+ '.wm-form label{opacity:0.75;font-size:11px}' +
+ '.wm-buttons{display:flex;gap:6px;flex-wrap:wrap}' +
+ '.wm-run{padding:4px 6px;border-radius:5px;background:rgba(255,255,255,0.05);margin-bottom:3px}' +
+ '.wm-title{font-weight:600}' +
+ '.wm-line{font-size:11px;opacity:0.8}' +
+ '.wm-empty{opacity:0.6;font-size:11px}'
+ })
+ );
+ el.appendChild(elem('div', { className: 'tbx-label', textContent: 'Build an arena' }));
+ const form = elem('div', { className: 'wm-form' });
+ const name = /** @type {HTMLInputElement} */ (elem('input', { type: 'text', value: DEFAULTS.name, maxLength: 40 }, INPUT_CSS));
+ const waves = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '1', max: '50', step: '1', value: String(DEFAULTS.waves) }, INPUT_CSS));
+ const sizeStart = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '1', max: '50', step: '1', value: String(DEFAULTS.sizeStart) }, INPUT_CSS));
+ const sizeStep = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '0', max: '20', step: '1', value: String(DEFAULTS.sizeStep) }, INPUT_CSS));
+ const interval = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '0', max: '60', step: '0.5', value: String(DEFAULTS.interval) }, INPUT_CSS));
+ const hp = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '1', max: '20', step: '1', value: '3' }, INPUT_CSS));
+ const source = select(['click', 'hit', 'touch'], 'click');
+ const speed = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '0.1', max: '20', step: '0.1', value: String(DEFAULTS.speed) }, INPUT_CSS));
+ const reach = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '0.5', max: '10', step: '0.5', value: String(DEFAULTS.reach) }, INPUT_CSS));
+ const enemyDamage = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '1', max: '20', step: '1', value: '1' }, INPUT_CSS));
+ const enemyRate = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '0.5', max: '10', step: '0.5', value: '1' }, INPUT_CSS));
+ const playerName = /** @type {HTMLInputElement} */ (elem('input', { type: 'text', value: 'me', maxLength: 40 }, INPUT_CSS));
+ const playerHp = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '1', max: '100', step: '1', value: '10' }, INPUT_CSS));
+ const spawnPrefix = /** @type {HTMLInputElement} */ (elem('input', { type: 'text', value: 'Spawn', maxLength: 40 }, INPUT_CSS));
+ const count = /** @type {HTMLInputElement} */ (elem('input', { type: 'number', min: '1', max: '20', step: '1', value: '4' }, INPUT_CSS));
+ for (const [label, control] of [
+ ['Enemies named', name],
+ ['Waves', waves],
+ ['First wave', sizeStart],
+ ['+ per wave', sizeStep],
+ ['Between (s)', interval],
+ ['Enemy hp', hp],
+ ['Killed by', source],
+ ['Walk m/s', speed],
+ ['Reach', reach],
+ ['Enemy dmg', enemyDamage],
+ ['…per second', enemyRate],
+ ['Player health', playerName],
+ ['Player hp', playerHp],
+ ['Spawn prefix', spawnPrefix],
+ ['Boxes to add', count]
+ ]) {
+ form.appendChild(elem('label', { textContent: label }));
+ form.appendChild(/** @type {any} */ (control));
+ }
+ el.appendChild(form);
+ const options = () => ({
+ name: name.value.trim() || DEFAULTS.name,
+ waves: Math.max(1, Math.min(50, Number(waves.value) || DEFAULTS.waves)),
+ sizeStart: Math.max(1, Math.min(50, Number(sizeStart.value) || DEFAULTS.sizeStart)),
+ sizeStep: Math.max(0, Math.min(20, Number(sizeStep.value) || 0)),
+ interval: Math.max(0, Math.min(60, Number(interval.value) || 0)),
+ hp: Math.max(1, Math.min(20, Number(hp.value) || 3)),
+ source: source.value,
+ speed: Math.max(0.1, Math.min(20, Number(speed.value) || DEFAULTS.speed)),
+ reach: Math.max(0.5, Math.min(10, Number(reach.value) || DEFAULTS.reach)),
+ enemyDamage: Math.max(1, Math.min(20, Number(enemyDamage.value) || 1)),
+ enemyRate: Math.max(0.5, Math.min(10, Number(enemyRate.value) || 1)),
+ playerName: playerName.value.trim() || 'me',
+ playerHp: Math.max(1, Math.min(100, Number(playerHp.value) || 10)),
+ playerRegen: 0,
+ spawnPrefix: spawnPrefix.value.trim() || 'Spawn'
+ });
+ const buttons = elem('div', { className: 'wm-buttons' });
+ const fromSelection = elem('button', { className: 'tbx-btn tbx-primary', textContent: 'Enemies from selection' });
+ fromSelection.title = 'The selected objects become the enemies; select the goal object too and it becomes the goal';
+ fromSelection.addEventListener('click', () => {
+ const selected = api.selectedUuids();
+ // the goal is the one selected object that is NOT an enemy: the last selected
+ const enemies = selected.length > 1 ? selected.slice(0, -1) : selected;
+ const goal = selected.length > 1 ? selected[selected.length - 1] : null;
+ build({ ...options(), goal }, enemies);
+ refresh();
+ });
+ const addBoxes = elem('button', { className: 'tbx-btn', textContent: 'Add boxes as enemies' });
+ addBoxes.title = 'Creates N boxes (replicated), parks them in a row, and makes them the enemies';
+ addBoxes.addEventListener('click', async () => {
+ const n = Math.max(1, Math.min(20, Number(count.value) || 4));
+ /** @type {string[]} */
+ const made = [];
+ for (let i = 0; i < n; i++) {
+ const ids = await api.create('/create box', { at: [-6 + i * 1.5, 0.5, -8] });
+ if (ids[0]) made.push(ids[0]);
+ }
+ const goal = api.selectedUuids()[0] ?? null;
+ build({ ...options(), goal }, made);
+ refresh();
+ });
+ buttons.appendChild(fromSelection);
+ buttons.appendChild(addBoxes);
+ el.appendChild(buttons);
+
+ const list = elem('div', {});
+ el.appendChild(list);
+ function refresh() {
+ list.textContent = '';
+ const runs = engine.all();
+ if (!runs.length) {
+ list.appendChild(elem('div', { className: 'wm-empty', textContent: 'No arena yet. Select the enemies (and a goal), or add boxes.' }));
+ return;
+ }
+ for (const s of runs) {
+ const box = elem('div', { className: 'wm-run' });
+ box.dataset.node = s.id;
+ box.appendChild(elem('div', { className: 'wm-title', textContent: s.name + ' · wave ' + s.wave + ' of ' + s.curve.waves + (s.done ? ' · cleared' : s.running ? (s.started ? ' · ' + s.alive + ' left' : ' · next wave soon') : ' · idle') }));
+ box.appendChild(elem('div', { className: 'wm-line', textContent: s.enemies.length + ' enemies · ' + (s.goal ? 'walking to the goal' : 'no goal') + ' · ' + s.spawns.length + ' spawn points · ' + engine.runLog(s.name).length + ' runs logged' }));
+ list.appendChild(box);
+ }
+ }
+ refresh();
+ const timer = setInterval(refresh, 500);
+ return () => clearInterval(timer);
+ }
+
+ const toolbox = api.registerToolbox({ id: 'arena', title: 'Waves', width: 280, minW: 250, sidebar: false, mount });
+ api.registerMenu('Open Waves', () => api.openToolbox(toolbox));
+ return { toolbox, build, arenaRecipe };
+}
diff --git a/modules/waves/test/curve.test.mjs b/modules/waves/test/curve.test.mjs
new file mode 100644
index 0000000..681337e
--- /dev/null
+++ b/modules/waves/test/curve.test.mjs
@@ -0,0 +1,53 @@
+// the curve, pure — run WITHOUT the app (npm run test:waves)
+import { curveOf, sizeOf, firstWaveOf, killsNeeded, killsOf, waveOf, aliveIn, usedIn, healsBefore, enemyPosition, spawnFor, runEntry, appendRun } from '../src/curve.js';
+
+/** @param {(ok: boolean, label: string) => void} check */
+export function run(check) {
+ const c = curveOf({ waves: 3, sizeStart: 2, sizeStep: 1 }, 4); // 4 enemies: waves of 2, 3, 4
+ check(sizeOf(1, c) === 2 && sizeOf(2, c) === 3 && sizeOf(3, c) === 4, 'sizes 2, 3, 4');
+ check(sizeOf(3, curveOf({ waves: 3, sizeStart: 2, sizeStep: 5 }, 4)) === 4, 'a wave never uses more enemies than there are');
+ check(firstWaveOf(0, c) === 1 && firstWaveOf(2, c) === 2 && firstWaveOf(3, c) === 3, 'enemies 0/2/3 first appear in waves 1/2/3');
+ check(firstWaveOf(9, c) === null, 'a tenth enemy never appears');
+ check(killsNeeded(0, 3, c) === 3 && killsNeeded(2, 3, c) === 2 && killsNeeded(3, 3, c) === 1 && killsNeeded(3, 1, c) === 0, 'kills needed accumulate per wave appeared in');
+ check(killsOf(6, 3) === 2 && killsOf(7, 3) === 2 && killsOf(0, 3) === 0, 'kills = floor(hits / hp)');
+
+ // the wave from the counters
+ check(JSON.stringify(waveOf([0, 0, 0, 0], c)) === '{"completed":0,"wave":1,"done":false}', 'nothing killed: wave 1');
+ check(JSON.stringify(waveOf([1, 0, 0, 0], c)) === '{"completed":0,"wave":1,"done":false}', 'one of two down: still wave 1');
+ check(JSON.stringify(waveOf([1, 1, 0, 0], c)) === '{"completed":1,"wave":2,"done":false}', 'both down: wave 2');
+ check(JSON.stringify(waveOf([2, 2, 1, 0], c)) === '{"completed":2,"wave":3,"done":false}', 'wave 2 cleared (the two veterans twice, the new one once): wave 3');
+ check(JSON.stringify(waveOf([3, 3, 2, 1], c)) === '{"completed":3,"wave":3,"done":true}', 'everything down for the last time: done, wave stays 3');
+ check(JSON.stringify(waveOf([3, 3, 2, 0], c)) === '{"completed":2,"wave":3,"done":false}', 'COUNTERFACTUAL: one straggler keeps the run open');
+ check(JSON.stringify(waveOf([1, 0, 5, 5], c)) === '{"completed":0,"wave":1,"done":false}', 'COUNTERFACTUAL: kills on later enemies do not skip a wave');
+ // two peers with the same counters derive the same wave, and a joiner with those
+ // counters derives it too — the whole point
+ check(waveOf([2, 2, 1, 0], c).wave === waveOf([2, 2, 1, 0], c).wave, 'the same counters give the same wave');
+
+ check(aliveIn(2, [1, 1, 0, 0], c).join() === '0,1,2', 'wave 2 starts with all three alive');
+ check(aliveIn(2, [2, 1, 1, 0], c).join() === '1', 'mid-wave 2: only enemy 1 left');
+ check(usedIn(3, c).join() === '0,1,2,3', 'wave 3 uses all four');
+ check(healsBefore(0, 3, c) === 2 && healsBefore(2, 3, c) === 1 && healsBefore(3, 3, c) === 0 && healsBefore(0, 1, c) === 0, 'heals before a wave: one per earlier appearance');
+
+ // movement, pure in time
+ const p = { start: [0, 0.5, 0], goal: [10, 0, 0], waveStart: 100, index: 0, speed: 2, stagger: 0.5 };
+ check(enemyPosition({ ...p, now: 99 }).join() === '0,0.5,0', 'before the wave: at the start');
+ check(enemyPosition({ ...p, now: 101 }).join() === '2,0.5,0', 'one second in at 2 m/s: 2 m along, y kept');
+ check(enemyPosition({ ...p, now: 200 }).join() === '10,0.5,0', 'arrived: stays at the goal');
+ check(enemyPosition({ ...p, index: 2, now: 101 }).join() === '0,0.5,0', 'the third enemy waits its stagger');
+ check(enemyPosition({ ...p, index: 2, now: 102 }).join() === '2,0.5,0', 'and then walks the same line');
+ check(enemyPosition({ ...p, goal: [0, 0, 0], now: 150 }).join() === '0,0.5,0', 'goal at the start: stays put');
+ const a = enemyPosition({ ...p, now: 103.25 });
+ const b = enemyPosition({ ...p, now: 103.25 });
+ check(a.join() === b.join(), 'two peers at the same clock place it identically');
+
+ check(spawnFor(4, [[1, 0, 0], [2, 0, 0], [3, 0, 0]], [9, 9, 9]).join() === '2,0,0', 'spawn points cycle');
+ check(spawnFor(0, [], [9, 9, 9]).join() === '9,9,9', 'no points: the fallback');
+
+ // the run log
+ const e = runEntry({ at: 5, waves: 3, reached: 3, cleared: true, rows: [{ name: 'b', kills: 2 }, { name: 'a', kills: 2 }, { name: 'c', kills: 5 }] });
+ check(e.players.map((x) => x.name).join() === 'c,a,b', 'players sort by kills then name');
+ const log = appendRun(appendRun([], e), e);
+ check(log.length === 1, 'appending the same run twice keeps one (idempotent by `at`)');
+ check(appendRun(log, { ...e, at: 6 }).length === 2, 'a different run appends');
+ check(appendRun(Array.from({ length: 60 }, (_, i) => ({ at: i })), { ...e, at: 999 }).length === 50, 'capped at 50');
+}
diff --git a/modules/waves/test/recipe.test.mjs b/modules/waves/test/recipe.test.mjs
new file mode 100644
index 0000000..9036857
--- /dev/null
+++ b/modules/waves/test/recipe.test.mjs
@@ -0,0 +1,47 @@
+// the arena recipe, the HUD graph and the def — shapes, no app
+import { arenaRecipe } from '../src/toolbox.js';
+import { hudGraph, arenaHud } from '../src/hud.js';
+import { wavesDef, toGraph } from '../src/def.js';
+
+/** @param {(ok: boolean, label: string) => void} check */
+export function run(check) {
+ const o = { name: 'enemy', waves: 2, sizeStart: 2, sizeStep: 1, interval: 1, hp: 2, source: 'click', speed: 4, reach: 1.5, enemyDamage: 1, enemyRate: 1, playerName: 'me', playerHp: 10, playerRegen: 0, spawnPrefix: 'Spawn' };
+ const r = arenaRecipe({ enemies: ['e1', 'e2', 'e3'], goal: 'g', options: o, row: 0, playerHealthId: null });
+ const types = r.nodes.reduce((m, n) => ((m[n.type] = (m[n.type] ?? 0) + 1), m), {});
+ check(types.waves === 1 && types.wavesevent === 1 && types.setgamestate === 1, 'one Waves node, one over event, one Set Game State');
+ check(types.health === 4 && types.healthreset === 4, 'a health + reset per enemy and one for the player');
+ check(types.damage === 7 && types.counter === 7 && types.heal === 3 && types.objectselector === 7, 'per enemy: a damage, two counters, a heal, a zone damage, two selectors; plus the player chain and the goal');
+ const w = r.nodes.findIndex((n) => n.type === 'waves');
+ check(r.edges.some((e) => e.to === w && e.handle === 'goal' && r.nodes[e.from].data.selected === 'g'), 'the goal selector wires into waves.goal');
+ const over = r.nodes.findIndex((n) => n.type === 'wavesevent');
+ const set = r.nodes.findIndex((n) => n.type === 'setgamestate');
+ check(r.edges.some((e) => e.from === over && e.to === set && e.handle === 'trigger') && r.nodes[set].data.state === 'over', 'over -> Set Game State (over)');
+ const player = r.nodes.findIndex((n) => n.type === 'health' && n.data.scope === 'player');
+ const zones = r.nodes.map((n, i) => [n, i]).filter(([n]) => n.type === 'damage' && n.data.source === 'zone').map(([, i]) => i);
+ check(zones.length === 3 && zones.every((z) => r.edges.some((e) => e.from === z && e.to === player && e.handle === 'damage')), 'every enemy\'s zone damage wires straight into the player health');
+ check(zones.every((z) => r.edges.some((e) => e.to === z && e.handle === 'zone' && r.nodes[e.from].type === 'objectselector')), 'and takes its enemy as the zone');
+ const heals = r.nodes.map((n, i) => [n, i]).filter(([n]) => n.type === 'heal').map(([, i]) => i);
+ check(heals.every((h) => { const c = r.edges.find((e) => e.from === h && e.handle === 'pulse')?.to; return typeof c === 'number' && r.nodes[c].type === 'counter' && r.edges.some((e) => e.from === c && e.handle === 'heal' && r.nodes[e.to].type === 'health'); }), 'heal -> counter.pulse -> health.heal per enemy');
+ check(r.nodes.filter((n) => n.type === 'health' && n.data.scope === 'object').every((n) => n.data.name === 'enemy' && n.data.max === 2 && n.data.deathAction === 'hide'), 'enemy healths carry the name, hp and hide');
+ check(r.rows === 4, 'four rows: the run row and one per enemy');
+ const again = arenaRecipe({ enemies: ['e1'], goal: null, options: o, row: 2, playerHealthId: 'existing' });
+ check(!again.nodes.some((n) => n.type === 'health' && n.data.scope === 'player') && again.edges.some((e) => e.to === 'existing' && e.handle === 'damage'), 'with a player health already there the zone wires into it, no second player chain');
+ check(!again.edges.some((e) => e.handle === 'goal'), 'no goal: no goal wire');
+ check(JSON.stringify(arenaRecipe({ enemies: ['e1'], goal: 'g', options: o, row: 0, playerHealthId: null })) === JSON.stringify(arenaRecipe({ enemies: ['e1'], goal: 'g', options: o, row: 0, playerHealthId: null })), 'the recipe is deterministic');
+
+ const h = hudGraph({ name: 'enemy', playerName: 'me' });
+ check(h.nodes.filter((n) => n.type === 'hudbutton').length === 2 && h.nodes.filter((n) => n.type === 'setgamestate').length === 2, 'two HUD buttons, each into a Set Game State');
+ check(h.nodes.some((n) => n.type === 'healthvalue' && n.data.read === 'fraction') && h.nodes.some((n) => n.type === 'hudbar' && n.data.max === 1), 'the player bar reads the health fraction');
+ check(h.nodes.some((n) => n.type === 'leaderboard' && n.data.variable === 'kills'), 'the leaderboard shows the kills rows');
+ const screens = arenaHud().scene.screens.map((s) => s.id + ':' + (s.showWhile ?? '-'));
+ check(screens.join() === 'menu:menu,hud:playing,over:over', 'three screens following the shell (' + screens.join() + ')');
+
+ const g = toGraph({ nodes: [{ type: 'a', x: 1, y: 2, data: { k: 1 } }, { type: 'b', x: 3, y: 4 }], edges: [{ from: 0, to: 1, handle: 'trigger' }, { from: 1, to: 0 }] }, 'p');
+ check(g.nodes[0].id === 'p0' && g.nodes[0].position.x === 1 && g.nodes[0].data.k === 1 && g.nodes[0].data.label === 'a', 'toGraph: ids, positions, data with a label');
+ check(g.edges[0].id === 'e-p0-p1.trigger' && g.edges[0].targetHandle === 'trigger' && g.edges[1].id === 'e-p1-p0' && !('targetHandle' in g.edges[1]), 'toGraph: canonical edge ids');
+ const d = wavesDef();
+ check(d.kind === 'game' && d.installModules.join() === 'health,waves' && d.objects.length === 10 && d.hud.scene.screens.length === 3, 'wavesDef: game, both modules, ten objects, three screens');
+ check(d.graphs.scene.nodes.every((n) => n.id && n.position && n.data.label) && d.graphs.scene.edges.every((e) => e.id.startsWith('e-')), 'the def graph is in the author script\'s shape');
+ check(d.graphs.scene.nodes.filter((n) => n.type === 'objectselector').every((n) => d.objects.some((o) => o.name === n.data.selected)), 'every selector names an object of the def');
+ check(JSON.stringify(wavesDef()) === JSON.stringify(wavesDef()), 'the def is deterministic (byte-identical twice)');
+}
diff --git a/modules/waves/test/run.mjs b/modules/waves/test/run.mjs
new file mode 100644
index 0000000..1a6725a
--- /dev/null
+++ b/modules/waves/test/run.mjs
@@ -0,0 +1,16 @@
+// Pure curve tests — run WITHOUT the app:
+// node modules/waves/test/run.mjs (also `npm run test:waves`)
+import { run as curveTests } from './curve.test.mjs';
+import { run as recipeTests } from './recipe.test.mjs';
+
+let failures = 0;
+function check(ok, label) {
+ console.log((ok ? 'PASS ' : 'FAIL ') + label);
+ if (!ok) failures++;
+}
+console.log('=== curve.test.mjs ===');
+curveTests(check);
+console.log('\n=== recipe.test.mjs ===');
+recipeTests(check);
+console.log('\n' + (failures === 0 ? 'ALL PASS' : failures + ' FAILURES'));
+process.exit(failures === 0 ? 0 : 1);
diff --git a/modules/waves/waves.def.json b/modules/waves/waves.def.json
new file mode 100644
index 0000000..7aa551a
--- /dev/null
+++ b/modules/waves/waves.def.json
@@ -0,0 +1,1588 @@
+{
+ "kind": "game",
+ "slug": "waves",
+ "title": "Waves",
+ "description": "Wave survival: hold the goal against waves of enemies walking in from the spawn points. Knock them down; the round ends when the last wave falls.",
+ "license": "CC0-1.0",
+ "author": "theprototype",
+ "tags": [
+ "vr",
+ "co-op",
+ "survival"
+ ],
+ "modules": [
+ {
+ "id": "health",
+ "version": "1.0.0"
+ },
+ {
+ "id": "waves",
+ "version": "1.0.0"
+ }
+ ],
+ "installModules": [
+ "health",
+ "waves"
+ ],
+ "env": {
+ "preset": "dusk",
+ "exposure": 0.9
+ },
+ "physics": {
+ "knock": {
+ "enabled": true,
+ "gain": 1,
+ "maxSpeed": 10,
+ "spin": 0.8
+ },
+ "play": {
+ "interaction": "grab",
+ "grounded": true,
+ "simOnPlay": true
+ }
+ },
+ "graphs": {
+ "scene": {
+ "nodes": [
+ {
+ "id": "wa0",
+ "type": "waves",
+ "position": {
+ "x": 60,
+ "y": 40
+ },
+ "data": {
+ "label": "waves",
+ "name": "enemy",
+ "waves": 3,
+ "sizeStart": 2,
+ "sizeStep": 1,
+ "interval": 3,
+ "speed": 1.5,
+ "stagger": 0.6,
+ "reach": 1.5,
+ "spawnPrefix": "Spawn"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa1",
+ "type": "objectselector",
+ "position": {
+ "x": 60,
+ "y": 140
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Goal"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa2",
+ "type": "wavesevent",
+ "position": {
+ "x": 280,
+ "y": 40
+ },
+ "data": {
+ "label": "wavesevent",
+ "name": "enemy",
+ "event": "over"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa3",
+ "type": "setgamestate",
+ "position": {
+ "x": 500,
+ "y": 40
+ },
+ "data": {
+ "label": "setgamestate",
+ "state": "over",
+ "outcome": "won"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa4",
+ "type": "damage",
+ "position": {
+ "x": 720,
+ "y": 40
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "wired"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa5",
+ "type": "counter",
+ "position": {
+ "x": 940,
+ "y": 40
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa6",
+ "type": "health",
+ "position": {
+ "x": 1160,
+ "y": 40
+ },
+ "data": {
+ "label": "health",
+ "name": "me",
+ "scope": "player",
+ "max": 10,
+ "regen": 0.5,
+ "deathAction": "respawn",
+ "respawnDelay": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa7",
+ "type": "healthreset",
+ "position": {
+ "x": 720,
+ "y": 140
+ },
+ "data": {
+ "label": "healthreset",
+ "name": "me"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa8",
+ "type": "damage",
+ "position": {
+ "x": 60,
+ "y": 240
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "hit",
+ "scale": "speed",
+ "speedRef": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa9",
+ "type": "counter",
+ "position": {
+ "x": 280,
+ "y": 240
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa10",
+ "type": "health",
+ "position": {
+ "x": 500,
+ "y": 240
+ },
+ "data": {
+ "label": "health",
+ "name": "enemy",
+ "scope": "object",
+ "max": 3,
+ "deathAction": "hide",
+ "respawnDelay": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa11",
+ "type": "objectselector",
+ "position": {
+ "x": 720,
+ "y": 240
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Enemy 1"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa12",
+ "type": "healthreset",
+ "position": {
+ "x": 60,
+ "y": 340
+ },
+ "data": {
+ "label": "healthreset",
+ "name": "enemy"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa13",
+ "type": "heal",
+ "position": {
+ "x": 280,
+ "y": 340
+ },
+ "data": {
+ "label": "heal",
+ "amount": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa14",
+ "type": "counter",
+ "position": {
+ "x": 500,
+ "y": 340
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa15",
+ "type": "damage",
+ "position": {
+ "x": 940,
+ "y": 240
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "zone",
+ "perSecond": 1,
+ "radius": 1.5
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa16",
+ "type": "objectselector",
+ "position": {
+ "x": 940,
+ "y": 340
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Enemy 1"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa17",
+ "type": "damage",
+ "position": {
+ "x": 60,
+ "y": 440
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "hit",
+ "scale": "speed",
+ "speedRef": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa18",
+ "type": "counter",
+ "position": {
+ "x": 280,
+ "y": 440
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa19",
+ "type": "health",
+ "position": {
+ "x": 500,
+ "y": 440
+ },
+ "data": {
+ "label": "health",
+ "name": "enemy",
+ "scope": "object",
+ "max": 3,
+ "deathAction": "hide",
+ "respawnDelay": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa20",
+ "type": "objectselector",
+ "position": {
+ "x": 720,
+ "y": 440
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Enemy 2"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa21",
+ "type": "healthreset",
+ "position": {
+ "x": 60,
+ "y": 540
+ },
+ "data": {
+ "label": "healthreset",
+ "name": "enemy"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa22",
+ "type": "heal",
+ "position": {
+ "x": 280,
+ "y": 540
+ },
+ "data": {
+ "label": "heal",
+ "amount": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa23",
+ "type": "counter",
+ "position": {
+ "x": 500,
+ "y": 540
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa24",
+ "type": "damage",
+ "position": {
+ "x": 940,
+ "y": 440
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "zone",
+ "perSecond": 1,
+ "radius": 1.5
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa25",
+ "type": "objectselector",
+ "position": {
+ "x": 940,
+ "y": 540
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Enemy 2"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa26",
+ "type": "damage",
+ "position": {
+ "x": 60,
+ "y": 640
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "hit",
+ "scale": "speed",
+ "speedRef": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa27",
+ "type": "counter",
+ "position": {
+ "x": 280,
+ "y": 640
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa28",
+ "type": "health",
+ "position": {
+ "x": 500,
+ "y": 640
+ },
+ "data": {
+ "label": "health",
+ "name": "enemy",
+ "scope": "object",
+ "max": 3,
+ "deathAction": "hide",
+ "respawnDelay": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa29",
+ "type": "objectselector",
+ "position": {
+ "x": 720,
+ "y": 640
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Enemy 3"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa30",
+ "type": "healthreset",
+ "position": {
+ "x": 60,
+ "y": 740
+ },
+ "data": {
+ "label": "healthreset",
+ "name": "enemy"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa31",
+ "type": "heal",
+ "position": {
+ "x": 280,
+ "y": 740
+ },
+ "data": {
+ "label": "heal",
+ "amount": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa32",
+ "type": "counter",
+ "position": {
+ "x": 500,
+ "y": 740
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa33",
+ "type": "damage",
+ "position": {
+ "x": 940,
+ "y": 640
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "zone",
+ "perSecond": 1,
+ "radius": 1.5
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa34",
+ "type": "objectselector",
+ "position": {
+ "x": 940,
+ "y": 740
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Enemy 3"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa35",
+ "type": "damage",
+ "position": {
+ "x": 60,
+ "y": 840
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "hit",
+ "scale": "speed",
+ "speedRef": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa36",
+ "type": "counter",
+ "position": {
+ "x": 280,
+ "y": 840
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa37",
+ "type": "health",
+ "position": {
+ "x": 500,
+ "y": 840
+ },
+ "data": {
+ "label": "health",
+ "name": "enemy",
+ "scope": "object",
+ "max": 3,
+ "deathAction": "hide",
+ "respawnDelay": 3
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa38",
+ "type": "objectselector",
+ "position": {
+ "x": 720,
+ "y": 840
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Enemy 4"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa39",
+ "type": "healthreset",
+ "position": {
+ "x": 60,
+ "y": 940
+ },
+ "data": {
+ "label": "healthreset",
+ "name": "enemy"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa40",
+ "type": "heal",
+ "position": {
+ "x": 280,
+ "y": 940
+ },
+ "data": {
+ "label": "heal",
+ "amount": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa41",
+ "type": "counter",
+ "position": {
+ "x": 500,
+ "y": 940
+ },
+ "data": {
+ "label": "counter",
+ "op": "up",
+ "step": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa42",
+ "type": "damage",
+ "position": {
+ "x": 940,
+ "y": 840
+ },
+ "data": {
+ "label": "damage",
+ "amount": 1,
+ "source": "zone",
+ "perSecond": 1,
+ "radius": 1.5
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wa43",
+ "type": "objectselector",
+ "position": {
+ "x": 940,
+ "y": 940
+ },
+ "data": {
+ "label": "objectselector",
+ "selected": "Enemy 4"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh0",
+ "type": "hudbutton",
+ "position": {
+ "x": 60,
+ "y": 1240
+ },
+ "data": {
+ "label": "hudbutton",
+ "element": "wv-start"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh1",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 1240
+ },
+ "data": {
+ "label": "setgamestate",
+ "state": "playing",
+ "outcome": ""
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh2",
+ "type": "hudbutton",
+ "position": {
+ "x": 60,
+ "y": 1340
+ },
+ "data": {
+ "label": "hudbutton",
+ "element": "wv-again"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh3",
+ "type": "setgamestate",
+ "position": {
+ "x": 280,
+ "y": 1340
+ },
+ "data": {
+ "label": "setgamestate",
+ "state": "playing",
+ "outcome": ""
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh4",
+ "type": "wavesvalue",
+ "position": {
+ "x": 60,
+ "y": 1440
+ },
+ "data": {
+ "label": "wavesvalue",
+ "name": "enemy",
+ "read": "wave"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh5",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 1440
+ },
+ "data": {
+ "label": "hudtext",
+ "element": "wv-wave",
+ "format": "Wave {v}",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh6",
+ "type": "wavesvalue",
+ "position": {
+ "x": 60,
+ "y": 1540
+ },
+ "data": {
+ "label": "wavesvalue",
+ "name": "enemy",
+ "read": "left"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh7",
+ "type": "hudtext",
+ "position": {
+ "x": 280,
+ "y": 1540
+ },
+ "data": {
+ "label": "hudtext",
+ "element": "wv-left",
+ "format": "{v} left",
+ "decimals": 0
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh8",
+ "type": "healthvalue",
+ "position": {
+ "x": 60,
+ "y": 1640
+ },
+ "data": {
+ "label": "healthvalue",
+ "name": "me",
+ "read": "fraction"
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh9",
+ "type": "hudbar",
+ "position": {
+ "x": 280,
+ "y": 1640
+ },
+ "data": {
+ "label": "hudbar",
+ "element": "wv-hp",
+ "min": 0,
+ "max": 1
+ },
+ "class": "w-[150px]"
+ },
+ {
+ "id": "wh10",
+ "type": "leaderboard",
+ "position": {
+ "x": 60,
+ "y": 1740
+ },
+ "data": {
+ "label": "leaderboard",
+ "variable": "kills",
+ "element": "wv-kills,wv-kills-over",
+ "limit": 8
+ },
+ "class": "w-[150px]"
+ }
+ ],
+ "edges": [
+ {
+ "id": "e-wa1-wa0.goal",
+ "source": "wa1",
+ "target": "wa0",
+ "targetHandle": "goal"
+ },
+ {
+ "id": "e-wa2-wa3.trigger",
+ "source": "wa2",
+ "target": "wa3",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-wa4-wa5.pulse",
+ "source": "wa4",
+ "target": "wa5",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa5-wa6.damage",
+ "source": "wa5",
+ "target": "wa6",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wa7-wa5.reset",
+ "source": "wa7",
+ "target": "wa5",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa8-wa9.pulse",
+ "source": "wa8",
+ "target": "wa9",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa9-wa10.damage",
+ "source": "wa9",
+ "target": "wa10",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wa11-wa10.target",
+ "source": "wa11",
+ "target": "wa10",
+ "targetHandle": "target"
+ },
+ {
+ "id": "e-wa12-wa9.reset",
+ "source": "wa12",
+ "target": "wa9",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa12-wa14.reset",
+ "source": "wa12",
+ "target": "wa14",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa13-wa14.pulse",
+ "source": "wa13",
+ "target": "wa14",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa14-wa10.heal",
+ "source": "wa14",
+ "target": "wa10",
+ "targetHandle": "heal"
+ },
+ {
+ "id": "e-wa16-wa15.zone",
+ "source": "wa16",
+ "target": "wa15",
+ "targetHandle": "zone"
+ },
+ {
+ "id": "e-wa15-wa6.damage",
+ "source": "wa15",
+ "target": "wa6",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wa17-wa18.pulse",
+ "source": "wa17",
+ "target": "wa18",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa18-wa19.damage",
+ "source": "wa18",
+ "target": "wa19",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wa20-wa19.target",
+ "source": "wa20",
+ "target": "wa19",
+ "targetHandle": "target"
+ },
+ {
+ "id": "e-wa21-wa18.reset",
+ "source": "wa21",
+ "target": "wa18",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa21-wa23.reset",
+ "source": "wa21",
+ "target": "wa23",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa22-wa23.pulse",
+ "source": "wa22",
+ "target": "wa23",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa23-wa19.heal",
+ "source": "wa23",
+ "target": "wa19",
+ "targetHandle": "heal"
+ },
+ {
+ "id": "e-wa25-wa24.zone",
+ "source": "wa25",
+ "target": "wa24",
+ "targetHandle": "zone"
+ },
+ {
+ "id": "e-wa24-wa6.damage",
+ "source": "wa24",
+ "target": "wa6",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wa26-wa27.pulse",
+ "source": "wa26",
+ "target": "wa27",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa27-wa28.damage",
+ "source": "wa27",
+ "target": "wa28",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wa29-wa28.target",
+ "source": "wa29",
+ "target": "wa28",
+ "targetHandle": "target"
+ },
+ {
+ "id": "e-wa30-wa27.reset",
+ "source": "wa30",
+ "target": "wa27",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa30-wa32.reset",
+ "source": "wa30",
+ "target": "wa32",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa31-wa32.pulse",
+ "source": "wa31",
+ "target": "wa32",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa32-wa28.heal",
+ "source": "wa32",
+ "target": "wa28",
+ "targetHandle": "heal"
+ },
+ {
+ "id": "e-wa34-wa33.zone",
+ "source": "wa34",
+ "target": "wa33",
+ "targetHandle": "zone"
+ },
+ {
+ "id": "e-wa33-wa6.damage",
+ "source": "wa33",
+ "target": "wa6",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wa35-wa36.pulse",
+ "source": "wa35",
+ "target": "wa36",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa36-wa37.damage",
+ "source": "wa36",
+ "target": "wa37",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wa38-wa37.target",
+ "source": "wa38",
+ "target": "wa37",
+ "targetHandle": "target"
+ },
+ {
+ "id": "e-wa39-wa36.reset",
+ "source": "wa39",
+ "target": "wa36",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa39-wa41.reset",
+ "source": "wa39",
+ "target": "wa41",
+ "targetHandle": "reset"
+ },
+ {
+ "id": "e-wa40-wa41.pulse",
+ "source": "wa40",
+ "target": "wa41",
+ "targetHandle": "pulse"
+ },
+ {
+ "id": "e-wa41-wa37.heal",
+ "source": "wa41",
+ "target": "wa37",
+ "targetHandle": "heal"
+ },
+ {
+ "id": "e-wa43-wa42.zone",
+ "source": "wa43",
+ "target": "wa42",
+ "targetHandle": "zone"
+ },
+ {
+ "id": "e-wa42-wa6.damage",
+ "source": "wa42",
+ "target": "wa6",
+ "targetHandle": "damage"
+ },
+ {
+ "id": "e-wh0-wh1.trigger",
+ "source": "wh0",
+ "target": "wh1",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-wh2-wh3.trigger",
+ "source": "wh2",
+ "target": "wh3",
+ "targetHandle": "trigger"
+ },
+ {
+ "id": "e-wh4-wh5.value",
+ "source": "wh4",
+ "target": "wh5",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-wh6-wh7.value",
+ "source": "wh6",
+ "target": "wh7",
+ "targetHandle": "value"
+ },
+ {
+ "id": "e-wh8-wh9.value",
+ "source": "wh8",
+ "target": "wh9",
+ "targetHandle": "value"
+ }
+ ]
+ }
+ },
+ "hud": {
+ "scene": {
+ "active": "",
+ "changedAt": 0,
+ "screens": [
+ {
+ "id": "menu",
+ "name": "Menu",
+ "showWhile": "menu",
+ "input": "menu",
+ "elements": [
+ {
+ "id": "menu-panel",
+ "kind": "panel",
+ "anchor": "center",
+ "x": 0,
+ "y": 0,
+ "w": 460,
+ "h": 340,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(24, 20, 30, 0.92)",
+ "radius": 16,
+ "border": "1px solid rgba(220, 120, 120, 0.25)"
+ }
+ },
+ {
+ "id": "title",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -120,
+ "w": 400,
+ "h": 54,
+ "z": 1,
+ "label": "WAVES",
+ "style": {
+ "size": 40,
+ "weight": "700",
+ "color": "#ff9c6b",
+ "align": "center"
+ }
+ },
+ {
+ "id": "subtitle",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -66,
+ "w": 420,
+ "h": 44,
+ "z": 1,
+ "label": "Hold the goal. Every wave brings more enemies; the round ends when the last one falls.",
+ "style": {
+ "size": 14,
+ "color": "#e6dede",
+ "align": "center"
+ },
+ "wrap": true
+ },
+ {
+ "id": "wv-start",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 10,
+ "w": 220,
+ "h": 48,
+ "z": 1,
+ "label": "Start",
+ "enabled": true,
+ "style": {
+ "size": 16,
+ "weight": "600",
+ "bg": "#c94a4a",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ },
+ {
+ "id": "wv-log",
+ "kind": "list",
+ "anchor": "center",
+ "x": 0,
+ "y": 90,
+ "w": 420,
+ "h": 80,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 12,
+ "color": "#d6c8c8",
+ "align": "center"
+ }
+ }
+ ]
+ },
+ {
+ "id": "hud",
+ "name": "HUD",
+ "showWhile": "playing",
+ "input": "game",
+ "elements": [
+ {
+ "id": "wv-wave",
+ "kind": "text",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 14,
+ "w": 320,
+ "h": 34,
+ "z": 1,
+ "label": "Wave 1",
+ "style": {
+ "size": 22,
+ "weight": "700",
+ "color": "#ff9c6b",
+ "align": "center"
+ }
+ },
+ {
+ "id": "wv-left",
+ "kind": "text",
+ "anchor": "top-center",
+ "x": 0,
+ "y": 48,
+ "w": 320,
+ "h": 24,
+ "z": 1,
+ "label": "",
+ "style": {
+ "size": 14,
+ "color": "#e6dede",
+ "align": "center"
+ }
+ },
+ {
+ "id": "wv-hp",
+ "kind": "bar",
+ "anchor": "bottom-center",
+ "x": 0,
+ "y": 24,
+ "w": 320,
+ "h": 18,
+ "z": 1,
+ "label": "",
+ "min": 0,
+ "max": 1,
+ "value": 1,
+ "orientation": "horizontal",
+ "showPercent": false,
+ "style": {
+ "color": "#6fcf7a",
+ "bg": "rgba(0,0,0,0.35)",
+ "radius": 9
+ }
+ },
+ {
+ "id": "wv-kills",
+ "kind": "list",
+ "anchor": "top-right",
+ "x": 16,
+ "y": 14,
+ "w": 220,
+ "h": 100,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 12,
+ "color": "#d6c8c8",
+ "align": "right"
+ }
+ }
+ ]
+ },
+ {
+ "id": "over",
+ "name": "Round over",
+ "showWhile": "over",
+ "input": "menu",
+ "elements": [
+ {
+ "id": "over-panel",
+ "kind": "panel",
+ "anchor": "center",
+ "x": 0,
+ "y": 0,
+ "w": 460,
+ "h": 340,
+ "z": 0,
+ "label": "",
+ "style": {
+ "bg": "rgba(24, 20, 30, 0.92)",
+ "radius": 16,
+ "border": "1px solid rgba(220, 120, 120, 0.25)"
+ }
+ },
+ {
+ "id": "over-title",
+ "kind": "text",
+ "anchor": "center",
+ "x": 0,
+ "y": -120,
+ "w": 420,
+ "h": 40,
+ "z": 1,
+ "label": "ARENA CLEARED",
+ "style": {
+ "size": 30,
+ "weight": "700",
+ "color": "#ff9c6b",
+ "align": "center"
+ }
+ },
+ {
+ "id": "wv-kills-over",
+ "kind": "list",
+ "anchor": "center",
+ "x": 0,
+ "y": -50,
+ "w": 420,
+ "h": 90,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 14,
+ "color": "#e6dede",
+ "align": "center"
+ }
+ },
+ {
+ "id": "wv-log-over",
+ "kind": "list",
+ "anchor": "center",
+ "x": 0,
+ "y": 40,
+ "w": 420,
+ "h": 70,
+ "z": 1,
+ "label": "",
+ "rows": [],
+ "style": {
+ "size": 12,
+ "color": "#d6c8c8",
+ "align": "center"
+ }
+ },
+ {
+ "id": "wv-again",
+ "kind": "button",
+ "anchor": "center",
+ "x": 0,
+ "y": 115,
+ "w": 220,
+ "h": 44,
+ "z": 1,
+ "label": "Again",
+ "enabled": true,
+ "style": {
+ "size": 16,
+ "weight": "600",
+ "bg": "#3b7dd8",
+ "color": "#ffffff",
+ "radius": 10
+ }
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "objects": [
+ {
+ "type": "box",
+ "name": "Ground",
+ "color": 2830134,
+ "size": [
+ 30,
+ 0.1,
+ 40
+ ],
+ "pos": [
+ 0,
+ -0.05,
+ -2
+ ],
+ "roughness": 0.95,
+ "physics": {
+ "mode": "static",
+ "friction": 0.7
+ }
+ },
+ {
+ "type": "box",
+ "name": "Goal",
+ "color": 3898840,
+ "size": [
+ 1.6,
+ 1,
+ 1.6
+ ],
+ "pos": [
+ 0,
+ 0.5,
+ 6
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Spawn 1",
+ "color": 13191754,
+ "size": [
+ 0.6,
+ 0.1,
+ 0.6
+ ],
+ "pos": [
+ -6,
+ 0.4,
+ -10
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Spawn 2",
+ "color": 13191754,
+ "size": [
+ 0.6,
+ 0.1,
+ 0.6
+ ],
+ "pos": [
+ 0,
+ 0.4,
+ -12
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Spawn 3",
+ "color": 13191754,
+ "size": [
+ 0.6,
+ 0.1,
+ 0.6
+ ],
+ "pos": [
+ 6,
+ 0.4,
+ -10
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ },
+ {
+ "type": "box",
+ "name": "Enemy 1",
+ "color": 16751723,
+ "size": [
+ 0.8,
+ 0.8,
+ 0.8
+ ],
+ "pos": [
+ -6,
+ 0.4,
+ -8
+ ],
+ "physics": {
+ "mode": "dynamic",
+ "mass": 1,
+ "friction": 0.6
+ }
+ },
+ {
+ "type": "box",
+ "name": "Enemy 2",
+ "color": 16751723,
+ "size": [
+ 0.8,
+ 0.8,
+ 0.8
+ ],
+ "pos": [
+ -4.5,
+ 0.4,
+ -8
+ ],
+ "physics": {
+ "mode": "dynamic",
+ "mass": 1,
+ "friction": 0.6
+ }
+ },
+ {
+ "type": "box",
+ "name": "Enemy 3",
+ "color": 16751723,
+ "size": [
+ 0.8,
+ 0.8,
+ 0.8
+ ],
+ "pos": [
+ -3,
+ 0.4,
+ -8
+ ],
+ "physics": {
+ "mode": "dynamic",
+ "mass": 1,
+ "friction": 0.6
+ }
+ },
+ {
+ "type": "box",
+ "name": "Enemy 4",
+ "color": 16751723,
+ "size": [
+ 0.8,
+ 0.8,
+ 0.8
+ ],
+ "pos": [
+ -1.5,
+ 0.4,
+ -8
+ ],
+ "physics": {
+ "mode": "dynamic",
+ "mass": 1,
+ "friction": 0.6
+ }
+ },
+ {
+ "type": "box",
+ "name": "Home",
+ "color": 7327610,
+ "size": [
+ 1,
+ 0.1,
+ 1
+ ],
+ "pos": [
+ 0,
+ 0.05,
+ 9
+ ],
+ "physics": {
+ "mode": "static"
+ }
+ }
+ ]
+}
diff --git a/package.json b/package.json
index f7b3384..1256b5d 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"license": "MIT",
- "description": "Optional/community modules for theprototype.app — one folder per module under modules/.",
+ "description": "Optional/community modules for theprototype.app \u2014 one folder per module under modules/.",
"repository": {
"type": "git",
"url": "https://github.com/theprototype-app/modules.git"
@@ -11,9 +11,19 @@
"scripts": {
"pack": "node scripts/pack.cjs",
"new": "node scripts/new.cjs",
+ "build:dungeon": "esbuild modules/dungeon/src/index.js --bundle --format=esm --outfile=modules/dungeon/module.js",
+ "test:dungeon": "node modules/dungeon/test/run.mjs",
"test": "node tests/run.cjs",
- "build:dungeon-realms": "esbuild modules/dungeon-realms/src/index.js --bundle --format=esm --outfile=modules/dungeon-realms/module.js",
- "test:dungeon-realms": "node modules/dungeon-realms/test/run.mjs"
+ "build:dungeon-realms": "esbuild modules/dungeon-realms/src/index.js --bundle --format=esm --outfile=modules/dungeon-realms/module.js && node modules/dungeon-realms/emit-def.mjs",
+ "test:dungeon-realms": "node modules/dungeon-realms/test/run.mjs",
+ "build:football": "esbuild modules/football/src/index.js --bundle --format=esm --outfile=modules/football/module.js && node modules/football/emit-def.mjs",
+ "test:football": "node modules/football/test/run.mjs",
+ "build:untangle": "esbuild modules/untangle/src/index.js --bundle --format=esm --outfile=modules/untangle/module.js && node modules/untangle/emit-def.mjs",
+ "test:untangle": "node modules/untangle/test/run.mjs",
+ "build:health": "esbuild modules/health/src/index.js --bundle --format=esm --outfile=modules/health/module.js",
+ "test:health": "node modules/health/test/run.mjs",
+ "build:waves": "esbuild modules/waves/src/index.js --bundle --format=esm --outfile=modules/waves/module.js && node modules/waves/emit-def.mjs",
+ "test:waves": "node modules/waves/test/run.mjs"
},
"devDependencies": {
"esbuild": "0.28.2",
diff --git a/tests/dungeon-realms.test.cjs b/tests/dungeon-realms.test.cjs
index 131b6e5..d005dd9 100644
--- a/tests/dungeon-realms.test.cjs
+++ b/tests/dungeon-realms.test.cjs
@@ -1,48 +1,58 @@
-// dungeon-realms test-flight: real zip install on TWO peers, seed-identical
-// dungeons (checksum match), the play-mode GUI (start menu -> P1/P2 slots ->
-// start), replicated gem collection, portal unseal + floor travel on both
-// sides, flight-key suppression, and a LATE JOINER catching up mid-game.
+// dungeon-realms test-flight (21-C C6): the game is an OVERLAY on the Dungeon Kit, so
+// BOTH real zips install on TWO peers. Seed-identical dungeons (Kit checksums match),
+// the Realms overlay (gems + portals in Realms' own group), the play-mode menu (start
+// menu -> P1/P2 slots -> start), the grounded rule riding the CONTRACT
+// (userData.play.grounded, with its counterfactual), the minimap markers Realms adds
+// (gems + portals, one fewer per collected gem), replicated gem collection, portal
+// unseal + floor travel on both sides through the Kit, the value/rows node readouts,
+// and a LATE JOINER catching up mid-game with both modules.
//
-// npm run pack -- dungeon-realms
-// APP_URL=https://localhost:5189/ node tests/dungeon-realms.test.cjs
+// npm run pack -- dungeon && npm run pack -- dungeon-realms
+// APP_URL=https://theprototype.app:5216/ node tests/dungeon-realms.test.cjs
const { launch, setupPage, installModule, connect, check, eventually, finish, run } = require('./helpers.cjs');
-/** one serializable snapshot of the module's game state on a page */
+/** one serializable snapshot of the Kit world + the Realms game on a page */
function snap(page) {
return page.evaluate(
() =>
new Promise((resolve) => {
window.__stores.globalScene.subscribe((scene) => {
- const group = scene?.getObjectByName('dungeon-module');
- const game = group?.userData?._dr?.game;
- const portal = group?.getObjectByName('dr-portal-up');
- const floors = group?.getObjectByName('dr-floors');
+ const kitGroup = scene?.getObjectByName('dungeon-module');
+ const overlay = scene?.getObjectByName('dungeon-realms');
+ const game = overlay?.userData?._dr?.game ?? null;
+ const portal = overlay?.getObjectByName('dr-portal-up');
+ const floors = kitGroup?.getObjectByName('dk-floors');
+ const gems = overlay?.getObjectByName('dr-gems');
+ const play = kitGroup?.userData?.play;
const minimap = document.getElementById('dungeon-minimap');
- resolve(
- group && game
- ? {
- seed: group.userData.seed,
- floorIndex: group.userData.floorIndex,
- checksum: group.userData.checksum,
- campaignChecksum: group.userData.campaignChecksum,
- floorInstances: floors ? floors.count : 0,
- started: !!game.state.started,
- won: !!game.state.wonAt,
- p1: game.state.slots.p1?.peerId ?? null,
- p2: game.state.slots.p2?.peerId ?? null,
- collected: Object.values(game.state.collected).reduce((sum, set) => sum + set.size, 0),
- sealedUp: portal ? portal.userData.portal.sealed : null,
- need: game.gemTotals().need,
- total: game.gemTotals().total,
- floors: game.state.campaign?.floors.length ?? 0,
- menu: !!document.getElementById('dr-menu'),
- hud: !!document.getElementById('dr-hud'),
- hudGems: document.getElementById('dr-gem-count')?.textContent ?? null,
- minimapVisible: !!minimap && !minimap.classList.contains('hidden')
- }
- : null
- );
+ resolve({
+ kit: !!kitGroup?.userData?.kit,
+ seed: kitGroup?.userData?.seed ?? null,
+ floorIndex: kitGroup?.userData?.floorIndex ?? null,
+ checksum: kitGroup?.userData?.checksum ?? null,
+ campaignChecksum: kitGroup?.userData?.campaignChecksum ?? null,
+ floorInstances: floors ? floors.count : 0,
+ grounded: play?.grounded ?? null,
+ markers: Array.isArray(play?.markers) ? play.markers.length : null,
+ markerKinds: [...new Set((play?.markers ?? []).map((m) => m.kind))].sort(),
+ overlay: !!overlay,
+ gemInstances: gems ? gems.count : 0,
+ game: !!game,
+ started: !!game?.state.started,
+ won: !!game?.state.wonAt,
+ p1: game?.state.slots.p1?.peerId ?? null,
+ p2: game?.state.slots.p2?.peerId ?? null,
+ collected: game ? Object.values(game.state.collected).reduce((sum, set) => sum + set.size, 0) : 0,
+ sealedUp: portal ? portal.userData.portal.sealed : null,
+ need: game?.gemTotals().need ?? 0,
+ total: game?.gemTotals().total ?? 0,
+ levels: game?.state.levelCount ?? 0,
+ gameFloor: game?.state.floorIndex ?? null,
+ menu: !!document.getElementById('dr-menu'),
+ hudDom: !!document.getElementById('dr-hud'),
+ minimapVisible: !!minimap && !minimap.classList.contains('hidden')
+ });
})();
})
);
@@ -63,19 +73,28 @@ run(async () => {
const A = await setupPage(browser, 'A');
const B = await setupPage(browser, 'B');
+ // both modules on both peers: the Kit is the world, Realms the game
+ await installModule(A, 'dungeon');
await installModule(A, 'dungeon-realms');
+ await installModule(B, 'dungeon');
await installModule(B, 'dungeon-realms');
- // node family present in the palette
+ // node families present in the palette
const groups = await A.page.evaluate(
() => new Promise((r) => window.__stores.moduleSDK.moduleNodeGroups.subscribe((g) => r(g.map((x) => [x.group, x.items.length])))())
);
- const family = groups.find(([name]) => name === 'Dungeon Realms');
- check(!!family && family[1] === 5, 'node palette has "Dungeon Realms" with 5 nodes (' + JSON.stringify(family) + ')');
+ const realms = groups.find(([name]) => name === 'Dungeon Realms');
+ const kit = groups.find(([name]) => name === 'Dungeon Kit');
+ check(!!realms && realms[1] === 6, 'node palette has "Dungeon Realms" with 6 nodes — rules, menu, prop, value, rows, event (' + JSON.stringify(realms) + ')');
+ check(!!kit && kit[1] === 1, 'node palette has "Dungeon Kit" with its Dungeon node');
+ const retired = await A.page.evaluate(
+ () => new Promise((r) => window.__stores.moduleSDK.moduleNodeGroups.subscribe((g) => r(g.some((x) => x.items.some((i) => i.type === 'drhud' || i.type === 'drdungeon'))))())
+ );
+ check(!retired, 'the drhud and drdungeon nodes are GONE (deleted, not ported)');
await connect(A, B);
- // A generates via the real module-card button; the op replicates the recipe
+ // A generates via the real module-card button -> Realms asks the Kit, the Kit replicates
await A.page.evaluate(
() =>
new Promise((resolve) => {
@@ -85,94 +104,94 @@ run(async () => {
})();
})
);
- await eventually(() => snap(A.page), (s) => !!s && s.floorInstances > 0, 'A builds the dungeon (instanced floors > 0)');
+ await eventually(() => snap(A.page), (s) => s.floorInstances > 0 && s.overlay && s.game, 'A: the Kit builds the level and Realms builds its overlay on top');
const a1 = await snap(A.page);
await eventually(
() => snap(B.page),
- (s) => !!s && s.checksum === a1.checksum && s.campaignChecksum === a1.campaignChecksum,
- 'B regenerates the IDENTICAL dungeon from {seed, params} (checksums match)'
+ (s) => s.checksum === a1.checksum && s.campaignChecksum === a1.campaignChecksum && s.overlay,
+ 'B regenerates the IDENTICAL dungeon from the Kit\'s {seed, params} and overlays it (checksums match)'
);
- check(a1.floors === 5 && a1.total >= 4, 'campaign has 5 floors, ' + a1.total + ' gems hidden on floor 1 (need ' + a1.need + ')');
+ check(a1.levels === 5 && a1.total >= 4 && a1.gemInstances === a1.total, 'campaign has 5 floors, ' + a1.total + ' gems on floor 1 as instances (need ' + a1.need + ')');
+ check(a1.markers === a1.total + 1 && a1.markerKinds.join(',') === 'door,gem', 'Realms puts its gems + the UP portal on the minimap markers (' + a1.markers + ')');
+ check(!a1.hudDom, 'no #dr-hud DOM: the HUD is core HUD elements the template authors');
// both press the real red Play button -> play mode -> the start menu appears
await A.page.locator('#play-button').click();
await B.page.locator('#play-button').click();
- await eventually(() => snap(A.page), (s) => s?.minimapVisible && s?.menu, 'A: play mode engages and the start menu appears');
- await eventually(() => snap(B.page), (s) => s?.minimapVisible && s?.menu, 'B: play mode engages and the start menu appears');
+ await eventually(() => snap(A.page), (s) => s.minimapVisible && s.menu, 'A: play mode engages (core minimap off the Kit contract) and the start menu appears');
+ await eventually(() => snap(B.page), (s) => s.minimapVisible && s.menu, 'B: play mode engages and the start menu appears');
// P1 / P2 slots replicate
check(await clickMenu(A.page, 'join-p1'), 'A clicks "Join as Player 1"');
check(await clickMenu(B.page, 'join-p2'), 'B clicks "Join as Player 2"');
- await eventually(() => snap(A.page), (s) => s?.p1 === A.id && s?.p2 === B.id, 'A sees both slots claimed (P1=A, P2=B)');
- await eventually(() => snap(B.page), (s) => s?.p1 === A.id && s?.p2 === B.id, 'B sees both slots claimed');
+ await eventually(() => snap(A.page), (s) => s.p1 === A.id && s.p2 === B.id, 'A sees both slots claimed (P1=A, P2=B)');
+ await eventually(() => snap(B.page), (s) => s.p1 === A.id && s.p2 === B.id, 'B sees both slots claimed');
- // start the adventure -> menu closes, HUD appears on BOTH
+ // start the adventure -> menu closes on BOTH
await clickMenu(A.page, 'start');
- await eventually(() => snap(A.page), (s) => s?.started && s?.hud && !s?.menu, 'A: game starts, HUD up, menu gone');
- await eventually(() => snap(B.page), (s) => s?.started && s?.hud, 'B: start replicated, HUD up');
-
- // flight keys are swallowed while the game runs (Game Rules ▸ disableFlight)
- const qSuppressed = await A.page.evaluate(
- () => !window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyQ', cancelable: true, bubbles: true }))
+ await eventually(() => snap(A.page), (s) => s.started && !s.menu, 'A: game starts, menu gone');
+ await eventually(() => snap(B.page), (s) => s.started, 'B: start replicated');
+
+ // disableFlight rides the CONTRACT: grounded true, and false when the rule is off
+ check(a1.grounded === true, 'Game Rules ▸ disableFlight publishes userData.play.grounded = true (no key swallowing)');
+ await A.page.evaluate(() => (window.__dungeonRealms.game.config.rules.disableFlight = false));
+ await eventually(() => snap(A.page), (s) => s.grounded === false, 'counterfactual: disableFlight off -> play.grounded false');
+ await A.page.evaluate(() => (window.__dungeonRealms.game.config.rules.disableFlight = true));
+ await eventually(() => snap(A.page), (s) => s.grounded === true, 'restored: grounded true again');
+ const qPassed = await A.page.evaluate(
+ () => window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyQ', cancelable: true, bubbles: true }))
);
- check(qSuppressed, 'KeyQ (fly up) is suppressed while the game runs');
+ check(qPassed, 'KeyQ is NOT swallowed at window capture any more (the contract does the work)');
- // collect one gem on A -> replicates to B (HUD counter agrees)
- await A.page.evaluate(
- () =>
- new Promise((resolve) => {
- window.__stores.globalScene.subscribe((scene) => {
- scene.getObjectByName('dungeon-module').userData._dr.game.collect(0);
- resolve();
- })();
- })
- );
- await eventually(() => snap(B.page), (s) => s?.collected === 1 && s?.hudGems === '1', 'B receives the gem (state + HUD counter = 1)');
+ // the value / rows nodes read the game
+ const rows = await A.page.evaluate(() => window.__dungeonRealms.nodes.rowsFor('all'));
+ check(Array.isArray(rows) && rows.some((r) => /needed/.test(r)) && rows.some((r) => /^LEVEL 1 \/ 5/.test(r)) && rows.some((r) => /^P1 /.test(r)), 'Realms HUD Rows publishes gems / level / players lines (' + rows.length + ' rows)');
- // collect up to the unseal threshold -> the UP portal unseals on BOTH peers
- await A.page.evaluate(
- () =>
- new Promise((resolve) => {
- window.__stores.globalScene.subscribe((scene) => {
- const game = scene.getObjectByName('dungeon-module').userData._dr.game;
- for (let i = 0; i < game.gemTotals().need; i++) game.collect(i);
- resolve();
- })();
- })
- );
- await eventually(() => snap(A.page), (s) => s?.sealedUp === false, 'A: UP portal unseals at the gem threshold');
- await eventually(() => snap(B.page), (s) => s?.sealedUp === false, 'B: unseal replicates');
+ // collect one gem on A -> replicates to B; one marker fewer on both
+ await A.page.evaluate(() => window.__dungeonRealms.game.collectGem(1, 0));
+ await eventually(() => snap(B.page), (s) => s.collected === 1, 'B receives the gem (state = 1)');
+ await eventually(() => snap(A.page), (s) => s.markers === a1.markers - 1, 'A: one minimap marker fewer after the pickup');
+ await eventually(() => snap(B.page), (s) => s.markers === a1.markers - 1, 'B: marker count agrees');
- // portal travel through the REAL click dispatch -> floor 2 on both, same world
+ // collect up to the unseal threshold -> the UP portal unseals on BOTH peers
+ await A.page.evaluate(() => {
+ const g = window.__dungeonRealms.game;
+ for (let i = 0; i < g.gemTotals().need; i++) g.collectGem(g.state.floorIndex, i);
+ });
+ await eventually(() => snap(A.page), (s) => s.sealedUp === false, 'A: UP portal unseals at the gem threshold');
+ await eventually(() => snap(B.page), (s) => s.sealedUp === false, 'B: unseal replicates');
+
+ // portal travel through the REAL click dispatch -> the Kit shows floor 2 on both, same world
await A.page.evaluate(
() =>
new Promise((resolve) => {
window.__stores.globalScene.subscribe((scene) => {
- const ring = scene.getObjectByName('dungeon-module').getObjectByName('dr-portal-up').children[0];
+ const ring = scene.getObjectByName('dungeon-realms').getObjectByName('dr-portal-up').children[0];
resolve(window.__stores.moduleSDK.moduleClickHandlers.some((handler) => handler(ring)));
})();
})
);
- await eventually(() => snap(A.page), (s) => s?.floorIndex === 2, 'A travels to floor 2');
+ await eventually(() => snap(A.page), (s) => s.floorIndex === 2 && s.gameFloor === 2 && s.overlay, 'A travels to floor 2 (Kit + overlay agree)');
const a2 = await snap(A.page);
await eventually(
() => snap(B.page),
- (s) => s?.floorIndex === 2 && s.checksum === a2.checksum,
+ (s) => s.floorIndex === 2 && s.gameFloor === 2 && s.checksum === a2.checksum,
'B travels with A — floor 2 checksums match'
);
- // late joiner: installs, connects mid-game, catches up wholesale.
+ // late joiner: installs BOTH, connects mid-game, catches up wholesale.
// A leaves play mode first — the approval toast is unreachable while the
// approving peer is pointer-locked in play mode.
await A.page.keyboard.press('Escape');
await A.page.waitForTimeout(500);
const C = await setupPage(browser, 'C');
+ await installModule(C, 'dungeon');
await installModule(C, 'dungeon-realms');
await connect(C, A);
await eventually(
() => snap(C.page),
- (s) => !!s && s.floorIndex === 2 && s.checksum === a2.checksum && s.started && s.collected === a2.collected,
- 'late joiner C rebuilds mid-game (floor 2, same checksum, gem count, started)',
+ (s) => s.floorIndex === 2 && s.checksum === a2.checksum && s.started && s.collected === a2.collected && s.gameFloor === 2 && s.overlay,
+ 'late joiner C rebuilds mid-game (floor 2, same checksum, gem count, started, overlay up)',
30000
);
diff --git a/tests/dungeon.test.cjs b/tests/dungeon.test.cjs
new file mode 100644
index 0000000..c185cac
--- /dev/null
+++ b/tests/dungeon.test.cjs
@@ -0,0 +1,148 @@
+// Dungeon Kit test-flight: the REAL zip on TWO peers, the registered TOOLBOX (21-C
+// C6.2 — `api.registerToolbox`, no `#dungeon-panel`), Generate through the toolbox
+// DOM -> seed-identical dungeons on both peers (checksums match), the play CONTRACT
+// a bare Kit publishes (grounded, NO markers, contract v2, world-space props/portals),
+// the app's own minimap reading it in play mode (dungeonData), the floor stepper
+// replicating travel, Clear replicating, the Dungeon node in the palette, and a LATE
+// JOINER rebuilding from {seed, params, floorIndex}.
+//
+// npm run pack -- dungeon
+// APP_URL=https://theprototype.app:5216/ node tests/dungeon.test.cjs
+
+const { launch, setupPage, installModule, connect, check, eventually, finish, run } = require('./helpers.cjs');
+
+/** the Kit's group + contract on a page */
+function snap(page) {
+ return page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ window.__stores.globalScene.subscribe((scene) => {
+ const group = scene?.getObjectByName('dungeon-module');
+ const floors = group?.getObjectByName('dk-floors');
+ const play = group?.userData?.play;
+ const minimap = document.getElementById('dungeon-minimap');
+ resolve(
+ group
+ ? {
+ hasKit: typeof group.userData.kit?.generate === 'function',
+ kitVersion: group.userData.kit?.version ?? null,
+ seed: group.userData.seed ?? null,
+ floorIndex: group.userData.floorIndex ?? null,
+ levelCount: group.userData.levelCount ?? null,
+ checksum: group.userData.checksum ?? null,
+ campaignChecksum: group.userData.campaignChecksum ?? null,
+ floorInstances: floors ? floors.count : 0,
+ hasPlay: !!play,
+ contract: play?.contract ?? null,
+ grounded: play?.grounded ?? null,
+ markers: Array.isArray(play?.markers) ? play.markers.length : null,
+ props: play?.props?.length ?? 0,
+ gems: (play?.props ?? []).filter((p) => p.kind === 'gem').length,
+ portals: (play?.portals ?? []).map((p) => p.kind),
+ rooms: play?.rooms?.length ?? 0,
+ entranceFirst: play?.rooms?.[0]?.type === 'entrance',
+ params: group.userData.params ?? null,
+ minimapVisible: !!minimap && !minimap.classList.contains('hidden'),
+ panelDom: !!document.getElementById('dungeon-panel'),
+ toolboxHost: window.__dungeonKit?.toolbox?.host ?? null
+ }
+ : null
+ );
+ })();
+ })
+ );
+}
+
+/** open the Kit toolbox through the store the sidebar uses; returns true when its DOM mounted */
+async function openToolbox(page) {
+ return page.evaluate(async () => {
+ const s = window.__stores;
+ let list = [];
+ s.moduleToolboxes.moduleToolboxes.subscribe((v) => (list = v))();
+ const box = list.find((b) => b.moduleId === 'dungeon');
+ if (!box) return { ok: false, why: 'no toolbox registered for dungeon' };
+ s.moduleToolboxes.openModuleToolbox(box.id);
+ for (let i = 0; i < 20 && !document.getElementById('dk-generate'); i++) await new Promise((r) => setTimeout(r, 100));
+ return { ok: !!document.getElementById('dk-generate'), id: box.id, title: box.title };
+ });
+}
+
+run(async () => {
+ const browser = await launch();
+ const A = await setupPage(browser, 'A');
+ const B = await setupPage(browser, 'B');
+
+ await installModule(A, 'dungeon');
+ await installModule(B, 'dungeon');
+
+ // the palette has the Kit's node, the group exists from the first frame, no fixed panel
+ const groups = await A.page.evaluate(
+ () => new Promise((r) => window.__stores.moduleSDK.moduleNodeGroups.subscribe((g) => r(g.map((x) => [x.group, x.items.length])))())
+ );
+ const family = groups.find(([name]) => name === 'Dungeon Kit');
+ check(!!family && family[1] === 1, 'node palette has "Dungeon Kit" with 1 node (' + JSON.stringify(family) + ')');
+ const empty = await snap(A.page);
+ check(!!empty && empty.hasKit && empty.kitVersion === 2 && !empty.hasPlay, 'the persistent group carries userData.kit (v2) before any dungeon exists, and no play contract');
+ check(empty.toolboxHost === 'toolbox' && !empty.panelDom, 'the toolbox is the registered ToolboxWindow host — no #dungeon-panel DOM (' + empty.toolboxHost + ')');
+
+ await connect(A, B);
+
+ // generate through the REAL toolbox DOM
+ const opened = await openToolbox(A.page);
+ check(opened.ok, 'A opens the Dungeon Kit toolbox (' + JSON.stringify(opened) + ')');
+ await A.page.evaluate(() => {
+ const seed = document.getElementById('dk-seed');
+ seed.value = '4242';
+ seed.dispatchEvent(new Event('change', { bubbles: true }));
+ document.getElementById('dk-generate').click();
+ });
+ await eventually(() => snap(A.page), (s) => !!s && s.floorInstances > 0 && s.seed === 4242, 'A builds seed 4242 from the toolbox (instanced floors > 0)');
+ const a1 = await snap(A.page);
+ await eventually(
+ () => snap(B.page),
+ (s) => !!s && s.checksum === a1.checksum && s.campaignChecksum === a1.campaignChecksum && s.seed === 4242,
+ 'B regenerates the IDENTICAL dungeon from {seed, params} (checksums match)'
+ );
+
+ // the contract a BARE Kit publishes
+ check(a1.contract === 2 && a1.hasPlay, 'userData.play carries contract v2');
+ check(a1.grounded === true, 'a dungeon is walked: play.grounded is true by default');
+ check(a1.markers === 0, 'a bare Kit puts NOTHING on the minimap (markers: [])');
+ check(a1.gems >= 4 && a1.props > a1.gems && a1.portals.includes('up'), 'props (' + a1.props + ', ' + a1.gems + ' gems) and portals (' + a1.portals.join(',') + ') are published in world space');
+ check(a1.rooms > 0 && a1.entranceFirst, 'rooms are spawn-ordered, entrance first (' + a1.rooms + ')');
+ check(a1.levelCount === 5 && a1.floorIndex === 1, 'campaign of 5 floors, floor 1 shown');
+ const stats = await A.page.evaluate(() => document.getElementById('dk-stats')?.textContent ?? '');
+ check(/checksum \d+/.test(stats) && /gems/.test(stats), 'the toolbox footer shows the stats line (' + stats.split('\n')[0] + ')');
+
+ // core reads the contract: the minimap shows in play mode (dungeonData -> userData.play)
+ await A.page.locator('#play-button').click();
+ await eventually(() => snap(A.page), (s) => s?.minimapVisible, 'A: play mode shows the core minimap off the Kit\'s contract');
+ await A.page.keyboard.press('Escape');
+ await A.page.waitForTimeout(500);
+
+ // the floor stepper replicates travel
+ await A.page.evaluate(() => document.getElementById('dk-floor-up').click());
+ await eventually(() => snap(A.page), (s) => s?.floorIndex === 2, 'A steps to floor 2 through the toolbox');
+ const a2 = await snap(A.page);
+ check(a2.checksum !== a1.checksum, 'floor 2 is a different floor (checksum changed)');
+ await eventually(() => snap(B.page), (s) => s?.floorIndex === 2 && s.checksum === a2.checksum, 'B follows to floor 2 — checksums match');
+
+ // late joiner: installs, connects, rebuilds {seed, params, floorIndex}
+ const C = await setupPage(browser, 'C');
+ await installModule(C, 'dungeon');
+ await connect(C, A);
+ await eventually(
+ () => snap(C.page),
+ (s) => !!s && s.seed === 4242 && s.floorIndex === 2 && s.checksum === a2.checksum,
+ 'late joiner C rebuilds seed 4242 on floor 2 (same checksum)',
+ 30000
+ );
+
+ // clear replicates; the group stays (persistent) but the contract goes
+ await A.page.evaluate(() => document.getElementById('dk-clear').click());
+ await eventually(() => snap(A.page), (s) => !!s && !s.hasPlay && s.floorInstances === 0, 'A clears: no play contract, no floors, the group persists');
+ await eventually(() => snap(B.page), (s) => !!s && !s.hasPlay, 'B: clear replicates');
+ await eventually(() => snap(C.page), (s) => !!s && !s.hasPlay, 'C: clear replicates');
+
+ await finish(browser);
+});
diff --git a/tests/football.test.cjs b/tests/football.test.cjs
new file mode 100644
index 0000000..a904d7f
--- /dev/null
+++ b/tests/football.test.cjs
@@ -0,0 +1,324 @@
+// football test-flight: the REAL zip on TWO peers (+ a late joiner), the pitch built by
+// the module's own recipe (the def's objects/graph, one source), zero-g + knock as scene
+// physics, both peers in play with the sim on the initiator; opposite teams claimed
+// through the physical buttons' click path, Start serves, peer B's hit (A1's probe hook)
+// sets lastTouch on BOTH, a ball teleported into gate A scores for B in B's OWN goals
+// row only, a defender's last touch is an own goal on the sheet, `ownGoals: ignore`
+// scores nothing, duel refuses a second red, free for all scores by player, time mode
+// ends the match for everyone and writes the saved log, a late joiner reads the same
+// score/slots/outcome, the lamps and the DOM HUD lists read the same nodes (B2), a
+// perPlayer HUD Button presses a Match Button, and Fit pitch replicates as moves (B3).
+//
+// npm run pack -- football
+// flock -w 5400 /tmp/tp-e2e.lock env APP_URL=https://theprototype.app:5208/ npm test -- football
+//
+// The hit feed: with A2 in the app the module reads api.onHit; on an A1-only build it
+// falls back to the knock debug hook, which the flight has on (debugStores). Either way
+// the probe that HITS is core's feedProbe — the module never fakes a touch.
+
+const { launch, setupPage, installModule, connect, check, eventually, finish, run, GPU_ARGS } = require('./helpers.cjs');
+
+/** the module's debug snapshot on a page */
+const snap = (page) => page.evaluate(() => window.__football?.snapshot() ?? null);
+
+/** the object under `uuid` clicked through the SAME dispatch the viewport uses */
+const clickObject = (page, uuid) =>
+ page.evaluate((uuid) => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ const object = group?.getObjectByProperty('uuid', uuid);
+ if (!object) return false;
+ return window.__stores.moduleSDK.moduleClickHandlers.some((handler) => handler(object));
+ }, uuid);
+
+/** the ball's position on that page */
+const posOf = (page, uuid) =>
+ page.evaluate((uuid) => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ const o = group?.getObjectByProperty('uuid', uuid);
+ return o ? o.position.toArray() : null;
+ }, uuid);
+
+const speedOf = (page, uuid) =>
+ page.evaluate((uuid) => {
+ const b = window.__stores.physics.physicsDebug().find((e) => e.uuid === uuid);
+ return b?.linvel ? Math.hypot(b.linvel.x, b.linvel.y, b.linvel.z) : null;
+ }, uuid);
+
+/** sweep a knock probe through the ball's CURRENT position on this page, along +x, at
+ * `speed` m/s on a synthetic clock — core's feedProbe, the A1 test hook */
+const hitBall = (page, uuid, speed = 4) =>
+ page.evaluate(
+ ({ uuid, speed }) => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ const o = group?.getObjectByProperty('uuid', uuid);
+ if (!o) return { hits: 0, armed: false, reason: 'no ball' };
+ const k = window.__stores.knock;
+ const id = 'fb-' + Math.random().toString(36).slice(2, 6);
+ k.dropProbe(id);
+ const [bx, by, bz] = o.position.toArray();
+ let hits = 0;
+ let armed = true;
+ let t = 1000;
+ const step = (speed * 16) / 1000;
+ for (let x = bx - 1.2; x <= bx + 0.05; x += step) {
+ const r = k.feedProbe(id, [x, by, bz], t);
+ hits += r.hits;
+ armed = armed && r.armed;
+ t += 16;
+ }
+ k.dropProbe(id);
+ return { hits, armed };
+ },
+ { uuid, speed }
+ );
+
+/**
+ * Park the ball INSIDE a gate sensor on the initiator (applyThrow reseats AND zeroes).
+ *
+ * Two waits, both load-bearing, and both learned from a red run:
+ * 1. the pending SERVE. A ball entering a gate while a serve is pending is ignored by
+ * design — it is still sitting in the gate it just scored in.
+ * 2. a lap through the CENTRE. The goal detector is an ENTER EDGE per gate, so a ball
+ * left inside the sensor by a gentle post-goal serve is already `inside` that gate:
+ * throwing it there again produces no edge at all and the next goal never fires.
+ * Parking at the centre for a frame clears that latch on the authority, which is
+ * what makes a second goal into the SAME gate assertable.
+ */
+const teleport = async (page, uuid, pos) => {
+ await eventually(() => snap(page), (s) => s?.started && s.serveAt === 0, ' (premise) no serve pending');
+ await page.evaluate(
+ ({ uuid, y }) => window.__stores.physics.applyThrow({ uuid, pos: [0, y, 0], rot: [0, 0, 0], linvel: [0, 0, 0], angvel: [0, 0, 0] }),
+ { uuid, y: pos[1] }
+ );
+ await page.waitForTimeout(400);
+ return page.evaluate(
+ ({ uuid, pos }) => window.__stores.physics.applyThrow({ uuid, pos, rot: [0, 0, 0], linvel: [0, 0, 0], angvel: [0, 0, 0] }),
+ { uuid, pos }
+ );
+};
+
+const myVar = (page, name) => page.evaluate((name) => window.__stores.peerVars.peerVarsDebug().mine[name] ?? 0, name);
+const rowOf = (page, name, id) => page.evaluate(({ name, id }) => window.__stores.peerVars.leaderboardRows(name).find((r) => r.id === id)?.value ?? 0, { name, id });
+const setRules = (page, patch) =>
+ page.evaluate((patch) => {
+ // the toolbox path: the live Match Rules node when there is one (the replicated
+ // nodedata merge), else the module's own replicated rules op
+ let graphs = null;
+ window.__stores.flowGraphs.subscribe((g) => (graphs = g))();
+ let found = null;
+ for (const [graphId, graph] of Object.entries(graphs ?? {}))
+ for (const n of graph.nodes ?? []) if (n.type === 'fbrules') found = { id: n.id, graphId };
+ if (found) {
+ window.__stores.nodesHandler.setNodeData(found.id, patch, found.graphId);
+ return 'node';
+ }
+ window.__football.game.setRules(patch);
+ return 'op';
+ }, patch);
+
+run(async () => {
+ const browser = await launch({ args: GPU_ARGS });
+ const A = await setupPage(browser, 'A');
+ const B = await setupPage(browser, 'B');
+ await installModule(A, 'football');
+ await installModule(B, 'football');
+
+ // ---- 1. the node family is in the palette ----------------------------------------
+ const groups = await A.page.evaluate(
+ () => new Promise((r) => window.__stores.moduleSDK.moduleNodeGroups.subscribe((g) => r(g.map((x) => [x.group, x.items.length])))())
+ );
+ const family = groups.find(([name]) => name === 'Football');
+ check(!!family && family[1] === 8, '1.1 node palette has "Football" with 8 nodes (' + JSON.stringify(family) + ')');
+ check((await snap(A.page))?.hitSource !== 'none', '1.2 the module found a hit feed (' + (await snap(A.page))?.hitSource + ')');
+
+ // ---- 2. A builds the pitch (the def's objects + graph, through the recipe) ----------
+ const names = await A.page.evaluate(() => window.__football.toolbox.buildPitch());
+ check(!!names && Object.keys(names).length === 41, '2.1 the recipe built 41 objects (' + Object.keys(names ?? {}).length + ')');
+ const ball = names['Football'];
+ const redGate = names['Red gate'];
+ const blueGate = names['Blue gate'];
+ // the zero-g block is the template's data (scene physics has no api write, DEVX #20)
+ await A.page.evaluate(() =>
+ window.__stores.scenePhysics.setScenePhysics({
+ // heavier damping than the template's 0.35: a 3 m/s ball in a 5 m box with 0.7 walls
+ // scores on its OWN, which is the game working but makes an expected score a lottery
+ gravity: 0, ground: { enabled: false }, damping: { linear: 1.2, angular: 0.5 }, ccd: true,
+ knock: { enabled: true, gain: 1, maxSpeed: 10, spin: 0.8 }, play: { interaction: 'grab', grounded: false, simOnPlay: true }
+ })
+ );
+ await eventually(() => snap(A.page), (s) => s?.ball === ball && Object.keys(s.gates).length === 2 && s.buttons.length === 4, '2.2 A: Match Rules names the ball, two gates and four buttons are owned by nodes');
+ const a0 = await snap(A.page);
+ check(a0.gates[redGate]?.team === 'red' && a0.gates[blueGate]?.team === 'blue', '2.3 the red gate node targets the red sensor, blue the blue');
+
+ // ---- 3. B joins: scene, graph and physics block replicate --------------------------
+ await connect(A, B);
+ await eventually(
+ () => B.page.evaluate((u) => { let g; window.__stores.objectsGroup.subscribe((v) => (g = v))(); return g?.children.length ?? 0; }, ball),
+ (n) => n >= 41,
+ '3.1 B received the pitch (41 objects)',
+ 30000
+ );
+ await eventually(() => snap(B.page), (s) => s?.ball === ball && Object.keys(s.gates).length === 2 && s.buttons.length === 4, '3.2 B: the graph replicated — same ball, gates and buttons', 20000);
+ await eventually(() => B.page.evaluate(() => window.__stores.scenePhysics.scenePhysicsDebug()), (p) => p.gravity === 0 && p.knock?.enabled === true, '3.3 B: zero-g + knock block reached B');
+
+ // ---- 4. both play; the sim starts on A (simOnPlay) ---------------------------------
+ await A.page.locator('#play-button').click();
+ await B.page.locator('#play-button').click();
+ await eventually(() => A.page.evaluate(() => new Promise((r) => window.__stores.physics.simulating.subscribe(r)())), (v) => v === true, '4.1 A simulates (sim on Play)');
+ await eventually(() => B.page.evaluate(() => new Promise((r) => window.__stores.physics.remoteSimulating.subscribe(r)())), (v) => !!v, '4.2 B knows A simulates');
+ await eventually(() => snap(A.page), (s) => s?.authority === true, '4.3 A is the match authority (the initiator)');
+ check((await snap(B.page))?.authority === false, '4.4 B is not');
+
+ // ---- 5. opposite teams through the physical buttons' click path --------------------
+ check(await clickObject(A.page, names['Join red']), '5.1 A clicks the Join red button object (module click handler consumed it)');
+ check(await clickObject(B.page, names['Join blue']), '5.2 B clicks Join blue');
+ await eventually(() => snap(A.page), (s) => s?.slots.red[0] === A.id && s?.slots.blue[0] === B.id, '5.3 A sees red=A, blue=B');
+ await eventually(() => snap(B.page), (s) => s?.slots.red[0] === A.id && s?.slots.blue[0] === B.id, '5.4 B agrees');
+
+ // ---- 6. Start serves --------------------------------------------------------------------
+ // a gentle kick-off for the flight (the template serves at 3 m/s): the ball must not
+ // reach a gate on its own between two assertions
+ check((await setRules(A.page, { serveSpeed: 0.8 })) === 'node', '6.0 serveSpeed 0.8 written on the live Match Rules node');
+ await eventually(() => snap(B.page), (s) => s?.rules.serveSpeed === 0.8, ' (premise) B reads it');
+ check(await clickObject(A.page, names['Start match']), '6.1 A presses Start');
+ await eventually(() => snap(B.page), (s) => s?.started === true, '6.2 B: the match started');
+ await eventually(() => snap(A.page), (s) => s?.serves >= 1, '6.3 A: the kick-off was served (serveDelay 2s)', 8000);
+ await eventually(() => speedOf(A.page, ball), (v) => v != null && v > 0.2, '6.4 the ball moves on the initiator (|v| > 0.2)');
+ await eventually(() => snap(B.page), (s) => s?.serves >= 1, '6.5 B logged the serve');
+
+ // ---- 7. B's hit sets lastTouch on both ----------------------------------------------------
+ const bHit = await hitBall(B.page, ball, 4);
+ check(bHit.armed && bHit.hits >= 1, '7.1 B\'s 4 m/s probe sweep knocks the ball (' + JSON.stringify(bHit) + ')');
+ await eventually(() => snap(B.page), (s) => s?.lastTouch?.by === B.id && s.lastTouch.team === 'blue', '7.2 B: lastTouch = B (blue)');
+ await eventually(() => snap(A.page), (s) => s?.lastTouch?.by === A.id ? false : s?.lastTouch?.by === B.id, '7.3 A: lastTouch = B too (the hit crossed the wire)');
+ await eventually(() => myVar(B.page, 'touches'), (v) => v >= 1, '7.4 B\'s own touches row = ' + (await myVar(B.page, 'touches')));
+
+ // ---- 8. the ball into the RED gate scores for BLUE, in B's row only ------------------------
+ const redPos = await posOf(A.page, redGate);
+ const servesBefore8 = (await snap(A.page)).serves;
+ await teleport(A.page, ball, redPos);
+ await eventually(() => snap(A.page), (s) => s?.score.blue === 1 && s.score.red === 0, '8.1 A: score blue 1 — red 0');
+ await eventually(() => snap(B.page), (s) => s?.score.blue === 1, '8.2 B: the goal replicated');
+ await eventually(() => myVar(B.page, 'goals'), (v) => v === 1, '8.3 B\'s OWN goals row = 1');
+ check((await myVar(A.page, 'goals')) === 0, '8.4 A\'s goals row stays 0');
+ await eventually(() => rowOf(A.page, 'goals', B.id), (v) => v === 1, '8.5 A\'s leaderboard shows B = 1 (peerVars replicated)');
+ await eventually(() => snap(A.page), (s) => s?.serves > servesBefore8, '8.6 the ball is re-served after the goal', 8000);
+
+ // ---- 9. a defender's last touch is an OWN GOAL on the sheet ----------------------------------
+ const aHit = await hitBall(A.page, ball, 4);
+ check(aHit.hits >= 1, '9.1 A (red) knocks the ball (' + JSON.stringify(aHit) + ')');
+ await eventually(() => snap(B.page), (s) => s?.lastTouch?.by === A.id, '9.2 B: lastTouch = A');
+ const servesBefore9 = (await snap(A.page)).serves;
+ await teleport(A.page, ball, redPos);
+ await eventually(() => snap(A.page), (s) => s?.score.blue === 2, '9.3 blue 2 — the own goal counts for blue');
+ await eventually(() => myVar(A.page, 'owngoals'), (v) => v === 1, '9.4 A\'s owngoals row = 1');
+ check((await myVar(A.page, 'goals')) === 0 && (await myVar(B.page, 'goals')) === 1, '9.5 nobody\'s goals row moved');
+ await eventually(() => snap(A.page), (s) => s?.serves > servesBefore9, '9.6 re-served', 8000);
+
+ // ---- 10. counterfactual: ownGoals 'ignore' scores nothing ----------------------------------------
+ check((await setRules(A.page, { ownGoals: 'ignore' })) === 'node', '10.1 the rule is edited on the live Match Rules node (replicated graph)');
+ await eventually(() => snap(B.page), (s) => s?.rules.ownGoals === 'ignore', '10.2 B reads ownGoals: ignore from the node');
+ await hitBall(A.page, ball, 4);
+ await eventually(() => snap(A.page), (s) => s?.lastTouch?.by === A.id, '10.3 A touched it');
+ const servesBefore10 = (await snap(A.page)).serves;
+ await teleport(A.page, ball, redPos);
+ await eventually(() => snap(A.page), (s) => s?.serves > servesBefore10, '10.4 the ball is re-served (the goal was seen)...', 8000);
+ check((await snap(A.page)).score.blue === 2 && (await myVar(A.page, 'owngoals')) === 1, '10.5 ...but the score and the sheet did not move');
+ await setRules(A.page, { ownGoals: 'count' });
+
+ // ---- 11. duel refuses a second red -----------------------------------------------------------------
+ await setRules(A.page, { mode: 'duel' });
+ await eventually(() => snap(B.page), (s) => s?.rules.mode === 'duel', '11.1 B reads mode duel');
+ check(await clickObject(B.page, names['Join red']), '11.2 B presses Join red...');
+ await B.page.waitForTimeout(800);
+ check((await snap(B.page)).slots.red.length === 1 && (await snap(B.page)).slots.red[0] === A.id && (await snap(B.page)).slots.blue[0] === B.id, '11.3 ...and is refused: red is still A alone, B still blue');
+ await setRules(A.page, { mode: 'teams' });
+ await eventually(() => snap(B.page), (s) => s?.rules.mode === 'teams', '11.4 (counterfactual) mode teams');
+ await clickObject(B.page, names['Join red']);
+ await eventually(() => snap(A.page), (s) => s?.slots.red.length === 2, '11.5 ...now B may join red (two reds)');
+ await clickObject(B.page, names['Join blue']);
+ await eventually(() => snap(A.page), (s) => s?.slots.blue[0] === B.id && s.slots.red.length === 1, '11.6 B back to blue');
+
+ // ---- 12. free for all scores by player ---------------------------------------------------------------------
+ await setRules(A.page, { mode: 'freeforall' });
+ await eventually(() => snap(B.page), (s) => s?.rules.mode === 'freeforall', '12.1 B reads free for all');
+ const bHit2 = await hitBall(B.page, ball, 4);
+ check(bHit2.hits >= 1, '12.2 B knocks the ball');
+ await eventually(() => snap(A.page), (s) => s?.lastTouch?.by === B.id, '12.3 A: lastTouch = B');
+ await teleport(A.page, ball, redPos);
+ await eventually(() => snap(A.page), (s) => s?.playerGoals[B.id] === 1, '12.4 A: B\'s player goal counted');
+ await eventually(() => snap(B.page), (s) => s?.playerGoals[B.id] === 1 && s.score.blue === 2, '12.5 B agrees; the team score did not move');
+ await eventually(() => myVar(B.page, 'goals'), (v) => v === 2, '12.6 B\'s goals row = 2');
+ await setRules(A.page, { mode: 'teams' });
+
+ // ---- 13. time mode ends the match for everyone and writes the saved log --------------------------------
+ await setRules(A.page, { winBy: 'time', matchSeconds: 30 });
+ await eventually(() => snap(A.page), (s) => s?.started === false && s.outcome?.reason === 'time' && s.outcome.winner === 'blue', '13.1 A: time is up — blue wins', 45000);
+ await eventually(() => snap(B.page), (s) => s?.started === false && s.outcome?.reason === 'time', '13.2 B: the match ended for everyone');
+ await eventually(() => snap(A.page), (s) => s?.log.length === 1 && s.log[0].blue === 2 && s.log[0].winner === 'blue', '13.3 A: the match log has the sheet (B5, gameState.vars)');
+ await eventually(() => snap(B.page), (s) => s?.log.length === 1, '13.4 B: the log replicated with the game singleton');
+ await eventually(() => A.page.evaluate(() => new Promise((r) => window.__stores.gameState.gameState.subscribe((g) => r(g.state))())), (v) => v === 'over', '13.5 the game shell reads over (Football Event -> Set Game State)');
+
+ // ---- 14. a late joiner reads the same match ----------------------------------------------------------------
+ await A.page.keyboard.press('Escape');
+ await A.page.waitForTimeout(500);
+ const C = await setupPage(browser, 'C');
+ await installModule(C, 'football');
+ await connect(C, A);
+ await eventually(
+ () => snap(C.page),
+ (s) => !!s && s.score.blue === 2 && s.slots.red[0] === A.id && s.slots.blue[0] === B.id && s.outcome?.winner === 'blue' && s.ball === ball,
+ '14.1 late joiner C reads score, slots, outcome and the ball (registerStateSync + graph)',
+ 30000
+ );
+ await eventually(() => rowOf(C.page, 'goals', B.id), (v) => v === 2, '14.2 C sees B\'s goals row (peerVars re-announced)');
+
+ // ---- 15. new match resets the score, keeps the sheet -----------------------------------------------------------
+ await A.page.evaluate(() => window.__football.game.act('new-match'));
+ await eventually(() => snap(B.page), (s) => s?.score.blue === 0 && s.started === false && s.outcome === null, '15.1 B: new match — score 0, menu');
+ check((await myVar(B.page, 'goals')) === 2, '15.2 B\'s session goals row survives a new match (records are per session)');
+
+ // ---- 16. B2: the physical UI and the DOM HUD read the same nodes -------------------------
+ // the def's HUD document, applied on A through the replicated hud path (the template
+ // carries it; the module only fills its lists)
+ await A.page.evaluate(() => window.__stores.hudDocs.setHudDocFor('scene', window.__football.hud().scene));
+ // play one more goal for the lamps: A starts, B touches, ball into the red gate
+ await A.page.evaluate(() => window.__football.game.act('start'));
+ await eventually(() => snap(A.page), (s) => s?.started && s.serves > 0 && s.serveAt === 0, '16.1 a fresh match is served', 8000);
+ await hitBall(B.page, ball, 4);
+ await eventually(() => snap(A.page), (s) => s?.lastTouch?.by === B.id, '16.2 B touched it');
+ await teleport(A.page, ball, redPos);
+ await eventually(() => snap(B.page), (s) => s?.score.blue === 1, '16.3 blue 1');
+ const lamp = (page, name) =>
+ page.evaluate((uuid) => {
+ let g; window.__stores.objectsGroup.subscribe((v) => (g = v))();
+ const o = g?.getObjectByProperty('uuid', uuid);
+ return o ? { lit: o.material.userData.fbLit === true, intensity: o.material.emissiveIntensity, hex: o.material.emissive.getHex() } : null;
+ }, names[name]);
+ await eventually(() => lamp(A.page, 'Blue lamp 1'), (l) => l?.lit && l.intensity > 1, '16.4 A: Blue lamp 1 is lit');
+ await eventually(() => lamp(B.page, 'Blue lamp 1'), (l) => l?.lit && l.intensity > 1, '16.5 B: Blue lamp 1 is lit too (same replicated score)');
+ check(!(await lamp(A.page, 'Blue lamp 2'))?.lit && !(await lamp(A.page, 'Red lamp 1'))?.lit, '16.6 counterfactual: Blue lamp 2 and Red lamp 1 stay dim');
+ const rows = (page, id) => page.evaluate((id) => window.__stores.flowRuntime.hudRowsOf(id), id);
+ await eventually(() => rows(A.page, 'fb-score'), (r) => r.some((line) => /RED 0 — 1 BLUE/.test(line)), '16.7 the Records node wrote the score line into the HUD list (' + JSON.stringify(await rows(A.page, 'fb-score')) + ')');
+ await eventually(() => rows(B.page, 'fb-sheet'), (r) => r.some((line) => /BLUE/.test(line) && /goals/.test(line)), '16.8 B: the sheet rows carry the sheet');
+ await eventually(() => A.page.locator('#hud-layer').textContent(), (t) => /RED 0 — 1 BLUE/.test(t ?? ''), '16.9 the DOM HUD shows the score while playing');
+ // a HUD Button press (perPlayer) reaches the Match Button through its `press` input
+ await A.page.evaluate(() => window.__stores.flowRuntime.fireHudButton('fb-join-blue'));
+ await eventually(() => snap(B.page), (s) => s?.slots.blue.includes(A.id) && !s.slots.red.includes(A.id), '16.10 A joined blue through the HUD button (hudbutton -> fbbutton.press)');
+ check(!(await snap(B.page)).slots.blue.includes(C.id), '16.11 counterfactual: the perPlayer press moved nobody else');
+ await A.page.evaluate(() => window.__stores.flowRuntime.fireHudButton('fb-join-red'));
+ await eventually(() => snap(B.page), (s) => s?.slots.red[0] === A.id && !s.slots.blue.includes(A.id), '16.12 ...and back to red (A LEAVES blue — the check is empty without 16.10)');
+
+ // ---- 17. B3: fit the pitch to a room — replicated moves ------------------------------------------
+ const before = await posOf(B.page, blueGate);
+ const moved = await A.page.evaluate(() => window.__football.toolbox.fitPitch({ length: 8, width: 4 }));
+ check(moved === 40, '17.1 A re-laid the 40 static objects for an 8 x 4 m room, never the live ball (' + moved + ')');
+ await eventually(() => posOf(B.page, blueGate), (p) => !!p && p[2] > before[2] + 1, '17.2 B sees the blue gate further out (' + before[2].toFixed(2) + ' -> ' + ((await posOf(B.page, blueGate)) ?? [0, 0, 0])[2].toFixed(2) + ')');
+ await eventually(() => posOf(B.page, names['Wall right']), (p) => !!p && Math.abs(p[0] - 2.025) < 0.05, '17.3 the right wall stands at half the new width');
+ check((await A.page.evaluate(() => window.__football.toolbox.roomBounds())) === null && (await A.page.evaluate(() => window.__football.toolbox.roomAnchor())) === null, '17.4 headless: no XR bounds and no room anchor — the sliders are the fallback');
+
+ await finish(browser);
+});
diff --git a/tests/helpers.cjs b/tests/helpers.cjs
index b167213..c8ad7e8 100644
--- a/tests/helpers.cjs
+++ b/tests/helpers.cjs
@@ -47,6 +47,9 @@ async function setupPage(browser, name, options = {}) {
localStorage.setItem('hasSeenWelcome', 'true');
if (peerConfig) localStorage.setItem('peerServerConfig', peerConfig);
}, PEER_CONFIG);
+ // options.audio installs the destination TAP before any app code runs, so
+ // everything the app plays can be measured (see audioMetrics).
+ if (options.audio) await ctx.addInitScript(AUDIO_TAP_SOURCE);
if (options.storage) {
await ctx.addInitScript((extra) => {
for (const [k, v] of Object.entries(extra)) localStorage.setItem(k, String(v));
@@ -92,7 +95,9 @@ async function installModule(peer, zipName, moduleId = zipName) {
async function openModules(page) {
await page.evaluate(() => window.__stores.modulesOpen.set(true));
await page.waitForTimeout(400);
- await page.getByRole('tab', { name: 'User', exact: true }).click();
+ // the tab label grows a count once a module is installed ("User (1)"), so a
+ // SECOND install on the same peer must match the prefix (the author script's rule)
+ await page.getByRole('tab', { name: /^User/ }).click();
await page.waitForTimeout(200);
}
@@ -145,6 +150,209 @@ async function eventually(fn, predicate, label, timeout = 10000) {
check(false, label);
}
+
+// ---- audio ---------------------------------------------------------------------
+//
+// Copied from the core repo's tests/e2e/helpers.cjs (23-A2/A3), because a module
+// that ships an AUDIO DEVICE has to be measured the same way core measures its own:
+// a tap in front of ctx.destination, sampled repeatedly, asserted on the PEAK.
+
+// Headless Chromium renders WebGL through SwiftShader by default, which makes the
+// viewport the frame-rate bottleneck (measured: ~4.5 fps at 1280x720 with the AO
+// pass on). These flags hand ANGLE the real GPU when there is one — ANGLE falls
+// back to SwiftShader on its own where there isn't, so they are safe everywhere.
+// Only worth passing when a test actually cares about frame rate (net-stress).
+//
+// THE ANGLE BACKEND IS PER PLATFORM. `d3d11` exists only on Windows; on Linux it
+// silently falls back to SwiftShader, which is the starved-loop failure above in
+// disguise. MEASURED 2026-09-03 on a Linux box with an AMD GPU, same page, same
+// 660Hz voice: d3d11 -> SwiftShader, 2 audio samples per 600 ms, 6 rAF/s; vulkan ->
+// the real GPU, 37 samples per 600 ms, 61 rAF/s. An 80 ms note is invisible to the
+// first and obvious to the second.
+const ANGLE_BACKEND = process.platform === 'win32' ? 'd3d11' : process.platform === 'darwin' ? 'metal' : 'vulkan';
+const GPU_ARGS = [
+ '--use-gl=angle',
+ '--use-angle=' + ANGLE_BACKEND,
+ ...(ANGLE_BACKEND === 'vulkan' ? ['--enable-features=Vulkan'] : []),
+ '--enable-gpu',
+ '--ignore-gpu-blocklist'
+];
+
+// Headless Chromium HAS no audio device, but WebAudio still runs against a null
+// sink and an AnalyserNode still sees the samples — measured: a 0.5-amplitude
+// 440Hz sine reads peak RMS 0.355 against a theoretical 0.5/sqrt(2) = 0.3536, and
+// an OfflineAudioContext render of a unit sine reads 0.7071 against 1/sqrt(2).
+// So audio IS measurable here; what is NOT is whether it sounds good.
+//
+// The autoplay flag is what keeps the context out of 'suspended' without a real
+// gesture. Pass these for any suite that asserts on sound.
+//
+// GPU_ARGS IS PART OF IT, and not optionally. An AnalyserNode only reports the
+// instant you read it, so the metrics loop has to sample often — and on a
+// SwiftShader page the 3D render starves the main thread badly enough that the
+// loop barely runs. MEASURED on the same ping chime: 2 samples per 500ms reading
+// peak 0.0051 without the GPU args, 30 samples reading 0.1303 with them. The low
+// number is not quieter audio, it is the loop missing the attack and catching only
+// the decay tail — so a threshold tuned on one is meaningless on the other. Folded
+// in here rather than documented, because this is exactly the mistake a caller
+// makes silently. (Same family as the GPU_ARGS rate-assertion rule in the e2e skill.)
+const AUDIO_ARGS = ['--autoplay-policy=no-user-gesture-required', ...GPU_ARGS];
+
+// An init script given a STRING is evaluated as SOURCE, so a bare function
+// expression is created and discarded — it has to be an IIFE to actually run.
+const AUDIO_TAP_SOURCE =
+ '(' +
+ function () {
+ const rawConnect = AudioNode.prototype.connect;
+ const rawDisconnect = AudioNode.prototype.disconnect;
+ /** @type {Map} */
+ const taps = new Map();
+ function tapFor(context) {
+ let tap = taps.get(context);
+ if (!tap) {
+ const analyser = context.createAnalyser();
+ analyser.fftSize = 2048;
+ analyser.smoothingTimeConstant = 0;
+ const bus = context.createGain();
+ rawConnect.call(bus, analyser);
+ rawConnect.call(analyser, context.destination);
+ tap = { analyser, bus, context };
+ taps.set(context, tap);
+ }
+ return tap;
+ }
+ AudioNode.prototype.connect = function (target, ...rest) {
+ // only DIVERT a connection whose target is the raw destination, and never
+ // the tap's own two nodes (that would be a feedback loop)
+ // NEVER tap an OfflineAudioContext. renderOffline makes one per call, and a
+ // rendered analyser LINGERS holding its result — measured: after three offline
+ // renders the live read returned 5 contexts and reported the 440Hz render at
+ // peak 0.708 instead of the 880Hz tone actually playing. An offline render is
+ // measured from its returned buffer; it has no business in the live tap.
+ const offline = typeof OfflineAudioContext !== 'undefined' && target?.context instanceof OfflineAudioContext;
+ if (target instanceof AudioDestinationNode && target.context && !offline) {
+ const tap = tapFor(target.context);
+ if (this !== tap.analyser && this !== tap.bus) return rawConnect.call(this, tap.bus, ...rest);
+ }
+ return rawConnect.call(this, target, ...rest);
+ };
+ AudioNode.prototype.disconnect = function (target, ...rest) {
+ if (target instanceof AudioDestinationNode && target.context) {
+ const tap = taps.get(target.context);
+ if (tap && this !== tap.analyser && this !== tap.bus)
+ return rawDisconnect.call(this, tap.bus, ...rest);
+ }
+ return rawDisconnect.apply(this, arguments.length ? [target, ...rest] : []);
+ };
+ window.__audioTap = {
+ /** how many AudioContexts have been tapped — see the note on audioMetrics */
+ contexts: () => taps.size,
+ /** every tapped context's analyser, paired with its context */
+ all: () => [...taps.values()].map((t) => ({ analyser: t.analyser, context: t.context })),
+ analyser: () => [...taps.values()][0]?.analyser ?? null,
+ context: () => [...taps.values()][0]?.context ?? null
+ };
+}.toString() +
+ ')();';
+
+/**
+ * Sample the destination tap for `ms` and report what was heard.
+ *
+ * An AnalyserNode reports only the instant you read it, so a one-shot read of a
+ * decaying note is a lottery — this samples repeatedly and reports the PEAK as
+ * well as the mean. Assert on `peak` for "did this make a sound", on `silent`
+ * for "did it stop", and on `centroid` for "did the timbre move" (a filter sweep
+ * or a distortion changes the centroid while the RMS may not move at all).
+ *
+ * `centroid` is the magnitude-weighted mean frequency in Hz, taken at the loudest
+ * sample — reading it at a quiet moment measures the noise floor's shape (on this
+ * box that reads ~11988Hz, which is the tell that nothing was playing).
+ *
+ * EVERY tapped AudioContext is sampled and the loudest wins, because a module is
+ * free to make its OWN context and most of them do — untangle, sabers,
+ * door-keypad, dungeon-realms and piano each call `new AudioContext()` rather than
+ * going through the app's shared one. Reading only the first tapped context made a
+ * module's audio measure as SILENCE, which is the worst possible failure: a suite
+ * asserting "the game makes a sound" fails inexplicably, and one asserting "it is
+ * quiet" passes while lying. `contexts` is reported so a suite can assert on how
+ * many were in play.
+ *
+ * @param {any} peer @param {number} [ms] @param {number} [floor] RMS counted as silence
+ * @returns {Promise<{peak:number,mean:number,centroid:number,samples:number,contexts:number,silent:boolean,error?:string}>}
+ */
+async function audioMetrics(peer, ms = 600, floor = 0.001) {
+ return peer.page.evaluate(
+ async ({ ms, floor }) => {
+ const tap = window.__audioTap;
+ if (!tap) return { error: 'no audio tap — pass {audio:true} to setupPage', peak: 0, mean: 0, centroid: 0, samples: 0, contexts: 0, silent: true };
+ const tapped = tap.all();
+ if (!tapped.length)
+ return { error: 'nothing has connected to any destination yet', peak: 0, mean: 0, centroid: 0, samples: 0, contexts: 0, silent: true };
+ const scratch = tapped.map((t) => ({
+ analyser: t.analyser,
+ context: t.context,
+ time: new Float32Array(t.analyser.fftSize),
+ freq: new Float32Array(t.analyser.frequencyBinCount)
+ }));
+ let peak = 0;
+ let sum = 0;
+ let count = 0;
+ /** the frequency frame of whichever context was loudest, and its bin width */
+ let loudest = null;
+ let loudestBinHz = 0;
+ const deadline = performance.now() + ms;
+ while (performance.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 16));
+ // the LOUDEST context this tick. A module playing into its OWN context must
+ // not be averaged away by the app's silent one — and taking a max rather
+ // than a sum keeps a single-context reading byte-identical to before.
+ let tickPeak = 0;
+ let tickWinner = null;
+ for (const entry of scratch) {
+ entry.analyser.getFloatTimeDomainData(entry.time);
+ let square = 0;
+ for (let i = 0; i < entry.time.length; i++) square += entry.time[i] * entry.time[i];
+ const rms = Math.sqrt(square / entry.time.length);
+ if (rms > tickPeak) {
+ tickPeak = rms;
+ tickWinner = entry;
+ }
+ }
+ sum += tickPeak;
+ count++;
+ if (tickPeak > peak && tickWinner) {
+ peak = tickPeak;
+ tickWinner.analyser.getFloatFrequencyData(tickWinner.freq);
+ loudest = tickWinner.freq.slice();
+ loudestBinHz = tickWinner.context.sampleRate / tickWinner.analyser.fftSize;
+ }
+ }
+ // magnitude-weighted mean frequency at the loudest moment. getFloatFrequencyData
+ // is dB, so convert back to linear before weighting or quiet bins dominate.
+ let weighted = 0;
+ let total = 0;
+ if (loudest) {
+ for (let i = 0; i < loudest.length; i++) {
+ const magnitude = Math.pow(10, loudest[i] / 20);
+ weighted += magnitude * i * loudestBinHz;
+ total += magnitude;
+ }
+ }
+ return {
+ peak,
+ mean: count ? sum / count : 0,
+ centroid: total ? weighted / total : 0,
+ samples: count,
+ contexts: tapped.length,
+ // a read that sampled NOTHING is a broken harness, never silence — the
+ // vacuous-premise trap, caught by this suite passing with no tap installed
+ silent: count > 0 && peak < floor
+ };
+ },
+ { ms, floor }
+ );
+}
+
/** Screen pixel of a world point on that page's camera (for real clicks). */
function projectPoint(page, world) {
return page.evaluate(
@@ -181,6 +389,9 @@ function run(body) {
module.exports = {
URL,
+ GPU_ARGS,
+ AUDIO_ARGS,
+ audioMetrics,
openModules,
check,
launch,
diff --git a/tests/module-collectible-signals.test.cjs b/tests/module-collectible-signals.test.cjs
new file mode 100644
index 0000000..30d3243
--- /dev/null
+++ b/tests/module-collectible-signals.test.cjs
@@ -0,0 +1,386 @@
+// collectible SIGNALS flight (R29 S4) — split out of module-collectible.test.cjs, which
+// already runs close to the runner's 8-minute cap. It covers what the module does with
+// core's R29 seams: the manager redraws on api.flow/game.onChange instead of a 500ms poll,
+// the recipe lands on api.flow.freeRegion, the form carries the touch radius — and the
+// SAME module source still runs on an app without those seams (a 1.14.0-shaped api).
+//
+// APP_URL=https://localhost:5183/ npm test -- collectible-signals
+//
+// (the original header of the main flight follows, for its helpers)
+// collectible test-flight — the REAL zip on two peers (and a third that joins late),
+// driving the module's own toolbox to build the graph and the app's own click dispatch
+// to collect.
+//
+// What this flight is really checking is that the module BORROWS core's rules instead of
+// re-implementing them: `whilePlaying` dormancy hands the object back, `perRound` retires
+// a stamp on a round bump, and `{replicate:false}` keeps a per-player pulse — the hide,
+// the latch AND the count — inside one peer.
+//
+// TWO THINGS IT DELIBERATELY DOES NOT ASSERT:
+// - the SHARED score after a shared collect. `add` is a per-peer read-modify-write off
+// one replicated stamp (core's standing Set Variable semantics), so two peers can
+// bank one pickup twice. Assert the WORLD — is the gem hidden on both screens — and
+// use `scope: 'player'` where an exact number matters.
+// - a real mouse click on the gem. The pick is core's; what belongs to the module is the
+// click HANDLER, so the flight dispatches through `moduleSDK.moduleClickHandlers` the
+// way door-keypad does.
+//
+// APP_URL=https://localhost:5183/ npm test -- collectible
+const h = require('./helpers.cjs');
+
+const TOOLBOX = 'mod-collectible-manager';
+
+// --- page reads -------------------------------------------------------------------
+const nodesOf = (peer, type) =>
+ peer.page.evaluate(
+ (t) =>
+ window.__stores
+ .allNodes()
+ .filter((n) => !t || n.type === t)
+ .map((n) => ({ id: n.id, type: n.type, graph: n.__graph ?? 'scene', data: { ...(n.data ?? {}) } })),
+ type ?? null
+ );
+
+const edgesOf = (peer) =>
+ peer.page.evaluate(() => {
+ let graphs;
+ window.__stores.flowGraphs.subscribe((v) => (graphs = v))();
+ return Object.entries(graphs ?? {}).flatMap(([graphId, doc]) =>
+ (doc.edges ?? []).map((e) => ({
+ id: e.id,
+ source: e.source,
+ target: e.target,
+ targetHandle: e.targetHandle ?? null,
+ graphId
+ }))
+ );
+ });
+
+const visibleOf = (peer, uuid) =>
+ peer.page.evaluate((u) => {
+ let group;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ return group?.getObjectByProperty('uuid', u)?.visible ?? null;
+ }, uuid);
+
+/** core's own round-aware trigger-log read — the module polls exactly this */
+const stampOf = (peer, id) =>
+ peer.page.evaluate((i) => window.__stores.flowRuntime.nodeTriggerStamp(i), id);
+
+/** a value node's published output (flowValues republishes ~6/s) */
+const valueOf = (peer, id) =>
+ peer.page.evaluate((i) => {
+ let values;
+ window.__stores.flowValues.subscribe((v) => (values = v))();
+ return values?.[i];
+ }, id);
+
+const sharedVar = (peer, name) =>
+ peer.page.evaluate((n) => window.__stores.gameState.gameVar(n, 0), name);
+const myVar = (peer, name) =>
+ peer.page.evaluate((n) => window.__stores.peerVars.myPeerVar(n, 0), name);
+
+const setPlay = (peer, value) =>
+ peer.page.evaluate((v) => window.__stores.isLocked.set(v), value);
+const setState = (peer, state) =>
+ peer.page.evaluate((s) => window.__stores.gameState.setGameState(s), state);
+
+// --- page actions -----------------------------------------------------------------
+/** a fresh replicated box with a distinct name, deselected afterwards */
+async function makeBox(peer, name) {
+ const uuid = await peer.page.evaluate(async () => {
+ const s = window.__stores;
+ s.commandsHandler.sceneCommand('/create box');
+ await new Promise((r) => setTimeout(r, 1300));
+ let group;
+ s.objectsGroup.subscribe((v) => (group = v))();
+ const last = group.children[group.children.length - 1];
+ s.objectActions.deselectObject();
+ return last?.uuid ?? null;
+ });
+ if (uuid) {
+ await peer.page.evaluate(
+ ({ uuid, name }) => window.__stores.objectActions.renameObject(uuid, name),
+ { uuid, name }
+ );
+ await peer.page.waitForTimeout(400);
+ }
+ return uuid;
+}
+
+/** the module's click handler, through the app's own dispatch array (door-keypad's recipe) */
+const clickObject = (peer, uuid) =>
+ peer.page.evaluate((u) => {
+ let group;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ const object = group?.getObjectByProperty('uuid', u);
+ if (!object) return 'no-object';
+ window.__stores.moduleSDK.moduleClickHandlers.forEach((fn) => fn(object));
+ return 'dispatched';
+ }, uuid);
+
+/** open the manager toolbox (the real opener core gives a module) */
+async function openToolbox(peer) {
+ await peer.page.evaluate(
+ (id) => window.__stores.moduleToolboxes.openModuleToolbox(id),
+ TOOLBOX
+ );
+ await peer.page.waitForTimeout(700);
+}
+
+/** THE RECIPE, driven through the module's own form + button */
+async function runRecipe(peer, options) {
+ const panel = peer.page.locator('.collectible-manager');
+ await panel.locator('.cm-form input[type=text]').fill(options.variable);
+ await panel.locator('.cm-form select').nth(0).selectOption(options.scope ?? 'shared');
+ await panel.locator('.cm-form select').nth(1).selectOption(options.trigger ?? 'click');
+ await panel.locator('.cm-form select').nth(2).selectOption(options.hide ?? 'on');
+ if (options.radius != null) await panel.locator('.cm-form input.cm-in-radius').fill(String(options.radius));
+ await panel.locator('.cm-form input.cm-in-respawn').fill(String(options.respawn ?? 0));
+ await panel.getByRole('button', { name: 'Make collectible' }).click();
+ await peer.page.waitForTimeout(1400);
+}
+
+/** select one object, run the recipe on it */
+async function collectibleFrom(peer, uuid, options) {
+ await peer.page.evaluate((u) => window.__stores.objectActions.selectObject(u), uuid);
+ await peer.page.waitForTimeout(400);
+ await runRecipe(peer, options);
+ await peer.page.evaluate(() => window.__stores.objectActions.deselectObject());
+ await peer.page.waitForTimeout(300);
+ // the node whose selector names this object
+ const nodes = await nodesOf(peer, 'collectible');
+ const edges = await edgesOf(peer);
+ const selectors = (await nodesOf(peer, 'objectselector'))
+ .filter((n) => n.data.selected === uuid)
+ .map((n) => n.id);
+ const node = nodes.find((n) => edges.some((e) => e.source === n.id && selectors.includes(e.target)));
+ return node ?? null;
+}
+
+h.run(async () => {
+ const browser = await h.launch();
+ const A = await h.setupPage(browser, 'A');
+ const B = await h.setupPage(browser, 'B');
+ await h.installModule(A, 'collectible');
+ await h.installModule(B, 'collectible');
+ await h.connect(A, B);
+ for (const peer of [A, B])
+ await peer.page.evaluate(() => {
+ const s = window.__stores;
+ s.clearGraphs();
+ s.gameState.clearGameState();
+ s.peerVars.clearPeerVars(false);
+ s.isLocked.set(null);
+ });
+ await A.page.waitForTimeout(1000);
+ await A.page.evaluate(() => {
+ window.__stores.moduleSDK.initModules([
+ { id: 'ctest', name: 'collectible flight helper', version: '1.0.0', register(api) { window.__ct = api; } }
+ ]);
+ });
+ await openToolbox(A);
+ // three rubies through the real form — rows for the manager to hold
+ const rubies = [await makeBox(A, 'Ruby1'), await makeBox(A, 'Ruby2'), await makeBox(A, 'Ruby3')];
+ await A.page.evaluate((uuids) => {
+ for (const u of uuids) window.__stores.objectActions.selectObject(u, false, true);
+ }, rubies);
+ await A.page.waitForTimeout(400);
+ await runRecipe(A, { variable: 'rubies', scope: 'shared', trigger: 'click', hide: 'on', respawn: 0 });
+ await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
+ await A.page.waitForTimeout(700);
+ const rubyNodes = async (peer) =>
+ (await nodesOf(peer, 'collectible')).filter((n) => n.data.variable === 'rubies');
+ h.check((await rubyNodes(A)).length === 3, 'premise: three rubies built through the form');
+ const rubyYs = (await A.page.evaluate(() => window.__ct.flow.nodes('collectible').map((n) => n.y))).sort((a, b) => a - b);
+ h.check(
+ rubyYs.length === 3 && rubyYs[0] < rubyYs[1] && rubyYs[1] < rubyYs[2],
+ 'each pair asked freeRegion again, so they stack instead of landing on one another (' + JSON.stringify(rubyYs) + ')'
+ );
+
+ // =====================================================================
+ // 11. R29 S4 — the manager listens instead of polling, the recipe lands on
+ // api.flow.freeRegion, the form carries the touch radius — and the module
+ // still runs on an app WITHOUT those seams (1.14.0-shaped)
+ // =====================================================================
+ const panel3 = A.page.locator('.collectible-manager');
+ h.check(
+ (await panel3.getAttribute('data-refresh')) === 'signal',
+ 'the manager redraws on core change signals (data-refresh=' + (await panel3.getAttribute('data-refresh')) + ')'
+ );
+ // IDLE: nothing changes for 2.5s, so a listening panel writes NOTHING to the DOM. The
+ // 500ms poll rewrote the count text on every pass, which a MutationObserver sees.
+ const idleMutations = await panel3.evaluate(
+ (el) =>
+ new Promise((resolve) => {
+ let n = 0;
+ const mo = new MutationObserver((list) => (n += list.length));
+ mo.observe(el, { subtree: true, childList: true, characterData: true, attributes: true });
+ setTimeout(() => {
+ mo.disconnect();
+ resolve(n);
+ }, 2500);
+ })
+ );
+ h.check(idleMutations === 0, 'an idle manager touches nothing — no poll (' + idleMutations + ' DOM mutations in 2.5s)');
+ // ...and a PEER's edit still reaches it, through the signal
+ const rubyTarget = (await rubyNodes(A))[0];
+ await B.page.evaluate(
+ (id) => {
+ const n = window.__stores.allNodes().find((x) => x.id === id);
+ window.__stores.nodesHandler.setNodeData(id, { respawn: 7 }, n?.__graph ?? 'scene');
+ },
+ rubyTarget.id
+ );
+ await h.eventually(
+ () => panel3.locator('.cm-row[data-node="' + rubyTarget.id + '"] input.cm-respawn').inputValue(),
+ (v) => v === '7',
+ "a peer's edit redraws the row through the change signal (respawn 7)"
+ );
+
+ // THE RECIPE lands on freeRegion: a user node parked where the old fixed-row rule would
+ // put the next pair must not be covered
+ const nextRowY = await A.page.evaluate(() => {
+ const count = window.__ct.flow.nodes('collectible').length;
+ return 40 + count * 190;
+ });
+ await A.page.evaluate((y) => window.__ct.flow.addNodes({ nodes: [{ type: 'time', x: 60, y, data: {} }] }), nextRowY);
+ const tiles = [await makeBox(A, 'Tile1'), await makeBox(A, 'Tile2')];
+ await A.page.evaluate((uuids) => {
+ window.__stores.objectActions.deselectObject();
+ for (const u of uuids) window.__stores.objectActions.selectObject(u, false, true);
+ }, tiles);
+ await A.page.waitForTimeout(400);
+ const picked = await A.page.evaluate(() => window.__ct.selectedUuids());
+ h.check(picked.length === 2, 'premise: two tiles selected (' + picked.length + ')');
+ // the TOUCH radius is on the form now, and refused (with the reason) under click
+ const radiusIn = panel3.locator('.cm-form input.cm-in-radius');
+ await panel3.locator('.cm-form select').nth(1).selectOption('click');
+ h.check(await radiusIn.isDisabled(), 'the radius field is disabled under a click trigger');
+ await runRecipe(A, { variable: 'tiles', scope: 'player', trigger: 'touch', hide: 'on', respawn: 0, radius: 3.5 });
+ const tileNodes = (await nodesOf(A, 'collectible')).filter((n) => n.data.variable === 'tiles');
+ h.check(
+ tileNodes.length === 2 && tileNodes.every((n) => Number(n.data.radius) === 3.5 && n.data.trigger === 'touch'),
+ 'the form\'s touch radius reaches the nodes (' + JSON.stringify(tileNodes.map((n) => n.data.radius)) + ')'
+ );
+ const layout = await A.page.evaluate(() => {
+ const ns = window.__ct.flow.nodes().filter((n) => n.graphId === 'scene');
+ const hits = [];
+ for (let i = 0; i < ns.length; i++)
+ for (let j = i + 1; j < ns.length; j++) {
+ const a = ns[i], b = ns[j];
+ if (a.x < b.x + 150 && b.x < a.x + 150 && a.y < b.y + 150 && b.y < a.y + 150) hits.push([a.type, b.type, a.x, a.y]);
+ }
+ return { n: ns.length, hits };
+ });
+ h.check(layout.hits.length === 0, 'the new pairs land on free space — no card overlaps another (' + JSON.stringify(layout.hits.slice(0, 3)) + ', ' + layout.n + ' nodes)');
+
+ // =====================================================================
+ // 12. R29 S3 — a group press is ONE undo step (core's api.flow.setNodesData)
+ // =====================================================================
+ await A.page.evaluate(() =>
+ window.__ct.flow.addNodes({
+ nodes: Array.from({ length: 20 }, (_, i) => ({
+ type: 'collectible',
+ x: 2400,
+ y: 40 + i * 60,
+ data: { variable: 'bulk', scope: 'player', trigger: 'click', hide: 'on', respawn: 0, perRound: true, whilePlaying: true }
+ }))
+ })
+ );
+ const bulkGroup = panel3.locator('.cm-group[data-var="bulk"]');
+ await h.eventually(() => bulkGroup.locator('.cm-row').count(), (n) => n === 20, 'premise: a twenty-member group in the manager');
+ const bulkNodes = async (peer) => (await nodesOf(peer, 'collectible')).filter((n) => n.data.variable === 'bulk');
+ const undoTop = () =>
+ A.page.evaluate(() => {
+ let v;
+ window.__stores.history.undoStack.subscribe((x) => (v = x))();
+ const e = v[v.length - 1];
+ return { depth: v.length, kind: e?.kind, op: e?.op, items: e?.items?.length ?? 0, moduleId: e?.moduleId };
+ });
+ const before12 = await undoTop();
+ await bulkGroup.locator('.cm-bulk select').nth(0).selectOption('touch');
+ await A.page.waitForTimeout(600);
+ const after12 = await undoTop();
+ h.check(
+ after12.depth === before12.depth + 1 && after12.kind === 'flownodes' && after12.op === 'data' && after12.items === 20,
+ 'one group press is ONE undo entry holding all twenty (' + before12.depth + ' -> ' + after12.depth + ', ' + after12.items + ' items)'
+ );
+ h.check(after12.moduleId === 'collectible', 'attributed to the module (' + after12.moduleId + ')');
+ await h.eventually(
+ () => bulkNodes(B),
+ (ns) => ns.length === 20 && ns.every((n) => n.data.trigger === 'touch'),
+ 'the press still replicates node by node — all twenty on the peer',
+ 25000
+ );
+ await A.page.evaluate(() => window.__stores.history.undo());
+ await h.eventually(
+ async () => [await bulkNodes(A), await bulkNodes(B)],
+ ([a, b]) => a.length === 20 && b.length === 20 && [...a, ...b].every((n) => n.data.trigger === 'click'),
+ 'ONE undo puts all twenty back, on both peers'
+ );
+ h.check((await undoTop()).depth === before12.depth, 'and leaves the stack where the press found it');
+
+ // THE FALLBACK: the same module source, registered against an api with the R29 seams
+ // REMOVED — what a user running core 1.14.0 hands it. It must still build and list.
+ const D = await h.setupPage(browser, 'D');
+ const source = require('fs').readFileSync(require('path').join(__dirname, '..', 'modules', 'collectible', 'module.js'), 'utf8');
+ await D.page.evaluate(async (src) => {
+ const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
+ const mod = (await import(url)).default;
+ await window.__stores.moduleSDK.initModules([
+ {
+ id: 'collectible',
+ name: 'Collectibles (1.14 api)',
+ version: mod.version,
+ description: 'fallback probe',
+ register(api) {
+ const old = {
+ ...api,
+ flow: { ...api.flow, onChange: undefined, freeRegion: undefined, setNodesData: undefined },
+ game: { ...api.game, onChange: undefined },
+ peerVars: { ...api.peerVars, onChange: undefined }
+ };
+ window.__oldApi = old;
+ mod.register(old);
+ }
+ }
+ ]);
+ }, source);
+ await D.page.evaluate(() => window.__stores.moduleToolboxes.openModuleToolbox('mod-collectible-manager'));
+ await D.page.waitForTimeout(800);
+ const panelD = D.page.locator('.collectible-manager');
+ h.check((await panelD.getAttribute('data-refresh')) === 'poll', 'on an app without onChange the manager falls back to its poll');
+ const oldBoxes = [];
+ for (const name of ['Old1', 'Old2']) oldBoxes.push(await makeBox(D, name));
+ await D.page.evaluate((uuids) => {
+ for (const u of uuids) window.__stores.objectActions.selectObject(u, false, true);
+ }, oldBoxes);
+ await D.page.waitForTimeout(400);
+ await runRecipe(D, { variable: 'old', scope: 'shared', trigger: 'click', hide: 'on', respawn: 0 });
+ const oldPos = await D.page.evaluate(() =>
+ window.__stores
+ .allNodes()
+ .filter((n) => n.type === 'collectible')
+ .map((n) => n.position.y)
+ .sort((a, b) => a - b)
+ );
+ h.check(
+ JSON.stringify(oldPos) === JSON.stringify([40, 230]),
+ 'and the recipe falls back to its fixed rows (' + JSON.stringify(oldPos) + ')'
+ );
+ await h.eventually(
+ () => panelD.locator('.cm-row').count(),
+ (n) => n === 2,
+ 'and the polled panel lists them'
+ );
+ // and a group press without setNodesData still writes every member, one by one
+ await panelD.locator('.cm-group[data-var="old"] .cm-bulk select').nth(0).selectOption('touch');
+ await h.eventually(
+ async () => (await nodesOf(D, 'collectible')).filter((n) => n.data.variable === 'old').map((n) => n.data.trigger),
+ (t) => t.length === 2 && t.every((x) => x === 'touch'),
+ 'without setNodesData the group press falls back to per-node writes'
+ );
+
+ await h.finish(browser);
+});
diff --git a/tests/module-collectible.test.cjs b/tests/module-collectible.test.cjs
index 8c310b1..f8085d1 100644
--- a/tests/module-collectible.test.cjs
+++ b/tests/module-collectible.test.cjs
@@ -126,7 +126,8 @@ async function runRecipe(peer, options) {
await panel.locator('.cm-form select').nth(0).selectOption(options.scope ?? 'shared');
await panel.locator('.cm-form select').nth(1).selectOption(options.trigger ?? 'click');
await panel.locator('.cm-form select').nth(2).selectOption(options.hide ?? 'on');
- await panel.locator('.cm-form input[type=number]').fill(String(options.respawn ?? 0));
+ if (options.radius != null) await panel.locator('.cm-form input.cm-in-radius').fill(String(options.radius));
+ await panel.locator('.cm-form input.cm-in-respawn').fill(String(options.respawn ?? 0));
await panel.getByRole('button', { name: 'Make collectible' }).click();
await peer.page.waitForTimeout(1400);
}
@@ -683,6 +684,13 @@ h.run(async () => {
troveCounts === '2 collected, 0 left of 2',
'and the header agrees with the count node (' + troveCounts + ')'
);
+ // S4: the counts include the hand-built LEGACY chain while only module nodes get rows —
+ // the header now says so instead of leaving a count that disagrees with the rows
+ const legacyLines = await panel.locator('.cm-legacy').evaluateAll((els) => els.map((el) => el.textContent));
+ h.check(
+ legacyLines.length === 1 && /^\+1 older recipe chain counted here/.test(legacyLines[0] ?? ''),
+ 'a group whose count includes an older recipe chain says so (' + JSON.stringify(legacyLines) + ')'
+ );
const gateRow = panel.locator('.cm-row').filter({ hasText: 'Gate' });
h.check(await gateRow.count() === 1, 'a row is named after its target object');
await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
@@ -1089,10 +1097,10 @@ h.run(async () => {
);
// ---- 10i. WHAT A BULK APPLY COSTS AT SCALE ----------------------------------
- // There is no batch node-data write in the SDK, so a bulk apply is N `nodedata`
- // messages. This measures the SYNCHRONOUS cost of one press on a twenty-member group and
- // proves all twenty land on the peer — inventing a core seam is not a module's call, so
- // the number is the deliverable.
+ // A bulk apply is N `nodedata` messages on the wire (core 1.15's setNodesData batches
+ // the UNDO, not the wire — the signals flight asserts the one entry). This measures the
+ // SYNCHRONOUS cost of one press on a twenty-member group and proves all twenty land on
+ // the peer.
const probeIds = await A.page.evaluate(() => {
const nodes = [];
for (let i = 0; i < 20; i++)
diff --git a/tests/module-health.test.cjs b/tests/module-health.test.cjs
new file mode 100644
index 0000000..b4e6188
--- /dev/null
+++ b/tests/module-health.test.cjs
@@ -0,0 +1,549 @@
+// health test-flight — the REAL zip on two peers (and a third that joins late), driving
+// the module's toolbox to build the chains and the app's own click dispatch to hit.
+//
+// THE CLAIM UNDER TEST is roadmap-29 fork 2: an object's health is DERIVED from damage
+// pulses counted by core's Counter (one trigger-log entry per pulse, applied once per
+// peer, the count riding the DEVX #18 handshake), so damage from two peers converges to
+// ONE number on every peer including a late joiner, with no authority. The counterfactual
+// is the shared-add model (read-modify-write on a game variable), shown losing a hit.
+// A PLAYER's points ride their own peerVars row (one writer), so they are per-player.
+//
+// APP_URL=https://theprototype.app:5215/ npm test -- health
+const h = require('./helpers.cjs');
+
+const TOOLBOX = 'mod-health-manager';
+
+// --- page reads -------------------------------------------------------------------
+const snapshot = (peer) => peer.page.evaluate(() => window.__health.snapshot());
+const healthOf = async (peer, uuid) => (await snapshot(peer)).find((s) => s.uuid === uuid) ?? null;
+const playerOf = async (peer, name) => (await snapshot(peer)).find((s) => s.scope === 'player' && s.name === name) ?? null;
+const nodesOf = (peer, type) =>
+ peer.page.evaluate(
+ (t) =>
+ window.__stores
+ .allNodes()
+ .filter((n) => !t || n.type === t)
+ .map((n) => ({ id: n.id, type: n.type, data: { ...(n.data ?? {}) } })),
+ type ?? null
+ );
+const edgesOf = (peer) =>
+ peer.page.evaluate(() => {
+ let graphs;
+ window.__stores.flowGraphs.subscribe((v) => (graphs = v))();
+ return Object.entries(graphs ?? {}).flatMap(([graphId, doc]) =>
+ (doc.edges ?? []).map((e) => ({ id: e.id, source: e.source, target: e.target, targetHandle: e.targetHandle ?? null, graphId }))
+ );
+ });
+const visibleOf = (peer, uuid) =>
+ peer.page.evaluate((u) => {
+ let group;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ return group?.getObjectByProperty('uuid', u)?.visible ?? null;
+ }, uuid);
+const valueOf = (peer, id) =>
+ peer.page.evaluate((i) => {
+ let values;
+ window.__stores.flowValues.subscribe((v) => (values = v))();
+ return values?.[i];
+ }, id);
+const sharedVar = (peer, name) => peer.page.evaluate((n) => window.__stores.gameState.gameVar(n, 0), name);
+const myVar = (peer, name) => peer.page.evaluate((n) => window.__stores.peerVars.myPeerVar(n, null), name);
+const setPlay = (peer, value) => peer.page.evaluate((v) => window.__stores.isLocked.set(v), value);
+const setState = (peer, state) => peer.page.evaluate((s) => window.__stores.gameState.setGameState(s), state);
+
+// --- page actions -----------------------------------------------------------------
+async function makeBox(peer, name) {
+ const uuid = await peer.page.evaluate(async () => {
+ const s = window.__stores;
+ s.commandsHandler.sceneCommand('/create box');
+ await new Promise((r) => setTimeout(r, 1300));
+ let group;
+ s.objectsGroup.subscribe((v) => (group = v))();
+ const last = group.children[group.children.length - 1];
+ s.objectActions.deselectObject();
+ return last?.uuid ?? null;
+ });
+ if (uuid) {
+ await peer.page.evaluate(({ uuid, name }) => window.__stores.objectActions.renameObject(uuid, name), { uuid, name });
+ await peer.page.waitForTimeout(400);
+ }
+ return uuid;
+}
+const clickObject = (peer, uuid) =>
+ peer.page.evaluate((u) => {
+ let group;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ const object = group?.getObjectByProperty('uuid', u);
+ if (!object) return 'no-object';
+ window.__stores.moduleSDK.moduleClickHandlers.forEach((fn) => fn(object));
+ return 'dispatched';
+ }, uuid);
+async function openToolbox(peer) {
+ await peer.page.evaluate((id) => window.__stores.moduleToolboxes.openModuleToolbox(id), TOOLBOX);
+ await peer.page.waitForTimeout(700);
+}
+/** the form: name · max · regen · on death · respawn · damage from · damage */
+async function fillForm(peer, o) {
+ const panel = peer.page.locator('.health-manager');
+ await panel.locator('.hm-form input[type=text]').fill(o.name ?? 'hp');
+ const numbers = panel.locator('.hm-form input[type=number]');
+ await numbers.nth(0).fill(String(o.max ?? 5));
+ await numbers.nth(1).fill(String(o.regen ?? 0));
+ await numbers.nth(2).fill(String(o.respawnDelay ?? 3));
+ await numbers.nth(3).fill(String(o.amount ?? 1));
+ await numbers.nth(4).fill(String(o.radius ?? 1.5));
+ const selects = panel.locator('.hm-form select');
+ await selects.nth(0).selectOption(o.deathAction ?? 'hide');
+ await selects.nth(1).selectOption(o.source ?? 'click');
+}
+async function press(page, code) {
+ await page.evaluate(() => document.activeElement?.blur?.());
+ await page.keyboard.down(code);
+ await page.waitForTimeout(60);
+ await page.keyboard.up(code);
+ await page.waitForTimeout(260);
+}
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+ const B = await h.setupPage(browser, 'B');
+ await h.installModule(A, 'health');
+ await h.installModule(B, 'health');
+ await h.connect(A, B);
+ for (const peer of [A, B])
+ await peer.page.evaluate(() => {
+ const s = window.__stores;
+ s.clearGraphs();
+ s.gameState.clearGameState();
+ s.peerVars.clearPeerVars(false);
+ s.isLocked.set(null);
+ });
+ await A.page.waitForTimeout(1000);
+
+ // =====================================================================
+ // 0. PREMISE — the family, the effect, the value nodes, the toolbox, the HUD action
+ // =====================================================================
+ const registered = await A.page.evaluate(() => {
+ const s = window.__stores;
+ let groups;
+ s.moduleSDK.moduleNodeGroups.subscribe((v) => (groups = v))();
+ const items = (groups ?? []).flatMap((g) => (g.items ?? []).map((i) => i.type));
+ let boxes;
+ s.moduleToolboxes.moduleToolboxes.subscribe((v) => (boxes = v))();
+ return {
+ items,
+ effect: !!s.moduleSDK.moduleEffects.health,
+ values: ['health', 'damage', 'heal', 'healthreset', 'healthvalue', 'healthevent'].filter((t) => !!s.moduleNodeIO.moduleValueNodes[t]),
+ types: { health: s.moduleNodeIO.moduleValueTypes.health, damage: s.moduleNodeIO.moduleValueTypes.damage, healthvalue: s.moduleNodeIO.moduleValueTypes.healthvalue },
+ inputs: s.moduleNodeIO.moduleNodeInputs.health,
+ toolbox: (boxes ?? []).map((b) => b.id),
+ debugLines: s.moduleHudKinds.moduleDebugLineTexts(),
+ actions: s.hudActions.actionsForKind('text').map((a) => a.key),
+ hook: !!window.__health
+ };
+ });
+ h.check(
+ ['health', 'damage', 'heal', 'healthreset', 'healthvalue', 'healthevent'].every((t) => registered.items.includes(t)),
+ 'the six nodes are in the palette (' + JSON.stringify(registered.items.filter((t) => /health|damage|heal/.test(t))) + ')'
+ );
+ h.check(!registered.effect && registered.types.health === 'number', 'health is a VALUE node (its hp), NOT an effect — an effect would pin its target\'s pose');
+ h.check(registered.values.length === 6, 'the six value/event nodes are registered (' + registered.values.join(',') + ')');
+ h.check(registered.types.damage === 'event' && registered.types.healthvalue === 'number', 'damage outputs an EVENT, healthvalue a number');
+ h.check(registered.inputs?.target === 'object' && registered.inputs?.damage === 'number' && registered.inputs?.heal === 'number' && registered.inputs?.respawnAt === 'object', 'health declares target/respawnAt (object) and damage/heal (number) inputs');
+ h.check(registered.toolbox.includes(TOOLBOX), 'the manager toolbox is registered as ' + TOOLBOX);
+ h.check(registered.actions.includes('mod-health-showhealth') && registered.actions.includes('mod-health-showhp'), 'the two HUD actions are in the catalog, namespaced');
+ h.check(registered.debugLines.length === 0, 'the debug line stays silent with no health in the scene');
+ h.check(registered.hook, 'the test hook is on window');
+
+ // =====================================================================
+ // 1. THE RECIPE — one chain per selected object, replicated, ONE undo per object
+ // =====================================================================
+ await openToolbox(A);
+ h.check(await A.page.locator('.health-manager').isVisible(), 'the toolbox mounted its DOM');
+ const crate1 = await makeBox(A, 'Crate1');
+ const crate2 = await makeBox(A, 'Crate2');
+ h.check(!!crate1 && !!crate2, 'premise: two boxes');
+ await A.page.evaluate(({ a, b }) => {
+ const s = window.__stores;
+ s.objectActions.selectObject(a);
+ s.objectActions.selectObject(b, false, true);
+ }, { a: crate1, b: crate2 });
+ await A.page.waitForTimeout(400);
+ await fillForm(A, { name: 'hp', max: 3, source: 'click', amount: 1, deathAction: 'hide' });
+ await A.page.locator('.health-manager').getByRole('button', { name: 'Make damageable' }).click();
+ await A.page.waitForTimeout(1500);
+ await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
+
+ const healths = await nodesOf(A, 'health');
+ h.check(healths.length === 2, 'one health node per selected object (' + healths.length + ')');
+ h.check(healths.every((n) => n.data.max === 3 && n.data.scope === 'object'), 'each carries the form');
+ const allA = await nodesOf(A);
+ const counts = allA.reduce((m, n) => ((m[n.type] = (m[n.type] ?? 0) + 1), m), {});
+ h.check(counts.damage === 2 && counts.counter === 2 && counts.healthreset === 2 && counts.objectselector === 2, 'each chain: damage, counter, health, reset, selector (' + JSON.stringify(counts) + ')');
+ const edgesA = await edgesOf(A);
+ const pulseEdges = edgesA.filter((e) => e.targetHandle === 'pulse');
+ const dmgEdges = edgesA.filter((e) => e.targetHandle === 'damage');
+ const resetEdges = edgesA.filter((e) => e.targetHandle === 'reset');
+ const targetEdges = edgesA.filter((e) => e.targetHandle === 'target');
+ h.check(pulseEdges.length === 2 && dmgEdges.length === 2 && resetEdges.length === 2 && targetEdges.length === 2, 'the wires: damage->counter.pulse, counter->health.damage, reset->counter.reset, selector->health.target');
+ h.check(edgesA.every((e) => e.id === 'e-' + e.source + '-' + e.target + (e.targetHandle ? '.' + e.targetHandle : '')), 'every edge id is the editor\'s canonical handle-qualified shape');
+ await h.eventually(() => nodesOf(B, 'health'), (n) => n.length === 2, 'both chains replicated to the peer', 12000);
+ await h.eventually(() => edgesOf(B), (e) => e.length === edgesA.length, 'and every wire (' + edgesA.length + ')');
+ await h.eventually(() => snapshot(A), (s) => s.length === 2 && s.every((x) => x.hp === 3 && !x.dead), 'both read 3/3 alive on A');
+ await h.eventually(() => snapshot(B), (s) => s.length === 2 && s.every((x) => x.hp === 3 && !x.dead), 'and on B');
+
+ await A.page.evaluate(() => window.__stores.history.undo());
+ await A.page.waitForTimeout(900);
+ h.check((await nodesOf(A)).length === 5, 'ONE undo removes one object\'s WHOLE chain (5 nodes left)');
+ await A.page.evaluate(() => window.__stores.history.redo());
+ await A.page.waitForTimeout(900);
+ h.check((await nodesOf(A)).length === 10, 'and one redo restores it');
+ await h.eventually(() => nodesOf(B), (n) => n.length === 10, 'the round trip left the peer converged');
+ await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u), crate1);
+ await A.page.waitForTimeout(300);
+ await A.page.locator('.health-manager').getByRole('button', { name: 'Make damageable' }).click();
+ await A.page.waitForTimeout(800);
+ await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
+ h.check((await nodesOf(A, 'health')).length === 2, 'an object that already has health is SKIPPED');
+
+ // =====================================================================
+ // 2. CONVERGENCE — damage from TWO peers is ONE number on both
+ // =====================================================================
+ await setState(A, 'playing');
+ await A.page.waitForTimeout(800);
+ for (const peer of [A, B]) await setPlay(peer, true);
+ await A.page.waitForTimeout(900);
+ h.check((await clickObject(A, crate1)) === 'dispatched' && (await clickObject(B, crate1)) === 'dispatched', 'premise: A and B each land one hit on Crate1');
+ await h.eventually(() => healthOf(A, crate1), (s) => s?.hp === 1, 'A reads Crate1 at 1/3 — its own hit AND the peer\'s', 10000);
+ await h.eventually(() => healthOf(B, crate1), (s) => s?.hp === 1, 'B reads 1/3 too: ONE number, no authority', 10000);
+ h.check((await healthOf(A, crate2))?.hp === 3 && (await healthOf(B, crate2))?.hp === 3, 'Crate2 is untouched on both');
+ const counterId = (await nodesOf(A, 'counter'))[0].id;
+ // THE COUNTERFACTUAL: the shared-add model. Both peers read the variable, THEN both
+ // write what they computed — the standing setvariable race, and one hit is lost.
+ const before = await Promise.all([sharedVar(A, 'cf'), sharedVar(B, 'cf')]);
+ await Promise.all([
+ A.page.evaluate((v) => window.__stores.gameState.setGameVar('cf', v + 1), before[0]),
+ B.page.evaluate((v) => window.__stores.gameState.setGameVar('cf', v + 1), before[1])
+ ]);
+ await A.page.waitForTimeout(1500);
+ const cfA = await sharedVar(A, 'cf');
+ const cfB = await sharedVar(B, 'cf');
+ h.check(cfA === cfB && cfA === 1, 'COUNTERFACTUAL: a counter ADD from two peers converges on 1 hit, not 2 (' + cfA + '/' + cfB + ') — the ledger read 2');
+
+ await clickObject(A, crate1);
+ await h.eventually(() => healthOf(A, crate1), (s) => s?.dead === true, 'the third hit kills it on A');
+ await h.eventually(() => healthOf(B, crate1), (s) => s?.dead === true, 'and on B');
+ await h.eventually(() => visibleOf(A, crate1), (v) => v === false, 'deathAction hide: Crate1 vanishes on A');
+ await h.eventually(() => visibleOf(B, crate1), (v) => v === false, 'and on B');
+ await clickObject(A, crate1);
+ await A.page.waitForTimeout(700);
+ h.check((await valueOf(A, counterId)) === 3 || (await valueOf(A, (await nodesOf(A, 'counter'))[1].id)) === 3, 'a hit on the dead is DROPPED (the overkill guard): the counter stays at 3');
+ // two readers through the module's own api (the same addNodes seam the toolbox uses)
+ const readers = await A.page.evaluate(() =>
+ window.__health.api.flow.addNodes({
+ nodes: [
+ { type: 'healthvalue', x: 900, y: 40, data: { name: 'hp', read: 'fraction' } },
+ { type: 'healthvalue', x: 900, y: 140, data: { name: 'hp', read: 'alive' } }
+ ]
+ })
+ );
+ await h.eventually(() => valueOf(A, readers[0]), (v) => v === 0.5, 'healthvalue fraction reads (0+3)/(3+3) = 0.5 for the group');
+ await h.eventually(() => valueOf(A, readers[1]), (v) => v === 1, 'healthvalue alive reads 1 of 2');
+ const line = await A.page.evaluate(() => window.__stores.moduleHudKinds.moduleDebugLineTexts());
+ h.check(line.length === 1 && /health \(hp\): 3\/6 · 1 of 2 alive/.test(line[0]), 'the debug line reads the group (' + JSON.stringify(line) + ')');
+
+ // =====================================================================
+ // 3. A LATE JOINER reads the same numbers, and re-applies NOTHING
+ // =====================================================================
+ // the Approve button lives in the editor chrome, so the host leaves play to answer
+ await setPlay(A, null);
+ await A.page.waitForTimeout(400);
+ const C = await h.setupPage(browser, 'C');
+ await h.installModule(C, 'health');
+ await h.connect(C, A);
+ await setPlay(A, true);
+ await h.eventually(() => nodesOf(C, 'health'), (n) => n.length === 2, 'the joiner holds both chains', 20000);
+ const joinerView = async () => ({
+ health: await healthOf(C, crate1),
+ trigger: await C.page.evaluate((i) => { let t; window.__stores.flowTriggers.subscribe((v) => (t = v))(); return t?.[i] ?? null; }, counterId),
+ value: await valueOf(C, counterId)
+ });
+ await h.eventually(joinerView, (v) => v.health?.dead === true && v.health.hp === 0, 'the joiner reads Crate1 DEAD (0/3) — the count arrived in the triggers handshake', 15000);
+ h.check((await healthOf(C, crate2))?.hp === 3, 'and Crate2 at 3/3');
+ await setPlay(C, true);
+ await h.eventually(() => visibleOf(C, crate1), (v) => v === false, 'Crate1 is hidden for the joiner too');
+ await C.page.waitForTimeout(1500);
+ h.check((await healthOf(A, crate1))?.hp === 0 && (await valueOf(A, counterId)) <= 3, 'the joiner re-applied NOTHING: A\'s counter did not move');
+ await clickObject(C, crate2);
+ await h.eventually(() => healthOf(A, crate2), (s) => s?.hp === 2, 'a hit from the joiner reaches A (2/3)', 10000);
+ await h.eventually(() => healthOf(B, crate2), (s) => s?.hp === 2, 'and B');
+ await h.eventually(() => healthOf(C, crate2), (s) => s?.hp === 2, 'and itself');
+
+ // =====================================================================
+ // 4. PER-PLAYER — my row, my number; regen; death fires ONCE; respawn
+ // =====================================================================
+ await setPlay(A, null);
+ await A.page.waitForTimeout(400);
+ await openToolbox(A);
+ await fillForm(A, { name: 'me', max: 5, regen: 0, source: 'wired', amount: 1, deathAction: 'respawn', respawnDelay: 2 });
+ await A.page.locator('.health-manager').getByRole('button', { name: 'Player health' }).click();
+ await A.page.waitForTimeout(1200);
+ const player = (await nodesOf(A, 'health')).find((n) => n.data.scope === 'player');
+ h.check(!!player && player.data.name === 'me', 'a player-scoped health node exists');
+ const dmgMe = (await nodesOf(A, 'damage')).find((n) => n.data.source === 'wired');
+ h.check(!!dmgMe, 'premise: its wired damage node');
+ // a PER-PLAYER key: the keypress pulse stays in the presser's log, so only the
+ // presser's damage fires (the collectible's {replicate:false} in core's own flag)
+ const wired = await A.page.evaluate(
+ ({ dmg }) =>
+ window.__health.api.flow.addNodes({
+ nodes: [
+ { type: 'keypress', x: 60, y: 700, data: { code: 'KeyR', pulse: 0.3, perPlayer: true } },
+ { type: 'healthevent', x: 60, y: 800, data: { name: 'me', event: 'death' } },
+ { type: 'counter', x: 280, y: 800, data: { op: 'up', step: 1 } }
+ ],
+ edges: [{ from: 0, to: dmg, handle: 'trigger' }, { from: 1, to: 2, handle: 'pulse' }]
+ }),
+ { dmg: dmgMe.id }
+ );
+ await h.eventually(() => nodesOf(B, 'keypress'), (n) => n.length === 1, 'the key node replicated');
+ await A.page.waitForTimeout(800);
+ await setPlay(A, true);
+ await A.page.waitForTimeout(500);
+ await press(A.page, 'KeyR');
+ await press(A.page, 'KeyR');
+ await h.eventually(() => myVar(A, 'me'), (v) => v === 3, 'two presses: A\'s OWN row reads 3 (of 5)');
+ await A.page.waitForTimeout(1200);
+ h.check((await myVar(B, 'me')) === null && (await playerOf(B, 'me'))?.hp === 5, 'B\'s row is untouched (5/5): per-player health is per-player');
+ const rows = await A.page.evaluate(() => window.__stores.peerVars.allPeerVars ? window.__stores.peerVars.allPeerVars('me').length : -1);
+ h.check(rows === -1 || rows >= 1, 'the row is a peerVars row (' + rows + ')');
+ // regen is a pure function of (row, clock): switch it on and watch the number climb
+ // while the ROW stays exactly where the last hit wrote it
+ await A.page.evaluate((id) => window.__health.api.flow.setNodeData(id, { regen: 1 }), player.id);
+ await h.eventually(() => playerOf(A, 'me'), (s) => s && s.hp > 3.5, 'regen 1/s: A\'s hp climbs from 3 without anything sent', 6000);
+ await h.eventually(() => playerOf(A, 'me'), (s) => s && s.hp === 5, 'and caps at 5', 8000);
+ h.check((await myVar(A, 'me')) === 3, 'the ROW still holds the base written at the hit (3): regen is derived, never stored');
+ await A.page.evaluate((id) => window.__health.api.flow.setNodeData(id, { regen: 0 }), player.id);
+ await A.page.waitForTimeout(600);
+ for (let i = 0; i < 5; i++) await press(A.page, 'KeyR');
+ await h.eventually(() => playerOf(A, 'me'), (s) => s?.dead === true, 'five presses: dead', 8000);
+ await h.eventually(() => valueOf(A, wired[2]), (v) => v === 1, 'the death event fired ONCE (its counter reads 1)');
+ await h.eventually(() => valueOf(B, wired[2]), (v) => !v, 'and not on B — B\'s player did not die');
+ await h.eventually(() => playerOf(A, 'me'), (s) => s && !s.dead && s.hp === 5, 'respawn after 2 s: back to 5/5', 8000);
+ await A.page.waitForTimeout(800);
+ h.check((await valueOf(A, wired[2])) === 1, 'still ONE death');
+
+ // =====================================================================
+ // 5. A NEW ROUND restores every object — core\'s round, our reset pulse
+ // =====================================================================
+ await setState(A, 'menu');
+ await A.page.waitForTimeout(1000);
+ await setState(A, 'playing');
+ await h.eventually(() => healthOf(A, crate1), (s) => s?.hp === 3 && !s.dead, 'Crate1 is back to 3/3 on A after a round bump', 8000);
+ await h.eventually(() => healthOf(B, crate1), (s) => s?.hp === 3 && !s.dead, 'and on B', 8000);
+ await h.eventually(() => healthOf(C, crate1), (s) => s?.hp === 3 && !s.dead, 'and on the joiner', 8000);
+ await h.eventually(() => visibleOf(B, crate1), (v) => v === true, 'and it is visible again');
+
+ // =====================================================================
+ // 6. HEAL — the opposite sign, through a counter of its own
+ // =====================================================================
+ const target2 = (await snapshot(A)).find((s) => s.uuid === crate2);
+ await clickObject(A, crate2);
+ await h.eventually(() => healthOf(A, crate2), (s) => s?.hp === 2, 'premise: Crate2 hurt to 2/3');
+ const healed = await A.page.evaluate(
+ ({ hp }) =>
+ window.__health.api.flow.addNodes({
+ nodes: [
+ { type: 'keypress', x: 60, y: 900, data: { code: 'KeyH', pulse: 0.3 } },
+ { type: 'heal', x: 280, y: 900, data: { amount: 1 } },
+ { type: 'counter', x: 500, y: 900, data: { op: 'up', step: 1 } }
+ ],
+ edges: [{ from: 0, to: 1, handle: 'trigger' }, { from: 1, to: 2, handle: 'pulse' }, { from: 2, to: hp, handle: 'heal' }]
+ }),
+ { hp: target2.id }
+ );
+ await h.eventually(() => nodesOf(B, 'heal'), (n) => n.length === 1, 'the heal chain replicated');
+ await A.page.waitForTimeout(800);
+ await press(A.page, 'KeyH');
+ await h.eventually(() => healthOf(A, crate2), (s) => s?.hp === 3, 'one heal: 3/3 on A');
+ await h.eventually(() => healthOf(B, crate2), (s) => s?.hp === 3, 'and on B (the key stamp replicated; each peer fired its own local heal)');
+ h.check(Array.isArray(healed) && healed.length === 3, 'premise: the heal chain was authored (' + healed.length + ' nodes)');
+
+ // =====================================================================
+ // 8. HIT — the knock feed: one `hit` message, every peer fires its own local pulses
+ // =====================================================================
+ // the damage node of Crate2's chain, found from the graph alone
+ const chainDamageOf = async (healthId) => {
+ const edges = await edgesOf(A);
+ const counter = edges.find((e) => e.target === healthId && e.targetHandle === 'damage')?.source;
+ return edges.find((e) => e.target === counter && e.targetHandle === 'pulse')?.source ?? null;
+ };
+ const dmg2 = await chainDamageOf(target2.id);
+ h.check(!!dmg2, 'premise: Crate2\'s damage node resolves from the wires');
+ await A.page.evaluate(({ id }) => window.__health.api.flow.setNodeData(id, { source: 'hit', scale: 'speed', speedRef: 3, amount: 1 }), { id: dmg2 });
+ await h.eventually(() => nodesOf(B, 'damage'), (n) => n.some((x) => x.id === dmg2 && x.data.source === 'hit' && x.data.scale === 'speed'), 'the edit replicated (source hit, speed-scaled)');
+ // the knock needs a body and the knock block (scene physics has no api write, DEVX #20)
+ await A.page.evaluate(() =>
+ window.__stores.scenePhysics.setScenePhysics({ knock: { enabled: true, gain: 1, maxSpeed: 10, spin: 0.8 }, play: { interaction: 'grab', grounded: false, simOnPlay: false } })
+ );
+ await A.page.evaluate((u) => window.__health.api.physics.set(u, { mode: 'dynamic', mass: 1, restitution: 0.2, friction: 0.6 }), crate2);
+ await A.page.waitForTimeout(800);
+ for (const peer of [A, B, C]) await setPlay(peer, true);
+ await A.page.evaluate(() => window.__stores.physics.warmup().catch(() => {}));
+ await A.page.evaluate(() => window.__stores.physics.toggleSimulation());
+ await h.eventually(() => A.page.evaluate(() => window.__stores.physics.physicsDebug().length), (n) => n > 0, 'premise: the simulation runs on A');
+ await A.page.waitForTimeout(1200);
+ const knock = await A.page.evaluate(
+ ({ uuid, speed }) => {
+ let group = null;
+ window.__stores.objectsGroup.subscribe((g) => (group = g))();
+ const o = group?.getObjectByProperty('uuid', uuid);
+ if (!o) return { hits: 0, reason: 'no object' };
+ const k = window.__stores.knock;
+ const id = 'hp-probe';
+ k.dropProbe(id);
+ const [bx, by, bz] = o.position.toArray();
+ let hits = 0;
+ let t = 1000;
+ const step = (speed * 16) / 1000;
+ for (let x = bx - 1.2; x <= bx + 0.05; x += step) {
+ hits += k.feedProbe(id, [x, by, bz], t).hits;
+ t += 16;
+ }
+ k.dropProbe(id);
+ return { hits };
+ },
+ { uuid: crate2, speed: 6 }
+ );
+ h.check(knock.hits >= 1, 'premise: a 6 m/s probe sweep knocks Crate2 (' + JSON.stringify(knock) + ')');
+ // 6 m/s against speedRef 3 = 2 points per knock, on every peer, from ONE message
+ const expectHp = Math.max(0, 3 - 2 * knock.hits);
+ await h.eventually(() => healthOf(A, crate2), (s) => s?.hp === expectHp, 'A: Crate2 took 2 points per knock (speed-scaled) -> ' + expectHp + '/3', 8000);
+ await h.eventually(() => healthOf(B, crate2), (s) => s?.hp === expectHp, 'B reads the same from the hit message it received', 10000);
+ await h.eventually(() => healthOf(C, crate2), (s) => s?.hp === expectHp, 'and so does C', 10000);
+ await A.page.evaluate(() => window.__stores.physics.stopSimulation());
+ await A.page.waitForTimeout(800);
+
+ // =====================================================================
+ // 9. TOUCH — self-proximity, an EDGE: the toucher fires a replicated pulse once
+ // =====================================================================
+ // In play the rig re-seats the camera at (0, 1.6, 0) every frame, so a flight moves
+ // the OBJECT (a replicated move) rather than the player. Only B is in play: touch is
+ // self-detected, and a peer out of play detects nothing.
+ const objectPos = (peer, uuid) =>
+ peer.page.evaluate((u) => {
+ let group;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ return group?.getObjectByProperty('uuid', u)?.position.toArray() ?? null;
+ }, uuid);
+ const moveTo = (uuid, pos) => A.page.evaluate(({ u, p }) => window.__health.api.moveObject(u, { pos: p }), { u: uuid, p: pos });
+ for (const peer of [A, C]) await setPlay(peer, null);
+ await A.page.waitForTimeout(400);
+ const crate3 = await makeBox(A, 'Crate3');
+ await moveTo(crate3, [6, 0, 0]);
+ await openToolbox(A);
+ await A.page.evaluate((u) => window.__stores.objectActions.selectObject(u), crate3);
+ await A.page.waitForTimeout(300);
+ await fillForm(A, { name: 'hp', max: 3, source: 'touch', amount: 1, radius: 2.5, deathAction: 'hide' });
+ await A.page.locator('.health-manager').getByRole('button', { name: 'Make damageable' }).click();
+ await A.page.waitForTimeout(1200);
+ await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
+ await h.eventually(() => healthOf(B, crate3), (s) => s?.hp === 3, 'Crate3 has a touch chain on B', 12000);
+ await h.eventually(() => objectPos(B, crate3), (p) => p && Math.abs(p[0] - 6) < 0.01, 'and stands 6 m away on B (the move replicated)');
+ // where B's player stands in play is the rig's business; the flight reads it and puts
+ // the crate exactly there (a replicated move), then takes it away and brings it back
+ const bPlayer = await B.page.evaluate(() => window.__health.api.playerPosition());
+ h.check(Array.isArray(bPlayer) && bPlayer.length === 3, 'premise: B\'s player has a position in play (' + JSON.stringify(bPlayer) + ')');
+ const away3 = [bPlayer[0] + 9, bPlayer[1], bPlayer[2] + 9];
+ await moveTo(crate3, bPlayer);
+ await h.eventually(() => healthOf(B, crate3), (s) => s?.hp === 2, 'Crate3 arrives under B: B touched it, 2/3 on the toucher', 8000);
+ await h.eventually(() => healthOf(A, crate3), (s) => s?.hp === 2, 'and on A (the toucher\'s pulse replicated; A is not even in play)', 8000);
+ await B.page.waitForTimeout(1500);
+ h.check((await healthOf(A, crate3))?.hp === 2, 'standing there does NOT keep hurting it: touch is an edge');
+ await moveTo(crate3, away3);
+ await B.page.waitForTimeout(700);
+ await moveTo(crate3, bPlayer);
+ await h.eventually(() => healthOf(A, crate3), (s) => s?.hp === 1, 'leaving and coming back is a second touch: 1/3', 8000);
+ await moveTo(crate3, away3);
+ await B.page.waitForTimeout(400);
+
+ // =====================================================================
+ // 10. ZONE — the PLAYER in a hazard: per second, local, on their own row
+ // =====================================================================
+ const zone = await A.page.evaluate(
+ ({ hp, crate }) =>
+ window.__health.api.flow.addNodes({
+ nodes: [
+ { type: 'damage', x: 60, y: 1100, data: { source: 'zone', amount: 1, perSecond: 2, radius: 2 } },
+ { type: 'objectselector', x: 60, y: 1200, data: { selected: crate } },
+ { type: 'counter', x: 280, y: 1100, data: { op: 'up', step: 1 } }
+ ],
+ edges: [{ from: 1, to: 0, handle: 'zone' }, { from: 0, to: 2, handle: 'pulse' }, { from: 2, to: hp, handle: 'damage' }]
+ }),
+ { hp: player.id, crate: crate3 }
+ );
+ h.check(zone.length === 3, 'premise: a zone chain into the player health, the zone being Crate3');
+ // the respawn point: Crate1, far off, wired into respawnAt
+ await moveTo(crate1, [40, 0, 40]);
+ const spawnSel = await A.page.evaluate(
+ ({ hp, crate }) => window.__health.api.flow.addNodes({ nodes: [{ type: 'objectselector', x: 700, y: 1100, data: { selected: crate } }], edges: [{ from: 0, to: hp, handle: 'respawnAt' }] }),
+ { hp: player.id, crate: crate1 }
+ );
+ h.check(spawnSel.length === 1, 'premise: Crate1 is the respawn point');
+ await setPlay(B, null);
+ await setPlay(A, true);
+ await A.page.waitForTimeout(2500); // the play rig settles the camera after a moment
+ h.check((await playerOf(A, 'me'))?.hp === 5, 'premise: A at 5/5 before the hazard');
+ const aPlayer = await A.page.evaluate(() => window.__health.api.playerPosition());
+ await A.page.waitForTimeout(600);
+ const aPlayer2 = await A.page.evaluate(() => window.__health.api.playerPosition());
+ h.check(Array.isArray(aPlayer) && aPlayer.every((v, i) => Math.abs(v - aPlayer2[i]) < 0.01), 'premise: A\'s play position is stable (' + JSON.stringify(aPlayer) + ')');
+ await moveTo(crate3, aPlayer2); // the hazard arrives under A
+ const zoneView = async () => ({
+ me: (await playerOf(A, 'me'))?.hp,
+ diag: await A.page.evaluate((u) => {
+ const api = window.__health.api;
+ let group;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ const o = group?.getObjectByProperty('uuid', u);
+ const edges = api.flow.edges().filter((e) => e.targetHandle === 'zone' || e.targetHandle === 'respawnAt').map((e) => ({ s: e.source.slice(0, 6), t: e.target.slice(0, 6), h: e.targetHandle }));
+ const zones = api.flow.nodes('damage').filter((n) => n.data.source === 'zone').map((n) => ({ id: n.id.slice(0, 6), radius: n.data.radius, perSecond: n.data.perSecond }));
+ return { playing: api.isPlaying(), player: api.playerPosition(), crate: o?.getWorldPosition(new api.THREE.Vector3()).toArray(), edges, zones, row: window.__stores.peerVars.myPeerVar('me', null) };
+ }, crate3)
+ });
+ await h.eventually(zoneView, (v) => typeof v.me === 'number' && v.me <= 3, 'in the zone: A loses about 2 points a second, on A\'s own row', 5000);
+ h.check((await playerOf(B, 'me'))?.hp === 5, 'B, out of play, is untouched (5/5)');
+ await h.eventually(() => playerOf(A, 'me'), (s) => s?.dead === true, 'staying kills A', 8000);
+ // leave play: the editor camera is free to fly, and the zone hurts nobody outside play
+ await setPlay(A, null);
+ await h.eventually(() => playerOf(A, 'me'), (s) => s && !s.dead && s.hp === 5, 'respawn after 2 s: 5/5 on the row', 8000);
+ await h.eventually(
+ () => A.page.evaluate(() => window.__health.api.playerPosition()),
+ (p) => p && Math.hypot(p[0] - 40, p[2] - 40) < 2,
+ 'and the camera flew to Crate1 (respawnAt) — the editor camera; in play the rig owns it (DEVX #23)',
+ 6000
+ );
+ await A.page.waitForTimeout(1200);
+ h.check((await playerOf(A, 'me'))?.hp === 5, 'the number holds at 5/5');
+ await moveTo(crate3, away3);
+
+ // =====================================================================
+ // 7. THE MANAGER — live rows
+ // =====================================================================
+ await setPlay(A, null);
+ await A.page.waitForTimeout(400);
+ await openToolbox(A);
+ await A.page.waitForTimeout(800);
+ const rowsUi = await A.page.evaluate(() =>
+ [...document.querySelectorAll('.health-manager .hm-row')].map((r) => ({ name: r.querySelector('.hm-name')?.textContent, hp: r.querySelector('.hm-hp')?.textContent, status: r.querySelector('.hm-status')?.textContent }))
+ );
+ h.check(rowsUi.length === 4, 'four rows: three crates and the player (' + JSON.stringify(rowsUi) + ')');
+ h.check(rowsUi.some((r) => /Crate1/.test(r.name) && r.hp === '3 / 3' && r.status === 'full'), 'Crate1 reads 3 / 3, full');
+ // Crate3 took its third touch when it arrived under A (A was in play as the hazard's
+ // bearer, and touch is self-detected by whoever is in play) — dead, and the row says so
+ h.check(rowsUi.some((r) => /Crate3/.test(r.name) && r.hp === '0 / 3' && r.status === 'dead'), 'Crate3 reads 0 / 3, dead (its third touch was A\'s, in the zone section)');
+ h.check(rowsUi.some((r) => /player/.test(r.name) && r.hp === '5 / 5'), 'the player row reads 5 / 5');
+
+ await h.finish(browser);
+});
diff --git a/tests/module-waves.test.cjs b/tests/module-waves.test.cjs
new file mode 100644
index 0000000..697ed34
--- /dev/null
+++ b/tests/module-waves.test.cjs
@@ -0,0 +1,289 @@
+// waves test-flight — the REAL health + waves zips on two peers (and a third that joins
+// late), the arena built through the module's own toolbox, the enemies killed through
+// the app's own click dispatch.
+//
+// THE CLAIM UNDER TEST: the wave is DERIVED from the enemies' hit counters (the health
+// module's ledger), every peer heals the survivors into the next wave with local pulses,
+// the enemies walk a line every peer computes from the wave's start stamp, and when the
+// last one falls every peer fires `over` and appends the same run to gameState.vars. So a
+// two-peer round ends when the last enemy dies, and a late joiner reads the same wave.
+//
+// APP_URL=https://theprototype.app:5215/ npm test -- waves
+const h = require('./helpers.cjs');
+
+const TOOLBOX = 'mod-waves-arena';
+
+const snapshot = (peer) => peer.page.evaluate(() => window.__waves.snapshot()[0] ?? null);
+const healthSnap = (peer) => peer.page.evaluate(() => window.__health.snapshot());
+const nodesOf = (peer, type) =>
+ peer.page.evaluate((t) => window.__stores.allNodes().filter((n) => !t || n.type === t).map((n) => ({ id: n.id, type: n.type, data: { ...(n.data ?? {}) } })), type ?? null);
+const edgesOf = (peer) =>
+ peer.page.evaluate(() => {
+ let graphs;
+ window.__stores.flowGraphs.subscribe((v) => (graphs = v))();
+ return Object.entries(graphs ?? {}).flatMap(([graphId, doc]) => (doc.edges ?? []).map((e) => ({ id: e.id, source: e.source, target: e.target, targetHandle: e.targetHandle ?? null, graphId })));
+ });
+const objectPos = (peer, uuid) =>
+ peer.page.evaluate((u) => {
+ let group;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ return group?.getObjectByProperty('uuid', u)?.position.toArray() ?? null;
+ }, uuid);
+const valueOf = (peer, id) =>
+ peer.page.evaluate((i) => {
+ let values;
+ window.__stores.flowValues.subscribe((v) => (values = v))();
+ return values?.[i];
+ }, id);
+const gameStateOf = (peer) =>
+ peer.page.evaluate(() => {
+ let g;
+ window.__stores.gameState.gameState.subscribe((v) => (g = v))();
+ return g?.state ?? null;
+ });
+const myVar = (peer, name) => peer.page.evaluate((n) => window.__stores.peerVars.myPeerVar(n, null), name);
+const setPlay = (peer, value) => peer.page.evaluate((v) => window.__stores.isLocked.set(v), value);
+const setState = (peer, state) => peer.page.evaluate((s) => window.__stores.gameState.setGameState(s), state);
+
+async function makeBox(peer, name) {
+ const uuid = await peer.page.evaluate(async () => {
+ const s = window.__stores;
+ s.commandsHandler.sceneCommand('/create box');
+ await new Promise((r) => setTimeout(r, 1300));
+ let group;
+ s.objectsGroup.subscribe((v) => (group = v))();
+ const last = group.children[group.children.length - 1];
+ s.objectActions.deselectObject();
+ return last?.uuid ?? null;
+ });
+ if (uuid) {
+ await peer.page.evaluate(({ uuid, name }) => window.__stores.objectActions.renameObject(uuid, name), { uuid, name });
+ await peer.page.waitForTimeout(400);
+ }
+ return uuid;
+}
+const moveTo = (peer, uuid, pos) => peer.page.evaluate(({ u, p }) => window.__waves.api.moveObject(u, { pos: p }), { u: uuid, p: pos });
+const clickObject = (peer, uuid) =>
+ peer.page.evaluate((u) => {
+ let group;
+ window.__stores.objectsGroup.subscribe((v) => (group = v))();
+ const object = group?.getObjectByProperty('uuid', u);
+ if (!object) return 'no-object';
+ window.__stores.moduleSDK.moduleClickHandlers.forEach((fn) => fn(object));
+ return 'dispatched';
+ }, uuid);
+async function openToolbox(peer) {
+ await peer.page.evaluate((id) => window.__stores.moduleToolboxes.openModuleToolbox(id), TOOLBOX);
+ await peer.page.waitForTimeout(700);
+}
+/** the form, in order: name · waves · first · +per · between · hp · [killed by] · walk · reach · dmg · rate · player · player hp · spawn prefix · boxes */
+async function fillForm(peer, o) {
+ const panel = peer.page.locator('.waves-manager');
+ const texts = panel.locator('.wm-form input[type=text]');
+ await texts.nth(0).fill(o.name ?? 'enemy');
+ await texts.nth(1).fill(o.playerName ?? 'me');
+ await texts.nth(2).fill(o.spawnPrefix ?? 'Spawn');
+ const numbers = panel.locator('.wm-form input[type=number]');
+ const values = [o.waves ?? 2, o.sizeStart ?? 2, o.sizeStep ?? 1, o.interval ?? 1, o.hp ?? 2, o.speed ?? 4, o.reach ?? 1.5, o.enemyDamage ?? 1, o.enemyRate ?? 1, o.playerHp ?? 10, o.count ?? 4];
+ for (let i = 0; i < values.length; i++) await numbers.nth(i).fill(String(values[i]));
+ await panel.locator('.wm-form select').nth(0).selectOption(o.source ?? 'click');
+}
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.GPU_ARGS });
+ const A = await h.setupPage(browser, 'A');
+ const B = await h.setupPage(browser, 'B');
+ for (const peer of [A, B]) {
+ await h.installModule(peer, 'health');
+ await h.installModule(peer, 'waves');
+ }
+ await h.connect(A, B);
+ for (const peer of [A, B])
+ await peer.page.evaluate(() => {
+ const s = window.__stores;
+ s.clearGraphs();
+ s.gameState.clearGameState();
+ s.peerVars.clearPeerVars(false);
+ s.isLocked.set(null);
+ });
+ await A.page.waitForTimeout(1000);
+
+ // =====================================================================
+ // 0. PREMISE
+ // =====================================================================
+ const registered = await A.page.evaluate(() => {
+ const s = window.__stores;
+ let groups;
+ s.moduleSDK.moduleNodeGroups.subscribe((v) => (groups = v))();
+ const items = (groups ?? []).flatMap((g) => (g.items ?? []).map((i) => i.type));
+ let boxes;
+ s.moduleToolboxes.moduleToolboxes.subscribe((v) => (boxes = v))();
+ return {
+ items,
+ types: { waves: s.moduleNodeIO.moduleValueTypes.waves, wavesevent: s.moduleNodeIO.moduleValueTypes.wavesevent },
+ inputs: s.moduleNodeIO.moduleNodeInputs.waves,
+ toolbox: (boxes ?? []).map((b) => b.id),
+ actions: s.hudActions.actionsForKind('text').map((a) => a.key),
+ debugLines: s.moduleHudKinds.moduleDebugLineTexts(),
+ hooks: !!window.__waves && !!window.__health
+ };
+ });
+ h.check(['waves', 'wavesvalue', 'wavesevent'].every((t) => registered.items.includes(t)) && registered.items.includes('health'), 'the Waves family and the Health family are both in the palette');
+ h.check(registered.types.waves === 'number' && registered.types.wavesevent === 'event' && registered.inputs?.goal === 'object', 'waves is a number (the wave) with a goal object input; wavesevent an event');
+ h.check(registered.toolbox.includes(TOOLBOX), 'the arena toolbox is registered as ' + TOOLBOX);
+ h.check(registered.actions.includes('mod-waves-showwave') && registered.actions.includes('mod-waves-showleft'), 'the HUD actions are in the catalog');
+ h.check(registered.debugLines.length === 0 && registered.hooks, 'debug line silent, hooks on window');
+
+ // =====================================================================
+ // 1. THE ARENA — three enemies, a spawn point, a goal, built by the toolbox
+ // =====================================================================
+ const enemies = [];
+ for (const name of ['Enemy1', 'Enemy2', 'Enemy3']) enemies.push(await makeBox(A, name));
+ const goal = await makeBox(A, 'Goal');
+ const spawn = await makeBox(A, 'Spawn 1');
+ h.check(enemies.every(Boolean) && !!goal && !!spawn, 'premise: five boxes');
+ await moveTo(A, enemies[0], [-3, 0.5, -14]);
+ await moveTo(A, enemies[1], [0, 0.5, -14]);
+ await moveTo(A, enemies[2], [3, 0.5, -14]);
+ await moveTo(A, spawn, [0, 0.5, -8]);
+ await moveTo(A, goal, [0, 0.5, 6]);
+ await A.page.waitForTimeout(600);
+ await openToolbox(A);
+ h.check(await A.page.locator('.waves-manager').isVisible(), 'the toolbox mounted its DOM');
+ await fillForm(A, { waves: 2, sizeStart: 2, sizeStep: 1, interval: 1, hp: 2, source: 'click', speed: 4 });
+ await A.page.evaluate(({ e, g }) => {
+ const s = window.__stores;
+ s.objectActions.selectObject(e[0]);
+ s.objectActions.selectObject(e[1], false, true);
+ s.objectActions.selectObject(e[2], false, true);
+ s.objectActions.selectObject(g, false, true); // the goal LAST
+ }, { e: enemies, g: goal });
+ await A.page.waitForTimeout(400);
+ await A.page.locator('.waves-manager').getByRole('button', { name: 'Enemies from selection' }).click();
+ await A.page.waitForTimeout(2500);
+ await A.page.evaluate(() => window.__stores.objectActions.deselectObject());
+
+ const wavesNodes = await nodesOf(A, 'waves');
+ h.check(wavesNodes.length === 1 && wavesNodes[0].data.waves === 2 && wavesNodes[0].data.sizeStart === 2, 'ONE Waves node with the form\'s curve');
+ const allA = await nodesOf(A);
+ const counts = allA.reduce((m, n) => ((m[n.type] = (m[n.type] ?? 0) + 1), m), {});
+ h.check(counts.health === 4 && counts.heal === 3 && counts.damage === 7 && counts.counter === 7 && counts.wavesevent === 1 && counts.setgamestate === 1, 'per enemy a health chain, a heal chain and a zone chain; the player health; over -> Set Game State (' + JSON.stringify(counts) + ')');
+ const edgesA = await edgesOf(A);
+ h.check(edgesA.some((e) => e.targetHandle === 'goal' && e.target === wavesNodes[0].id), 'the goal is wired into waves.goal');
+ h.check(edgesA.every((e) => e.id === 'e-' + e.source + '-' + e.target + (e.targetHandle ? '.' + e.targetHandle : '')), 'every edge id is canonical');
+ await h.eventually(() => nodesOf(B), (n) => n.length === allA.length, 'the whole arena replicated to B (' + allA.length + ' nodes)', 15000);
+ await h.eventually(() => snapshot(A), (s) => s && s.enemies.length === 3 && s.wave === 1 && !s.running && s.goal && s.spawns === 1, 'A derives: 3 enemies, wave 1, idle, a goal, one spawn point');
+ await h.eventually(() => snapshot(B), (s) => s && s.enemies.length === 3 && s.wave === 1 && s.spawns === 1, 'and so does B');
+ const line = await A.page.evaluate(() => window.__stores.moduleHudKinds.moduleDebugLineTexts());
+ h.check(line.some((l) => /waves \(enemy\): wave 1\/2, idle/.test(l)), 'the debug line reads the run (' + JSON.stringify(line) + ')');
+
+ // =====================================================================
+ // 2. THE ROUND STARTS — wave 1 uses two enemies, and they walk
+ // =====================================================================
+ await setState(A, 'playing');
+ await A.page.waitForTimeout(600);
+ for (const peer of [A, B]) await setPlay(peer, true);
+ await h.eventually(() => snapshot(A), (s) => s?.running && s.started && s.wave === 1 && s.size === 2 && s.alive === 2, 'the run is on: wave 1, two of three enemies, both alive', 8000);
+ await h.eventually(() => snapshot(B), (s) => s?.running && s.started && s.wave === 1 && s.alive === 2, 'B agrees', 8000);
+ const order = (await snapshot(A)).enemies.map((e) => e.uuid);
+ h.check(order.join() === enemies.join(), 'enemy order is by name: Enemy1, Enemy2, Enemy3 (deterministic on every peer)');
+ const p0 = await objectPos(A, order[0]);
+ await A.page.waitForTimeout(1200);
+ const p1 = await objectPos(A, order[0]);
+ h.check(p0 && p1 && p1[2] > p0[2] + 1, 'the first enemy walks toward the goal (+z): ' + p0[2].toFixed(1) + ' -> ' + p1[2].toFixed(1));
+ const pB = await objectPos(B, order[0]);
+ const pA = await objectPos(A, order[0]);
+ h.check(pA && pB && Math.abs(pA[2] - pB[2]) < 0.8, 'B places it where A does (same stamp, same clock): ' + pA[2].toFixed(1) + ' vs ' + pB[2].toFixed(1));
+ const p2 = await objectPos(A, order[2]);
+ h.check(p2 && Math.abs(p2[2] - -14) < 0.01, 'the third enemy is not in wave 1: parked');
+
+ // =====================================================================
+ // 3. TWO PEERS CLEAR WAVE 1 — the wave advances from the counters, the survivors heal
+ // =====================================================================
+ await clickObject(A, order[0]);
+ await A.page.waitForTimeout(300);
+ await clickObject(A, order[0]);
+ await h.eventually(() => snapshot(A), (s) => s?.alive === 1 && s.enemies[0].kills === 1, 'A kills the first enemy (two clicks on hp 2): one left');
+ await h.eventually(() => myVar(A, 'kills'), (v) => v === 1, 'A\'s own kills row reads 1');
+ await clickObject(B, order[1]);
+ await B.page.waitForTimeout(300);
+ await clickObject(B, order[1]);
+ await h.eventually(() => snapshot(A), (s) => s?.completed === 1, 'B kills the second: wave 1 is complete on A (B\'s pulses replicated)', 8000);
+ await h.eventually(() => myVar(B, 'kills'), (v) => v === 1, 'B\'s own kills row reads 1');
+ h.check((await myVar(A, 'kills')) === 1, 'and A\'s is still 1: one writer per row');
+ await h.eventually(() => snapshot(A), (s) => s?.wave === 2 && s.started && s.alive === 3, 'after the interval: wave 2, all three alive again (the survivors healed with local pulses)', 8000);
+ await h.eventually(() => snapshot(B), (s) => s?.wave === 2 && s.started && s.alive === 3, 'B: wave 2 too', 8000);
+ // the heal pulses landed this sweep; the counter's value republishes ~6/s
+ await h.eventually(() => snapshot(A), (s) => s && s.enemies[0].hits === 2 && s.enemies[0].heals === 2 && s.enemies[0].hp === 2 && s.enemies[2].hits === 0, 'the ledger: enemy 1 has 2 hits and 2 heals (hp 2), enemy 3 untouched');
+ await h.eventually(() => healthSnap(A), (all) => all.find((s) => s.uuid === order[0])?.hp === 2, 'the health module reads the healed enemy at 2/2');
+ const readers = await A.page.evaluate(() =>
+ window.__waves.api.flow.addNodes({ nodes: [{ type: 'wavesvalue', x: 1200, y: 40, data: { name: 'enemy', read: 'wave' } }, { type: 'wavesvalue', x: 1200, y: 140, data: { name: 'enemy', read: 'left' } }] })
+ );
+ await h.eventually(() => valueOf(A, readers[0]), (v) => v === 2, 'a Waves Value node reads wave 2');
+ await h.eventually(() => valueOf(A, readers[1]), (v) => v === 3, 'and 3 left');
+
+ // =====================================================================
+ // 4. A LATE JOINER reads wave 2 from the counters alone
+ // =====================================================================
+ await setPlay(A, null);
+ await A.page.waitForTimeout(400);
+ const C = await h.setupPage(browser, 'C');
+ await h.installModule(C, 'health');
+ await h.installModule(C, 'waves');
+ await h.connect(C, A);
+ await setPlay(A, true);
+ await h.eventually(() => snapshot(C), (s) => s?.wave === 2 && s.enemies.length === 3, 'the joiner reads WAVE 2 — the counts arrived in the triggers handshake', 25000);
+ const cE = (await snapshot(C)).enemies;
+ h.check(cE[0].hits === 2 && cE[0].heals === 2 && cE[1].hits === 2 && cE[1].heals === 2 && cE[2].hits === 0, 'and the same ledger: hits and heals per enemy match the host');
+ h.check((await myVar(C, 'kills')) === null, 'the joiner has no kills row yet');
+ await setPlay(C, true);
+ await h.eventually(() => valueOf(C, readers[0]), (v) => v === 2, 'its Waves Value node reads 2 as well');
+
+ // =====================================================================
+ // 5. THE LAST WAVE — three peers, one kill each; the round ends; the log agrees
+ // =====================================================================
+ for (const [peer, i] of [[A, 0], [B, 1], [C, 2]]) {
+ await clickObject(peer, order[i]);
+ await peer.page.waitForTimeout(300);
+ await clickObject(peer, order[i]);
+ }
+ await h.eventually(() => snapshot(A), (s) => s?.done === true && s.alive === 0, 'the last enemy falls: DONE on A', 10000);
+ await h.eventually(() => snapshot(B), (s) => s?.done === true, 'done on B', 8000);
+ await h.eventually(() => snapshot(C), (s) => s?.done === true, 'done on C', 8000);
+ await h.eventually(() => gameStateOf(A), (s) => s === 'over', 'over -> Set Game State: the shell is OVER on A', 8000);
+ await h.eventually(() => gameStateOf(B), (s) => s === 'over', 'and on B');
+ await h.eventually(() => gameStateOf(C), (s) => s === 'over', 'and on C');
+ const kills = [await myVar(A, 'kills'), await myVar(B, 'kills'), await myVar(C, 'kills')];
+ h.check(kills.join() === '2,2,1', 'kills rows: A 2, B 2, C 1 — each peer credited only its own killing blows (' + kills.join() + ')');
+ await h.eventually(() => snapshot(A), (s) => s?.log.length === 1 && s.log[0].cleared && s.log[0].waves === 2 && s.log[0].reached === 2, 'ONE run logged in gameState.vars: cleared, 2 waves', 8000);
+ const logA = (await snapshot(A)).log[0];
+ h.check(logA.players.length === 3 && logA.players[0].kills === 2 && logA.players[2].kills === 1, 'with the three players\' kills (' + JSON.stringify(logA.players.map((p) => p.kills)) + ')');
+ await h.eventually(() => snapshot(B), (s) => s?.log.length === 1 && JSON.stringify(s.log[0]) === JSON.stringify(logA), 'B holds the SAME entry (every peer appended the same object; idempotent by its stamp)', 8000);
+ await h.eventually(() => snapshot(C), (s) => s?.log.length === 1 && s.log[0].at === logA.at, 'and so does C');
+
+ // =====================================================================
+ // 6. THE DEF + HUD, and the manager
+ // =====================================================================
+ const def = await A.page.evaluate(() => ({ screens: window.__waves.hud().scene.screens.map((s) => s.id), graph: window.__waves.hudGraph({ name: 'enemy', playerName: 'me' }).nodes.length }));
+ h.check(def.screens.join() === 'menu,hud,over' && def.graph === 11, 'the HUD def: menu / hud / over, an eleven-node driver graph');
+ await setPlay(A, null);
+ await A.page.waitForTimeout(400);
+ await openToolbox(A);
+ await A.page.waitForTimeout(800);
+ const panel = await A.page.evaluate(() => document.querySelector('.waves-manager .wm-title')?.textContent ?? '');
+ h.check(/enemy · wave 2 of 2 · cleared/.test(panel), 'the manager reads the run (' + panel + ')');
+
+ // =====================================================================
+ // 7. A NEW ROUND starts over — the health module zeroes the counters, wave 1 again
+ // =====================================================================
+ await setState(A, 'menu');
+ await A.page.waitForTimeout(800);
+ await setState(A, 'playing');
+ await setPlay(A, true);
+ await h.eventually(() => snapshot(A), (s) => s?.wave === 1 && !s.done && s.running && s.enemies.every((e) => e.hits === 0 && e.heals === 0), 'a new round: wave 1, every counter zero on A', 10000);
+ await h.eventually(() => snapshot(B), (s) => s?.wave === 1 && !s.done && s.enemies.every((e) => e.hits === 0), 'and on B', 8000);
+ await h.eventually(() => snapshot(C), (s) => s?.wave === 1 && !s.done, 'and on C', 8000);
+ h.check((await snapshot(A)).log.length === 1, 'the log keeps the finished run');
+
+ await h.finish(browser);
+});
diff --git a/tests/music-beat-lab.test.cjs b/tests/music-beat-lab.test.cjs
new file mode 100644
index 0000000..274dabf
--- /dev/null
+++ b/tests/music-beat-lab.test.cjs
@@ -0,0 +1,284 @@
+// music-beat-lab test-flight (#23 C2): two people editing the same pattern, and every
+// peer's kick landing on the same beat from the same shared transport.
+//
+// The pattern is a param of the drum machine's device document, so it replicates, saves
+// and undoes as one thing; steps are scheduled through the engine's look-ahead scheduler,
+// so nothing crosses the wire during playback. Every timing claim here is measured at
+// the destination tap as ONSETS against the WALL CLOCK (Date.now), never against tick
+// counts - the fixed-timestep lesson - and every peer is read from its own tap.
+//
+// THE GPU FLAGS ARE PART OF THE MEASUREMENT (h.AUDIO_ARGS) - see music-lab.test.cjs.
+const h = require('./helpers.cjs');
+
+const inPage = (page, body, arg) =>
+ page.evaluate(
+ ([src, a]) =>
+ Object.getPrototypeOf(async function () {}).constructor('s', 'ad', 'ap', 'mc', 'arg', src)(
+ window.__stores,
+ window.__stores.audioDevices,
+ window.__stores.audioPatch,
+ window.__stores.musicClock,
+ a
+ ),
+ [body, arg ?? null]
+ );
+
+const K = {
+ drums: 'mod-music-lab-drums',
+ sampler: 'mod-music-lab-sampler',
+ transport: 'mod-music-lab-transport',
+ speaker: 'mod-music-lab-speaker'
+};
+const KICKS = '7000700070007000/0000000000000000/0000000000000000/0000000000000000/0000000000000000/0000000000000000/0000000000000000/0000000000000000';
+const BEAT_MS = 500; // 120 bpm
+
+const docOf = (page, uuid) => inPage(page, 'return ad.deviceOf(ad.findDeviceObject(arg))', uuid);
+const patternOf = (page, uuid) => docOf(page, uuid).then((d) => d?.params?.pattern ?? null);
+const undoLen = (page) => page.evaluate(() => { let n = 0; window.__stores.history.undoStack.subscribe((v) => (n = v.length))(); return n; });
+const bpmOf = (page) => inPage(page, 'return mc.transportNow().bpm');
+const playingOf = (page) => inPage(page, 'return mc.transportNow().playing');
+/** the click seam: the module's registered handler, called with the exact mesh the way
+ * core's tap / raycast would (music-vr-patch's precedent) @param {any} page @param {string} uuid @param {string} name */
+const clickMesh = (page, uuid, name) =>
+ inPage(page, "const o = ad.findDeviceObject(arg.uuid); const mesh = o?.getObjectByName(arg.name); if (!mesh) return { took: false, missing: true }; return { took: s.moduleSDK.moduleClickHandlers.some((fn) => fn(mesh)) }", { uuid, name });
+
+/**
+ * Onsets at this peer's tap for `ms`, SAMPLE-ACCURATE: a big analyser window (8192 samples,
+ * ~186 ms) is chained off every tapped analyser, and each read scans only the samples that
+ * arrived since the previous read, in 64-sample chunks, back-dating a threshold crossing
+ * by its offset inside the window. So a JavaScript loop that stalls under CPU load (this
+ * box is shared) still yields the true onset time - a measurement of the SCHEDULER, not of
+ * setTimeout. Threshold: 35% of the loudest chunk seen (re-arm below 12%). Returns the
+ * onsets (Date.now ms), the peak, and the read count.
+ */
+const onsets = (peer, ms) =>
+ peer.page.evaluate(async (ms) => {
+ const N = 8192;
+ const CHUNK = 64;
+ const taps = window.__audioTap.all().map((t) => {
+ const mine = t.context.createAnalyser();
+ mine.fftSize = N;
+ mine.smoothingTimeConstant = 0;
+ t.analyser.connect(mine);
+ return { src: t.analyser, mine, sr: t.context.sampleRate, buf: new Float32Array(N), lastRead: 0 };
+ });
+ /** @type {{t: number, v: number}[]} */
+ const chunks = [];
+ const t0 = Date.now();
+ let lastLoop = performance.now();
+ while (Date.now() - t0 < ms) {
+ const now = performance.now();
+ const wall = Date.now();
+ const advanced = Math.min(N, Math.round(((now - lastLoop) / 1000) * taps[0].sr) + 256);
+ lastLoop = now;
+ /** the loudest tap per chunk position */
+ const merged = new Float32Array(Math.ceil(advanced / CHUNK));
+ for (const tap of taps) {
+ tap.mine.getFloatTimeDomainData(tap.buf);
+ const from = N - advanced;
+ for (let c = 0; c < merged.length; c++) {
+ const a = from + c * CHUNK;
+ const b = Math.min(N, a + CHUNK);
+ let sum = 0;
+ for (let i = a; i < b; i++) sum += tap.buf[i] * tap.buf[i];
+ merged[c] = Math.max(merged[c], Math.sqrt(sum / Math.max(1, b - a)));
+ }
+ }
+ for (let c = 0; c < merged.length; c++) {
+ const endIndex = N - advanced + (c + 1) * CHUNK;
+ chunks.push({ t: wall - ((N - endIndex) / taps[0].sr) * 1000, v: merged[c] });
+ }
+ await new Promise((r) => setTimeout(r, 4));
+ }
+ for (const tap of taps) tap.src.disconnect(tap.mine);
+ let peak = 0;
+ for (const c of chunks) peak = Math.max(peak, c.v);
+ const high = peak * 0.35;
+ const low = peak * 0.12;
+ let armed = true;
+ let last = -Infinity;
+ const out = [];
+ for (const c of chunks) {
+ if (armed && c.v >= high) {
+ if (c.t - last > 120) out.push(Math.round(c.t));
+ last = c.t;
+ armed = false;
+ } else if (!armed && c.v <= low) armed = true;
+ }
+ return { onsets: out, peak, reads: chunks.length };
+ }, ms);
+
+/** the phase of a set of onsets on a period grid, in ms, and how far each strays from it */
+function grid(times, period) {
+ if (times.length < 2) return { phase: NaN, maxDev: NaN, spacing: [] };
+ const first = times[0];
+ const rel = times.map((t) => t - first);
+ // the phase is the circular mean of (t mod period)
+ let sx = 0, sy = 0;
+ for (const r of rel) { const a = (2 * Math.PI * (r % period)) / period; sx += Math.cos(a); sy += Math.sin(a); }
+ const phase = ((Math.atan2(sy, sx) / (2 * Math.PI)) * period + period) % period;
+ let maxDev = 0;
+ for (const r of rel) { let d = ((r - phase) % period + period) % period; if (d > period / 2) d -= period; maxDev = Math.max(maxDev, Math.abs(d)); }
+ const spacing = rel.slice(1).map((r, i) => r - rel[i]);
+ return { phase: (phase + first) % period, maxDev, spacing };
+}
+/** circular distance between two phases on a period */
+const phaseGap = (a, b, period) => { let d = Math.abs(a - b) % period; return Math.min(d, period - d); };
+
+const wav = () => {
+ const sr = 22050, n = sr;
+ const pcm = new Int16Array(n);
+ for (let i = 0; i < n; i++) pcm[i] = Math.round(Math.sin((i / sr) * 2 * Math.PI * 330) * 12000 * Math.exp((-i / sr) * 6));
+ const buf = new ArrayBuffer(44 + n * 2);
+ const v = new DataView(buf);
+ const w = (o, str) => { for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i)); };
+ w(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); w(8, 'WAVE'); w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
+ v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); w(36, 'data'); v.setUint32(40, n * 2, true);
+ new Int16Array(buf, 44).set(pcm);
+ return Array.from(new Uint8Array(buf));
+};
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.AUDIO_ARGS });
+ const A = await h.setupPage(browser, 'A', { audio: true });
+ const B = await h.setupPage(browser, 'B', { audio: true });
+ await h.installModule(A, 'music-lab');
+ await h.installModule(B, 'music-lab');
+ await h.connect(A, B);
+
+ console.log('\n=== 1. the rig: four devices on A, held and BUILT on B ===');
+ const kindsA = await inPage(A.page, 'return ad.devicesDebug().kinds');
+ h.check([K.drums, K.sampler, K.transport].every((k) => kindsA.includes(k)), '1.1 the beat lab kinds register beside the speaker and the piano (' + kindsA.length + ' kinds)');
+ // the speaker sits AT the listener (the camera), so the tap reads the rig loud and clear
+ const ids = await inPage(A.page, "const cam = await new Promise((r) => s.globalCamera.subscribe((c) => r(c))()); const at = cam.getWorldPosition(new s.THREE.Vector3()).toArray(); const t = ad.addDevice(arg.transport, { position: [-1.6, 0.5, -2] }); const d = ad.addDevice(arg.drums, { position: [0, 0.8, -2] }); const sm = ad.addDevice(arg.sampler, { position: [1.2, 0.8, -2] }); const sp = ad.addDevice(arg.speaker, { position: at }); ap.addCable({ from: { uuid: d.uuid, port: 'out' }, to: { uuid: sp.uuid, port: 'in' } }); ap.addCable({ from: { uuid: sm.uuid, port: 'out' }, to: { uuid: sp.uuid, port: 'in' } }); return { t: t.uuid, d: d.uuid, sm: sm.uuid, sp: sp.uuid }", K);
+ await inPage(A.page, 'ad.setDeviceFor(arg.d, { params: { pattern: arg.p } }); return 1', { d: ids.d, p: KICKS });
+ await h.eventually(() => inPage(B.page, 'return ad.devicesDebug().built.map((b) => b.builtAs).sort().join(",")'), (v) => v === [K.drums, K.sampler, K.speaker, K.transport].sort().join(','), '1.2 B built its own copy of all four (' + Object.values(K).length + ' kinds)', 15000);
+ await h.eventually(() => patternOf(B.page, ids.d), (v) => v === KICKS, '1.3 the pattern reached B inside the device document - no channel of its own');
+ const facesB = await inPage(B.page, "const t = ad.findDeviceObject(arg.t); const d = ad.findDeviceObject(arg.d); let lit = 0; d.traverse((c) => { if (c.name.startsWith('step-') && c.material?.color?.getHex?.() !== 0x4b4b52) lit++; }); return { display: !!t?.getObjectByName('tp-display')?.material?.map, lit, cells: d.children.filter((c) => c.name.startsWith('step-')).length }", ids);
+ h.check(facesB.display && facesB.lit === 4 && facesB.cells === 128, '1.4 B drew its own transport display and lit the four kick cells of the replicated grid (' + JSON.stringify(facesB) + ')');
+
+ console.log('\n=== 2. editing: a click is one write, a drag is ONE write, one undo ===');
+ const undo0 = await undoLen(A.page);
+ const took = await clickMesh(A.page, ids.d, 'step-1-4');
+ h.check(took.took === true, '2.1 a step cell takes the click (the module consumed it, no selection)');
+ const rowNow = await inPage(A.page, "const hd = ad.deviceHandle(arg); const d = ad.findDeviceObject(arg); return { row: hd.h.rows[1], lit: d.getObjectByName('step-1-4').material?.color?.getHex?.() !== 0x4b4b52, doc: ad.deviceOf(d).params.pattern.split('/')[1] }", ids.d);
+ h.check(rowNow.row === '0000700000000000' && rowNow.lit && rowNow.doc === '0000000000000000', '2.2 the cell lights LOCALLY at once (handle + paint) while the DOCUMENT is still untouched - the stroke previews like a knob scrub (' + JSON.stringify(rowNow) + ')');
+ await A.page.waitForTimeout(1300);
+ const afterClick = await Promise.all([patternOf(A.page, ids.d), undoLen(A.page)]);
+ h.check(afterClick[0].split('/')[1] === '0000700000000000' && afterClick[1] === undo0 + 1, '2.3 the stroke commits ONE write when it goes idle (undo ' + undo0 + ' -> ' + afterClick[1] + ')');
+ await h.eventually(() => patternOf(B.page, ids.d), (v) => v === afterClick[0], '2.4 and B holds the same pattern');
+ // a DRAG: click one cell, then sweep the mouse across three more inside the idle window -
+ // the frame task follows api.pointerRay() over the grid
+ const cells = await inPage(A.page, "const d = ad.findDeviceObject(arg); const v = new s.THREE.Vector3(); return [0, 1, 2, 3].map((step) => d.getObjectByName('step-3-' + step).getWorldPosition(v.clone()).toArray())", ids.d);
+ const points = [];
+ for (const world of cells) points.push(await h.projectPoint(A.page, world));
+ const undo1 = await undoLen(A.page);
+ await A.page.mouse.move(points[0].x, points[0].y);
+ await clickMesh(A.page, ids.d, 'step-3-0');
+ for (let i = 1; i < 4; i++) {
+ await A.page.mouse.move(points[i].x, points[i].y, { steps: 4 });
+ await A.page.waitForTimeout(120);
+ }
+ const during = await inPage(A.page, "return { row: ad.deviceHandle(arg).h.rows[3], doc: ad.deviceOf(ad.findDeviceObject(arg)).params.pattern.split('/')[3] }", ids.d);
+ h.check(during.row === '7777000000000000' && during.doc === '0000000000000000', '2.5 the sweep painted all four cells LOCALLY before anything was written (' + JSON.stringify(during) + ')');
+ await A.page.waitForTimeout(1300);
+ const afterDrag = await Promise.all([patternOf(A.page, ids.d), undoLen(A.page)]);
+ h.check(afterDrag[0].split('/')[3] === '7777000000000000' && afterDrag[1] === undo1 + 1, '2.6 the whole drag is ONE write and ONE undo entry (undo ' + undo1 + ' -> ' + afterDrag[1] + ')');
+ await h.eventually(() => patternOf(B.page, ids.d), (v) => v === afterDrag[0], '2.7 replicated to B as one document');
+ await A.page.evaluate(() => window.__stores.history.undo());
+ await A.page.waitForTimeout(500);
+ const undone = await Promise.all([patternOf(A.page, ids.d), patternOf(B.page, ids.d)]);
+ h.check(undone[0].split('/')[3] === '0000000000000000' && undone[0].split('/')[1] === '0000700000000000', '2.8 one Ctrl+Z reverts the whole drag and leaves the earlier click (' + undone[0].split('/')[3] + ')');
+ h.check(undone[1] === undone[0], '2.9 and B follows the undo');
+ // back to kicks only for the timing sections
+ await inPage(A.page, 'ad.setDeviceFor(arg.d, { params: { pattern: arg.p } }); return 1', { d: ids.d, p: KICKS });
+ await h.eventually(() => patternOf(B.page, ids.d), (v) => v === KICKS, '2.10 (premise) kicks-only pattern on both again');
+
+ console.log('\n=== 3. play from the transport face: every peer\'s kick on the same beat ===');
+ await inPage(A.page, 'mc.setBpm(120); return 1');
+ const play = await clickMesh(A.page, ids.t, 'tp-play');
+ h.check(play.took === true, '3.1 the Play button takes the click');
+ await h.eventually(() => playingOf(B.page), (v) => v === true, '3.2 the SHARED transport runs on B too');
+ await A.page.waitForTimeout(700);
+ const [oa, ob] = await Promise.all([onsets(A, 2300), onsets(B, 2300)]);
+ h.check(oa.peak > 0.002 && ob.peak > 0.002, '3.3 both taps hear the kicks (peak A ' + oa.peak.toFixed(4) + ', B ' + ob.peak.toFixed(4) + ')');
+ h.check(oa.onsets.length >= 4 && oa.onsets.length <= 5 && ob.onsets.length >= 4 && ob.onsets.length <= 5, '3.4 four to five kicks in 2.3 s on each (' + oa.onsets.length + '/' + ob.onsets.length + ')');
+ const ga = grid(oa.onsets, BEAT_MS), gb = grid(ob.onsets, BEAT_MS);
+ h.check(ga.maxDev < 25 && gb.maxDev < 25, '3.5 each peer is on a 500 ms grid (max deviation A ' + ga.maxDev.toFixed(1) + ' ms, B ' + gb.maxDev.toFixed(1) + ' ms)');
+ const gap = phaseGap(ga.phase, gb.phase, BEAT_MS);
+ h.check(gap < 35, '3.6 and the two grids are IN PHASE - B synthesizes the same beat from the same transport (' + gap.toFixed(1) + ' ms apart)');
+
+ console.log('\n=== 4. a late joiner lands in phase ===');
+ const C = await h.setupPage(browser, 'C', { audio: true });
+ await h.installModule(C, 'music-lab');
+ await h.connect(C, A);
+ await h.eventually(() => patternOf(C.page, ids.d), (v) => v === KICKS, '4.1 C holds the pattern from the handshake');
+ await h.eventually(() => playingOf(C.page), (v) => v === true, '4.2 and the running transport');
+ await C.page.waitForTimeout(1200);
+ const diagC = await inPage(C.page, "const hd = ad.deviceHandle(arg.d); return { built: ad.devicesDebug().built.map((b) => b.builtAs).sort(), cables: ap.patchDebug ? ap.patchDebug().cables?.length ?? null : null, hits: hd?.h?.hits?.length ?? null, ctx: s.audioEngine.ensureAudioContext()?.state, clock: (() => { try { return JSON.stringify(mc.clockDebug()).slice(0, 300); } catch (e) { return String(e); } })() }", ids);
+ console.log(' C diag ' + JSON.stringify(diagC));
+ const [oa2, oc] = await Promise.all([onsets(A, 2300), onsets(C, 2300)]);
+ console.log(' C tap peak ' + oc.peak.toFixed(5) + ' onsets ' + oc.onsets.length + ' reads ' + oc.reads);
+ const gc = grid(oc.onsets, BEAT_MS), ga2 = grid(oa2.onsets, BEAT_MS);
+ h.check(oc.onsets.length >= 4 && gc.maxDev < 25, '4.3 C\'s kicks sit on the grid (' + oc.onsets.length + ' onsets, max deviation ' + gc.maxDev.toFixed(1) + ' ms)');
+ const gapC = phaseGap(ga2.phase, gc.phase, BEAT_MS);
+ h.check(gapC < 40, '4.4 in phase with A (' + gapC.toFixed(1) + ' ms apart) - the joiner\'s first hit is the NEXT beat, never a catch-up');
+
+ console.log('\n=== 5. thirty seconds across bar boundaries, no drift ===');
+ const long = await onsets(A, 30000);
+ const gl = grid(long.onsets, BEAT_MS);
+ h.check(long.onsets.length >= 58 && long.onsets.length <= 61, '5.1 ' + long.onsets.length + ' kicks in 30 s (60 expected)');
+ h.check(gl.maxDev < 30, '5.2 the last kick is as close to the grid as the first - max deviation ' + gl.maxDev.toFixed(1) + ' ms over 30 s, measured on the wall clock');
+ const worst = Math.max(...gl.spacing.map((x) => Math.abs(x - BEAT_MS)));
+ h.check(worst < 40, '5.3 no beat is early or late by more than 40 ms (worst ' + worst.toFixed(1) + ')');
+
+ console.log('\n=== 6. the scheduler holds under a throttled tab ===');
+ const cdp = await A.page.context().newCDPSession(A.page);
+ await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
+ const slow = await onsets(A, 3300);
+ await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
+ const gs = grid(slow.onsets, BEAT_MS);
+ h.check(slow.onsets.length >= 5 && slow.onsets.length <= 7, '6.1 kicks keep coming with the CPU throttled 4x (' + slow.onsets.length + ' in 3.3 s)');
+ h.check(gs.maxDev < 40, '6.2 and stay on the wall-clock grid (max deviation ' + gs.maxDev.toFixed(1) + ' ms) - the look-ahead, not the tick rate, places them');
+
+ console.log('\n=== 7. the sampler: a sample dropped from the Explorer, heard on every peer ===');
+ const stop = await clickMesh(A.page, ids.t, 'tp-play');
+ h.check(stop.took === true, '7.0 (premise) Play again stops the transport');
+ await h.eventually(() => playingOf(B.page), (v) => v === false, '7.1 stopped on B as well');
+ const dropped = await A.page.evaluate(async ({ bytes, uuid }) => {
+ const item = await window.__stores.explorer.addItemFromBytes(new Uint8Array(bytes).buffer, 'ping.wav', null);
+ const sm = window.__stores.audioDevices.findDeviceObject(uuid);
+ const pad = sm.getObjectByName('spad-5');
+ const took = window.__stores.moduleSDK.moduleDropHandlers.some((fn) => fn(pad, { id: item.id, name: item.name, kind: 'audio', hash: item.hash }, { object: sm, hit: pad }));
+ return { took, hash: item.hash };
+ }, { bytes: wav(), uuid: ids.sm });
+ h.check(dropped.took === true, '7.2 the module takes an audio item dropped on a pad (registerDropHandler)');
+ const padsA = await docOf(A.page, ids.sm).then((d) => JSON.parse(d.params.pads || '{}'));
+ h.check(padsA['5']?.sample === dropped.hash && padsA['5']?.name === 'ping.wav', '7.3 pad 6 holds the sample HASH in the device document');
+ await h.eventually(() => docOf(B.page, ids.sm).then((d) => JSON.parse(d?.params?.pads || '{}')['5']?.sample ?? null), (v) => v === dropped.hash, '7.4 B holds it too');
+ await h.eventually(() => inPage(B.page, "return ad.deviceHandle(arg)?.h?.buffers?.size ?? 0", ids.sm), (v) => v >= 1, '7.5 B pulled and decoded the bytes by hash (api.audio.sample)', 20000);
+ const quietB = await onsets(B, 400);
+ const watchB = onsets(B, 900);
+ await A.page.waitForTimeout(100);
+ const hit = await clickMesh(A.page, ids.sm, 'spad-5');
+ const heardB = await watchB;
+ h.check(hit.took === true && heardB.onsets.length === 1 && heardB.peak > Math.max(0.002, quietB.peak * 3), '7.6 a pad press on A is HEARD once on B - the note replicated, B played its own copy of the sample (peak ' + heardB.peak.toFixed(4) + ')');
+
+ // 23-D1: the pad's sample is a SCENE ASSET by hash (spec.assets), so an export carries it
+ await h.eventually(() => inPage(A.page, "let list; s.sceneAssets.sceneAssets.subscribe((v) => (list = v))(); return list.filter((e) => e.group === 'audio').map((e) => ({ hash: e.hash, name: e.name }))"), (list) => list.some((e) => e.hash === dropped.hash && e.name === 'ping.wav'), '7.7 the sampler declares its pad sample to the Scene manifest by hash, named', 6000);
+
+ console.log('\n=== 8. the transport face: BPM buttons and tap tempo write the SHARED clock ===');
+ await clickMesh(A.page, ids.t, 'tp-bpm+');
+ await h.eventually(() => bpmOf(B.page), (v) => v === 125, '8.1 bpm+ is 125 on B');
+ await clickMesh(A.page, ids.t, 'tp-bpm-');
+ await h.eventually(() => bpmOf(B.page), (v) => v === 120, '8.2 bpm- back to 120 on B');
+ for (let i = 0; i < 4; i++) {
+ await clickMesh(A.page, ids.t, 'tp-tap');
+ if (i < 3) await A.page.waitForTimeout(400);
+ }
+ const tapped = await Promise.all([bpmOf(A.page), bpmOf(B.page)]);
+ h.check(Math.abs(tapped[0] - 150) <= 6 && tapped[0] === tapped[1], '8.3 four taps 400 ms apart set ~150 bpm on both (' + tapped[0] + '/' + tapped[1] + ')');
+ await inPage(A.page, 'mc.setBpm(120); return 1');
+
+ await h.finish(browser);
+});
diff --git a/tests/music-dj.test.cjs b/tests/music-dj.test.cjs
new file mode 100644
index 0000000..6c55874
--- /dev/null
+++ b/tests/music-dj.test.cjs
@@ -0,0 +1,210 @@
+// music-dj test-flight (#23 C4): two decks and a crossfader, the deck's position derived by
+// every peer from three replicated numbers, a scrub as previews + ONE commit.
+//
+// Every audible claim is read at the destination tap (spectrum: the strongest bin and the
+// level at each deck's tone); every position claim is the module's own derivation read on
+// two peers at the same instant. THE GPU FLAGS ARE PART OF THE MEASUREMENT (h.AUDIO_ARGS).
+const h = require('./helpers.cjs');
+
+const inPage = (page, body, arg) =>
+ page.evaluate(
+ ([src, a]) =>
+ Object.getPrototypeOf(async function () {}).constructor('s', 'ad', 'ap', 'arg', src)(window.__stores, window.__stores.audioDevices, window.__stores.audioPatch, a),
+ [body, arg ?? null]
+ );
+
+const K = { deck: 'mod-music-dj-deck', xf: 'mod-music-dj-crossfader' };
+const SINK = "ad.registerAudioDevice({ kind: 'dj-sink', label: 'Sink', ports: { in: [{ id: 'in', kind: 'audio' }], out: [] }, params: [], build(ctx) { const g = ctx.createGain(); g.connect(window.__stores.audioEngine.bus('instruments')); return { input: g, dispose() { g.disconnect(); } }; } }); return 1";
+
+const docOf = (page, uuid) => inPage(page, 'return ad.deviceOf(ad.findDeviceObject(arg))?.params ?? null', uuid);
+const posOf = (page, uuid) => inPage(page, 'const hd = ad.deviceHandle(arg); return hd?.h ? { pos: hd.h.position(), at: Date.now(), decoded: !!hd.h.buffer, playing: !!hd.h.doc.playing } : null', uuid);
+const undoLen = (page) => page.evaluate(() => { let n = 0; window.__stores.history.undoStack.subscribe((v) => (n = v.length))(); return n; });
+const clickMesh = (page, uuid, name) => inPage(page, "const o = ad.findDeviceObject(arg.uuid); const mesh = o?.getObjectByName(arg.name); if (!mesh) return { took: false, missing: true }; return { took: s.moduleSDK.moduleClickHandlers.some((fn) => fn(mesh)) }", { uuid, name });
+/** the tap's spectrum: the strongest bin and the dB at two tones, averaged over ~20 reads */
+const spectrum = (page, tones) =>
+ page.evaluate(async (tones) => {
+ const t = window.__audioTap.all()[0];
+ const freq = new Float32Array(t.analyser.frequencyBinCount);
+ const binHz = t.context.sampleRate / t.analyser.fftSize;
+ const acc = new Float32Array(freq.length);
+ let reads = 0;
+ for (let i = 0; i < 20; i++) {
+ await new Promise((r) => setTimeout(r, 20));
+ t.analyser.getFloatFrequencyData(freq);
+ for (let b = 0; b < freq.length; b++) acc[b] += Number.isFinite(freq[b]) ? freq[b] : -160;
+ reads++;
+ }
+ let best = -Infinity, bestBin = 0;
+ for (let b = 1; b < acc.length; b++) if (acc[b] > best) { best = acc[b]; bestBin = b; }
+ const at = (hz) => { const b = Math.round(hz / binHz); let m = -Infinity; for (let k = b - 1; k <= b + 1; k++) m = Math.max(m, acc[k] / reads); return m; };
+ return { peakHz: bestBin * binHz, db: tones.map(at) };
+ }, tones);
+const wav = (hz) => {
+ const sr = 22050, n = sr * 6;
+ const pcm = new Int16Array(n);
+ for (let i = 0; i < n; i++) pcm[i] = Math.round(Math.sin((i / sr) * 2 * Math.PI * hz) * 9000);
+ const buf = new ArrayBuffer(44 + n * 2);
+ const v = new DataView(buf);
+ const w = (o, str) => { for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i)); };
+ w(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); w(8, 'WAVE'); w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
+ v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); w(36, 'data'); v.setUint32(40, n * 2, true);
+ new Int16Array(buf, 44).set(pcm);
+ return Array.from(new Uint8Array(buf));
+};
+/** drop a WAV on a deck through the module's drop handler; returns the item hash */
+const load = (page, uuid, hz, name) =>
+ page.evaluate(async ({ bytes, uuid, name }) => {
+ const item = await window.__stores.explorer.addItemFromBytes(new Uint8Array(bytes).buffer, name, null);
+ const deck = window.__stores.audioDevices.findDeviceObject(uuid);
+ const took = window.__stores.moduleSDK.moduleDropHandlers.some((fn) => fn(deck.getObjectByName('jog'), { id: item.id, name: item.name, kind: 'audio', hash: item.hash }, { object: deck }));
+ return { took, hash: item.hash };
+ }, { bytes: wav(hz), uuid, name });
+/** the two peers' derived positions at (nearly) the same instant, normalised to A's read time */
+async function agree(pa, pb, ua, ub) {
+ const [a, b] = await Promise.all([posOf(pa, ua), posOf(pb, ub)]);
+ if (!a || !b) return { gap: NaN, a, b };
+ const rate = 1;
+ const bAtA = b.pos + ((a.at - b.at) / 1000) * rate * (b.playing ? 1 : 0);
+ return { gap: Math.abs(a.pos - bAtA) * 1000, a, b };
+}
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.AUDIO_ARGS });
+ const A = await h.setupPage(browser, 'A', { audio: true });
+ const B = await h.setupPage(browser, 'B', { audio: true });
+ await h.installModule(A, 'music-dj');
+ await h.installModule(B, 'music-dj');
+ await inPage(A.page, SINK);
+ await inPage(B.page, SINK);
+ await h.connect(A, B);
+
+ console.log('\n=== 1. the booth: two decks into a crossfader into a sink, tracks by hash ===');
+ const kinds = await inPage(A.page, 'return ad.devicesDebug().kinds');
+ h.check(kinds.includes(K.deck) && kinds.includes(K.xf), '1.1 deck and crossfader kinds register (' + kinds.filter((k) => k.startsWith('mod-music-dj')).join(', ') + ')');
+ const ids = await inPage(A.page, "const l = ad.addDevice(arg.deck, { position: [-0.9, 0.8, -2] }); const r = ad.addDevice(arg.deck, { position: [0.9, 0.8, -2] }); const x = ad.addDevice(arg.xf, { position: [0, 0.8, -1.6] }); const sk = ad.addDevice('dj-sink', { position: [0, 0, -3] }); ap.addCable({ from: { uuid: l.uuid, port: 'out' }, to: { uuid: x.uuid, port: 'a' } }); ap.addCable({ from: { uuid: r.uuid, port: 'out' }, to: { uuid: x.uuid, port: 'b' } }); ap.addCable({ from: { uuid: x.uuid, port: 'out' }, to: { uuid: sk.uuid, port: 'in' } }); return { l: l.uuid, r: r.uuid, x: x.uuid, sk: sk.uuid }", K);
+ const left = await load(A.page, ids.l, 330, 'left.wav');
+ const right = await load(A.page, ids.r, 660, 'right.wav');
+ h.check(left.took && right.took, '1.2 a WAV dropped on a deck is taken by the module (registerDropHandler)');
+ const docL = await docOf(A.page, ids.l);
+ h.check(docL.track === left.hash && docL.playing === false && docL.offset === 0, '1.3 the deck document holds the track HASH, stopped at 0');
+ await h.eventually(() => docOf(B.page, ids.r).then((d) => d?.track ?? null), (v) => v === right.hash, '1.4 B holds the right deck\'s hash');
+ await h.eventually(() => posOf(B.page, ids.l).then((p) => p?.decoded ?? false), (v) => v === true, '1.5 B pulled and decoded the left track by hash', 20000);
+ await inPage(A.page, 'ad.setDeviceFor(arg, { params: { position: -1 } }); return 1', ids.x);
+ // 23-D1: both tracks are SCENE ASSETS by hash (spec.assets), so an export carries them
+ await h.eventually(() => inPage(A.page, "let list; s.sceneAssets.sceneAssets.subscribe((v) => (list = v))(); return list.filter((e) => e.group === 'audio').map((e) => e.hash)"), (list) => list.includes(left.hash) && list.includes(right.hash), '1.6 both decks declare their track to the Scene manifest by hash', 6000);
+
+ console.log('\n=== 2. play: the position is derived by every peer, and heard ===');
+ const play = await clickMesh(A.page, ids.l, 'dk-play');
+ h.check(play.took === true, '2.1 the Play button takes the click');
+ await h.eventually(() => docOf(B.page, ids.l).then((d) => d?.playing ?? null), (v) => v === true, '2.2 B\'s document says playing, with startedAt');
+ await A.page.waitForTimeout(600);
+ const [spA, spB] = await Promise.all([spectrum(A.page, [330, 660]), spectrum(B.page, [330, 660])]);
+ h.check(Math.abs(spA.peakHz - 330) < 15 && Math.abs(spB.peakHz - 330) < 15, '2.3 both taps hear the left deck\'s 330 Hz (peak A ' + spA.peakHz.toFixed(0) + ', B ' + spB.peakHz.toFixed(0) + ')');
+ const g1 = await agree(A.page, B.page, ids.l, ids.l);
+ h.check(g1.gap < 60, '2.4 the two peers derive the same playhead (' + g1.gap.toFixed(1) + ' ms apart)');
+
+ console.log('\n=== 3. a scrub on the platter: previews while it moves, ONE commit, one undo ===');
+ // the platter's rim, four points 30 degrees apart, projected to the screen
+ const rim = await inPage(A.page, "const d = ad.findDeviceObject(arg); const jog = d.getObjectByName('jog'); const v = new s.THREE.Vector3(); return [0, 30, 60, 90].map((deg) => { const a = (deg * Math.PI) / 180; return jog.localToWorld(v.set(Math.sin(a) * 0.12, 0.02, -Math.cos(a) * 0.12)).toArray(); })", ids.l);
+ const pts = [];
+ for (const world of rim) pts.push(await h.projectPoint(A.page, world));
+ const undo0 = await undoLen(A.page);
+ const offsetBefore = (await docOf(B.page, ids.l)).offset;
+ await A.page.mouse.move(pts[0].x, pts[0].y);
+ const jog = await clickMesh(A.page, ids.l, 'jog');
+ h.check(jog.took === true, '3.1 the platter takes the click and opens a gesture');
+ for (let i = 1; i < 4; i++) {
+ await A.page.mouse.move(pts[i].x, pts[i].y, { steps: 5 });
+ await A.page.waitForTimeout(140);
+ }
+ const mid = await Promise.all([docOf(B.page, ids.l), undoLen(A.page)]);
+ h.check(mid[0].offset !== offsetBefore && mid[1] === undo0, '3.2 while the hand moves B already follows the PREVIEWS (offset ' + Number(offsetBefore).toFixed(3) + ' -> ' + Number(mid[0].offset).toFixed(3) + ') and nothing entered history (undo ' + undo0 + ')');
+ await A.page.waitForTimeout(1100);
+ const after = await undoLen(A.page);
+ h.check(after === undo0 + 1, '3.3 the gesture commits ONE write, one undo entry (' + undo0 + ' -> ' + after + ')');
+ const g2 = await agree(A.page, B.page, ids.l, ids.l);
+ h.check(g2.gap < 60, '3.4 after the scrub the peers agree on the position again (' + g2.gap.toFixed(1) + ' ms apart)');
+ await A.page.evaluate(() => window.__stores.history.undo());
+ await A.page.waitForTimeout(300);
+ const undone = await docOf(A.page, ids.l);
+ h.check(Math.abs(undone.offset - offsetBefore) < 1e-6, '3.5 one undo puts the offset back where the scrub started (' + Number(undone.offset).toFixed(3) + ')');
+
+ console.log('\n=== 4. the crossfader: each extreme is ONE deck\'s signal ===');
+ await clickMesh(A.page, ids.r, 'dk-play');
+ await h.eventually(() => docOf(B.page, ids.r).then((d) => d?.playing ?? null), (v) => v === true, '4.0 (premise) the right deck plays too');
+ await A.page.waitForTimeout(400);
+ const atA = await spectrum(A.page, [330, 660]);
+ h.check(atA.db[0] - atA.db[1] > 30, '4.1 at -1 only the left deck is heard (330 Hz is ' + (atA.db[0] - atA.db[1]).toFixed(0) + ' dB above 660)');
+ await inPage(A.page, 'ad.setDeviceFor(arg, { params: { position: 1 } }); return 1', ids.x);
+ await A.page.waitForTimeout(400);
+ const [atB, atBB] = await Promise.all([spectrum(A.page, [330, 660]), spectrum(B.page, [330, 660])]);
+ h.check(atB.db[1] - atB.db[0] > 30 && atBB.db[1] - atBB.db[0] > 30, '4.2 at +1 only the right deck, on A and on B (' + (atB.db[1] - atB.db[0]).toFixed(0) + ' / ' + (atBB.db[1] - atBB.db[0]).toFixed(0) + ' dB)');
+ await inPage(A.page, "ad.setDeviceFor(arg, { params: { position: 0, curve: 'power' } }); return 1", ids.x);
+ await A.page.waitForTimeout(400);
+ const centre = await spectrum(A.page, [330, 660]);
+ h.check(Math.abs(centre.db[0] - centre.db[1]) < 6, '4.3 centred, constant power: both within 6 dB (' + (centre.db[0] - centre.db[1]).toFixed(1) + ')');
+ // the fader gesture: drag the knob to the left end
+ const railPts = await inPage(A.page, "const x = ad.findDeviceObject(arg); const v = new s.THREE.Vector3(); return [0, -0.09, -0.17].map((dx) => x.localToWorld(v.set(dx, 0.06, 0)).toArray())", ids.x);
+ const rp = [];
+ for (const world of railPts) rp.push(await h.projectPoint(A.page, world));
+ const undoX = await undoLen(A.page);
+ await A.page.mouse.move(rp[0].x, rp[0].y);
+ await clickMesh(A.page, ids.x, 'xf-knob');
+ for (let i = 1; i < 3; i++) { await A.page.mouse.move(rp[i].x, rp[i].y, { steps: 5 }); await A.page.waitForTimeout(140); }
+ await A.page.waitForTimeout(1100);
+ const faded = await Promise.all([docOf(B.page, ids.x), undoLen(A.page)]);
+ h.check(faded[0].position <= -0.8 && faded[1] === undoX + 1, '4.4 dragging the knob to the left end is one commit, replicated (position ' + faded[0].position + ', undo ' + undoX + ' -> ' + faded[1] + ')');
+
+ console.log('\n=== 5. the pitch fader re-bases the position so it stays continuous everywhere ===');
+ const before5 = await posOf(A.page, ids.l);
+ const pitchPts = await inPage(A.page, "const d = ad.findDeviceObject(arg); const v = new s.THREE.Vector3(); return [0, 0.06, 0.11].map((dx) => d.localToWorld(v.set(0.16 + dx, 0.09, -0.12)).toArray())", ids.l);
+ const pp = [];
+ for (const world of pitchPts) pp.push(await h.projectPoint(A.page, world));
+ const undoP = await undoLen(A.page);
+ await A.page.mouse.move(pp[0].x, pp[0].y);
+ await clickMesh(A.page, ids.l, 'dk-pitch');
+ for (let i = 1; i < 3; i++) { await A.page.mouse.move(pp[i].x, pp[i].y, { steps: 5 }); await A.page.waitForTimeout(140); }
+ await A.page.waitForTimeout(1100);
+ const pitched = await Promise.all([docOf(A.page, ids.l), docOf(B.page, ids.l), undoLen(A.page), posOf(A.page, ids.l)]);
+ h.check(pitched[0].rate > 1.3 && pitched[0].rate === pitched[1].rate, '5.1 the fader at the right end is ~1.5x on both (' + pitched[0].rate + ')');
+ h.check(pitched[2] === undoP + 1 && typeof pitched[0].startedAt === 'number' && pitched[0].startedAt > before5.at - 5000, '5.2 rate, offset and startedAt commit together as ONE write (re-based, undo ' + undoP + ' -> ' + pitched[2] + ')');
+ // the track is 6 s and loops, so the playhead is read on the circle: it must sit between
+ // 'elapsed at 1x' and 'elapsed at the new rate' past where it was, never jump elsewhere
+ const DUR = 6;
+ const elapsed = (pitched[3].at - before5.at) / 1000;
+ const lo = (before5.pos + elapsed * 1.0) % DUR, hi = (before5.pos + elapsed * pitched[0].rate) % DUR;
+ const circ = (from, to) => ((to - from) % DUR + DUR) % DUR;
+ const arc = circ(lo, hi), where = circ(lo, pitched[3].pos);
+ h.check(where >= -0.001 && where <= arc + 0.15, '5.3 the playhead is continuous through the change (' + before5.pos.toFixed(2) + ' -> ' + pitched[3].pos.toFixed(2) + ' s after ' + elapsed.toFixed(2) + ' s; expected between ' + lo.toFixed(2) + ' and ' + hi.toFixed(2) + ')');
+ const g3 = await agree(A.page, B.page, ids.l, ids.l);
+ h.check(g3.gap < 120, '5.4 and the peers still agree (' + g3.gap.toFixed(1) + ' ms apart at 1.5x)');
+
+ console.log('\n=== 6. a late joiner lands at the right position ===');
+ await inPage(A.page, 'ad.setDeviceFor(arg, { params: { position: -1 } }); return 1', ids.x);
+ const C = await h.setupPage(browser, 'C', { audio: true });
+ await h.installModule(C, 'music-dj');
+ await inPage(C.page, SINK);
+ await h.connect(C, A);
+ await h.eventually(() => docOf(C.page, ids.l).then((d) => d?.playing ?? null), (v) => v === true, '6.1 C holds the playing deck from the handshake');
+ await h.eventually(() => posOf(C.page, ids.l).then((p) => p?.decoded ?? false), (v) => v === true, '6.2 C pulled and decoded the track', 20000);
+ await C.page.waitForTimeout(400);
+ const g4 = await agree(A.page, C.page, ids.l, ids.l);
+ h.check(g4.gap < 120, '6.3 C derives the same playhead as A (' + g4.gap.toFixed(1) + ' ms apart)');
+ const rateC = (await docOf(C.page, ids.l)).rate;
+ const spC = await spectrum(C.page, [330 * rateC, 660]);
+ h.check(Math.abs(spC.peakHz - 330 * rateC) < 330 * rateC * 0.04, '6.4 and hears it, at the deck\'s rate (peak ' + spC.peakHz.toFixed(0) + ' Hz for 330 x ' + rateC + ')');
+
+ console.log('\n=== 7. pause freezes the offset, play resumes from it ===');
+ await clickMesh(A.page, ids.l, 'dk-play');
+ await h.eventually(() => docOf(B.page, ids.l).then((d) => d?.playing ?? null), (v) => v === false, '7.1 B sees the pause');
+ const frozen = await docOf(A.page, ids.l);
+ await A.page.waitForTimeout(500);
+ const still = await posOf(B.page, ids.l);
+ h.check(Math.abs(still.pos - frozen.offset) < 0.01, '7.2 paused, the position holds at the frozen offset on B (' + still.pos.toFixed(3) + ')');
+ await clickMesh(A.page, ids.l, 'dk-play');
+ await A.page.waitForTimeout(400);
+ const resumed = await posOf(A.page, ids.l);
+ h.check(resumed.playing && resumed.pos > frozen.offset && resumed.pos < frozen.offset + 1.2, '7.3 play resumes from the frozen offset (' + frozen.offset.toFixed(3) + ' -> ' + resumed.pos.toFixed(3) + ')');
+
+ await h.finish(browser);
+});
diff --git a/tests/music-fx.test.cjs b/tests/music-fx.test.cjs
new file mode 100644
index 0000000..b4da7aa
--- /dev/null
+++ b/tests/music-fx.test.cjs
@@ -0,0 +1,513 @@
+// music-fx test-flight (#23 C3): a mixer and five pedals, measured through the tap.
+//
+// Everything here is asserted through the SPECTRUM at the destination tap, never
+// through the document alone: a pedal that builds, cables and reports itself
+// perfectly while changing nothing about the sound is the failure this module exists
+// to prevent. A filter moves the centroid, distortion and the bitcrusher put energy
+// above the fundamental, the delay and the reverb keep sounding after the source
+// stops, a re-ordered chain reads differently and repeatably, the mixer's mute and
+// solo drop one tone and keep the other, and a fader move is a param write with no
+// click and no restart.
+//
+// The SOURCE and the SINK are test kinds registered in-page on every peer (the
+// core suite's shape), so the flight depends on nothing but this module: the source's
+// output reaches no bus of its own and the sink is the only thing here touching one.
+//
+// THE GPU FLAGS ARE PART OF THE MEASUREMENT (h.AUDIO_ARGS): a SwiftShader page starves
+// the sampling loop — see the note over GPU_ARGS in helpers.cjs.
+const h = require('./helpers.cjs');
+
+/** run a body in the page with the audio stores bound (the core suites' shape) */
+const inPage = (page, body, arg) =>
+ page.evaluate(
+ ([src, a]) =>
+ Object.getPrototypeOf(async function () {}).constructor('s', 'ad', 'ap', 'eng', 'arg', src)(
+ window.__stores,
+ window.__stores.audioDevices,
+ window.__stores.audioPatch,
+ window.__stores.audioEngine,
+ a
+ ),
+ [body, arg ?? null]
+ );
+
+const SILENT = 0.001; // the RMS floor the core helpers count as silence
+const K = (kind) => 'mod-music-fx-' + kind;
+const PEDALS = ['filter', 'distortion', 'delay', 'reverb', 'bitcrush'];
+const KINDS = ['mixer', ...PEDALS].map(K);
+
+/**
+ * The two test kinds. `fx-src` is an oscillator whose params are audible (freq, gain,
+ * wave) and whose output connects to NOTHING; `fx-sink` connects its input to the
+ * instruments bus. `__oscStarts` counts every oscillator start, so a check can prove a
+ * fader move never rebuilt a source.
+ */
+const TEST_KINDS =
+ 'window.__oscStarts = window.__oscStarts || 0;' +
+ 'ad.registerAudioDevice({' +
+ " kind: 'fx-src', label: 'FX test source', group: 'test'," +
+ " ports: { in: [], out: [{ id: 'out', label: 'Out', kind: 'audio' }] }," +
+ ' params: [' +
+ " { key: 'freq', label: 'Frequency', kind: 'range', min: 20, max: 5000, step: 1, default: 220, unit: 'Hz' }," +
+ " { key: 'gain', label: 'Level', kind: 'range', min: 0, max: 1, step: 0.01, default: 0 }," +
+ " { key: 'wave', label: 'Wave', kind: 'select', default: 'sine', options: [{ value: 'sine', label: 'Sine' }, { value: 'sawtooth', label: 'Saw' }] }" +
+ ' ],' +
+ ' build(ctx, node, params) {' +
+ ' const osc = ctx.createOscillator(); osc.type = params.wave; osc.frequency.value = params.freq;' +
+ ' const amp = ctx.createGain(); amp.gain.value = params.gain;' +
+ ' osc.connect(amp); osc.start(); window.__oscStarts++;' +
+ ' return { output: amp, osc, amp, dispose() { osc.stop(); osc.disconnect(); amp.disconnect(); } };' +
+ ' },' +
+ " onParam(hd, key, value) { if (key === 'freq') hd.osc.frequency.value = value; if (key === 'gain') hd.amp.gain.value = value; if (key === 'wave') hd.osc.type = value; }" +
+ '});' +
+ 'ad.registerAudioDevice({' +
+ " kind: 'fx-sink', label: 'FX test sink', group: 'test'," +
+ " ports: { in: [{ id: 'in', label: 'In', kind: 'audio' }], out: [] }, params: []," +
+ " build(ctx) { const input = ctx.createGain(); input.connect(eng.bus('instruments')); return { input, dispose() { input.disconnect(); } }; }" +
+ '});' +
+ 'return ad.devicesDebug().kinds;';
+
+const kinds = (page) => inPage(page, 'return ad.devicesDebug().kinds');
+const built = (page) => inPage(page, 'return ad.devicesDebug().built');
+const isBuilt = (list, uuid, kind) => list.some((d) => d.uuid === uuid && d.builtAs === kind && d.fellBackFrom === null);
+const setParam = (page, uuid, key, value) => inPage(page, "return ad.setDeviceParam(arg.uuid, arg.key, arg.value)", { uuid, key, value });
+const setParams = async (page, uuid, params) => {
+ for (const [key, value] of Object.entries(params)) await setParam(page, uuid, key, value);
+};
+const docOn = (page, uuid) =>
+ inPage(page, 'const g = await new Promise((r) => s.objectsGroup.subscribe(r)()); const o = g.getObjectByProperty("uuid", arg); return o ? o.userData.device ?? null : null', uuid);
+const addDevice = (page, kind, x) => inPage(page, "const o = ad.addDevice(arg.kind, { position: [arg.x, 1, -2] }); return o ? o.uuid : null", { kind, x });
+const cable = (page, from, fromPort, to, toPort) =>
+ inPage(page, 'return ap.addCable({ from: { uuid: arg.from, port: arg.fromPort }, to: { uuid: arg.to, port: arg.toPort } })', { from, fromPort, to, toPort });
+const uncable = (page, ids) => inPage(page, 'for (const id of arg) ap.removeCable(id); return true', ids);
+const cablesOf = (page, uuid) => inPage(page, 'return ap.cablesOf(arg).length', uuid);
+const wait = (page, ms) => page.waitForTimeout(ms);
+
+/** Plug a source through ONE pedal into the sink; returns the cable ids to pull later. */
+async function chain(page, uuids) {
+ const ids = [];
+ for (let i = 0; i + 1 < uuids.length; i++) {
+ const [from, fromPort] = Array.isArray(uuids[i]) ? uuids[i] : [uuids[i], 'out'];
+ const [to, toPort] = Array.isArray(uuids[i + 1]) ? uuids[i + 1] : [uuids[i + 1], 'in'];
+ ids.push(await cable(page, from, fromPort, to, toPort));
+ }
+ await wait(page, 350); // routing reconciles per frame; the glides settle in ~75 ms
+ return ids;
+}
+
+/**
+ * Sample every tapped context for `ms` and describe what was heard, from the LOUDEST
+ * frame: peak and minimum RMS, the magnitude-weighted centroid, the strongest bin,
+ * the share of POWER above `hfHz` (a pure sine reads ~0 here; harmonics and aliases
+ * read well above it), the MAGNITUDE share's maximum over every frame (`hfMax`: a
+ * click is a frame of broadband energy), and the level in dB at each frequency in `hz`.
+ */
+const measure = (peer, opts) =>
+ peer.page.evaluate(async (arg) => {
+ const tapped = window.__audioTap.all();
+ const scratch = tapped.map((t) => ({
+ analyser: t.analyser,
+ context: t.context,
+ time: new Float32Array(t.analyser.fftSize),
+ freq: new Float32Array(t.analyser.frequencyBinCount)
+ }));
+ /** the share of the frame above `hfHz`: by POWER (a harmonic series reads well
+ * above a pure sine's ~0) or by MAGNITUDE (a step's 1/f leakage is a few tenths
+ * here and a thousandth by power — the click metric) */
+ const share = (freq, binHz, byMagnitude) => {
+ let above = 0;
+ let total = 0;
+ for (let b = 1; b < freq.length; b++) {
+ const weight = Math.pow(10, freq[b] / (byMagnitude ? 20 : 10));
+ total += weight;
+ if (b * binHz >= arg.hfHz) above += weight;
+ }
+ return total ? above / total : 0;
+ };
+ let peak = 0;
+ let minRms = Infinity;
+ let sum = 0;
+ let samples = 0;
+ let hfMax = 0;
+ let loudest = null;
+ let binHz = 0;
+ const deadline = performance.now() + arg.ms;
+ while (performance.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 10));
+ samples++;
+ let tickRms = 0;
+ let winner = null;
+ for (const entry of scratch) {
+ entry.analyser.getFloatTimeDomainData(entry.time);
+ let square = 0;
+ for (let i = 0; i < entry.time.length; i++) square += entry.time[i] * entry.time[i];
+ const rms = Math.sqrt(square / entry.time.length);
+ if (rms > tickRms) {
+ tickRms = rms;
+ winner = entry;
+ }
+ }
+ sum += tickRms;
+ if (tickRms < minRms) minRms = tickRms;
+ if (winner && tickRms > 0.002) {
+ winner.analyser.getFloatFrequencyData(winner.freq);
+ const width = winner.context.sampleRate / winner.analyser.fftSize;
+ const ratio = share(winner.freq, width, true);
+ if (ratio > hfMax) hfMax = ratio;
+ if (tickRms > peak) {
+ loudest = winner.freq.slice();
+ binHz = width;
+ }
+ }
+ if (tickRms > peak) peak = tickRms;
+ }
+ let centroid = 0;
+ let peakHz = 0;
+ let hf = 0;
+ const levels = {};
+ if (loudest) {
+ let weighted = 0;
+ let total = 0;
+ let best = -Infinity;
+ for (let b = 1; b < loudest.length; b++) {
+ const magnitude = Math.pow(10, loudest[b] / 20);
+ weighted += magnitude * b * binHz;
+ total += magnitude;
+ if (loudest[b] > best) {
+ best = loudest[b];
+ peakHz = b * binHz;
+ }
+ }
+ centroid = total ? weighted / total : 0;
+ hf = share(loudest, binHz);
+ for (const hz of arg.hz ?? []) levels[hz] = loudest[Math.round(hz / binHz)];
+ }
+ return { peak, minRms: samples ? minRms : 0, mean: samples ? sum / samples : 0, samples, centroid, peakHz, hf, hfMax, levels, contexts: tapped.length };
+ }, { ms: 500, hfHz: 600, hz: [], ...opts });
+
+/**
+ * Stop a source (its gain to 0, a replicated write) and read the loudest RMS between
+ * `from` and `to` ms after — a pedal with memory keeps sounding, a bypass does not.
+ */
+const tail = (peer, uuid, from, to) =>
+ peer.page.evaluate(async (arg) => {
+ const tapped = window.__audioTap.all();
+ const scratch = tapped.map((t) => ({ analyser: t.analyser, time: new Float32Array(t.analyser.fftSize) }));
+ const rmsNow = () => {
+ let loudest = 0;
+ for (const entry of scratch) {
+ entry.analyser.getFloatTimeDomainData(entry.time);
+ let square = 0;
+ for (let i = 0; i < entry.time.length; i++) square += entry.time[i] * entry.time[i];
+ loudest = Math.max(loudest, Math.sqrt(square / entry.time.length));
+ }
+ return loudest;
+ };
+ const before = rmsNow();
+ const t0 = performance.now();
+ window.__stores.audioDevices.setDeviceParam(arg.uuid, 'gain', 0);
+ let tailPeak = 0;
+ let samples = 0;
+ while (performance.now() - t0 < arg.to) {
+ await new Promise((r) => setTimeout(r, 10));
+ if (performance.now() - t0 < arg.from) continue;
+ samples++;
+ tailPeak = Math.max(tailPeak, rmsNow());
+ }
+ return { before, tailPeak, samples };
+ }, { uuid, from, to });
+
+const fmt = (n, d = 3) => (typeof n === 'number' ? n.toFixed(d) : String(n));
+const same = (a, b, tolerance) => Math.abs(a - b) <= tolerance * Math.max(Math.abs(a), Math.abs(b), 1e-9);
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.AUDIO_ARGS });
+ const A = await h.setupPage(browser, 'A', { audio: true });
+ const B = await h.setupPage(browser, 'B', { audio: true });
+
+ await h.installModule(A, 'music-fx');
+ await h.installModule(B, 'music-fx');
+ await inPage(A.page, TEST_KINDS);
+ await inPage(B.page, TEST_KINDS);
+ await h.connect(A, B);
+
+ // ---------------------------------------------------------------- section 1
+ console.log('\n=== 1. the module supplies six device kinds ===');
+ await h.eventually(() => kinds(A.page), (k) => KINDS.every((kind) => k.includes(kind)), '1.1 A registers mixer + five pedals, namespaced mod-music-fx-*');
+ await h.eventually(() => kinds(B.page), (k) => KINDS.every((kind) => k.includes(kind)), '1.2 so does B — a module is installed on both, it does not travel');
+ const catalog = await inPage(A.page, "return ad.deviceCatalog().filter((k) => k.kind.startsWith('mod-music-fx-')).map((k) => ({ kind: k.kind, ins: k.ports.in.map((p) => p.id), outs: k.ports.out.map((p) => p.id), params: k.params.length }))");
+ const mixerCat = catalog.find((k) => k.kind === K('mixer'));
+ h.check(
+ !!mixerCat && mixerCat.ins.join() === 'in1,in2,in3,in4' && mixerCat.outs.join() === 'out,send',
+ '1.3 the mixer declares in1..in4 and out + send (' + JSON.stringify(mixerCat) + ')'
+ );
+ h.check(
+ PEDALS.every((p) => {
+ const entry = catalog.find((k) => k.kind === K(p));
+ return entry && entry.ins.join() === 'in' && entry.outs.join() === 'out';
+ }),
+ '1.4 every pedal declares one in and one out'
+ );
+
+ // ---------------------------------------------------------------- section 2
+ console.log('\n=== 2. a source, a pedal, a sink ===');
+ const src = await addDevice(A.page, 'fx-src', -3);
+ const src2 = await addDevice(A.page, 'fx-src', -2.5);
+ const sink = await addDevice(A.page, 'fx-sink', 3);
+ const pedal = {};
+ for (let i = 0; i < PEDALS.length; i++) pedal[PEDALS[i]] = await addDevice(A.page, K(PEDALS[i]), -1.5 + i * 0.6);
+ const mixer = await addDevice(A.page, K('mixer'), 2);
+ h.check(!!src && !!src2 && !!sink && !!mixer && PEDALS.every((p) => !!pedal[p]), '2.1 addDevice made the two sources, the sink, five pedals and the mixer');
+ await h.eventually(
+ () => built(A.page),
+ (list) => PEDALS.every((p) => isBuilt(list, pedal[p], K(p))) && list.some((d) => d.uuid === mixer && d.builtAs === K('mixer') && d.hasInput && d.hasOutput),
+ '2.2 all six build as the REAL kinds on A, the mixer with an input and an output'
+ );
+ await h.eventually(
+ () => built(B.page),
+ (list) => PEDALS.every((p) => isBuilt(list, pedal[p], K(p))) && isBuilt(list, mixer, K('mixer')) && isBuilt(list, src, 'fx-src') && isBuilt(list, sink, 'fx-sink'),
+ '2.3 the objects replicate and build as the real kinds on B too',
+ 15000
+ );
+ const meshes = await inPage(
+ A.page,
+ 'const g = await new Promise((r) => s.objectsGroup.subscribe(r)()); return arg.map((u) => { const o = g.getObjectByProperty("uuid", u); return { type: o.type, plugs: [] .concat(...o.children.map((c) => c.name)).filter((n) => n.startsWith("vrpatch-")).sort() }; })',
+ [pedal.filter, mixer]
+ );
+ h.check(
+ meshes[0].type === 'Mesh' && meshes[1].type === 'Mesh' && meshes[0].plugs.join() === 'vrpatch-in:in,vrpatch-out:out' && meshes[1].plugs.length === 6,
+ '2.4 the device roots are MESHES with a plug per port (' + meshes[0].plugs.join() + ' / ' + meshes[1].plugs.length + ' on the mixer)'
+ );
+ await setParams(A.page, src, { wave: 'sawtooth', gain: 0.25 });
+ const silent = await measure(A, { ms: 300 });
+ h.check(silent.peak < SILENT, '2.5 (premise) a source cabled to nothing is SILENT — its output reaches no bus (peak ' + fmt(silent.peak, 5) + ')');
+ let ids = await chain(A.page, [src, pedal.filter, sink]);
+ h.check(ids.every((id) => typeof id === 'string' && id), '2.6 two cables plug src -> filter -> sink (' + ids.join(', ') + ')');
+ await h.eventually(() => cablesOf(B.page, pedal.filter), (n) => n === 2, '2.7 both cables replicate to B');
+
+ // ---------------------------------------------------------------- section 3
+ console.log('\n=== 3. the filter moves the centroid ===');
+ await setParams(A.page, pedal.filter, { mix: 0 });
+ await wait(A.page, 200);
+ const bypass = await measure(A, { ms: 500 });
+ h.check(bypass.samples > 20, '3.1 (premise) the sampling loop actually ran (' + bypass.samples + ' samples in 500 ms)');
+ h.check(bypass.peak > SILENT * 10 && Math.abs(bypass.peakHz - 220) < 40, '3.2 the saw is HEARD through the bypassed pedal at 220 Hz (peak ' + fmt(bypass.peak) + ', strongest bin ' + Math.round(bypass.peakHz) + ' Hz, centroid ' + Math.round(bypass.centroid) + ' Hz)');
+ await setParams(A.page, pedal.filter, { mix: 1, type: 'lowpass', cutoff: 300, q: 1 });
+ await wait(A.page, 250);
+ const low = await measure(A, { ms: 500 });
+ h.check(low.centroid < bypass.centroid * 0.5 && low.peak > SILENT * 10, '3.3 a low-pass at 300 Hz halves the centroid (' + Math.round(bypass.centroid) + ' -> ' + Math.round(low.centroid) + ' Hz)');
+ await setParams(A.page, pedal.filter, { type: 'highpass', cutoff: 2000 });
+ await wait(A.page, 250);
+ const high = await measure(A, { ms: 500 });
+ h.check(high.centroid > bypass.centroid * 1.3 && high.peakHz > 400, '3.4 a high-pass at 2 kHz raises it and the fundamental is no longer the strongest bin (centroid ' + Math.round(high.centroid) + ' Hz, strongest ' + Math.round(high.peakHz) + ' Hz)');
+ await setParams(A.page, pedal.filter, { type: 'lowpass', cutoff: 8000 });
+ await uncable(A.page, ids);
+
+ // ---------------------------------------------------------------- section 4
+ console.log('\n=== 4. distortion adds harmonics ===');
+ await setParams(A.page, src, { wave: 'sine' });
+ ids = await chain(A.page, [src, pedal.distortion, sink]);
+ await setParams(A.page, pedal.distortion, { mix: 0 });
+ await wait(A.page, 200);
+ const clean = await measure(A, { ms: 500, hfHz: 600 });
+ h.check(clean.peak > SILENT * 10 && clean.hf < 0.01, '4.1 (premise) a bypassed sine has ~no power above 600 Hz (share ' + fmt(clean.hf, 5) + ')');
+ await setParams(A.page, pedal.distortion, { mix: 1, drive: 20, tone: 8000 });
+ await wait(A.page, 250);
+ const driven = await measure(A, { ms: 500, hfHz: 600 });
+ h.check(driven.hf > 0.02 && driven.hf > clean.hf * 10, '4.2 driven, the share above 600 Hz jumps (' + fmt(clean.hf, 5) + ' -> ' + fmt(driven.hf, 4) + ')');
+ // the tone knob is read above 1 kHz: the 3rd harmonic (660 Hz) sits UNDER a 700 Hz cutoff
+ const bright = await measure(A, { ms: 400, hfHz: 1000 });
+ await setParams(A.page, pedal.distortion, { tone: 700 });
+ await wait(A.page, 250);
+ const dark = await measure(A, { ms: 500, hfHz: 1000 });
+ h.check(bright.hf > 0.01 && dark.hf < bright.hf * 0.5, '4.3 the tone knob (a post low-pass at 700 Hz) takes most of the power above 1 kHz back (' + fmt(bright.hf, 4) + ' -> ' + fmt(dark.hf, 4) + ')');
+ await uncable(A.page, ids);
+
+ // ---------------------------------------------------------------- section 5
+ console.log('\n=== 5. the bitcrusher ===');
+ await h.eventually(() => inPage(A.page, 'return ad.deviceHandle(arg)?.mode ?? null', pedal.bitcrush), (m) => m === 'worklet' || m === 'script', '5.1 the crusher attached its processor');
+ const mode = await inPage(A.page, 'return ad.deviceHandle(arg)?.mode', pedal.bitcrush);
+ console.log(' bitcrush path: ' + mode + (mode === 'worklet' ? ' (AudioWorklet from a blob URL)' : ' (ScriptProcessorNode fallback)'));
+ ids = await chain(A.page, [src, pedal.bitcrush, sink]);
+ await setParams(A.page, pedal.bitcrush, { mix: 0 });
+ await wait(A.page, 200);
+ const smooth = await measure(A, { ms: 500, hfHz: 600 });
+ await setParams(A.page, pedal.bitcrush, { mix: 1, bits: 4, rate: 8 });
+ await wait(A.page, 250);
+ const crushed = await measure(A, { ms: 500, hfHz: 600 });
+ h.check(crushed.peak > SILENT * 10 && crushed.hf > 0.02 && crushed.hf > smooth.hf * 10, '5.2 4 bits at 1/8 rate put energy above the fundamental (share ' + fmt(smooth.hf, 5) + ' -> ' + fmt(crushed.hf, 4) + ', via ' + mode + ')');
+ await setParams(A.page, pedal.bitcrush, { bits: 16, rate: 1 });
+ await wait(A.page, 250);
+ const restored = await measure(A, { ms: 500, hfHz: 600 });
+ h.check(restored.hf < crushed.hf * 0.2, '5.3 16 bits at full rate is transparent again (share ' + fmt(restored.hf, 5) + ')');
+ await uncable(A.page, ids);
+
+ // ---------------------------------------------------------------- section 6
+ console.log('\n=== 6. the delay keeps sounding, and follows the tempo ===');
+ ids = await chain(A.page, [src, pedal.delay, sink]);
+ await setParams(A.page, pedal.delay, { time: 'free', ms: 250, feedback: 0.5, mix: 0.5 });
+ await wait(A.page, 700);
+ const echo = await tail(A, src, 150, 900);
+ h.check(echo.before > SILENT * 10 && echo.tailPeak > 0.01, '6.1 after the source stops the echoes go on (before ' + fmt(echo.before) + ', tail peak ' + fmt(echo.tailPeak) + ' over ' + echo.samples + ' reads)');
+ await wait(A.page, 1500);
+ await setParams(A.page, pedal.delay, { mix: 0 });
+ await setParams(A.page, src, { gain: 0.25 });
+ await wait(A.page, 500);
+ const noEcho = await tail(A, src, 150, 600);
+ h.check(noEcho.before > SILENT * 10 && noEcho.tailPeak < SILENT * 2, '6.2 bypassed, it stops when the source does (tail peak ' + fmt(noEcho.tailPeak, 5) + ')');
+ await setParams(A.page, src, { gain: 0.25 });
+ await setParams(A.page, pedal.delay, { time: '1/8', mix: 0.5 });
+ await wait(A.page, 200);
+ const eighth = await inPage(A.page, 'const h = ad.deviceHandle(arg); return { seconds: h.seconds, bpm: h.bpm, value: h.delay.delayTime.value }', pedal.delay);
+ h.check(Math.abs(eighth.seconds - 0.25) < 1e-6 && eighth.bpm === 120, '6.3 synced to 1/8 at the default 120 bpm the time is 0.25 s (' + JSON.stringify(eighth) + ')');
+ await inPage(A.page, 's.musicClock.setBpm(90)');
+ await h.eventually(
+ () => inPage(A.page, 'const h = ad.deviceHandle(arg); return { seconds: h.seconds, value: h.delay.delayTime.value }', pedal.delay),
+ (r) => Math.abs(r.seconds - 60 / 90 / 2) < 1e-6 && Math.abs(r.value - 60 / 90 / 2) < 0.01,
+ '6.4 setBpm(90) moves it to 0.333 s — the delayTime AudioParam followed the SHARED transport'
+ );
+ await h.eventually(
+ () => inPage(B.page, 'const h = ad.deviceHandle(arg); return h ? { seconds: h.seconds, bpm: h.bpm } : null', pedal.delay),
+ (r) => !!r && Math.abs(r.seconds - 60 / 90 / 2) < 1e-6,
+ "6.5 B's delay lands on the same 0.333 s from the replicated tempo"
+ );
+ await inPage(A.page, 's.musicClock.setBpm(120)');
+ await uncable(A.page, ids);
+
+ // ---------------------------------------------------------------- section 7
+ console.log('\n=== 7. the reverb has a tail ===');
+ // a SAW, not a sine, and the module's impulse is SEEDED: a noise burst's response at
+ // any one frequency is a random draw, and with Math.random in the module a sine's
+ // tail read 0.009..0.039 across runs — the same claim, a different number each time
+ await setParams(A.page, src, { wave: 'sawtooth' });
+ ids = await chain(A.page, [src, pedal.reverb, sink]);
+ await setParams(A.page, pedal.reverb, { size: 1.5, decay: 3, mix: 0.5 });
+ await wait(A.page, 700);
+ const verb = await tail(A, src, 120, 700);
+ h.check(verb.before > SILENT * 10 && verb.tailPeak > 0.004, '7.1 after the source stops the room rings on (tail peak ' + fmt(verb.tailPeak, 4) + ')');
+ await wait(A.page, 1500);
+ await setParams(A.page, pedal.reverb, { mix: 0 });
+ await setParams(A.page, src, { gain: 0.25 });
+ await wait(A.page, 400);
+ const dryRoom = await tail(A, src, 120, 500);
+ h.check(dryRoom.tailPeak < SILENT * 2, '7.2 bypassed, it does not (tail peak ' + fmt(dryRoom.tailPeak, 5) + ')');
+ await setParams(A.page, src, { gain: 0.25 });
+ await uncable(A.page, ids);
+
+ // ---------------------------------------------------------------- section 8
+ console.log('\n=== 8. re-ordering the chain by re-cabling ===');
+ await setParams(A.page, src, { wave: 'sawtooth' });
+ await setParams(A.page, pedal.filter, { type: 'lowpass', cutoff: 500, q: 1, mix: 1 });
+ await setParams(A.page, pedal.distortion, { drive: 20, tone: 12000, mix: 1 });
+ const readOrder = async (order) => {
+ const cables = await chain(A.page, [src, ...order.map((p) => pedal[p]), sink]);
+ await wait(A.page, 200);
+ const first = await measure(A, { ms: 400, hfHz: 1500 });
+ const second = await measure(A, { ms: 400, hfHz: 1500 });
+ await uncable(A.page, cables);
+ return { first, second };
+ };
+ const fd = await readOrder(['filter', 'distortion']);
+ const df = await readOrder(['distortion', 'filter']);
+ const describe = (r) => 'hf ' + fmt(r.hf, 4) + ' / centroid ' + Math.round(r.centroid);
+ h.check(same(fd.first.hf, fd.second.hf, 0.35) && same(fd.first.centroid, fd.second.centroid, 0.2), '8.1 filter -> distortion reads the same twice (' + describe(fd.first) + ' vs ' + describe(fd.second) + ')');
+ h.check(same(df.first.hf, df.second.hf, 0.35) && same(df.first.centroid, df.second.centroid, 0.2), '8.2 distortion -> filter reads the same twice (' + describe(df.first) + ' vs ' + describe(df.second) + ')');
+ h.check(fd.first.hf > df.first.hf * 3 && fd.first.centroid > df.first.centroid * 1.3, '8.3 and the two orders differ: distortion AFTER the filter regenerates the harmonics the filter removed (' + describe(fd.first) + ' vs ' + describe(df.first) + ')');
+
+ // ---------------------------------------------------------------- section 9
+ console.log('\n=== 9. the mixer ===');
+ await setParams(A.page, src, { wave: 'sine', freq: 220, gain: 0.25 });
+ await setParams(A.page, src2, { wave: 'sine', freq: 880, gain: 0.25 });
+ ids = await chain(A.page, [[src, 'out'], [mixer, 'in1']]);
+ ids.push(...(await chain(A.page, [[src2, 'out'], [mixer, 'in2']])));
+ ids.push(...(await chain(A.page, [[mixer, 'out'], [sink, 'in']])));
+ await wait(A.page, 200);
+ const both = await measure(A, { ms: 400, hz: [220, 880] });
+ h.check(both.peak > SILENT * 10 && Math.abs(both.levels[220] - both.levels[880]) < 12, '9.1 two sources on in1 and in2 both reach out (220 Hz at ' + fmt(both.levels[220], 1) + ' dB, 880 Hz at ' + fmt(both.levels[880], 1) + ' dB)');
+ await setParam(A.page, mixer, 'mute2', true);
+ await wait(A.page, 250);
+ const muted = await measure(A, { ms: 400, hz: [220, 880] });
+ h.check(muted.levels[880] < both.levels[880] - 20 && Math.abs(muted.levels[220] - both.levels[220]) < 3, '9.2 muting channel 2 drops 880 Hz by ' + fmt(both.levels[880] - muted.levels[880], 0) + ' dB and leaves 220 Hz alone');
+ await setParam(A.page, mixer, 'mute2', false);
+ await setParam(A.page, mixer, 'solo1', true);
+ await wait(A.page, 250);
+ const soloed = await measure(A, { ms: 400, hz: [220, 880] });
+ h.check(soloed.levels[880] < both.levels[880] - 20 && Math.abs(soloed.levels[220] - both.levels[220]) < 3, '9.3 soloing channel 1 isolates it (880 Hz down ' + fmt(both.levels[880] - soloed.levels[880], 0) + ' dB, 220 Hz within ' + fmt(Math.abs(soloed.levels[220] - both.levels[220]), 1) + ' dB)');
+ await setParam(A.page, mixer, 'solo1', false);
+ await wait(A.page, 250);
+ const unsoloed = await measure(A, { ms: 400, hz: [220, 880] });
+ h.check(Math.abs(unsoloed.levels[880] - both.levels[880]) < 3, '9.4 solo off brings channel 2 back (880 Hz at ' + fmt(unsoloed.levels[880], 1) + ' dB)');
+ // the fader: a continuous gesture, so it must be a param write that glides the running
+ // node — no click (no frame of broadband energy), no gap, no rebuilt input, no restart
+ const before = await inPage(A.page, 'window.__fxIn1 = ad.deviceHandle(arg).inputs.in1; return { starts: window.__oscStarts }', mixer);
+ const rest = await measure(A, { ms: 400, hfHz: 3000 });
+ const moving = measure(A, { ms: 900, hfHz: 3000 });
+ for (let i = 0; i < 8; i++) {
+ await wait(A.page, 90);
+ await setParam(A.page, mixer, 'gain1', i % 2 ? 1 : 0.5);
+ }
+ const fader = await moving;
+ await setParam(A.page, mixer, 'gain1', 1);
+ const after = await inPage(A.page, 'return { same: window.__fxIn1 === ad.deviceHandle(arg).inputs.in1, starts: window.__oscStarts }', mixer);
+ h.check(fader.hfMax < Math.max(rest.hfMax * 4, 0.01), '9.5 eight fader writes in 0.9 s do not CLICK — no frame of broadband energy (magnitude share above 3 kHz at rest ' + fmt(rest.hfMax, 5) + ', moving ' + fmt(fader.hfMax, 5) + ')');
+ h.check(fader.minRms > fader.peak * 0.35, '9.6 and the level never gaps while it moves (min ' + fmt(fader.minRms) + ' vs peak ' + fmt(fader.peak) + ' over ' + fader.samples + ' reads)');
+ h.check(after.same === true, '9.7 the input node a cable is plugged into is the SAME node after the moves — a fader is a param write, never a rebuild');
+ h.check(after.starts === before.starts, '9.8 and no source oscillator was restarted (' + after.starts + ' starts before and after)');
+ // the send: a second output, post-fader, per channel
+ await uncable(A.page, [ids[2]]);
+ const sendCable = await cable(A.page, mixer, 'send', sink, 'in');
+ await wait(A.page, 300);
+ const sendOff = await measure(A, { ms: 300, hz: [220, 880] });
+ h.check(sendOff.peak < SILENT, '9.9 (premise) the send bus is silent while every send is 0 (peak ' + fmt(sendOff.peak, 5) + ')');
+ await setParam(A.page, mixer, 'send1', 1);
+ await wait(A.page, 250);
+ const sendOn = await measure(A, { ms: 400, hz: [220, 880] });
+ h.check(sendOn.peak > SILENT * 10 && Math.abs(sendOn.peakHz - 220) < 40 && sendOn.levels[880] < sendOn.levels[220] - 20, '9.10 send 1 up puts channel 1 alone on the send port (strongest ' + Math.round(sendOn.peakHz) + ' Hz, 880 Hz ' + fmt(sendOn.levels[220] - sendOn.levels[880], 0) + ' dB below)');
+ await setParam(A.page, mixer, 'mute1', true);
+ await wait(A.page, 250);
+ const sendMuted = await measure(A, { ms: 300 });
+ h.check(sendMuted.peak < SILENT * 2, '9.11 the send is post-fader: muting channel 1 silences it (peak ' + fmt(sendMuted.peak, 5) + ')');
+ await setParam(A.page, mixer, 'mute1', false);
+ await setParam(A.page, mixer, 'send1', 0);
+ await uncable(A.page, [ids[0], ids[1], sendCable]);
+ await setParam(A.page, src2, 'gain', 0);
+
+ // ---------------------------------------------------------------- section 10
+ console.log('\n=== 10. a knob turned on A is heard on B ===');
+ await setParams(A.page, src, { wave: 'sawtooth', gain: 0.25 });
+ await setParams(A.page, pedal.filter, { type: 'lowpass', cutoff: 8000, q: 1, mix: 1 });
+ ids = await chain(A.page, [src, pedal.filter, sink]);
+ await wait(B.page, 600);
+ const openOnB = await measure(B, { ms: 500 });
+ h.check(openOnB.peak > SILENT * 10 && Math.abs(openOnB.peakHz - 220) < 40, '10.1 B hears the chain it built from the replicated document and patch (peak ' + fmt(openOnB.peak) + ', centroid ' + Math.round(openOnB.centroid) + ' Hz)');
+ await setParam(A.page, pedal.filter, 'cutoff', 300);
+ await h.eventually(() => docOn(B.page, pedal.filter), (d) => d?.params?.cutoff === 300, "10.2 the cutoff written on A lands in B's document");
+ await h.eventually(
+ () => inPage(B.page, 'return ad.deviceHandle(arg)?.biquad.frequency.value ?? null', pedal.filter),
+ (v) => typeof v === 'number' && Math.abs(v - 300) < 15,
+ "10.3 and B's built BiquadFilterNode followed it through onParam (frequency -> 300)"
+ );
+ const closedOnB = await measure(B, { ms: 500 });
+ h.check(closedOnB.centroid < openOnB.centroid * 0.5, "10.4 B's own spectrum moved with it (centroid " + Math.round(openOnB.centroid) + ' -> ' + Math.round(closedOnB.centroid) + ' Hz)');
+
+ // ---------------------------------------------------------------- section 11
+ console.log('\n=== 11. a late joiner walks into the finished rig ===');
+ const C = await h.setupPage(browser, 'C', { audio: true });
+ await h.installModule(C, 'music-fx');
+ await inPage(C.page, TEST_KINDS);
+ await h.connect(C, A);
+ await h.eventually(
+ () => built(C.page),
+ (list) => PEDALS.every((p) => isBuilt(list, pedal[p], K(p))) && isBuilt(list, mixer, K('mixer')) && isBuilt(list, src, 'fx-src') && isBuilt(list, sink, 'fx-sink'),
+ '11.1 C holds every device as the REAL kind, not a placeholder',
+ 20000
+ );
+ await h.eventually(() => cablesOf(C.page, pedal.filter), (n) => n === 2, '11.2 and the chain came with the handshake');
+ const docC = await docOn(C.page, pedal.filter);
+ h.check(docC?.params?.cutoff === 300 && docC?.params?.type === 'lowpass', "11.3 with the filter's document as A last wrote it (" + JSON.stringify(docC?.params) + ')');
+ await wait(C.page, 500);
+ const heardC = await measure(C, { ms: 600 });
+ h.check(heardC.peak > SILENT * 10 && heardC.centroid < openOnB.centroid * 0.5, '11.4 C hears the chain, low-passed (peak ' + fmt(heardC.peak) + ', centroid ' + Math.round(heardC.centroid) + ' Hz)');
+
+ await h.finish(browser);
+});
diff --git a/tests/music-jam-room.test.cjs b/tests/music-jam-room.test.cjs
new file mode 100644
index 0000000..402dd34
--- /dev/null
+++ b/tests/music-jam-room.test.cjs
@@ -0,0 +1,102 @@
+// music-jam-room test-flight (#23 D3): the Jam Room template's whole path, on the modules
+// that make it. The room is built exactly as scripts/author-templates.cjs builds it - the
+// modules' own menus - exported through the REAL .tpscene path, then imported on a peer
+// that HAS the modules (audible, cabled, the same room) and on one that does NOT (the
+// prompt names them, because the requirement is derived from the device kinds alone: the
+// room holds no flow node).
+const h = require('./helpers.cjs');
+const inPage = (page, body, arg) =>
+ page.evaluate(([src, a]) => Object.getPrototypeOf(async function () {}).constructor('s', 'ad', 'ap', 'arg', src)(window.__stores, window.__stores.audioDevices, window.__stores.audioPatch, a), [body, arg ?? null]);
+const SILENT = 0.001;
+/** a SECOND module on a page the helper already used: the authoring script's own sequence -
+ * open the manager, the User tab, the real zip, wait for the id, close */
+const installZip = async (peer, id) => {
+ const fs = require('fs');
+ const path = require('path');
+ const zip = path.join(__dirname, '..', id + '.zip');
+ await peer.page.evaluate(() => window.__stores.modulesOpen.set(true));
+ await peer.page.waitForTimeout(400);
+ await peer.page.getByRole('tab', { name: /^User/ }).click();
+ await peer.page.waitForTimeout(200);
+ await peer.page.locator('#install-module-zip').setInputFiles({ name: id + '.zip', mimeType: 'application/zip', buffer: fs.readFileSync(zip) });
+ await peer.page.waitForFunction((want) => window.__stores.moduleSDK.loadedModules.some((m) => m.id === want), id, { timeout: 20000 });
+ await peer.page.evaluate(() => window.__stores.modulesOpen.set(false));
+ await peer.page.waitForTimeout(300);
+ h.check(true, id + ' installs from its real zip (second module on the page)');
+};
+const menu = (page, label, moduleId) =>
+ inPage(page, "let items; s.moduleSDK.moduleMenuItems.subscribe((v) => (items = v))(); const hit = items.find((it) => it.label === arg.label && it.moduleId === arg.moduleId); if (!hit) return false; hit.action(); return true", { label, moduleId });
+const roomOf = (page) => inPage(page, "let g; s.objectsGroup.subscribe((v) => (g = v))(); const kinds = {}; let n = 0; g.traverse((o) => { const k = o.userData?.device?.kind; if (k) { n++; kinds[k] = (kinds[k] || 0) + 1; } }); let p; ap.patch.subscribe((v) => (p = v))(); return { devices: n, kinds, cables: p.cables.length, built: ad.devicesDebug().built.filter((b) => b.builtAs !== '__placeholder').length, placeholders: ad.devicesDebug().built.filter((b) => b.builtAs === '__placeholder').length }");
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.AUDIO_ARGS });
+ const A = await h.setupPage(browser, 'A', { audio: true });
+ await h.installModule(A, 'music-lab');
+ await installZip(A, 'music-fx');
+ const page = A.page;
+
+ console.log('\n=== 1. the room, built the way the template is authored ===');
+ const ran = [];
+ for (const [label, id] of [['Music Lab: piano + speaker', 'music-lab'], ['Music Lab: beat lab', 'music-lab'], ['Music FX: demo chain', 'music-fx']]) {
+ ran.push(await menu(page, label, id));
+ await page.waitForTimeout(1500);
+ }
+ h.check(ran.every(Boolean), '1.1 the three module menus the authoring script runs all exist');
+ const room = await roomOf(page);
+ h.check(room.devices === 12 && Object.keys(room.kinds).length === 11 && room.cables === 10, '1.2 twelve devices of eleven kinds, ten cables (' + JSON.stringify({ devices: room.devices, cables: room.cables }) + ')');
+ h.check(room.built === 12 && room.placeholders === 0, '1.3 every device is built real here (' + room.built + ' built, ' + room.placeholders + ' placeholders)');
+ const payload = await inPage(page, "const p = s.sessions.buildSessionPayload('Jam Room'); return { modules: p.modules ?? null, graphs: Object.values(p.graphs ?? {}).reduce((n, g) => n + (g.nodes?.length ?? 0), 0), cables: p.patch?.cables?.length ?? null }");
+ h.check(Array.isArray(payload.modules) && payload.modules.map((m) => m.id).sort().join(',') === 'music-fx,music-lab' && payload.graphs === 0, '1.4 the payload names BOTH modules with no flow node in the room - derived from the device kinds (' + JSON.stringify(payload.modules) + ')');
+ const bytes = await inPage(page, "const p = s.sessions.buildSessionPayload('Jam Room'); const b = await s.sessions.exportSessionZip(p, { assets: false, packs: false, flow: true }); return Array.from(new Uint8Array(b instanceof Blob ? await b.arrayBuffer() : b))");
+ h.check(bytes.length > 10000, '1.5 exported through the real .tpscene path (' + bytes.length + ' bytes)');
+
+ console.log('\n=== 2. a peer WITH the modules loads it: the same room, cabled and audible ===');
+ const B = await h.setupPage(browser, 'B', { audio: true });
+ await h.installModule(B, 'music-lab');
+ await installZip(B, 'music-fx');
+ // the templates modal's own path: importSessionZip stores the payload as a session,
+ // requestLoadSession loads it (the peer-consent flow, immediate with no peers)
+ await B.page.evaluate((arr) => { window.__importResult = 'pending'; window.__stores.sessions.importSessionZip(new Uint8Array(arr).buffer).then(async (p) => { if (!p) return (window.__importResult = 'cancelled'); await window.__stores.sessions.requestLoadSession(p.id); window.__importResult = 'applied'; }).catch((e) => (window.__importResult = 'error ' + e)); }, bytes);
+ // a format confirm may sit in front of the import: accept whatever dialog appears
+ for (let i = 0; i < 20; i++) {
+ const r = await B.page.evaluate(() => window.__importResult);
+ if (r !== 'pending') break;
+ const ok = B.page.locator('#confirm-dialog-ok, #confirm-dialog-confirm, #confirm-dialog-install');
+ if ((await ok.count()) > 0) await ok.first().click().catch(() => {});
+ await B.page.waitForTimeout(500);
+ }
+ const result = await B.page.evaluate(() => window.__importResult);
+ h.check(result === 'applied', '2.1 the import applies with the modules present and the session loads (' + result + ')');
+ await h.eventually(() => roomOf(B.page), (r) => r.devices === 12 && r.cables === 10, '2.2 B holds the same twelve devices and ten cables', 15000);
+ await h.eventually(() => roomOf(B.page), (r) => r.built === 12 && r.placeholders === 0, '2.3 and built all of them real', 15000);
+ const quiet = await h.audioMetrics(B, 400);
+ const pressed = await inPage(B.page, "let g; s.objectsGroup.subscribe((v) => (g = v))(); let t = null; g.traverse((o) => { if (o.userData?.device?.kind === 'mod-music-lab-transport') t = o; }); const play = t?.getObjectByName('tp-play'); if (!play) return false; return s.moduleSDK.moduleClickHandlers.some((fn) => fn(play))");
+ h.check(pressed === true, '2.3b the loaded transport face takes a Play press');
+ await B.page.waitForTimeout(900);
+ const loud = await h.audioMetrics(B, 1200);
+ // the flight's room is the menus' raw drop (the template's layout turns the speakers to the
+ // listener; here they face away, ~3 m off), so the floor is the harness silence x2, and
+ // the claim is the ratio over the quiet read before Play
+ h.check(loud.peak > Math.max(SILENT * 2, quiet.peak * 4), '2.4 Play on the loaded transport and the loaded beat is HEARD - drums cabled through to a speaker (peak ' + loud.peak.toFixed(4) + ' vs ' + quiet.peak.toFixed(4) + ' before)');
+ await inPage(B.page, 's.musicClock.stopTransport(); return 1');
+
+ console.log('\n=== 3. a peer WITHOUT the modules is asked for them by name ===');
+ const C = await h.setupPage(browser, 'C', { audio: true });
+ await C.page.evaluate((arr) => { window.__importResult = 'pending'; window.__stores.sessions.importSessionZip(new Uint8Array(arr).buffer).then((p) => (window.__importResult = p ? 'applied' : 'cancelled')); }, bytes);
+ // the format confirm (if any) comes first; the module prompt has its own Install button
+ for (let i = 0; i < 20; i++) {
+ if ((await C.page.locator('#confirm-dialog-install').count()) > 0) break;
+ const ok = C.page.locator('#confirm-dialog-ok, #confirm-dialog-confirm');
+ if ((await ok.count()) > 0) await ok.first().click().catch(() => {});
+ await C.page.waitForTimeout(400);
+ }
+ h.check((await C.page.locator('#confirm-dialog-install').count()) === 1, '3.1 the module prompt appears before anything is touched');
+ const text = await C.page.evaluate(() => new Promise((r) => window.__stores.confirmDialog.confirmDialog.subscribe((d) => r(d?.message ?? ''))()));
+ h.check(/music-lab/.test(text) && /music-fx/.test(text), '3.2 and names BOTH modules (' + JSON.stringify(text.slice(0, 100)) + ')');
+ await C.page.locator('#confirm-dialog-cancel').click();
+ await h.eventually(() => C.page.evaluate(() => window.__importResult), (v) => v === 'cancelled', '3.3 Cancel is a silent no-op');
+ const untouched = await roomOf(C.page);
+ h.check(untouched.devices === 0, '3.4 nothing was loaded on C (' + untouched.devices + ' devices)');
+
+ await h.finish(browser);
+});
diff --git a/tests/music-lab.test.cjs b/tests/music-lab.test.cjs
new file mode 100644
index 0000000..704d25a
--- /dev/null
+++ b/tests/music-lab.test.cjs
@@ -0,0 +1,295 @@
+// music-lab test-flight (#23 C1): the audio-device architecture, end to end, with
+// two devices and one cable between them.
+//
+// Everything here is asserted through ACTUAL SOUND at the destination tap, never
+// through the document alone: a device that builds, cables and reports itself
+// perfectly while making no noise is the failure this module exists to prevent. The
+// numbers that matter are peaks — silent uncabled, loud cabled, quiet when the
+// speaker is carried away or turned around, silent again when it is unplugged.
+//
+// THE GPU FLAGS ARE PART OF THE MEASUREMENT (h.AUDIO_ARGS). On this Linux box a
+// SwiftShader page starves the sampling loop badly enough that an 80 ms note reads
+// as silence — see the note over GPU_ARGS in helpers.cjs.
+const h = require('./helpers.cjs');
+
+/** run a body in the page with the audio stores bound (the core suites' shape) */
+const inPage = (page, body, arg) =>
+ page.evaluate(
+ ([src, a]) =>
+ Object.getPrototypeOf(async function () {}).constructor('s', 'ad', 'ap', 'arg', src)(
+ window.__stores,
+ window.__stores.audioDevices,
+ window.__stores.audioPatch,
+ a
+ ),
+ [body, arg ?? null]
+ );
+
+const SILENT = 0.001; // the RMS floor the core helpers count as silence
+const KIND_PIANO = 'mod-music-lab-piano';
+const KIND_SPEAKER = 'mod-music-lab-speaker';
+
+const kinds = (page) => inPage(page, 'return ad.devicesDebug().kinds');
+const built = (page) => inPage(page, 'return ad.devicesDebug().built');
+
+/** the AudioListener follows this camera, so the geometry of every check is measured
+ * from it rather than assumed */
+const cameraPos = (page) =>
+ page.evaluate(
+ () =>
+ new Promise((r) =>
+ window.__stores.globalCamera.subscribe((c) =>
+ r(c ? c.getWorldPosition(new window.__stores.THREE.Vector3()).toArray() : null)
+ )()
+ )
+ );
+
+/**
+ * Watch one peer for `ms`: the loudest RMS at any tapped context, the strongest FFT
+ * bin at that moment (a triangle wave's fundamental — the harness centroid is a
+ * weighted mean that the noise floor drags around), and how far the piano's A4 key
+ * moved while we looked. `fire` plays the note from THIS peer first; leaving it off
+ * and starting the watch before another peer plays is how the remote cases are read.
+ */
+const watch = (peer, options) =>
+ peer.page.evaluate(async (arg) => {
+ const stores = window.__stores;
+ const group = await new Promise((r) => stores.objectsGroup.subscribe(r)());
+ const key = arg.piano ? group.getObjectByProperty('uuid', arg.piano)?.getObjectByName('key-' + arg.note) : null;
+ const base = key ? key.position.y : 0;
+ const tapped = window.__audioTap.all();
+ const scratch = tapped.map((t) => ({
+ analyser: t.analyser,
+ context: t.context,
+ time: new Float32Array(t.analyser.fftSize),
+ freq: new Float32Array(t.analyser.frequencyBinCount)
+ }));
+ if (arg.fire) stores.audioDevices.noteDevice(arg.piano, { note: arg.note, velocity: 0.9 });
+ let peak = 0;
+ let loudest = null;
+ let binHz = 0;
+ let dip = base;
+ let samples = 0;
+ const deadline = performance.now() + arg.ms;
+ while (performance.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 10));
+ samples++;
+ if (key) dip = Math.min(dip, key.position.y);
+ for (const entry of scratch) {
+ entry.analyser.getFloatTimeDomainData(entry.time);
+ let square = 0;
+ for (let i = 0; i < entry.time.length; i++) square += entry.time[i] * entry.time[i];
+ const rms = Math.sqrt(square / entry.time.length);
+ if (rms > peak) {
+ peak = rms;
+ entry.analyser.getFloatFrequencyData(entry.freq);
+ loudest = entry.freq.slice();
+ binHz = entry.context.sampleRate / entry.analyser.fftSize;
+ }
+ }
+ }
+ let hz = 0;
+ let best = -Infinity;
+ if (loudest)
+ for (let b = 1; b < loudest.length; b++)
+ if (loudest[b] > best) {
+ best = loudest[b];
+ hz = b * binHz;
+ }
+ return { peak, hz, samples, contexts: tapped.length, base, dip, after: key ? key.position.y : 0, hadKey: !!key };
+ }, { ms: 800, note: 69, fire: false, piano: null, ...options });
+
+/**
+ * Move / turn a device object the way the app's own transform gizmo does: write the
+ * pose locally and broadcast the `move` message. Both halves matter — the local write
+ * is what the module's frame task re-aims the panner from, and the broadcast is what
+ * makes the peers' copies of the rig the same rig.
+ */
+async function place(page, uuid, pose) {
+ const moved = await inPage(
+ page,
+ 'const group = await new Promise((r) => s.objectsGroup.subscribe(r)());' +
+ "const o = group.getObjectByProperty('uuid', arg.uuid);" +
+ 'if (!o) return null;' +
+ 'if (arg.pos) o.position.fromArray(arg.pos);' +
+ "if (typeof arg.rotY === 'number') o.rotation.y = arg.rotY;" +
+ 'o.updateMatrixWorld(true);' +
+ 'const peer = await new Promise((r) => s.peers.subscribe(r)());' +
+ "if (peer) peer.send({ type: 'move', uuid: o.uuid, pos: o.position.toArray(), rot: o.rotation.toArray(), scale: o.scale.toArray() });" +
+ 'return o.position.toArray();',
+ { uuid, ...pose }
+ );
+ await page.evaluate(() => window.__stores.objectsGroup.update((v) => v));
+ await page.waitForTimeout(400); // the module re-aims its panners at 20 Hz
+ return moved;
+}
+
+/** where a peer holds an object, and which way it faces */
+const poseOf = (page, uuid) =>
+ inPage(
+ page,
+ 'const group = await new Promise((r) => s.objectsGroup.subscribe(r)());' +
+ "const o = group.getObjectByProperty('uuid', arg);" +
+ 'return o ? { pos: o.position.toArray(), rotY: o.rotation.y } : null;',
+ uuid
+ );
+
+const wait = (page, ms) => page.waitForTimeout(ms);
+
+h.run(async () => {
+ const browser = await h.launch({ args: h.AUDIO_ARGS });
+ const A = await h.setupPage(browser, 'A', { audio: true });
+ const B = await h.setupPage(browser, 'B', { audio: true });
+
+ await h.installModule(A, 'music-lab');
+ await h.installModule(B, 'music-lab');
+ await h.connect(A, B);
+
+ // ---------------------------------------------------------------- section 1
+ console.log('\n=== 1. the module supplies two device kinds ===');
+ await h.eventually(
+ () => kinds(A.page),
+ (k) => k.includes(KIND_PIANO) && k.includes(KIND_SPEAKER),
+ '1.1 A registers both kinds, namespaced mod-music-lab-*'
+ );
+ await h.eventually(
+ () => kinds(B.page),
+ (k) => k.includes(KIND_PIANO) && k.includes(KIND_SPEAKER),
+ '1.2 so does B — a module does not travel, it is installed on both'
+ );
+
+ // ---------------------------------------------------------------- section 2
+ console.log('\n=== 2. a piano, a speaker, and a cable ===');
+ // the listener sits at the camera, so put the speaker 4 m in FRONT of it (the
+ // speaker's cone points down its own -Z) and the piano off to one side
+ const cam = await cameraPos(A.page);
+ console.log(' A camera at ' + cam.map((n) => n.toFixed(1)).join(', '));
+ const NEAR = [cam[0], cam[1], cam[2] + 4];
+ const FAR = [cam[0] + 60, cam[1], cam[2] + 60];
+ const rig = await inPage(
+ A.page,
+ "const p = ad.addDevice('" + KIND_PIANO + "', { position: arg.piano });" +
+ "const k = ad.addDevice('" + KIND_SPEAKER + "', { position: arg.speaker });" +
+ 'return { piano: p ? p.uuid : null, speaker: k ? k.uuid : null, keys: p ? p.children.filter((c) => typeof c.userData.midi === "number").length : 0 };',
+ { piano: [cam[0] - 2, cam[1] - 1, cam[2] + 4], speaker: NEAR }
+ );
+ h.check(!!rig.piano && !!rig.speaker, '2.1 addDevice made both objects');
+ h.check(rig.keys === 12, '2.2 the piano mesh carries one octave of keys with userData.midi (' + rig.keys + ')');
+ await h.eventually(
+ () => built(A.page),
+ (list) => list.some((d) => d.builtAs === KIND_PIANO && d.hasOutput) && list.some((d) => d.builtAs === KIND_SPEAKER && d.hasInput),
+ '2.3 both build as the REAL kinds on A — the piano with an output, the speaker with an input'
+ );
+ await h.eventually(
+ () => built(B.page),
+ (list) => list.some((d) => d.builtAs === KIND_PIANO) && list.some((d) => d.builtAs === KIND_SPEAKER),
+ '2.4 the objects replicate and build as the real kinds on B too'
+ );
+ // PLACE the rig explicitly. `addDevice`'s own `position` does not reach the peers on
+ // this build (core bug, reported: the broadcast toJSON is taken before three has
+ // updated the object's matrix, so every peer receives it at the origin) — and a rig
+ // that is somewhere else on B is not the same rig, which is the whole subject here.
+ await place(A.page, rig.speaker, { pos: NEAR });
+ await place(A.page, rig.piano, { pos: [cam[0] - 2, cam[1] - 1, cam[2] + 4] });
+ await h.eventually(
+ () => poseOf(B.page, rig.speaker),
+ (p) => !!p && Math.hypot(p.pos[0] - NEAR[0], p.pos[1] - NEAR[1], p.pos[2] - NEAR[2]) < 0.01,
+ '2.5 B holds the speaker at the same place — the two peers have the SAME rig'
+ );
+ const cable = await inPage(
+ A.page,
+ "return ap.addCable({ from: { uuid: arg.piano, port: 'out' }, to: { uuid: arg.speaker, port: 'in' } })",
+ rig
+ );
+ h.check(typeof cable === 'string' && !!cable, '2.6 addCable plugs the piano into the speaker (' + cable + ')');
+ await h.eventually(
+ () => inPage(B.page, 'return ap.cablesOf(arg).length', rig.piano),
+ (n) => n === 1,
+ '2.7 the cable replicates to B'
+ );
+
+ // ---------------------------------------------------------------- section 3
+ console.log('\n=== 3. it makes a sound, and the key dips ===');
+ const quiet = await watch(A, { ms: 400 });
+ h.check(quiet.peak < SILENT, '3.1 (premise) nothing is playing (peak ' + quiet.peak.toFixed(5) + ', ' + quiet.samples + ' samples, ' + quiet.contexts + ' context(s))');
+ const near = await watch(A, { piano: rig.piano, fire: true, ms: 900 });
+ h.check(near.samples > 20, '3.2 (premise) the sampling loop actually ran (' + near.samples + ' samples — SwiftShader reads ~2)');
+ h.check(near.peak > SILENT * 10 && Math.abs(near.hz - 440) < 40, '3.3 A4 is HEARD through the speaker at 440 Hz (peak ' + near.peak.toFixed(4) + ', strongest bin ' + Math.round(near.hz) + ' Hz)');
+ h.check(near.hadKey && near.dip <= near.base - 0.015, '3.4 the pressed key dipped (' + near.base.toFixed(3) + ' -> ' + near.dip.toFixed(3) + ')');
+ await wait(A.page, 500);
+ const back = await watch(A, { piano: rig.piano, ms: 200 });
+ h.check(Math.abs(back.after - back.base) < 1e-6, '3.5 and came back up');
+
+ // ---------------------------------------------------------------- section 4
+ console.log('\n=== 4. the peer hears the same note ===');
+ const remote = watch(B, { piano: rig.piano, ms: 1100 });
+ await wait(A.page, 120);
+ await inPage(A.page, "return ad.noteDevice(arg, { note: 69, velocity: 0.9 })", rig.piano);
+ const onB = await remote;
+ h.check(onB.peak > SILENT * 10 && Math.abs(onB.hz - 440) < 40, '4.1 B synthesizes the replicated note itself (peak ' + onB.peak.toFixed(4) + ', ' + Math.round(onB.hz) + ' Hz)');
+ h.check(onB.hadKey && onB.dip <= onB.base - 0.015, "4.2 and B's key dips too — the dip runs from onNote, never from the click");
+ await wait(A.page, 800);
+
+ // ---------------------------------------------------------------- section 5
+ console.log('\n=== 5. moving the speaker moves where it sounds from ===');
+ await place(A.page, rig.speaker, { pos: FAR });
+ const far = await watch(A, { piano: rig.piano, fire: true, ms: 900 });
+ const drop = 1 - far.peak / near.peak;
+ h.check(far.peak < near.peak * 0.3, '5.1 60 m away the same note is far quieter (' + near.peak.toFixed(4) + ' -> ' + far.peak.toFixed(4) + ', ' + Math.round(drop * 100) + '% down)');
+ await wait(A.page, 800);
+ await place(A.page, rig.speaker, { pos: NEAR });
+ const restored = await watch(A, { piano: rig.piano, fire: true, ms: 900 });
+ h.check(restored.peak > far.peak * 3, '5.2 carrying it back restores it (peak ' + restored.peak.toFixed(4) + ')');
+ await wait(A.page, 800);
+
+ // ---------------------------------------------------------------- section 6
+ console.log('\n=== 6. unplugging drops the bus to the noise floor ===');
+ await inPage(A.page, 'ap.removeCable(arg); return true', cable);
+ await wait(A.page, 400);
+ const unplugged = await watch(A, { piano: rig.piano, fire: true, ms: 900 });
+ h.check(unplugged.peak < SILENT, '6.1 with the cable pulled the note is SILENT — the piano reaches no bus of its own (peak ' + unplugged.peak.toFixed(5) + ')');
+ await wait(A.page, 600);
+ const cable2 = await inPage(
+ A.page,
+ "return ap.addCable({ from: { uuid: arg.piano, port: 'out' }, to: { uuid: arg.speaker, port: 'in' } })",
+ rig
+ );
+ await wait(A.page, 400);
+ const replugged = await watch(A, { piano: rig.piano, fire: true, ms: 900 });
+ h.check(!!cable2 && replugged.peak > SILENT * 10, '6.2 plugging it back in brings it straight back (peak ' + replugged.peak.toFixed(4) + ')');
+ await wait(A.page, 800);
+
+ // ---------------------------------------------------------------- section 7
+ console.log('\n=== 7. a late joiner walks into the finished rig ===');
+ const C = await h.setupPage(browser, 'C', { audio: true });
+ await h.installModule(C, 'music-lab');
+ await h.connect(C, A);
+ await h.eventually(
+ () => built(C.page),
+ (list) => list.some((d) => d.builtAs === KIND_PIANO) && list.some((d) => d.builtAs === KIND_SPEAKER),
+ '7.1 C holds both devices as the REAL kinds, not placeholders'
+ );
+ await h.eventually(
+ () => inPage(C.page, 'return ap.cablesOf(arg).length', rig.piano),
+ (n) => n === 1,
+ '7.2 and the cable came with the handshake'
+ );
+ const onC = watch(C, { ms: 1100 });
+ await wait(A.page, 120);
+ await inPage(A.page, "return ad.noteDevice(arg, { note: 69, velocity: 0.9 })", rig.piano);
+ const heardC = await onC;
+ h.check(heardC.peak > SILENT * 10 && Math.abs(heardC.hz - 440) < 40, '7.3 C hears a note played on A (peak ' + heardC.peak.toFixed(4) + ', ' + Math.round(heardC.hz) + ' Hz)');
+ await wait(A.page, 800);
+
+ // ---------------------------------------------------------------- section 8
+ console.log('\n=== 8. pointing it matters ===');
+ await place(A.page, rig.speaker, { rotY: Math.PI });
+ const away = await watch(A, { piano: rig.piano, fire: true, ms: 900 });
+ h.check(away.peak < restored.peak * 0.6, '8.1 turned 180 deg the same note reads quieter — the cone (facing ' + restored.peak.toFixed(4) + ' -> away ' + away.peak.toFixed(4) + ')');
+ await wait(A.page, 800);
+ await place(A.page, rig.speaker, { rotY: 0 });
+ const facing = await watch(A, { piano: rig.piano, fire: true, ms: 900 });
+ h.check(facing.peak > away.peak * 1.5, '8.2 turning it back to face the listener restores it (peak ' + facing.peak.toFixed(4) + ')');
+
+ await h.finish(browser);
+});
diff --git a/tests/music-voice.test.cjs b/tests/music-voice.test.cjs
new file mode 100644
index 0000000..ffc3504
--- /dev/null
+++ b/tests/music-voice.test.cjs
@@ -0,0 +1,387 @@
+// music-voice test-flight (#23 C5): a Mic, a Looper, a Synth and a Theremin on the engine, on
+// Chrome's fake media device (a generated signal, so the mic reads real bytes with no hardware).
+//
+// Every audible claim is read at the destination tap of an in-page SINK device (music-dj's
+// idiom: a kind that connects its input to the instruments bus) that each device under test
+// is cabled into. Every timing claim is measured SAMPLE-ACCURATELY: the onset detector below
+// (music-beat-lab's) chains an 8192-sample analyser off the tap and back-dates each crossing
+// by its offset inside the window, so a JavaScript loop that stalls under load (this box is
+// shared) still yields the true onset time - a measurement of the scheduler, not of setTimeout.
+// Wall clock (Date.now), never ticks. THE GPU FLAGS ARE PART OF THE MEASUREMENT (h.AUDIO_ARGS).
+const h = require('./helpers.cjs');
+
+const inPage = (page, body, arg) =>
+ page.evaluate(
+ ([src, a]) =>
+ Object.getPrototypeOf(async function () {}).constructor('s', 'ad', 'ap', 'mc', 'arg', src)(
+ window.__stores,
+ window.__stores.audioDevices,
+ window.__stores.audioPatch,
+ window.__stores.musicClock,
+ a
+ ),
+ [body, arg ?? null]
+ );
+
+const MEDIA_ARGS = ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'];
+const K = { mic: 'mod-music-voice-mic', looper: 'mod-music-voice-looper', synth: 'mod-music-voice-synth', theremin: 'mod-music-voice-theremin' };
+const BAR_MS = 2000; // 120 bpm, four beats
+const SILENT = 0.001;
+
+/** the sink: an in-page kind whose input reaches the instruments bus (and so the tap) */
+const SINK = "ad.registerAudioDevice({ kind: 'vt-sink', label: 'Sink', ports: { in: [{ id: 'in', kind: 'audio' }], out: [] }, params: [], build(ctx) { const g = ctx.createGain(); g.connect(window.__stores.audioEngine.bus('instruments')); return { input: g, dispose() { g.disconnect(); } }; } }); return 1";
+/** a test oscillator: a 150 ms burst of `hz` at EVERY BAR through the transport's scheduler,
+ * pure of `at` - so a loop of it carries one onset exactly on the bar */
+const OSC = "ad.registerAudioDevice({ kind: 'vt-osc', label: 'Test osc', ports: { in: [], out: [{ id: 'out', kind: 'audio' }] }, params: [{ key: 'hz', kind: 'range', min: 20, max: 4000, step: 1, default: 330 }], build(ctx, node, params) { const out = ctx.createGain(); const hz = Number(params.hz) || 330; const burst = (at) => { const o = ctx.createOscillator(); o.frequency.value = hz; const g = ctx.createGain(); g.gain.setValueAtTime(0, at); g.gain.linearRampToValueAtTime(0.6, at + 0.002); g.gain.setValueAtTime(0.6, at + 0.14); g.gain.linearRampToValueAtTime(0, at + 0.15); o.connect(g); g.connect(out); o.start(at); o.stop(at + 0.16); }; const cancel = mc.schedule(0, (e) => { if (!e.late) burst(e.at); }, { every: 4, swing: false }); return { output: out, dispose() { cancel(); out.disconnect(); } }; } }); return 1";
+
+const docOf = (page, uuid) => inPage(page, 'return ad.deviceOf(ad.findDeviceObject(arg))?.params ?? null', uuid);
+const handleOf = (page, uuid, body, extra = {}) => inPage(page, 'const hd = ad.deviceHandle(arg.uuid); const h = hd?.h; if (!h) return null; ' + body, { uuid, ...extra });
+const undoLen = (page) => page.evaluate(() => { let n = 0; window.__stores.history.undoStack.subscribe((v) => (n = v.length))(); return n; });
+const setParams = (page, uuid, params) => inPage(page, 'ad.setDeviceFor(arg.uuid, { params: arg.params }); return 1', { uuid, params });
+const preview = (page, uuid, params) => inPage(page, 'ad.previewDeviceParams(arg.uuid, arg.params, { broadcast: true }); return 1', { uuid, params });
+/** the click seam: the module's registered handler, called with the exact mesh the way core's
+ * tap / raycast would; returns the page's clock and beat at the click */
+const clickMesh = (page, uuid, name) =>
+ inPage(page, "const o = ad.findDeviceObject(arg.uuid); const mesh = o?.getObjectByName(arg.name); if (!mesh) return { took: false, missing: true }; const y0 = mesh.position.y; const took = s.moduleSDK.moduleClickHandlers.some((fn) => fn(mesh)); return { took, at: Date.now(), beat: mc.transportNow().beat, dipped: mesh.position.y < y0 - 0.005 }", { uuid, name });
+/** the SHARED transport as this peer holds it */
+const transportOf = (page) => inPage(page, 'let st; mc.transport.subscribe((v) => (st = v))(); const t = mc.transportNow(); return { startedAt: st.startedAt, bpm: st.bpm, playing: st.playing, beat: t.beat, now: Date.now() }');
+const wait = (page, ms) => page.waitForTimeout(ms);
+
+/**
+ * The tap's spectrum for `ms`: the strongest bin, and the dB at each of `tones` - MAX-HELD over
+ * the reads (so a burst counts fully) or averaged (`mean: true`) for a steady signal. `delay`
+ * lets an attack land first.
+ */
+const spectrum = (page, tones, opts = {}) =>
+ page.evaluate(
+ async ({ tones, ms, delay, mean }) => {
+ await new Promise((r) => setTimeout(r, delay));
+ const t = window.__audioTap.all()[0];
+ const freq = new Float32Array(t.analyser.frequencyBinCount);
+ const binHz = t.context.sampleRate / t.analyser.fftSize;
+ const acc = new Float32Array(freq.length).fill(mean ? 0 : -Infinity);
+ let reads = 0;
+ const deadline = performance.now() + ms;
+ while (performance.now() < deadline) {
+ await new Promise((r) => setTimeout(r, 16));
+ t.analyser.getFloatFrequencyData(freq);
+ for (let b = 0; b < freq.length; b++) {
+ const v = Number.isFinite(freq[b]) ? freq[b] : -160;
+ acc[b] = mean ? acc[b] + v : Math.max(acc[b], v);
+ }
+ reads++;
+ }
+ const val = (b) => (mean ? acc[b] / Math.max(1, reads) : acc[b]);
+ let best = -Infinity;
+ let bestBin = 0;
+ for (let b = 1; b < acc.length; b++) if (val(b) > best) { best = val(b); bestBin = b; }
+ const at = (hz) => { const b = Math.round(hz / binHz); let m = -Infinity; for (let k = b - 1; k <= b + 1; k++) m = Math.max(m, val(k)); return m; };
+ return { peakHz: bestBin * binHz, peakDb: best, db: tones.map(at), reads, binHz };
+ },
+ { tones, ms: opts.ms ?? 600, delay: opts.delay ?? 0, mean: !!opts.mean }
+ );
+
+/**
+ * Onsets at this peer's tap for `ms`, SAMPLE-ACCURATE (music-beat-lab's detector): a big
+ * analyser window (8192 samples) chained off every tapped analyser, each read scanning only
+ * the samples that arrived since the previous read in 64-sample chunks, back-dating a
+ * threshold crossing by its offset inside the window. Threshold 35% of the loudest chunk
+ * (re-arm below 12%). Returns the onsets (Date.now ms), the peak, and the read count.
+ */
+const onsets = (peer, ms) =>
+ peer.page.evaluate(async (ms) => {
+ const N = 8192;
+ const CHUNK = 64;
+ const taps = window.__audioTap.all().map((t) => {
+ const mine = t.context.createAnalyser();
+ mine.fftSize = N;
+ mine.smoothingTimeConstant = 0;
+ t.analyser.connect(mine);
+ return { src: t.analyser, mine, sr: t.context.sampleRate, buf: new Float32Array(N) };
+ });
+ const ctx = taps[0].mine.context;
+ const sr = taps[0].sr;
+ /** @type {{t: number, v: number, fresh: boolean}[]} */
+ const chunks = [];
+ const t0 = Date.now();
+ let lastAudio = ctx.currentTime;
+ let stalls = 0;
+ let lost = 0;
+ while (Date.now() - t0 < ms) {
+ const wall = Date.now();
+ // scan ONLY what the AUDIO clock says arrived since the previous read: a window that
+ // has not advanced (the renderer stalled) is not re-scanned - re-reading a static
+ // window would stamp its rising edge again, later. More than a window gone (this
+ // loop stalled) is counted, and the first chunk after it is not trusted as a rise.
+ const audioNow = ctx.currentTime;
+ const arrived = Math.round((audioNow - lastAudio) * sr);
+ lastAudio = audioNow;
+ if (arrived < CHUNK) {
+ stalls++;
+ await new Promise((r) => setTimeout(r, 4));
+ continue;
+ }
+ const dropped = arrived > N;
+ if (dropped) lost++;
+ const advanced = Math.min(N, arrived);
+ const merged = new Float32Array(Math.floor(advanced / CHUNK));
+ for (const tap of taps) {
+ tap.mine.getFloatTimeDomainData(tap.buf);
+ const from = N - merged.length * CHUNK;
+ for (let c = 0; c < merged.length; c++) {
+ const a = from + c * CHUNK;
+ const b = a + CHUNK;
+ let sum = 0;
+ for (let i = a; i < b; i++) sum += tap.buf[i] * tap.buf[i];
+ merged[c] = Math.max(merged[c], Math.sqrt(sum / CHUNK));
+ }
+ }
+ for (let c = 0; c < merged.length; c++) {
+ const endIndex = N - merged.length * CHUNK + (c + 1) * CHUNK;
+ chunks.push({ t: wall - ((N - endIndex) / sr) * 1000, v: merged[c], fresh: !(dropped && c === 0) });
+ }
+ await new Promise((r) => setTimeout(r, 4));
+ }
+ for (const tap of taps) tap.src.disconnect(tap.mine);
+ let peak = 0;
+ for (const c of chunks) peak = Math.max(peak, c.v);
+ const high = peak * 0.35;
+ const low = peak * 0.12;
+ let armed = true;
+ let last = -Infinity;
+ const out = [];
+ for (const c of chunks) {
+ if (!c.fresh) armed = false; // a rise that happened while samples were lost is not an onset we can place
+ if (armed && c.v >= high) {
+ if (c.t - last > 120) out.push(Math.round(c.t));
+ last = c.t;
+ armed = false;
+ } else if (!armed && c.v <= low) armed = true;
+ }
+ // a burst already IN PROGRESS when the detector starts reads as an onset AT the start
+ // (there is no rising edge to back-date): the first 250 ms are not a measurement
+ return { onsets: out.filter((t) => t - t0 > 250), peak, reads: chunks.length, stalls, lost };
+ }, ms);
+
+/** the phase of a set of onsets on a period grid, in ms, and how far each strays from it */
+function grid(times, period) {
+ if (times.length < 2) return { phase: NaN, maxDev: NaN, spacing: [] };
+ const first = times[0];
+ const rel = times.map((t) => t - first);
+ let sx = 0, sy = 0;
+ for (const r of rel) { const a = (2 * Math.PI * (r % period)) / period; sx += Math.cos(a); sy += Math.sin(a); }
+ const phase = ((Math.atan2(sy, sx) / (2 * Math.PI)) * period + period) % period;
+ let maxDev = 0;
+ for (const r of rel) { let d = ((r - phase) % period + period) % period; if (d > period / 2) d -= period; maxDev = Math.max(maxDev, Math.abs(d)); }
+ const spacing = rel.slice(1).map((r, i) => r - rel[i]);
+ return { phase: (phase + first) % period, maxDev, spacing };
+}
+/** circular distance between two phases on a period */
+const phaseGap = (a, b, period) => { let d = Math.abs(a - b) % period; return Math.min(d, period - d); };
+/** the phase a set of onsets holds against a bar grid anchored at `startedAt` */
+const barGap = (times, startedAt) => phaseGap(grid(times, BAR_MS).phase, ((startedAt % BAR_MS) + BAR_MS) % BAR_MS, BAR_MS);
+
+/** the RMS at the tap in windows (ms after `fire`), sampled every 10 ms: an envelope */
+const envelope = (page, uuid, note, windows) =>
+ inPage(
+ page,
+ 'const t = window.__audioTap.all()[0]; const buf = new Float32Array(t.analyser.fftSize); const acc = arg.windows.map(() => ({ sum: 0, n: 0 })); const t0 = performance.now(); ad.noteDevice(arg.uuid, { note: arg.note, velocity: 0.9 }); const end = Math.max(...arg.windows.map((w) => w[1])); while (performance.now() - t0 < end) { await new Promise((r) => setTimeout(r, 10)); const at = performance.now() - t0; t.analyser.getFloatTimeDomainData(buf); let sq = 0; for (let i = 0; i < buf.length; i++) sq += buf[i] * buf[i]; const rms = Math.sqrt(sq / buf.length); arg.windows.forEach((w, i) => { if (at >= w[0] && at < w[1]) { acc[i].sum += rms; acc[i].n++; } }); } return acc.map((a) => (a.n ? a.sum / a.n : 0));',
+ { uuid, note, windows }
+ );
+
+const isHash = (v) => typeof v === 'string' && /^[0-9a-f]{40,64}$/.test(v);
+
+h.run(async () => {
+ const browser = await h.launch({ args: [...h.AUDIO_ARGS, ...MEDIA_ARGS] });
+ const A = await h.setupPage(browser, 'A', { audio: true });
+ const B = await h.setupPage(browser, 'B', { audio: true });
+ await h.installModule(A, 'music-voice');
+ await h.installModule(B, 'music-voice');
+ await inPage(A.page, SINK);
+ await inPage(B.page, SINK);
+ await inPage(A.page, OSC); // A only: on B the test oscillator is a placeholder, so B's sink hears only what replicates
+ await h.connect(A, B);
+
+ console.log('\n=== 1. the module supplies four device kinds ===');
+ await h.eventually(() => inPage(A.page, 'return ad.devicesDebug().kinds'), (k) => Object.values(K).every((kind) => k.includes(kind)), '1.1 A registers mic, looper, synth and theremin, namespaced mod-music-voice-*');
+ await h.eventually(() => inPage(B.page, 'return ad.devicesDebug().kinds'), (k) => Object.values(K).every((kind) => k.includes(kind)), '1.2 so does B - a module does not travel, it is installed on both');
+
+ console.log('\n=== 2. the mic: this peer\'s RAW microphone into the graph, a take by hash ===');
+ const rig = await inPage(A.page, "const sk = ad.addDevice('vt-sink', { position: [0, 0, -3] }); const m = ad.addDevice(arg.mic, { position: [0.7, 0.8, -2] }); const c = ap.addCable({ from: { uuid: m.uuid, port: 'out' }, to: { uuid: sk.uuid, port: 'in' } }); return { sink: sk.uuid, mic: m.uuid, micCable: c }", K);
+ const stream = await (async () => { let last = null; for (let i = 0; i < 25 && !last?.tracks; i++) { last = await handleOf(A.page, rig.mic, "const tr = h.stream?.getAudioTracks?.()[0]; if (!tr) return { tracks: 0, error: h.error }; const st = tr.getSettings(); let micOn; s.voiceChat.micActive.subscribe((v) => (micOn = v))(); return { tracks: 1, enabled: tr.enabled, state: tr.readyState, aec: st.echoCancellation, ns: st.noiseSuppression, agc: st.autoGainControl, micOn, source: !!h.source }"); if (!last?.tracks) await wait(A.page, 400); } return last; })();
+ h.check(!!stream && stream.tracks === 1 && stream.enabled === true && stream.state === 'live' && stream.source, '2.1 the mic builds on this peer\'s raw capture: a live, ENABLED track into a MediaStreamSource (' + JSON.stringify(stream) + ')');
+ h.check(!!stream && stream.aec === false && stream.ns === false && stream.agc === false && stream.micOn === false, '2.2 with echo cancellation, noise suppression and auto gain OFF, while voice chat\'s mic toggle is off');
+ const heard = await h.audioMetrics(A, 700);
+ h.check(heard.peak > SILENT * 2 && heard.samples > 10, '2.3 the mic carries SIGNAL to the sink with the mic toggle off (peak ' + heard.peak.toFixed(4) + ', ' + heard.samples + ' samples) - a node on the voice stream would read silence here');
+ await setParams(A.page, rig.mic, { level: 0 });
+ await wait(A.page, 400);
+ const muted = await h.audioMetrics(A, 500);
+ h.check(muted.peak < SILENT, '2.4 level 0 is silent (peak ' + muted.peak.toFixed(5) + ')');
+ await setParams(A.page, rig.mic, { level: 1 });
+ await h.eventually(() => handleOf(B.page, rig.mic, 'return { built: true, stream: !!h.stream, source: !!h.source }'), (v) => v?.built && v.stream && v.source, '2.5 B builds the replicated mic as the real kind, on ITS OWN raw capture (a Mic is each peer\'s own mic; the takes are what travel)', 15000);
+ // a take from the face
+ await setParams(A.page, rig.mic, { seconds: 2 });
+ const undoMic = await undoLen(A.page);
+ const rec = await clickMesh(A.page, rig.mic, 'mic-rec');
+ h.check(rec.took === true, '2.6 the Record button takes the click');
+ await h.eventually(() => inPage(A.page, 'let st; s.micCapture.recording.subscribe((v) => (st = v))(); return st'), (st) => st?.active === true && st.maxSeconds === 2, '2.7 core\'s recorder runs with the face\'s take length (the LED reads this store)', 3000);
+ await h.eventually(() => docOf(A.page, rig.mic).then((d) => d?.lastTake ?? null), (v) => isHash(v), '2.8 the take lands and its CONTENT HASH is written into the device document', 9000);
+ const takeA = await docOf(A.page, rig.mic);
+ const item = await inPage(A.page, 'const it = s.explorer.itemByHash(arg); return it ? { kind: it.kind, name: it.name, size: it.size } : null', takeA.lastTake);
+ h.check(!!item && item.kind === 'audio' && item.name === takeA.lastTakeName && /^mic-.*\.(webm|ogg|m4a)$/.test(item.name) && item.size > 1000, '2.9 it is an Explorer AUDIO item (' + JSON.stringify(item) + ') and the document names it');
+ h.check((await undoLen(A.page)) === undoMic + 1, '2.10 the take is one undo entry');
+ await h.eventually(() => docOf(B.page, rig.mic).then((d) => d?.lastTake ?? null), (v) => v === takeA.lastTake, '2.11 B holds the hash in its document');
+ await h.eventually(() => B.page.evaluate((hash) => !!window.__stores.explorer.itemByHash(hash), takeA.lastTake), (v) => v === true, '2.12 B received the bytes by content hash (assetShare)', 20000);
+ // the manifest is a debounced VIEW (400 ms), polled
+ await h.eventually(() => inPage(A.page, "let list; s.sceneAssets.sceneAssets.subscribe((v) => (list = v))(); return list.filter((e) => e.group === 'audio').map((e) => ({ hash: e.hash, name: e.name, item: !!e.itemId }))"), (list) => list.some((e) => e.hash === takeA.lastTake && e.name === takeA.lastTakeName && e.item), '2.13 the take is in the Scene manifest by hash, named, with its Explorer item (spec.assets)', 6000);
+ // a second press ends a long take early
+ await setParams(A.page, rig.mic, { seconds: 20 });
+ await clickMesh(A.page, rig.mic, 'mic-rec');
+ await wait(A.page, 600);
+ const stop = await clickMesh(A.page, rig.mic, 'mic-rec');
+ await h.eventually(() => docOf(A.page, rig.mic).then((d) => d?.lastTake ?? null), (v) => isHash(v) && v !== takeA.lastTake, '2.14 pressing Record while a 20 s take runs ends it early: a new take lands within seconds (' + (stop.took ? 'took' : 'missed') + ')', 6000);
+ await inPage(A.page, 'ap.removeCable(arg); return 1', rig.micCable); // the fake device must not colour the spectra below
+
+ console.log('\n=== 3. the looper: a record window on the BAR, the same bytes on every peer, in phase ===');
+ const lp = await inPage(A.page, "const o = ad.addDevice('vt-osc', { position: [-1.5, 0.8, -2], params: { hz: 330 } }); const l = ad.addDevice(arg.K.looper, { position: [-0.2, 0.8, -2] }); const c1 = ap.addCable({ from: { uuid: o.uuid, port: 'out' }, to: { uuid: l.uuid, port: 'in' } }); ap.addCable({ from: { uuid: l.uuid, port: 'out' }, to: { uuid: arg.sink, port: 'in' } }); ad.setDeviceFor(l.uuid, { params: { bars: 1, thru: false } }); return { osc: o.uuid, looper: l.uuid, oscCable: c1 }", { K, sink: rig.sink });
+ await h.eventually(() => inPage(B.page, 'const b = ad.devicesDebug().built.find((d) => d.uuid === arg); return b ? { as: b.builtAs, i: b.hasInput, o: b.hasOutput } : null', lp.looper), (v) => v?.as === K.looper && v.i && v.o, '3.1 the looper builds on B as the real kind with an input and an output');
+ await inPage(A.page, 'mc.setBpm(120); mc.playTransport(); return 1');
+ await wait(A.page, 400);
+ const tp0 = await transportOf(A.page);
+ const undo0 = await undoLen(A.page);
+ const arm = await clickMesh(A.page, lp.looper, 'lp-rec');
+ h.check(arm.took === true && tp0.playing && tp0.bpm === 120, '3.2 Record takes the click on a running 120 bpm transport (armed at beat ' + arm.beat.toFixed(2) + ')');
+ const armedDoc = await docOf(A.page, lp.looper);
+ h.check(armedDoc.state === 'armed' && !armedDoc.loop, '3.3 the looper is ARMED (a preview in the document) and holds no loop yet');
+ await h.eventually(() => docOf(B.page, lp.looper).then((d) => d?.state ?? null), (v) => v === 'armed' || v === 'recording', '3.4 B\'s copy shows the armed / recording state - the LED preview replicates');
+ await h.eventually(() => docOf(A.page, lp.looper).then((d) => d?.loop ?? null), (v) => isHash(v), '3.5 the loop lands by hash (lead + one bar + tail + encode)', 9000);
+ const land = await inPage(A.page, 'return Date.now()');
+ const loop1 = await docOf(A.page, lp.looper);
+ h.check(land - arm.at < 6500, '3.6 within maxSeconds + margin of the press (' + (land - arm.at) + ' ms)');
+ h.check(loop1.startedBeat % 4 === 0 && loop1.startedBeat > arm.beat && loop1.startedBar === loop1.startedBeat / 4 && loop1.loopBars === 1 && loop1.loopBpm === 120, '3.7 the record window started on a BAR BOUNDARY after the press (beat ' + loop1.startedBeat + ', bar ' + loop1.startedBar + ', armed at ' + arm.beat.toFixed(2) + ')');
+ const expectedAt = tp0.startedAt + (loop1.startedBeat * 60000) / 120;
+ h.check(Math.abs(loop1.startedAt - expectedAt) < 30 && loop1.startedAt > arm.at, '3.8 startedAt is that bar on the transport\'s own grid (' + (loop1.startedAt - expectedAt).toFixed(1) + ' ms from it, ' + (loop1.startedAt - arm.at) + ' ms after the press)');
+ h.check(loop1.state === 'idle' && (await undoLen(A.page)) === undo0 + 1, '3.9 the whole take - previews and the commit - is ONE undo entry (' + undo0 + ' -> ' + (await undoLen(A.page)) + ')');
+ await h.eventually(() => handleOf(A.page, lp.looper, 'return h.buffer ? { n: h.buffer.length, sr: h.buffer.sampleRate, edge: h.trim.edge, start: h.trim.start, raw: h.trim.rawSeconds } : null'), (v) => !!v && v.n === Math.round(2 * v.sr) && v.edge > 0, '3.10 A decoded its own take, found the sync pip and cut EXACTLY one bar: 2.000 s to the sample', 8000);
+ const trimA = await handleOf(A.page, lp.looper, 'return { edge: h.trim.edge, sr: h.buffer.sampleRate, raw: h.trim.rawSeconds }');
+ console.log(' A trim: pip edge at ' + (trimA.edge / trimA.sr * 1000).toFixed(1) + ' ms of a ' + trimA.raw.toFixed(2) + ' s take');
+ const spA = await spectrum(A.page, [330], { ms: 2300 });
+ h.check(Math.abs(spA.peakHz - 330) < 25, '3.11 the loop PLAYS at the sink on A at 330 Hz (peak ' + spA.peakHz.toFixed(0) + ' Hz; thru is off, so this is the loop)');
+ await h.eventually(() => docOf(B.page, lp.looper).then((d) => d?.loop ?? null), (v) => v === loop1.loop, '3.12 B holds the same hash and bar');
+ await h.eventually(() => handleOf(B.page, lp.looper, 'return h.buffer ? { n: h.buffer.length, sr: h.buffer.sampleRate, edge: h.trim.edge } : null'), (v) => !!v && v.n === Math.round(2 * v.sr) && v.edge === trimA.edge, '3.13 B pulled the bytes by hash and cut the SAME samples (pip edge ' + trimA.edge + ')', 20000);
+ const spB = await spectrum(B.page, [330], { ms: 2300 });
+ h.check(Math.abs(spB.peakHz - 330) < 25, '3.14 and hears the loop at 330 Hz (peak ' + spB.peakHz.toFixed(0) + ' Hz)');
+ const [oa, ob] = await Promise.all([onsets(A, 4600), onsets(B, 4600)]);
+ const ga = grid(oa.onsets, BAR_MS), gb = grid(ob.onsets, BAR_MS);
+ h.check(oa.onsets.length >= 2 && oa.onsets.length <= 3 && ga.maxDev < 25, '3.15 across two loops A\'s onsets sit on a 2000 ms grid (' + oa.onsets.length + ' onsets, max deviation ' + ga.maxDev.toFixed(1) + ' ms) - the period is exact');
+ const tpA = await transportOf(A.page);
+ h.check(barGap(oa.onsets, tpA.startedAt) < 30, '3.16 and ON THE BAR of the shared transport (' + barGap(oa.onsets, tpA.startedAt).toFixed(1) + ' ms from the bar grid)');
+ h.check(ob.onsets.length >= 2 && gb.maxDev < 25 && phaseGap(ga.phase, gb.phase, BAR_MS) < 35, '3.17 B plays it IN PHASE with A (' + phaseGap(ga.phase, gb.phase, BAR_MS).toFixed(1) + ' ms apart, ' + ob.onsets.length + ' onsets, max deviation ' + gb.maxDev.toFixed(1) + ' ms)');
+ // an OVERDUB with a second tone
+ const od = await inPage(A.page, "ap.removeCable(arg.c); const o2 = ad.addDevice('vt-osc', { position: [-1.5, 0.8, -1.4], params: { hz: 440 } }); ap.addCable({ from: { uuid: o2.uuid, port: 'out' }, to: { uuid: arg.l, port: 'in' } }); return o2.uuid", { c: lp.oscCable, l: lp.looper });
+ await wait(A.page, 300);
+ const undo1 = await undoLen(A.page);
+ const arm2 = await clickMesh(A.page, lp.looper, 'lp-rec');
+ await h.eventually(() => docOf(A.page, lp.looper).then((d) => d?.loop ?? null), (v) => isHash(v) && v !== loop1.loop, '3.18 the overdub lands as a NEW hash', 9000);
+ const loop2 = await docOf(A.page, lp.looper);
+ h.check(arm2.took && loop2.takes === 2 && loop2.startedBeat % 4 === 0 && loop2.startedBeat > arm2.beat && (await undoLen(A.page)) === undo1 + 1, '3.19 on a bar boundary again, take 2, one more undo entry');
+ await h.eventually(() => handleOf(A.page, lp.looper, 'return h.buffer && h.doc.loop === arg.hash ? h.trim.edge : -1', { hash: loop2.loop }).catch(() => -1), (v) => v > 0, '3.20 A decoded the overdub', 8000);
+ const sp2 = await spectrum(A.page, [330, 440], { ms: 2300 });
+ h.check(sp2.peakDb > -80 && sp2.db[0] > sp2.peakDb - 15 && sp2.db[1] > sp2.peakDb - 15, '3.21 the overdub carries BOTH tones: 330 Hz (the first loop, through feedback) and 440 Hz (the new take) within 15 dB of the peak (' + sp2.db.map((d) => d.toFixed(0)).join(' / ') + ' dB, peak ' + sp2.peakDb.toFixed(0) + ')');
+ await h.eventually(() => docOf(B.page, lp.looper).then((d) => d?.loop ?? null), (v) => v === loop2.loop, '3.22 B holds the new hash');
+ await h.eventually(() => handleOf(B.page, lp.looper, 'return h.buffer && h.doc.loop === arg.hash ? h.trim.edge : -1', { hash: loop2.loop }).catch(() => -1), (v) => v > 0, '3.23 and decoded it', 20000);
+ const sp2b = await spectrum(B.page, [330, 440], { ms: 2300 });
+ h.check(sp2b.peakDb > -80 && sp2b.db[0] > sp2b.peakDb - 15 && sp2b.db[1] > sp2b.peakDb - 15, '3.24 B hears both tones too (' + sp2b.db.map((d) => d.toFixed(0)).join(' / ') + ' dB)');
+ await A.page.evaluate(() => window.__stores.history.undo());
+ await wait(A.page, 300);
+ h.check((await docOf(A.page, lp.looper)).loop === loop1.loop, '3.25 one undo puts the FIRST loop back (the commit carried the document from before the take)');
+ await h.eventually(() => docOf(B.page, lp.looper).then((d) => d?.loop ?? null), (v) => v === loop1.loop, '3.26 and B follows');
+ const play = await clickMesh(A.page, lp.looper, 'lp-play');
+ await wait(A.page, 500);
+ const stopped = await h.audioMetrics(A, 500);
+ h.check(play.took && stopped.peak < SILENT * 3, '3.27 the Play button stops the loop (peak ' + stopped.peak.toFixed(5) + ')');
+ await h.eventually(() => docOf(B.page, lp.looper).then((d) => d?.play ?? null), (v) => v === false, '3.28 replicated to B');
+ await inPage(A.page, 'mc.stopTransport(); return 1'); // the test oscillators only sound while it runs
+ await wait(A.page, 400);
+
+ console.log('\n=== 4. the synth: polyphonic, the oldest stolen, ADSR, notes replicated ===');
+ const sy = await inPage(A.page, "const o = ad.addDevice(arg.K.synth, { position: [-1.2, 0.8, -2] }); ap.addCable({ from: { uuid: o.uuid, port: 'out' }, to: { uuid: arg.sink, port: 'in' } }); ad.setDeviceFor(o.uuid, { params: { wave: 'sine', polyphony: 4, gate: 1.6, release: 0.5, attack: 0.005, decay: 0.05, sustain: 0.8, cutoff: 8000 } }); return o.uuid", { K, sink: rig.sink });
+ await h.eventually(() => inPage(B.page, 'const b = ad.devicesDebug().built.find((d) => d.uuid === arg); return { poly: ad.deviceOf(ad.findDeviceObject(arg))?.params?.polyphony ?? null, built: b?.builtAs ?? null, cables: ap.cablesOf(arg).length }', sy), (v) => v?.poly === 4 && v.built === K.synth && v.cables === 1, '4.0 (premise) B holds the synth document (polyphony 4), built it, and has its cable to the sink');
+ await wait(B.page, 300); // the routing sweep runs per frame once the cable is in
+ const CHORD = [60, 64, 67, 72];
+ const HZ = CHORD.map((n) => 440 * Math.pow(2, (n - 69) / 12));
+ const remote = spectrum(B.page, HZ, { ms: 900 });
+ await wait(A.page, 120);
+ await inPage(A.page, 'for (const n of arg.notes) ad.noteDevice(arg.uuid, { note: n, velocity: 0.9 }); return 1', { uuid: sy, notes: CHORD });
+ const chordA = await spectrum(A.page, HZ, { ms: 500, delay: 120, mean: true });
+ h.check(chordA.peakDb > -80 && chordA.db.every((d) => d > chordA.peakDb - 15), '4.1 four simultaneous notes: all four fundamentals within 15 dB of the peak on A (' + chordA.db.map((d) => d.toFixed(0)).join(' / ') + ' dB)');
+ const live = await handleOf(A.page, sy, 'return h.voices.filter((v) => !v.released).map((v) => v.note)');
+ h.check(JSON.stringify(live) === JSON.stringify(CHORD), '4.2 four live voices (' + live.join(',') + ')');
+ const chordB = await remote;
+ // a silent tap reads -160 dB on EVERY bin and would pass the relative test vacuously: signal first
+ h.check(chordB.peakDb > -80 && chordB.db.every((d) => d > chordB.peakDb - 15), '4.3 B synthesizes the same chord from the replicated notes (' + chordB.db.map((d) => d.toFixed(0)).join(' / ') + ' dB, peak ' + chordB.peakDb.toFixed(0) + ')');
+ await inPage(A.page, 'ad.noteDevice(arg.uuid, { note: 76, velocity: 0.9 }); return 1', { uuid: sy });
+ const after = await handleOf(A.page, sy, 'return { live: h.voices.filter((v) => !v.released).map((v) => v.note), stolen: h.voices.filter((v) => v.stolen).map((v) => v.note) }');
+ h.check(JSON.stringify(after.live) === JSON.stringify([64, 67, 72, 76]) && JSON.stringify(after.stolen) === JSON.stringify([60]), '4.4 a fifth note past polyphony 4 STEALS THE OLDEST (live ' + after.live.join(',') + ', stolen ' + after.stolen.join(',') + ')');
+ const HZ5 = [HZ[0], 440 * Math.pow(2, (76 - 69) / 12)];
+ const steal = await spectrum(A.page, HZ5, { ms: 400, delay: 100, mean: true });
+ h.check(steal.db[1] > steal.peakDb - 15 && steal.db[0] < chordA.db[0] - 12, '4.5 the spectrum agrees: 659 Hz is in, 262 Hz is gone (' + chordA.db[0].toFixed(0) + ' -> ' + steal.db[0].toFixed(0) + ' dB)');
+ await wait(A.page, 2400);
+ await setParams(A.page, sy, { gate: 0.6, release: 1.0 });
+ const env = await envelope(A.page, sy, 69, [[150, 450], [900, 1300], [1900, 2200]]);
+ h.check(env[0] > 0.003, '4.6 ADSR: the note sustains while the gate is open (rms ' + env[0].toFixed(4) + ')');
+ h.check(env[1] > env[0] * 0.1 && env[1] < env[0] * 0.9, '4.7 mid-way through a 1 s release it is decaying (' + (env[1] / env[0] * 100).toFixed(0) + '% of the sustain)');
+ h.check(env[2] < env[0] * 0.05, '4.8 and after the release it is gone (' + (env[2] / env[0] * 100).toFixed(1) + '%)');
+ const key = await clickMesh(A.page, sy, 'key-64');
+ h.check(key.took === true && key.dipped === true, '4.9 a key on the face takes the click and dips FROM onNote');
+ await h.eventually(() => handleOf(B.page, sy, 'return h.voices.length'), (v) => v >= 1, '4.10 B got the key\'s note - the click went through api.audio.note, replicated', 3000);
+ await wait(A.page, 1800);
+
+ console.log('\n=== 5. the theremin: the hand pose is the DOCUMENT, every peer synthesizes it ===');
+ const th = await inPage(A.page, "const o = ad.addDevice(arg.K.theremin, { position: [1.5, 0.8, -2] }); ap.addCable({ from: { uuid: o.uuid, port: 'out' }, to: { uuid: arg.sink, port: 'in' } }); return o.uuid", { K, sink: rig.sink });
+ await h.eventually(() => docOf(B.page, th).then((d) => d?.hand ?? null), (v) => v === 'right', '5.0 (premise) B holds the theremin (hand: right)');
+ const undoT = await undoLen(A.page);
+ await preview(A.page, th, { pitch: 440, volume: 0.8 });
+ await wait(A.page, 400);
+ const t440 = await spectrum(A.page, [440], { ms: 400, mean: true });
+ h.check(Math.abs(t440.peakHz - 440) < 25, '5.1 previewParams {pitch 440, volume 0.8}: the sink\'s strongest bin is 440 Hz on A (' + t440.peakHz.toFixed(0) + ')');
+ await h.eventually(() => docOf(B.page, th), (d) => d?.pitch === 440 && d?.volume === 0.8, '5.2 B holds the pose in its document');
+ const t440b = await spectrum(B.page, [440], { ms: 400, mean: true });
+ h.check(Math.abs(t440b.peakHz - 440) < 25, '5.3 and B synthesizes it: 440 Hz at B\'s sink (' + t440b.peakHz.toFixed(0) + ')');
+ await preview(A.page, th, { pitch: 880 });
+ await wait(A.page, 400);
+ const [t880, t880b] = await Promise.all([spectrum(A.page, [880, 1760], { ms: 400, mean: true }), spectrum(B.page, [880], { ms: 400, mean: true })]);
+ h.check(Math.abs(t880.peakHz - 880) < 25 && Math.abs(t880b.peakHz - 880) < 25, '5.4 pitch 880 moves the bin on both (' + t880.peakHz.toFixed(0) + ' / ' + t880b.peakHz.toFixed(0) + ')');
+ await preview(A.page, th, { wave: 'sawtooth' });
+ await wait(A.page, 300);
+ const saw = await spectrum(A.page, [1760], { ms: 300, mean: true });
+ h.check(saw.db[0] > t880.db[1] + 20, '5.5 wave = saw adds the second harmonic at 1760 Hz (' + t880.db[1].toFixed(0) + ' -> ' + saw.db[0].toFixed(0) + ' dB)');
+ await preview(A.page, th, { volume: 0 });
+ await wait(A.page, 500);
+ const [quietA, quietB] = await Promise.all([h.audioMetrics(A, 400), h.audioMetrics(B, 400)]);
+ h.check(quietA.peak < SILENT && quietB.peak < SILENT, '5.6 volume 0 silences it on A and B (' + quietA.peak.toFixed(5) + ' / ' + quietB.peak.toFixed(5) + ')');
+ h.check((await undoLen(A.page)) === undoT, '5.7 none of those previews entered history (undo ' + undoT + ')');
+ await setParams(A.page, th, { volume: 0.5 });
+ await wait(A.page, 700);
+ const afterT = await Promise.all([docOf(A.page, th), docOf(B.page, th), undoLen(A.page)]);
+ h.check(afterT[2] === undoT + 1 && afterT[0].volume === 0.5 && afterT[0].pitch === 880 && afterT[1].volume === 0.5 && afterT[1].pitch === 880, '5.8 a real setParams is one entry, and on desktop (no VR hand) nothing overwrote the written pose (' + JSON.stringify({ pitch: afterT[0].pitch, volume: afterT[0].volume }) + ')');
+ await setParams(A.page, th, { volume: 0 });
+
+ console.log('\n=== 6. a late joiner walks into the finished rig ===');
+ await setParams(A.page, lp.looper, { play: true });
+ await inPage(A.page, 'mc.setBpm(120); mc.playTransport(); return 1');
+ const C = await h.setupPage(browser, 'C', { audio: true });
+ await h.installModule(C, 'music-voice');
+ await inPage(C.page, SINK);
+ await h.connect(C, A);
+ await h.eventually(() => docOf(C.page, lp.looper).then((d) => d?.loop ?? null), (v) => v === loop1.loop, '6.1 C holds the looper\'s loop hash and bar from the handshake');
+ await h.eventually(() => handleOf(C.page, lp.looper, 'return h.buffer && h.doc.loop === arg.hash ? h.trim.edge : -1', { hash: loop1.loop }).catch(() => -1), (v) => v === trimA.edge, '6.2 C pulled the bytes and cut the same samples', 25000);
+ await wait(C.page, 500);
+ const [oa2, oc] = await Promise.all([onsets(A, 4600), onsets(C, 4600)]);
+ const ga2 = grid(oa2.onsets, BAR_MS), gc = grid(oc.onsets, BAR_MS);
+ h.check(oc.onsets.length >= 2 && gc.maxDev < 25 && phaseGap(ga2.phase, gc.phase, BAR_MS) < 40, '6.3 C plays the loop on the grid, IN PHASE with A (' + phaseGap(ga2.phase, gc.phase, BAR_MS).toFixed(1) + ' ms apart, ' + oc.onsets.length + ' onsets, max deviation ' + gc.maxDev.toFixed(1) + ' ms)');
+ const docsC = await Promise.all([docOf(C.page, th), docOf(C.page, rig.mic)]);
+ h.check(docsC[0]?.pitch === 880 && isHash(docsC[1]?.lastTake), '6.4 C holds the theremin pose and the mic\'s take hash too');
+
+ await h.finish(browser);
+});
diff --git a/tests/untangle.test.cjs b/tests/untangle.test.cjs
new file mode 100644
index 0000000..d9e6e5a
--- /dev/null
+++ b/tests/untangle.test.cjs
@@ -0,0 +1,95 @@
+// untangle test-flight (21-C C7): the REAL zip on TWO peers. With NO Untangle Board node
+// in any graph the module falls back to today's behaviour (a level-1 board appears on its
+// own after the node-or-fallback window), the board stands at the default pose, drops
+// replicate as authoritative moves, a solve is detected on EVERY peer from the same
+// positions and both advance in lockstep, the desktop shows NO canvas sprite (the sprite
+// is the VR-only HUD path), and a LATE JOINER gets {level, positions}.
+//
+// npm run pack -- untangle
+// APP_URL=https://theprototype.app:5216/ node tests/untangle.test.cjs
+
+const { launch, setupPage, installModule, connect, check, eventually, finish, run } = require('./helpers.cjs');
+
+const snap = (page) =>
+ page.evaluate(() => {
+ let scene;
+ window.__stores.globalScene.subscribe((v) => (scene = v))();
+ const group = scene?.getObjectByName('untangle-module');
+ const s = window.__untangle?.state() ?? null;
+ return s
+ ? {
+ ...s,
+ positions: s.positions.map((p) => [Math.round(p[0] * 1000) / 1000, Math.round(p[1] * 1000) / 1000]),
+ group: !!group,
+ groupY: group ? Math.round(group.position.y * 100) / 100 : null,
+ dots: group ? group.children.filter((c) => c.name.startsWith('untangle-dot-')).length : 0,
+ spriteInScene: !!group?.getObjectByName('untangle-hud')
+ }
+ : null;
+ });
+
+run(async () => {
+ const browser = await launch();
+ const A = await setupPage(browser, 'A');
+ const B = await setupPage(browser, 'B');
+ await installModule(A, 'untangle');
+ await installModule(B, 'untangle');
+
+ const groups = await A.page.evaluate(
+ () => new Promise((r) => window.__stores.moduleSDK.moduleNodeGroups.subscribe((g) => r(g.map((x) => [x.group, x.items.length])))())
+ );
+ const family = groups.find(([name]) => name === 'Untangle');
+ check(!!family && family[1] === 3, 'node palette has "Untangle" with 3 nodes — board, value, event (' + JSON.stringify(family) + ')');
+
+ // no node anywhere -> the fallback: a level-1 board on its own, at the default pose
+ await eventually(() => snap(A.page), (s) => !!s && s.built && s.level === 1 && s.dots > 0, 'A: with no Untangle Board node the level-1 board appears on its own (the fallback window)');
+ const a0 = await snap(A.page);
+ check(a0.nodeOwned === false && a0.groupY === 1.6 && a0.board.radius === 1.1, 'A: not node-owned, default pose (boardY 1.6, radius 1.1)');
+ check(a0.dots === a0.positions.length && a0.positions.length === 6, 'level 1 has 6 dots (' + a0.dots + ')');
+ check(a0.spriteInScene === false && a0.sprite === false, 'no canvas sprite on desktop — the sprite is the VR-only HUD path');
+ check(a0.crossings > 0, 'the scramble starts tangled (' + a0.crossings + ' crossings)');
+ check(a0.touched === false, 'an untouched fallback board answers the state exchange with NOTHING (it must never overwrite a room)');
+
+ await connect(A, B);
+ await eventually(() => snap(B.page), (s) => !!s && s.built && s.level === 1, 'B: its own level-1 board');
+ check(JSON.stringify((await snap(B.page)).positions) === JSON.stringify(a0.positions), 'B generated the IDENTICAL scramble (determinism is the netcode)');
+
+ // a drop replicates as an authoritative move
+ check(await A.page.evaluate(() => window.__untangle.move(0, [0.25, -0.4])), 'A drops dot 0 at (0.25, -0.4)');
+ await eventually(() => snap(B.page), (s) => s.positions[0][0] === 0.25 && s.positions[0][1] === -0.4, 'B receives the move');
+ check(await A.page.evaluate(() => window.__untangle.move(1, [5, -5])), 'A drops dot 1 far outside the board');
+ await eventually(() => snap(B.page), (s) => s.positions[1][0] === 1 && s.positions[1][1] === -1, 'B: the drop was clamped to the board edge (1, -1)');
+
+ // solve on A -> detected on BOTH from the same positions -> lockstep advance to level 2
+ check(await A.page.evaluate(() => window.__untangle.solve()), 'A drops every dot on the solution circle (0 crossings)');
+ await eventually(() => snap(B.page), (s) => s.crossings === 0, 'B: zero crossings from the same positions');
+ await eventually(() => snap(A.page), (s) => s.level === 2 && s.dots === 7, 'A: autoAdvance to level 2 (7 dots)', 6000);
+ await eventually(() => snap(B.page), (s) => s.level === 2 && s.dots === 7, 'B: advanced in lockstep with no "win" message', 6000);
+ const a2 = await snap(A.page);
+ check(a2.solvedCount === 1 && (await snap(B.page)).solvedCount === 1, 'both peers count ONE solve');
+
+ // Restart level replicates
+ await A.page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ window.__stores.moduleSDK.moduleMenuItems.subscribe((items) => {
+ items.find((item) => item.moduleId === 'untangle' && item.label === 'Restart level')?.action();
+ resolve();
+ })();
+ })
+ );
+ await eventually(() => snap(B.page), (s) => s.level === 2 && JSON.stringify(s.positions) === JSON.stringify(a2.positions), 'B: Restart level re-scrambles level 2 identically');
+
+ // late joiner: installs, connects, gets {level, positions}
+ check(await A.page.evaluate(() => window.__untangle.move(2, [0.3, 0.3])), 'A moves dot 2 before the joiner arrives');
+ const C = await setupPage(browser, 'C');
+ await installModule(C, 'untangle');
+ await connect(C, A);
+ const aEnd = await snap(A.page);
+ await eventually(() => snap(C.page), (s) => !!s && s.level === 2 && JSON.stringify(s.positions) === JSON.stringify(aEnd.positions), 'late joiner C has level 2 and A\'s exact positions', 30000);
+ await A.page.waitForTimeout(1500);
+ const aAfter = await snap(A.page);
+ check(aAfter.level === 2 && JSON.stringify(aAfter.positions) === JSON.stringify(aEnd.positions), 'counterfactual: the joiner\'s untouched level-1 board did NOT overwrite A (the symmetric state exchange)');
+
+ await finish(browser);
+});