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>
285 lines
17 KiB
Markdown
285 lines
17 KiB
Markdown
# 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, faceTopY−depth+1 … faceTopY−1, 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, 1–9/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 / maxX−18, 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.
|