TURNCRAFT/docs/SIDEB_M_handoff.md
type-two 33232fdeba SIDE B: the crate worlds — three dive-in pockets, stamps, golden slipmat
Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.

- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
  pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
  flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
  tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
  beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
  (default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
  survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
  vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
  feedback-echo + spring, disco pentatonic tiles) over a boothGain
  duck-and-handback that can never restore stale state; portal/stamp/golden
  SFX; bonus stem.

Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.

Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:22:11 +10:00

157 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SIDE B — Lane M handoff (portals, world quests, stamps, relay)
**Brief:** [docs/briefs/SIDEB_MACHINES.md](briefs/SIDEB_MACHINES.md)
**Status:** ✅ All items delivered and verified live in the real game against a
real local relay AND against Lane W's landed pocket geometry (their build
arrived mid-session; every position below is aligned to their actual voxels,
not brief guesses). `npm run typecheck` clean; zero console errors in the
final build; `demo-machines.html` still runs standalone. **Not committed**
integrator commits (shared tree).
---
## Files touched (all inside Lane M ownership)
| file | what |
|---|---|
| `src/machines/worlds/common.ts` | **new** — pocket bounds/nearest-world helpers, structural `teleport`/`isFlying` access to Lane B's player (no cross-lane import), `interactAt()` (the `index`-carrying `machine:interact` emitter), mulberry32 |
| `src/machines/worlds/stamps.ts` | **new**`Stamps` store (Set<WorldKey>): idempotent `apply()` emits `stamp:got` / `stamp:all` + HUD toasts (suppressed by `quiet` for join replay); `Stamps.coerceKey` default-closed wire validation |
| `src/machines/worlds/portal.ts` | **new** — three dive plates at the crate discs (E → `world:enter` + teleport to `entry`), exit pads (stand 0.25 s → `world:exit` + teleport to `returnPos`), y>141 out-of-pocket safety net, proximity HUD prompts, accent glow panes |
| `src/machines/worlds/acidQuest.ts` | **new** — Tune the 303: 3 hold-E sweep pedestals, per-entry seeded hidden targets, release-inside-±0.07 locks, `fader:move acid_0..2` + `workshop:torque` gauge |
| `src/machines/worlds/dubQuest.ts` | **new** — Feed the Spring: charge carry (wire-carry pattern), 8-segment wobbling kinematic deck overlay (travelling sine, honest `velocityAt`), pit-fall forgiveness, mid-deck mouth feed |
| `src/machines/worlds/discoQuest.ts` | **new** — beat-Simon on the 4×4 tile grid: rounds 4→8, standing-on-tile detection, `audio:beat` sync + 122 BPM free-run fallback, **tile glow plates (lighting is Lane M's, per Lane S's scope)** |
| `src/machines/machine.ts` | + `IdInteractions` (optional structural per-collider press/hold surface for world machines) |
| `src/machines/index.ts` | constructs Portals + 3 quests + `Stamps` inside `createMachines`; `MachineSet.stamps`; `stamp:all` → deck A golden slipmat; re-exports |
| `src/machines/platter.ts` | + `setGoldenSlipmat(on)` / `isGoldenSlipmat()` (gold emissive material swap, idempotent) |
| `src/interact/interaction.ts` | generalized the workshop's press/hold routing: machines exposing `IdInteractions` get E with the hit collider id and may run hold-E; with none, behavior is byte-identical to before |
| `src/net/NetClient.ts` | sends `{t:'stamp', w}` on local `stamp:got` (guarded, re-validated); applies remote `stamp` + `hello.state.stamps` (quiet) via `Stamps.apply` |
| `server/relay.mjs` | **one additive message** `{t:'stamp', w}` — see protocol below |
**No other lane's files were touched.** Lane W's geometry constants are
mirrored (with file references), not imported — cross-lane imports stay
contract-only.
## Integrator wiring needed in main.ts: **none**
Everything rides existing wiring: the world machines are created inside
`createMachines()` (colliders/scene/update/attachPlayer already flow through
`MachineSet`), and the stamp sync lives in `NetClient`, which already receives
`machines`. Two facts worth knowing, no action required:
- `MachineSet` gained a `stamps: Stamps` field.
- Portals reach `PlayerController.teleport` / `isFlying` **structurally** via
`attachPlayer`'s object. A player without `teleport` (demo mock) means dives
emit events but don't move — harmless in the demo, real game unaffected.
## Event vocabulary emitted (for Lanes S / E2)
The `machine:interact` payloads carry the index BOTH ways E2's handoff asks
for: **trailing digits of `machineId`** (`acid_0..2`, `disco_tile_0..15`) AND
a runtime `index` property (typed-contract-safe via a widened variable —
`core/events.ts` untouched).
| event | when |
|---|---|
| `world:enter {world}` | E on a dive plate, before the teleport (S iris, E2 duck+groove) |
| `world:exit {world}` | exit pad (0.25 s stand) or the y>141 safety net |
| `fader:move {faderId:'acid_0'..'acid_2', value}` | pedestal sweeps; **initial (wrong) values re-emitted on `world:enter` + once more ~0.35 s later** (registration-order proofing) |
| `machine:interact {machineId:'acid_<i>', action:'acid_knob', index}` | ~6/s while a band sweeps |
| `machine:interact {machineId:'acid_<i>', action:'acid_lock', index}` | band locked (value snaps to target, so the last `fader:move` is exactly right) |
| `machine:interact {machineId:'acid', action:'acid_tuned'}` | all three locked (fires on toy replays too; stamp events do not) |
| `machine:interact {machineId:'dub', action:'dub_pickup' / 'dub_drop' / 'dub_feed'}` | charge taken / lost (pit fall OR leaving the pocket mid-carry) / fed to the tank |
| `machine:interact {machineId:'disco_tile_<i>', action:'disco_cue', index}` | sequence playback, one per beat |
| `machine:interact {machineId:'disco_tile_<i>', action:'disco_step', index}` | player steps a correct tile (E2 echoes a fifth down) |
| `machine:interact {machineId:'disco', action:'disco_miss' / 'disco_lit'}` | wrong step (gentle reset) / full 8 done |
| `machine:interact {machineId:'portals', action:'portal_dive' / 'portal_exit'}` | teleports (extra flavor hooks, ignorable) |
| `stamp:got {world, count, total}` | stamp landed: local win, remote win, **and once per stamped world from the join snapshot** (idempotent — never re-fires for a world already applied) |
| `stamp:all {}` | third stamp — exactly on the fresh transition, including when the join snapshot completes the set |
| `workshop:torque {value / null}` | acid hold gauge (reuses the HUD radial) |
| `workshop:msg {text}` | prompts/toasts (join-replay stamp toasts suppressed) |
Consumed: `audio:beat` (disco show pacing; free-runs at 122 BPM when silent —
the quest can't stall in a dead booth), `world:enter/exit` (self-wiring).
**Join handshake (S §"Join-replay coordination", E2 §"boot handshake"):**
`stamp:got`/`stamp:all` replays fire while `NetClient` handles `hello`, i.e.
on the ws connect during page boot — measured well inside FxSystem's 4 s
celebration-suppression grace. One edge: if the relay is unreachable at boot,
the replay arrives on the first successful (re)connect (15 s backoff) and
would land OUTSIDE the grace — rare, cosmetic (one extra confetti), noted.
## Relay protocol (additive, v3 + stamp)
- Client → server `{t:'stamp', w}`: `w` must be exactly `acid|dub|disco`
(default-closed; anything else dropped). Duplicate = dropped (no
rebroadcast). Accept → persist **immediately**, rebroadcast to other peers.
- `hello.state.stamps: string[]` — join snapshot; old clients ignore it, old
state files load as no stamps.
- **`reset` deliberately does NOT clear `stamps`** — stamps are pressed into
the records, not the booth (M5). Comment sits in the reset case.
## Geometry alignment (to Lane W's landed build)
- **Portals:** dive plate hugs each disc's lower rim (probed live: the rim
overhangs the stand at eye height — solid y4 to z 35, y5 to z 37), centre
`[discX+1, 4.0, 34.6]`, small enough not to wall the crate aisle.
- **Acid:** plinth colliders/knobs at world centres `[52.5, ·, 44.5/52.5/60.5]`;
our chrome knob rides ON Lane W's led pip voxel (top face 148), lock lamp above.
- **Dub:** deck overlay x 223..226 (centre 224.5), z 40..64, base **147.62**
(static deck top 147 — ours never dips below it: 147.07 min, so the ride
never pops onto voxels). Mouth = their rca_gold inlay `[224,146,52]` under
our floating deck → feeding is E on the deck within ±3.2 of z 52.5 (the pit's
reach geometry makes the bridge the only spot). Charge home `[232,146.2,78]`
in front of their horn (x 232 — their build + ours both keep the horn walk
off the exit-pad lane; an earlier centred placement walked players over the
pad, found live). Pit fall = feet < 143.6 while carrying (pit walks at 143,
canyon at 144 stays safe).
- **Disco:** tile centres at Lane W's **pitch-8** grid `x 384.5/392.5/400.5/408.5`,
`z 52.5/60.5/68.5/76.5` (not the brief's pitch-6 sketch), 5×5 halves. Glow
plates hover at y 144.05.
- **Exit pads:** trigger centred on the pad's world centre (`exitPad+0.5`),
±1.3 with a **0.25 s dwell** clipping a pad corner mid-quest can't yank you
(the disco far row is 2 voxels from its pad); a 1 s post-teleport cooldown
stops re-triggers.
## Design decisions worth knowing
- **Acid locks evaluate on hold RELEASE** (release inside ±0.07 = lock, value
snaps to target). Sweeping through the sweet spot doesn't auto-lock you
tune by ear and let go, radio style. Shift reverses the sweep; ends ping-pong.
- **Quests replay as toys** once stamped: same loop, flourish message, no
stamp events (Stamps.apply is the single idempotence gate, so E2's "4 bars
of glory" only fires on real stamps).
- Acid targets/values and disco sequences reseed per `world:enter`; disco
sequences avoid immediate repeats (re-entry detection can't see doubles).
- Dub deck `velocityAt` reports the true lateral surface velocity of the
weave; vertical rides on Lane B's ride-snap (verified 89%+ grounded on the
wobble at carry amplitude, worst-case rates ~25× inside the envelope).
- The safety net skips flying players (dev flight stays usable) and counts as
a `world:exit` of the nearest world so E2's duck always unwinds.
## Verification record (real game, port 5194; local relay only)
- Relay (node scripts, fresh state): stamp round-trip AB; **no sender echo**;
duplicates dropped; **14-case fuzz** (bad types/keys/enums/proto/broken
JSON) all dropped with the socket alive; join snapshot carries stamps;
persistence + `winAt` rewind; **reset clears repairs, stamps survive**,
quest replayable. `server/booth-state.json` deleted before AND after.
- In-browser (headless-driven via `window.TURNCRAFT`, real raycast/E/hold
routing): all three portals dive with prompts; exit pad + dwell + cooldown;
safety net teleports non-flyers and leaves flyers alone; ACID three locks
stamp (exact vocab above, gauge shown/hidden); DUB grounded wobble ride,
pickup, canyon-roam safe, pit fall `dub_drop` + retry toast, feed
stamp; DISCO wrong-step forgiveness, rounds 48 with cue/step/plate
lighting stamp #3 `stamp:all` **golden slipmat**; reload join
replay (3× `stamp:got` quiet + golden restored `stamp:all` fired);
**full reset→re-win cycle**: reset broadcast (`by` name), booth dead,
stamps 3/3 + golden intact, dead-booth dive+exit work, re-win with stamps
intact. Zero console errors in the final build; `demo-machines.html` clean.
## Known edges
- Late-reconnect join replay can land outside Lane S's 4 s grace (above).
- The `stamp:got` HUD toast for REMOTE stamps shows live (by design a peer
pressing a stamp is multiplayer news); only join-snapshot replays are quiet.
- If a future phase ever wants stamps resettable, that's a relay change
(clear `stamps` in the reset case) + nothing client-side.