TURNCRAFT/docs/INTEGRATION_NOTES.md
jing 05c2765bd8 SOCIAL_RESET: avatars, emotes, the reset ritual, who's-here roster
Built by the Opus session per docs/briefs/SOCIAL_RESET.md; integrated and
verified by Fable (typecheck clean, relay validation reviewed, 3D avatar
gear/face pixel-confirmed live — the one item the build session couldn't
capture in its frozen preview pane).

- Avatars: hue/gear/face enums in src/net/avatarLook.ts shared by the 3D
  build and the Menu AVATAR tab's 2D mirror; live sync with client-side
  coalescing (two-tab testing caught the relay rate limit permanently
  desyncing rapid editors - trailing send fixes convergence).
- Emotes Z/X/C/V: wave, beat-synced nod, point, rate-limited airhorn
  (detuned saws + confetti), server + client caps.
- Reset ritual: after a win the fuse takes a deliberate 5s hold (reuses
  the workshop radial meter as the warning dial) -> server-authoritative
  reset with 10-min cooldown; music brakes to silence, lights die over 3s,
  workshop resets to wires-off with a SERVER-generated random weight notch
  (both tabs land identical), player builds survive, quest replayable.
- Roster: hold Tab for who's-in-the-booth with hue swatches.
- interact/ addition (flagged friction): additive setBreakGuard hook so
  interaction.ts never learns what a fuse is.
- launch.json: main dev config uses autoPort (5173 collisions with
  parallel sessions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:01:42 +10:00

285 lines
17 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.

# TURNCRAFT — Integration Notes (what the integrator changed and why)
Integration performed per [INTEGRATION.md](./INTEGRATION.md) after all five
lanes landed. Every cross-lane glue decision is listed here.
## Contract changes (core is integrator-owned)
1. **`Y_RECORD_TOP`: 85 → 81** (`src/core/constants.ts`). At 85 the vinyl
surface was a 5-voxel cliff above the plinth (jump height ≈ 1.3 — the
record was unreachable), and Lane D's platter stack floated on the plinth
top, ignoring the 6-deep well Lane C carved. Now the record sits 1 proud of
the plinth (auto-steppable, like a real 1200) and the platter body fills
the well. Everything downstream (tonearm heights, audio panner positions,
FX rim sparkle, D's quest defaults) derives from the constant and moved
automatically. Added `PLATTER.wellDepth = 6` alongside.
2. Lane B's requested `velocityAt` out-param overload was NOT taken: Lane D
already returns a reused per-collider scratch array (no allocation), which
satisfies the hot-path concern with no contract change.
## Cross-lane glue (in `src/main.ts`)
- **QuestPositions adapter**: Lane C's nested `QUEST_POS` is mapped to Lane
D's flat `QuestPositions`; the crossfader slot span is derived from C's
`MIXER` anchors (centerX ± halfLen, faceTopYdepth+1 … faceTopY1, z 1…+2)
so D's slide gate covers exactly the tram volume C carved.
- **`machines.attachPlayer(player)`** called after the player is built
(Lane D's added step "6.5") — enables walk-push faders and walk-press plates.
- **Hotbar**: D's `Hotbar` structurally satisfies E's `HotbarModel`; passed
directly as `getHotbar`, with `hotbar.onChange(() => hud.refreshHotbar())`.
- **Input wiring**: LMB hold = mine, RMB = place, E = use, 19/wheel = hotbar,
all gated on pointer lock (matching Lane B's own key gating). Start splash
click → `audio.init()` + pointer lock; resume click → re-lock only.
- **Fixed-step loop**: machines → player → interaction → fx at `FIXED_DT`,
with `MAX_SUBSTEPS` catch-up and an accumulator clamp so a backgrounded tab
doesn't spiral; `audio.updateListener` + `chunks.update()` + render per frame.
- Debug handle `window.TURNCRAFT` (world/player/machines/quest/hotbar/audio/
fx/hud/chunks/interaction) — used by the smoke tests, harmless in play.
## Lane-file adjustments (documented per CONTRACTS "integrator resolves")
- `src/machines/platter.ts` — platter body now spans the well floor to 1
below the vinyl (visuals) and its collider spans well floor → `Y_RECORD_TOP`
so the spinning body also pushes the player out of the well's 2-voxel gap
ring (which is now a real, intentional fall-in hazard).
- `src/fx/layout.ts` — VU overlay sprites aligned to Lane C's physical LED
towers (x = mixer minX+18 / maxX18, 12 segments, in front of the open
casing face) per Lane E's friction note #5.
- `src/interact/interaction.ts` — per-material break times (soft 0.12 s,
glass 0.25 s, default 0.4 s). The crossfader jam is ~240 dust blocks; at a
flat 0.4 s it was a 96-second chore.
- `docs/DESIGN.md` — elevation table updated for the Y_RECORD_TOP change.
## Deviations accepted as-is
- **45 speed available pre-win** (D's reading; DESIGN's "45 unlocked at win"
treated as flavor). `Platter.unlocked45` exists if we ever gate it.
- **`fader_cross` does not drive audio** (E maps pitch + channel digits only);
crossfader is quest/physical-only in v1.
- **Both deck pitch faders drive the one global mix pitch** (E has a single
transport; fine until per-deck stems exist).
- **Tonearm pedestal (C's voxels) vs D's arm pivot** are ~11 voxels apart —
cosmetic misalignment, queued for polish, gameplay unaffected.
## Verified at integration (live browser, real code paths)
- Boot: booth builds + meshes in ~670 ms dev / 46 modules build in 0.5 s;
fillBox fast path ENABLED; **120 fps**; zero console errors all session.
- Ride: standing on the record at 33 → clean circle, r drift 20.00→20.02 over
4 s, y = 81 exactly, spin-up ramp visible; at 45 near the rim → 10.7 v/s
(= ω×r), jump lands back on the moving disc.
- Mining: real DDA aim → dust breaks into hotbar; crossfader stays gated
while dust remains.
- Quest chain: stems stack 0→5 per `signal:repair`, power switch arms exactly
after the 4th repair, real button press → `game:win` once, both platters
auto-start, HUD banner + tracker 5/5, AudioContext running.
- The platter-well gap ring behaves as a trench (collider pushes, no clipping).
## Concept-art pass (post-integration, from John's Gemini mockup)
- **Terraced records**: the flat disc became a stepped "groove amphitheater" —
3 vinyl tiers rising 1 voxel each to the label plateau, then a 4-tier
stepped spindle tower with an amber lamp (all in `TIERS`, `platter.ts`).
Each tier is its own stacked kinematic cylinder; you walk/spiral up a
SPINNING staircase. Tonearm raised (`HEAD_Y` +2.6) and `R_INNER` pulled to
16 so the needle clears the terraces and never hits the tower.
- **Player physics upgrades this required** (`PlayerController.ts`):
cylinder contacts now resolve by minimum penetration (up vs radial), which
fixes a latent slingshot bug (feet dipping below a disc's top while deep
inside its footprint used to teleport the player radially to the rim);
1-voxel collider lips auto-step like voxel ledges; cylinder tops have a
half-foot radius grace so you can stand on rims.
- **Well seam**: the platter well's outer ring was a 7-deep inescapable
trench; floor raised to 1 below the plinth top (worldgen) — now a Technics
seam you step out of.
- **Room feel**: plywood ceiling with six recessed amber lamp panels
(worldgen) + matching warm PointLights (main.ts), warmer/brighter
hemi/ambient/fog (renderer.ts).
- **Mixer color pop**: knob caps get a deterministic palette scatter (red/
white/gold/LED) in `buildMixer`. **Rim crumble**: vinyl chips/dust/screws
scattered around the well mouths in `buildDeck`.
- Dev nicety: `window.TURNCRAFT_CINE = { pos, look }` detaches the camera
for screenshots/trailers; `window.TURNCRAFT` exposes camera/render too.
- Verified: walk-in from the rim spirals up to the label plateau at 33 rpm
(fighting the spin is intentional physics — stop the deck for an easy
climb); tower fully climbable (83→87 by walking); seam escape 1-step;
121 fps with ceiling + lights; quest/win chain re-verified end-to-end.
## Headshell Workshop (Lane D) — wiring & contract additions
The `stylus` node is now a five-stage cartridge assembly at the Deck A pedestal
(`src/machines/workshop/**`). One machine, `machines.workshop` (a `Workshop`),
owns the visible/collidable assembly arm; the real `Tonearm` sits in **service
mode** until the node is repaired, then takes back over (cue/track/return).
**Events added** (`src/core/events.ts`, additive — sanctioned by the brief):
- `workshop:test { errors: string[]; clean: boolean }` — needle-drop verdict;
the HUD names the first error. `errors` are `TestErrors` keys in `ERROR_ORDER`.
- `workshop:torque { value: number | null }` — radial HUD meter 0..1 while a
screw is being torqued; `null` hides it.
- `workshop:msg { text: string }` — transient HUD subtitle (stage prompts).
The audio + HUD lanes consume these; Lane D only emits them (never renders the
meter/subtitle itself).
**Multiplayer** — one new relay message both ways: `{ t: 'workshop', s: WorkshopState }`,
last-write-wins.
- `WorkshopState` (in `workshop/types.ts`) is JSON-primitive and range-bounded:
`{ cartSeated, cartRotation:0..3, wires:(0..3|null)[4], screws:[0..1,0..1],
weightNotch:0..10, tested }`.
- `NetClient` sends on `workshop.onChange` (guarded by `applyingRemote` so remote
applies don't echo); remote messages call `workshop.setState(s)` (diff+animate).
- `server/relay.mjs` `validWorkshop()` validates shape/enums/ranges (clamps
screws + notch), stores it beside `platters`, persists it, and hands it to late
joiners in `hello.state.workshop`. `types.ts:coerceState` mirrors this on the
client. Fuzzed/malformed shapes are dropped on both sides.
- If `repaired.stylus` is already true on join, the workshop spawns in its
**completed pose** (`completedState()` — cart seated square, wires on, screws
tight, notch 8) and the real tonearm is live.
**Surgical edits to other lanes' files:**
- `tonearm.ts` — additive `setService(on)` (parks the arm low + inerts its
colliders while the workshop owns the assembly arm) and `setStylus(on)`.
- `PlayerController.ts` (Lane B) — one additive knob, `jumpScale` (default 1).
`main.ts` sets it to `0.5` while `workshop.isCarrying()` is non-null so a
carried tag-wire tugs the jump to half height. Purely additive; no reorder.
- `interact/interaction.ts``useDown/useUp/dropCarry` route E (press vs
hold=crimp/torque) and Q (drop wire) to the workshop; `advanceWorkshopHold`
cancels a hold if aim leaves the target.
- `ui/Menu.ts` — STYLUS help text + the E-hold / Q controls updated.
- `worldgen/buildBooth.ts` dressing was intentionally **not** touched: the
workshop renders all its own meshes (pedestal, loom, pins, scope, level), so
no worldgen edit is needed and the glow-up lane keeps that file conflict-free.
**Deferred / stretch:** anti-skate dial (brief out-of-scope), Deck B arm stays
pre-assembled. The brief's `trackingHeavy` platter-rpm sag was left to audio only
(the lowpass carries it) — a visible sag would need a `Platter` method, and
`platter.ts` is outside this brief's sanctioned edit list.
**Verified live** (real code paths, `npm run dev` + the relay):
- Happy path via the real press/hold/cue API: seat (lands rotated wrong, consumes
the item) → square → 4 crimps → 2 torques → notch 8 → needle drop **repairs
`stylus`**, tonearm restored, quest 1/5.
- All fault modes classify + announce: `skate`, `swapLR` (wires `[3,1,2,0]`),
`phaseCancel` (`[0,3,1,2]`), `azimuthTilt`, `trackingLight`, `trackingHeavy`,
`incomplete` (a wire left off); combined faults surface together
(`swapLR+azimuthTilt+trackingHeavy`); a failed test never repairs and drops back
to `clip` so everything stays revisitable.
- **Seesaw ride**: standing on `ws_ride3` while sweeping the notch — player motion
**0.075 voxels/tick**, zero xz drift, y tracks the beam (84.64→84.03), finite
throughout, at **both** 33 rpm and stopped. No slingshot.
- Wire carry: catenary (14 pts, sags below the chord), `jumpScale` 0.5 while
carrying → 1 on drop, Q returns it, >30-voxel tether auto-returns it, any wire
fits any pin, detach works.
- **Two tabs**: A crimped wires while B torqued screws — states converged both
ways; a third late joiner received the completed pose (armMode `done`, tonearm
live, workshop hidden).
- Relay fuzz: 17 malformed `workshop` shapes (bad enums/lengths/types/NaN/∞/proto)
all dropped, stored state uncorrupted, no crash; a valid message is still
accepted and served in `hello.state.workshop`.
- `npm run typecheck` clean; zero console errors across three tabs.
(Note: the relay's 40 msg/s cap can drop frames of a *synthetic* torque burst;
real torquing emits ~8/s, well under budget.)
## Physics — riding tilting / vertically-moving kinematic platforms (Lane B)
For the Headshell Workshop's Stage-4 seesaw (the tonearm you stand on while
balancing). Lane B (`PlayerController.ts`) now rides a platform that **moves
vertically under the player**, not just horizontally. What the workshop lane
needs to know:
- **Model the arm as kinematic colliders updated each fixed tick, before
`player.update`.** `tonearm.ts` already does this (an aabb chain tiling the
pivot→head line + a head aabb). For the seesaw, recompute each segment's
`min.y/max.y` from the tilt each tick and expose a `velocityAt` that returns
the segment's surface velocity. The loop order `machines.update(dt)`
`player.update(dt)` is what makes the ride correct — don't reorder.
- **Lane B guarantees the ride; do NOT reposition the player yourself.** Just
move the colliders. The controller:
- grounds on the single **highest supporting top** under the footprint;
- a **rising** arm grounds by penetration (it rises into you) and snaps you up;
- a **tilting/descending** arm keeps you glued via **ride-snap**: if you were
riding a platform and it drops out from under you, you snap back onto its top
across a per-tick vertical gap up to `RIDE_SNAP` (0.75 voxel). Gated to a
downward/level velocity, so it never fights a jump;
- horizontal surface motion is carried via `velocityAt` (x/z);
- min-penetration keeps you centered — **no lateral slingshot**.
- **Safe envelope (measured in `demo-player.html`):** perfectly glued
(grounded 100 %, feet on the slab) for tilt rates up to ~2 rad/s / ~24 voxel/s
surface speed — far beyond a counterweight-driven balance (~0.25 rad/s). Only
absurd >3 rad/s "mousetrap" tilts separate, and then **gracefully** (you slide
off, no explosion). Verified at platter-33 and stopped (independent colliders).
- **Verified against the REAL arm** (not just the demo mock): a headless harness
drove the actual `Workshop.ts` `ws_ride*` geometry — the same `beamY`/`theta`
tilt math, `RIDE_SEGMENTS=6`, 0.6-voxel-tall slabs — through `PlayerController`.
Standing on the beam tail (where you shove the sled) while the weight sweeps the
full notch range **0↔10**: the realistic balance ritual rides **99.6 % grounded,
`maxYerr` 0.023, zero lateral drift (`maxZoff` 0.000)**, and every transient
ungrounded tick is re-caught in **exactly 1 tick**. Because the workshop's
`theta` eases at `dt*5` toward a one-notch (0.025 rad) target, the per-tick
vertical travel at the player's feet is ≤ ~0.03 voxel — ~25× inside the
`RIDE_SNAP` budget. Even a physically-impossible instant full-swing-every-tick
slam only floats the player upward briefly (still `maxZoff` 0, never falls).
- **Gotchas:**
- The arm is a *staircase of flat slabs*; you stand at each segment's midpoint
height. Finer segments = smoother ride. Keep each segment ≤ ~2 voxels tall so
min-penetration prefers the top (a very tall arm segment could resolve as a
wall push instead of a floor).
- `velocityAt`'s vertical magnitude does **not** affect grounding (ride-snap
handles vertical); a nonzero horizontal component keeps the player tracking a
sweeping arm. Returning `[0,0,0]` is fine for a purely vertical tilt.
- Jumping off the arm works and inherits the arm's horizontal carry (the
record-edge launch path); vertical is not inherited (no surprise pop).
- **Eyeball it:** `demo-player.html` has a **Seesaw** toggle (oscillates a
segmented tilting arm) + **Teleport to arm**; stand on it and watch `groundedOn`
stay `seesawN` as it tilts.
## Social & the Reset Ritual (SOCIAL_RESET.md)
Relay protocol v2 — **the relay and client ship together** (the server is the
authority for avatars, emote rate limits and reset):
- **New messages.** `hi` gains `avatar`; new `avatar`, `emote`, `reset`
(client→server intent) and `reset` (server→clients authoritative).
`hello.peers[]` and `join` now carry `avatar`. Every field is a bounded int —
garbage defaults rather than rejects (`validAvatar`), names stay sanitized.
- **Rate limits (server-side, drop-never-queue):** emote 1/4 s/peer, avatar
1/2 s/peer, reset once per 10-min global cooldown.
- **`booth-state.json` gains `winAt`** (ms epoch of the win, gates the reset
cooldown). Old state files without it load as `winAt = 0` = "won long ago" =
cooldown already expired, so an existing prod state file is safe to keep.
- **Reset is server-authoritative:** it validates win + cooldown, clears
`repaired`, stops both platters, sets the workshop to a reset pose, **keeps
every block edit** (player builds survive), persists, and broadcasts to
*everyone including the breaker*. The workshop pose rides along in the
`reset` message so live clients land on exactly the state a late joiner gets
from `hello` — the pose contains a random `weightNotch`, so deriving it
client-side would desync (verified: both tabs landed on notch 4).
Client glue:
- **`Quest.reset(by)`** + bus event **`quest:reset { by }`** (new, additive) —
AudioEngine brakes the transport to silence (power-down, not a mute),
FxSystem eases the emissive boost back to 0.35 over ~3 s, HUD clears, the
platters stop, and `main.ts` writes the one block the reset owns: the blown
fuse back into the box.
- **`player:emote { kind, self, at }`** (new, additive) — audio plays the
airhorn (positional for peers), FX throws confetti.
- **`Interaction.setBreakGuard(g)`** (new, additive, `src/interact/`): lets a
caller demand a deliberate hold on ONE voxel without interaction.ts knowing
what a fuse is. With no guard installed mining is byte-for-byte unchanged.
main.ts uses it for the 5 s fuse hold and reuses the workshop's radial meter
as the "KEEP MINING TO KILL THE MIX" dial.
*(`src/interact/**` is not in SOCIAL_RESET's ownership list — this was the
minimum additive hook needed for the ritual; flagged for the integrator.)*
- **`NetClient.setAvatar` coalesces.** The relay drops avatar churn > 1/2 s and
we send the whole look (not a delta), so a naive drop left peers on a stale
avatar forever (found in two-tab testing: hue landed, gear/face never did).
It now schedules the LATEST look for when the server window reopens.
## Not yet done (deploy phase)
Run `/ship-check` before exposing anything publicly; deploy per deploy-map
conventions (static `dist/` — 156 KB gzipped). Cloudflare/host wiring is a
separate step, to be done with the token holder present.