TURNCRAFT/docs/INTEGRATION_NOTES.md
jing 74482e5693 Glow-up review fix: concentric groove caps on record tiers
Fresh-eyes review of the glow-up found the G5 side-ring texture applied to
whole tier cylinders - linear UVs on the caps rendered as diagonal stripes
across the disc. CylinderGeometry takes [side, top, bottom] materials: sides
keep Lane E's ring texture, caps get a new radial recordCapGrooveTexture()
(machines/util.ts) with ring count scaled per tier radius so groove pitch
stays constant. Also lands the three handoff-doc updates from the phase.

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

15 KiB
Raw Blame History

TURNCRAFT — Integration Notes (what the integrator changed and why)

Integration performed per 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.tsuseDown/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.

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.