diff --git a/THREADS.md b/THREADS.md index 5502da5..437a808 100644 --- a/THREADS.md +++ b/THREADS.md @@ -81,6 +81,92 @@ Format: `[lane letter] YYYY-MM-DD — note` `~/Documents/shades-laneD/` and work there** — B, C and E already have their own. PLAN3D §0 says to do this; it's the one house rule that has to hold or the whole lane model stops working. +[D] 2026-07-16 — Worktree collision: mine, sorry — acknowledged and fixed. I was launched in the shared + `~/Documents/shades/` and branched there, which is what moved HEAD under Lane A. Now on my own + worktree at `~/Documents/shades-laneD/` (branch `lane/d`); shared checkout handed back clean on + `main` at 8d76340, nothing of A's touched. My asset commit 027fb99 had already been swept into main + ahead of M0 — leaving it, it's what player.js loads. + +[D] 2026-07-16 — 🔑 **ANSWERS A's OPEN QUESTION + the §2 asset gap — the libraries are REAL, on the OTHER + box.** Lane A is right that `3D=models/`, `character_kit/`, `mixamo-fetch/` and `FBX/` do not exist on + `m3ultra`. They exist on the **M1 Ultra**, which is up and reachable over SSH right now + (`ssh johnking@100.91.239.7`, hostname `ultra.local`, no password needed). Verified there: + · `~/Documents/FBX/` — Running.fbx, Falling.fbx, Crouch To Stand.fbx, Reaction.fbx, Death.fbx… + · `~/Documents/3D=models/animations/` — 32 clips incl. Happy Idle.fbx, Walk.fbx, Start Walking.fbx + · `~/Documents/character_kit/` (rigged/, scripts/merge_anims.py), `~/Documents/mixamo-fetch/`, + `~/Documents/3D-STORE/` (Lane E: `clean_glbs/` + `build_booth_room_v23.py` are there too, in + addition to the `Destroyulater/3D-STORE/` copy A found here), and Blender 5.0.1. + **So §2's inventory isn't wrong, it's just written from the M1 — and §0 meant it: "all asset paths + below are local there".** This does NOT mean lanes should move. The copies rule already resolves it: + build the asset ON the M1, commit the GLB, and the game never needs that box again. That's what I did + — `player_anims.glb` was built there and is committed; `python3 server.py` on m3ultra needs nothing + remote. **Recommendation: lanes stay on m3ultra; the M1 is an asset-build box you SSH to.** Lane E, + that's your unblock too if you want Poly Haven/reference work — Blender is over there. + +[D] 2026-07-16 — ⚠️ **BLOCKS THE PLAYER SWAP — Lane A, one line in index.html.** Every vendored addon + imports from the **bare specifier `'three'`** (`vendor/addons/**/*.js` all end `} from 'three';`). + index.html has no importmap, so the moment it imports player.js it dies with "Failed to resolve + module specifier 'three'". Nothing hit this before because main/world/camera.js import + `../vendor/three.module.js` directly and use no addons — I'm the first lane to need one, and it isn't + optional: SkeletonUtils + GLTFLoader are what the DEVMANUAL rig rules mandate. Fix is the 90sDJsim + line, in `` before the module script: + `` + (selftest.html does NOT need it — d.test.js only imports the zero-dep sim, which is why it's green.) + **Lane E: this will land on you too** the moment you load a GLB. Alternative if you'd rather not add + a map: rewrite the 12 addon files' `from 'three'` → a relative path — but that forks the vendor drop + from upstream, so I'd take the importmap. + +[D] 2026-07-16 — **PLAYER LANDED** on `lane/d`, rebased on M0, ready for the boot() swap. + `player.sim.js` (deterministic core, zero imports) · `player.js` (rig/view + `createPlayer`) · + `interact.js` (hold-E + `wireYardActions`) · `js/tests/d.test.js` · `dev_player.html` (my mock + harness — Lane A owns the real shell; I never touched main.js/index.html/selftest.html). + Selftest: **38 pass / 3 skip**, 20 of them Lane D. Verified in a real scene, not just asserts: + head bone **1.715 m** at fig scale 0.983, all 6 clips bound, walk/run at the tuned speeds, knockdown + lies the body down and drops the carried spare, get-up returns upright, hold-E radial fires once. + `checkContract('player', createPlayer(...))` → **CONFORMS**, and it clamps to `world.heightAt()`. + **Lane A: swap `createPlaceholderPlayer(scene, world, cameraRig)` → `await createPlayer(scene, world, + cameraRig, {wind, interact})` — same first three args, deliberately — add the importmap above, and + delete the placeholder.** It's async (two GLB fetches), so boot() must await it. + +[D] 2026-07-16 — ❗ **CONTRACT NEEDS FROM LANE B — not urgent, but §5-D.4 can't finish without them.** + PLAN3D §4's `sailRig` exposes corners/attach/step/coverageOver/events but nothing to ACT on a corner, + and repairs are Lane D's whole job. `interact.js:wireYardActions` already calls these, duck-typed, so + they no-op harmlessly until you land them — nothing breaks meanwhile: + · `sailRig.repair(i)` — re-rig corner i (I gate it on the player carrying a spare, 2.5 s hold, and + I consume the spare). Needed for the M2 "one mid-storm repair must be survivable" line in §7. + · `sailRig.trim(i, delta)` — per-corner turnbuckle, ±tension at ONE corner (1.2 s hold). This is + §5-D.4's "new vs prototype, makes corners individual". + · `corner.pos` (or `sailRig.cornerPos(i)`) → world Vector3 — I need somewhere to put the prompt. + Live-read each frame, so a flogging corner's prompt tracks it. + Shout if the shapes fight your sim and I'll adapt — you own sail.js, I'll move. + +[D] 2026-07-16 — 📌 **PLAN3D §5-D.1 is not buildable as written — the peds cannot go through Blender.** + §5-D.1 says merge clips onto a ped via the character_kit pipeline. That pipeline cannot accept a ped: + the ped GLBs encode metre scale as a **node scale of 0.01 on `mixamorig*:Hips`** with every child bone + in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the glTF + importer silently drops that 0.01 — straight after import the rig already reads Hips at 0.99 (metres) + while HeadTop_End reads 76.88 (centimetres) and LeftToe_End sits **96 m under the floor**. Exploded + before a single clip is merged. (It's also why `dancer.glb` is 30x small — its base, Hum_M_1.fbx, is + an FBX with no such trick, head bone 0.0563 m. And `merge_anims.py`'s own comment warns about exactly + this class of bug from the other end.) **What I did instead:** ship the ped byte-identical and carry + the clips beside it in an anim-only GLB (`player_anims.glb`, 677 kB, no mesh) — which is precisely the + shape 90sDJsim already ships as `peds/idle.glb` + `peds/walk.glb`, so it's the house pattern, not a + workaround. Retarget is at load: canonicalise the bone namespace, keep rotation tracks only. + Rebuild: `tools/character/build_player_anims.py` (header has the full why + the ssh one-liner). + Two gotchas worth knowing if you touch rigs: + · three.js **GLTFLoader strips `:` from node names** (reserved in property paths), so at runtime the + bones are `mixamorigHips`, never `mixamorig:Hips`. `_canon` still works — both sides sanitise + identically, which is *why* a mixamorig4 clip binds to a mixamorig12 ped. + · Blender 5.0 **removed `Action.fcurves`** (slotted actions — they're under + `layers[].strips[].channelbags[]`), so `character_kit/scripts/merge_anims.py` no longer runs there + as written. My script handles both layouts. + +[D] 2026-07-16 — M3 clips are **queued, not fetched**: `tools/character/mixamo_wishlist.txt` (Climbing + Ladder, Turning Key, Digging + repair/storm extras). `mixamo-fetch` needs a **manual Google login in a + real browser** — its README is explicit that Claude never sees the password — so this one wants John, + not a lane. Everything M0–M2 needs is already on disk and in `player_anims.glb`. + [A] 2026-07-16 — ❓ **OPEN QUESTION, needs a human.** PLAN3D §0 says lanes run on "the M1 Ultra (`johnking@100.91.239.7`, Tailscale)", but this box is `m3ultra` and already has `~/Documents/shades-laneB/` and `shades-laneE/` checked out — so lanes are in fact running here, and diff --git a/tools/character/mixamo_wishlist.txt b/tools/character/mixamo_wishlist.txt new file mode 100644 index 0000000..50f3625 --- /dev/null +++ b/tools/character/mixamo_wishlist.txt @@ -0,0 +1,34 @@ +# SHADES animation wishlist — Mixamo search terms, one per line. (Lane D, for M3) +# +# Same pipeline as 90sDJsim's dj_wishlist.txt and the op-shop clerk's wishlist.txt: +# cd ~/Documents/mixamo-fetch +# node fetch.cjs login # a real browser opens; John logs in by hand, once +# cp ./shades_wishlist.txt +# node fetch.cjs anims # loops the wishlist, downloads each "Without Skin" +# then add the new FBX to CLIPS in tools/character/build_player_anims.py and rebuild the pack. +# +# NOTE: fetch.cjs needs a MANUAL Google login in a real browser window (its README is explicit that +# Claude never sees the password), so this list is queued for John to run — not something a lane +# fetches on its own. Everything M0-M2 needs is already on disk and already in player_anims.glb: +# Idle / Walk / Run / Falling / CrouchToStand / Reaction. +# +# '#' and blank lines ignored. Names are fuzzy search terms (top hit wins); a miss is skipped. +# In-place clips only — the game translates the player itself, and player.js keeps rotation tracks +# only, so anything with big root travel is wasted. + +## --- M3: the repair verbs (PLAN3D §5-D.1) --- +Climbing Ladder +Turning Key +Digging + +## --- M3: worth having while the browser is open (DESIGN.md "limited hands" toolset) --- +Hammering +Sweeping Floor +Picking Up Object +Carrying Box +Standing Up Ready + +## --- storm reactions (better than reusing Reaction for everything) --- +Covering Head +Bracing +Stumble Backwards diff --git a/web/world/dev_player.html b/web/world/dev_player.html new file mode 100644 index 0000000..5307c17 --- /dev/null +++ b/web/world/dev_player.html @@ -0,0 +1,203 @@ + + + + +SHADES — Lane D player harness + + + + + + +
+
+ wind 4 m/s
+ dir
+ + + +
+
+ + + + + diff --git a/web/world/js/interact.js b/web/world/js/interact.js new file mode 100644 index 0000000..aca72cc --- /dev/null +++ b/web/world/js/interact.js @@ -0,0 +1,181 @@ +/** + * interact.js — hold-E actions with radial progress. (Lane D) + * + * contracts.js: interact.register({id, pos, radius, holdSecs, label, canUse()->bool, onDone()}) + * + * Zero imports (same reason as player.sim.js): the whole thing is fixed-dt and headless-testable. + * `pos` is duck-typed {x,y,z}, so a THREE.Vector3 or a plain object both work. + * + * The busy handshake: this module both ENTERS and LEAVES the player's `busy` state. Nothing else + * writes it, so a dropped release cannot strand the player — and if the world takes the player away + * mid-hold (a gust puts them down), we notice `player.state` is no longer 'busy' and abort without + * stomping on the state the world just set. + */ + +export class Interact { + constructor() { + this.targets = new Map(); + this.active = null; // the target currently being held + this.progress = 0; // 0..1 — the radial + this.latched = false; // a completed action re-arms only after E is released (see step) + this.events = []; // {type:'done'|'cancel', id, t} — drained by hud.js + } + + /** + * @param {object} spec + * @param {string} spec.id + * @param {object} spec.pos {x,y,z} — read live each step, so it may move (a sail corner does) + * @param {number} [spec.radius] metres + * @param {number} [spec.holdSecs] + * @param {string|function} [spec.label] string, or (player)->string for live text + * @param {function} [spec.canUse] (player) -> bool + * @param {function} [spec.onDone] (player, t) -> void + * @returns {function} unregister + */ + register(spec) { + if (!spec || !spec.id) throw new Error('interact.register: id required'); + const target = { + radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, ...spec, + }; + this.targets.set(target.id, target); + return () => this.unregister(target.id); + } + + unregister(id) { + if (this.active && this.active.id === id) this.active = null, this.progress = 0; + return this.targets.delete(id); + } + + labelOf(target, player) { + return typeof target.label === 'function' ? target.label(player) : target.label; + } + + _usable(target, player) { + return !target.canUse || !!target.canUse(player); + } + + /** Nearest registered target in range whose canUse() passes. */ + nearest(player) { + let best = null, bestD = Infinity; + for (const target of this.targets.values()) { + const p = typeof target.pos === 'function' ? target.pos() : target.pos; + if (!p) continue; + const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z); + if (d <= target.radius && d < bestD && this._usable(target, player)) { best = target; bestD = d; } + } + return best; + } + + cancel(t, player) { + if (!this.active) return; + // only hand the player back if they're still ours — a knockdown mid-hold already re-stated them + if (player.state === 'busy') player.setState('idle', t); + this.events.push({ type: 'cancel', id: this.active.id, t }); + this.active = null; + this.progress = 0; + } + + /** + * @param {number} dt @param {number} t + * @param {PlayerSim} player + * @param {boolean} holding is E held this frame + * @returns {{target, progress, label, holding}} for hud.js to draw the prompt + radial + */ + step(dt, t, player, holding) { + // One press, one action: a completed hold latches until E is released. Without this, a held key + // re-arms the instant the action finishes and the same action fires every holdSecs forever + // (leaning on the shed table would deal you a spare a second, indefinitely). + if (!holding) this.latched = false; + + const near = this.nearest(player); + + if (this.active) { + const stolen = player.state !== 'busy'; // something else claimed the player + if (!holding || near !== this.active || !this._usable(this.active, player) || stolen) { + this.cancel(t, player); + } + } + + if (!this.active && holding && !this.latched && near && !player.busy) { + this.active = near; + this.progress = 0; + player.setState('busy', t); + } + + if (this.active) { + this.progress += dt / Math.max(1e-6, this.active.holdSecs); + if (this.progress >= 1) { + const done = this.active; + this.active = null; + this.progress = 0; + this.latched = true; + player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy + if (done.onDone) done.onDone(player, t); + this.events.push({ type: 'done', id: done.id, t }); + } + } + + const shown = this.active || near; + return { + target: shown, + progress: this.progress, + label: shown ? this.labelOf(shown, player) : '', + holding: !!this.active, + }; + } +} + +/** + * Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D + * never edits Lane B's or Lane A's files — anything not yet landed is simply skipped. + * + * @param {Interact} interact + * @param {object} deps {sailRig, world, spares} + * sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B) + * sailRig.repair(i) -> void [PROPOSED — see THREADS.md] + * sailRig.trim(i,d) -> void [PROPOSED — per-corner turnbuckle, see THREADS.md] + * world.shedTable -> {pos} (Lane A/E) + */ +export function wireYardActions(interact, deps = {}) { + const { sailRig, world } = deps; + const wired = []; + + if (sailRig && Array.isArray(sailRig.corners)) { + sailRig.corners.forEach((corner, i) => { + // re-rig a broken corner — costs the spare you're carrying + wired.push(interact.register({ + id: `rerig_${i}`, + pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)), + radius: 1.8, + holdSecs: 2.5, + label: 're-rig corner', + canUse: (p) => corner.broken && p.carrying === 'spare', + onDone: (p) => { p.carrying = null; if (sailRig.repair) sailRig.repair(i); }, + })); + // per-corner turnbuckle trim — new vs the prototype; makes corners individual + wired.push(interact.register({ + id: `trim_${i}`, + pos: () => corner.pos || (sailRig.cornerPos && sailRig.cornerPos(i)), + radius: 1.8, + holdSecs: 1.2, + label: 'tighten turnbuckle', + canUse: () => !corner.broken && !!sailRig.trim, + onDone: () => sailRig.trim && sailRig.trim(i, +0.1), + })); + }); + } + + if (world && world.shedTable) { + wired.push(interact.register({ + id: 'spare_table', + pos: world.shedTable.pos, + radius: 1.5, + holdSecs: 0.6, + label: (p) => (p.carrying ? 'hands full' : 'take a spare'), + canUse: (p) => !p.carrying, // hands-full rule + onDone: (p, t) => p.pickUp('spare', t), + })); + } + + return () => wired.forEach((un) => un()); +} diff --git a/web/world/js/player.js b/web/world/js/player.js new file mode 100644 index 0000000..5a2e1a7 --- /dev/null +++ b/web/world/js/player.js @@ -0,0 +1,267 @@ +/** + * player.js — the small person: rig, clips, camera-relative control. (Lane D) + * + * The deterministic half lives in player.sim.js; this file is the view. It follows the 90sDJsim + * DEVMANUAL "Rigged animated characters" rules, which are law here: + * · SkeletonUtils.clone() for instances — a plain .clone() breaks skinned meshes + * · height-normalise off the HEAD BONE and plant the feet — Mixamo scale is unreliable, so the + * scale factor is always MEASURED, never a blind setScalar of a guessed constant + * · canonicalise the bone namespace so any clip binds to any character + * + * Assets (see models/MODELS.md): player_01.glb is an untouched ped (metre-scale, head bone 1.75 m); + * player_anims.glb is an anim-only carrier built by tools/character/build_player_anims.py. The clips + * are retargeted onto the ped at load — see _rotOnly for why that is safe at any carrier scale. + */ +import * as THREE from '../vendor/three.module.js'; +import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js'; +import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js'; +import { PlayerSim, STATES, TUNE } from './player.sim.js'; + +export { PlayerSim, STATES, TUNE }; + +export const CHAR_URL = '/world/models/player_01.glb'; +export const ANIM_URL = '/world/models/player_anims.glb'; + +/** + * Canonicalise the Mixamo skeleton namespace (mixamorig4: vs mixamorig12:) so any clip binds to any + * character. Every Mixamo auto-rig upload gets its own numbered namespace and three.js binds tracks + * BY NODE NAME — a mismatched clip binds to nothing and plays a silent T-pose, with no error. + * (Straight from 90sDJsim index.html. The \d+ is deliberate: a bare "mixamorig:" is already canonical.) + */ +const _canon = (s) => s.replace(/mixamorig\d+/g, 'mixamorig'); + +const canonRig = (r) => { + if (!r) return r; + if (r.scene) r.scene.traverse((o) => { o.name = _canon(o.name); }); + if (r.anims) r.anims.forEach((a) => a.tracks.forEach((t) => { t.name = _canon(t.name); })); + return r; +}; + +/** + * Shared-clip filter: keep limb/spine rotations only. Drop ALL position tracks (a different-scale + * source inflates or crumples the target) AND Hips.quaternion (a different-orientation source lays + * the target flat). Quaternions are scale-invariant, which is the whole reason a clip carrier at any + * scale retargets cleanly onto the metre-scale ped. + * + * The cost is that a clip can no longer lie the body down — so the knockdown does NOT come from the + * Falling clip's root. player.sim.js pitches the root itself, which is also how the fall gets to go + * DOWNWIND of the gust that caused it. The clip only supplies the flail. + */ +const _rotOnly = (c) => new THREE.AnimationClip(c.name, c.duration, + c.tracks.filter((t) => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name))); + +const _loadGLTF = (loader, url) => new Promise((res, rej) => + loader.load(url, (g) => res({ scene: g.scene, anims: g.animations }), undefined, + () => rej(new Error(`player: failed to load ${url}`)))); + +const UP = new THREE.Vector3(0, 1, 0); + +export class PlayerView { + /** + * @param {object} rig {scene, anims} — the character + * @param {Array} clips retargeted AnimationClips + * @param {number} height metres to the top of the head + */ + constructor(rig, clips, height = 1.72) { + // root: world position + facing + knockdown pitch. fig: the rig, lifted so its feet sit at y=0. + this.root = new THREE.Group(); + this.root.name = 'player'; + + const fig = skeletonClone(rig.scene); + this.fig = fig; + fig.traverse((o) => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = true; } }); + this.root.add(fig); + this.root.updateWorldMatrix(true, true); + + // measure, then scale — never a blind setScalar (DEVMANUAL). /head/i matches both Head and + // HeadTop_End; max() takes the crown, which is what "height" means. + const wp = new THREE.Vector3(); + let headY = 0; + fig.traverse((o) => { + if (!o.isBone) return; + o.getWorldPosition(wp); + if (/head/i.test(o.name)) headY = Math.max(headY, wp.y); + }); + if (headY > 1e-4) { fig.scale.setScalar(height / headY); fig.updateWorldMatrix(true, true); } + this.height = height; + + // plant the feet: lift the rig so its lowest bone sits on the root's origin + let minY = Infinity; + fig.traverse((o) => { if (o.isBone) { o.getWorldPosition(wp); minY = Math.min(minY, wp.y); } }); + if (minY < Infinity) fig.position.y = -minY; + + this.mixer = new THREE.AnimationMixer(fig); + this.actions = {}; + // some characters lack bones a shared clip animates (thumb joints, say) — bind only what exists, + // else three.js spams "No target node found" for every missing bone + const nodes = new Set(); + fig.traverse((o) => { if (o.name) nodes.add(o.name); }); + for (const clip of clips) { + const bindable = clip.tracks.filter((t) => nodes.has(t.name.split('.')[0])); + if (!bindable.length) continue; + const use = bindable.length === clip.tracks.length + ? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable); + this.actions[clip.name] = this.mixer.clipAction(use); + } + this.current = null; + this._axis = new THREE.Vector3(); + this._qYaw = new THREE.Quaternion(); + this._qPitch = new THREE.Quaternion(); + } + + /** @returns {string[]} clip names that bound to at least one bone */ + get clipNames() { return Object.keys(this.actions); } + + play(name, loop = true, fade = 0.18) { + const next = this.actions[name]; + if (!next || next === this.current) return; + next.reset(); + next.setEffectiveWeight(1); + next.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1); + next.clampWhenFinished = !loop; + if (this.current) next.crossFadeFrom(this.current, fade, false); + next.play(); + this.current = next; + } + + /** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */ + sync(sim, dt) { + const st = STATES[sim.state]; + this.play(st.clip, st.loop !== false); + + this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z); + + // yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the + // feet. At pitch 0 this is exactly the yaw, so upright play is untouched. + this._qYaw.setFromAxisAngle(UP, sim.facing); + if (sim.pitch > 1e-4) { + this._axis.set(sim.knockDir.z, 0, -sim.knockDir.x); + if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0); + this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5); + this.root.quaternion.copy(this._qPitch).multiply(this._qYaw); + } else { + this.root.quaternion.copy(this._qYaw); + } + + this.mixer.update(dt); + } + + dispose() { + this.mixer.stopAllAction(); + this.root.removeFromParent(); + } +} + +/** + * Load the player and attach it to a scene. + * @param {THREE.Scene|THREE.Object3D} scene + * @param {object} [opts] {start, facing, height, groundAt, tune, charUrl, animUrl} + * @returns {Promise<{sim: PlayerSim, view: PlayerView, step: function}>} + */ +export async function loadPlayer(scene, opts = {}) { + const loader = new GLTFLoader(); + const [rig, animPack] = await Promise.all([ + _loadGLTF(loader, opts.charUrl || CHAR_URL), + _loadGLTF(loader, opts.animUrl || ANIM_URL), + ]); + canonRig(rig); + canonRig(animPack); + + const clips = animPack.anims.map(_rotOnly).filter((c) => c.tracks.length); + const view = new PlayerView(rig, clips, opts.height || 1.72); + scene.add(view.root); + + const sim = new PlayerSim(opts); + + const missing = Object.values(STATES).map((s) => s.clip).filter((c, i, a) => + a.indexOf(c) === i && !view.actions[c]); + if (missing.length) console.warn('player: state machine wants clips that did not bind:', missing); + + return { + sim, + view, + /** Convenience: step the sim then push it to the rig. */ + step(dt, t, input, wind) { + sim.step(dt, t, input, wind); + view.sync(sim, dt); + return sim.state; + }, + }; +} + +/** + * The `Player` contract factory (contracts.js) — this is the one main.js calls. + * Drop-in for createPlaceholderPlayer(scene, world, cameraRig): same first three args, so boot() + * only changes which function it calls. Everything the player needs per frame it pulls itself, so + * `update(dt, t)` matches the contract's signature exactly. + * + * @param {THREE.Object3D} scene + * @param {object} world contracts World — `heightAt(x,z)` clamps the player to the ground + * @param {object} cameraRig contracts Camera — `yaw` is what WASD is relative to + * @param {object} [opts] {wind, interact, start, facing, height, tune, charUrl, animUrl} + * @returns {Promise} satisfies checkContract('player', …) + */ +export async function createPlayer(scene, world, cameraRig, opts = {}) { + const p = await loadPlayer(scene, { + ...opts, + groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined, + start: opts.start || { x: 0, y: 0, z: 6 }, + }); + const keyboard = new KeyboardInput(); + const { sim, view } = p; + + return { + get pos() { return sim.pos; }, + get carrying() { return sim.carrying; }, + set carrying(v) { sim.carrying = v; }, + get busy() { return sim.busy; }, + + /** @param {number} dt @param {number} t — main.js's fixed-dt loop drives this */ + update(dt, t) { + const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0); + sim.step(dt, t, input, opts.wind); + if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding); + view.sync(sim, dt); + }, + + /** The Object3D to follow/frame. Lane A's camera wants this, not the raw rig. */ + get object() { return view.root; }, + sim, + view, + keyboard, + dispose() { keyboard.dispose(); view.dispose(); }, + }; +} + +/** + * Keyboard → the sim's input shape. Camera yaw comes from Lane A's camera each frame. + * Kept out of PlayerSim so the sim stays headless. + */ +export class KeyboardInput { + constructor(target = window) { + this.keys = new Set(); + this._down = (e) => { + this.keys.add(e.code); + if (/^(Arrow|Space)/.test(e.code)) e.preventDefault(); + }; + this._up = (e) => this.keys.delete(e.code); + target.addEventListener('keydown', this._down); + target.addEventListener('keyup', this._up); + this._target = target; + } + + get holding() { return this.keys.has('KeyE'); } + + /** @param {number} camYaw radians */ + read(camYaw = 0) { + const k = this.keys; + const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0); + const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0); + return { x, z, run: k.has('ShiftLeft') || k.has('ShiftRight'), camYaw }; + } + + dispose() { + this._target.removeEventListener('keydown', this._down); + this._target.removeEventListener('keyup', this._up); + } +} diff --git a/web/world/js/player.sim.js b/web/world/js/player.sim.js new file mode 100644 index 0000000..91e18d4 --- /dev/null +++ b/web/world/js/player.sim.js @@ -0,0 +1,246 @@ +/** + * player.sim.js — the small person's deterministic core. (Lane D) + * + * Zero imports, on purpose. PLAN3D §0 requires the sim to be fast-forwardable in selftest with a + * fixed dt and no renderer, so nothing in here touches THREE, the DOM, rAF, Date.now() or + * Math.random(). `step(dt, t, …)` is the entire clock: same inputs → same trace, every run. + * + * player.js owns the view (rig, clips, camera). This file owns the truth: where the person is, + * what state they're in, and what the wind is doing to them. + */ + +/** + * The state machine, as a table (PLAN3D §5-D.5 asks for a table test). + * clip — clip name in player_anims.glb + * locked — movement input is ignored, and `player.busy` is true + * secs — timed states auto-advance to `next` after this long + * Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own) + * or is released by an external actor. `busy` is the only externally-released state — interact.js + * both enters and leaves it, so a dropped release can't strand the player. + */ +export const STATES = { + idle: { clip: 'Idle', locked: false, loop: true }, + walk: { clip: 'Walk', locked: false, loop: true }, + run: { clip: 'Run', locked: false, loop: true }, + busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' }, + stagger: { clip: 'Reaction', locked: true, loop: false, secs: 0.9, next: 'idle' }, + knocked: { clip: 'Falling', locked: true, loop: false, secs: 1.4, next: 'getup' }, + getup: { clip: 'CrouchToStand', locked: true, loop: false, secs: 1.3, next: 'idle' }, +}; + +/** + * Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and + * m/s per PLAN3D §1 ("port the behaviour, retune the constants"). + */ +export const TUNE = { + walkSpeed: 1.5, // m/s — a person crossing a 30 m yard, unhurried + runSpeed: 4.4, // m/s — shift + accel: 16, // m/s² toward the wanted velocity + turnRate: 11, // rad/s — facing chases the movement direction + + // prototype: slow = 1 - min(0.35, wind.speed / 160). Its wind ran ~4 (calm) to ~38 (gust peak), + // which is already m/s-shaped, so the curve ports across directly. + slowMax: 0.35, + slowRef: 160, + + // prototype: push = ws*ws*0.55 — "wind pressure goes with speed², gusts have teeth"; and shove + // only applied while wind.gust > 8, never from the base wind. + shoveK: 0.0035, // shove accel (m/s²) = shoveK · ws² → ~3.2 m/s² in a 30 m/s gust + shoveGustMin: 8, // m/s of gust (over baseline) before the wind can push you at all + shoveDamp: 2.5, // 1/s foot-friction bleed → terminal drift ≈ shoveK·ws²/shoveDamp + + // Baseline tracker: contracts.js exposes wind.sample() (total) and wind.gustTelegraph() (before + // the gust), but nothing reports gust magnitude DURING the hold. Rather than widen Lane C's + // contract, we recover it: a slow EMA of local wind speed is the base curve, and everything above + // it is gust. Self-calibrating to whatever storm JSON Lane C authors. + baseTrack: 0.25, // 1/s — ~4 s memory; gust holds are ~1.7 s, so they read as gust, not base + + // Knockdown mirrors the sail's failure rule (PLAN3D §1: break after 0.4 s SUSTAINED overload) — + // same verb for cloth and for people, so the player reads one language. + knockWind: 30, // m/s sustained local wind → you go down + knockSustain: 0.5, // s above knockWind before it happens + knockBleed: 2, // exposure drains this many × faster than it fills + pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch) +}; + +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + +/** Shortest-arc angle step from a toward b, at most `maxStep` radians. */ +function turnToward(a, b, maxStep) { + let d = (b - a) % (Math.PI * 2); + if (d > Math.PI) d -= Math.PI * 2; + if (d < -Math.PI) d += Math.PI * 2; + return a + clamp(d, -maxStep, maxStep); +} + +export class PlayerSim { + /** + * @param {object} [opts] + * @param {object} [opts.start] {x,y,z} spawn, metres + * @param {function} [opts.groundAt] (x,z) -> y. Lane A's world.js provides the real one. + * @param {object} [opts.tune] overrides for TUNE + */ + constructor(opts = {}) { + const s = opts.start || { x: 0, y: 0, z: 0 }; + this.pos = { x: s.x, y: s.y || 0, z: s.z }; + this.vel = { x: 0, z: 0 }; // intended (input-driven) velocity + this.shove = { x: 0, z: 0 }; // wind-driven velocity, decays through foot friction + this.facing = opts.facing || 0; // yaw; models face +Z at 0 (90sDJsim convention) + + this.state = 'idle'; + this.stateT = 0; + this.carrying = null; // contract: player.carrying — one item, hands-full rule + this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD + + this.exposure = 0; // s spent above knockWind + this.windBase = 0; // EMA baseline (see TUNE.baseTrack) + this.windSpeed = 0; // last sampled local speed, m/s — HUD/audio read this + this.gust = 0; // windSpeed - windBase, clamped ≥0 + this.pitch = 0; // 0 upright … 1 flat on the ground + this.knockDir = { x: 0, z: 1 }; // which way the body went down + + this.groundAt = opts.groundAt || (() => 0); + this.tune = { ...TUNE, ...(opts.tune || {}) }; + } + + /** contract: player.busy */ + get busy() { return !!STATES[this.state].locked; } + get clip() { return STATES[this.state].clip; } + get speed() { return Math.hypot(this.vel.x, this.vel.z); } + + setState(s, t = 0) { + if (this.state === s) return false; + if (!STATES[s]) throw new Error(`player: unknown state ${s}`); + this.state = s; + this.stateT = 0; + this.events.push({ type: 'state', state: s, t }); + return true; + } + + /** Drop whatever's carried (knockdown does this per PLAN3D §5-D.3). */ + drop(t = 0) { + if (!this.carrying) return null; + const item = this.carrying; + this.carrying = null; + this.events.push({ type: 'drop', item, t }); + return item; + } + + /** @returns {boolean} true if the pickup was accepted (hands-full rule). */ + pickUp(item, t = 0) { + if (this.carrying || this.busy) return false; + this.carrying = item; + this.events.push({ type: 'pickup', item, t }); + return true; + } + + /** Light hit — a glancing debris clip. Ignored if already down. */ + staggerHit(t = 0) { + if (this.state === 'knocked' || this.state === 'getup') return false; + return this.setState('stagger', t); + } + + /** + * Put the player on the ground. debris.js (Lane C) calls this on a solid hit; the sim calls it + * itself on sustained extreme wind. + * @param {number} [dirX] @param {number} [dirZ] which way to fall — defaults to downwind/facing. + */ + knockdown(t = 0, dirX, dirZ) { + if (this.state === 'knocked' || this.state === 'getup') return false; + let x = dirX, z = dirZ; + if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); } + const m = Math.hypot(x, z) || 1; + this.knockDir = { x: x / m, z: z / m }; + this.setState('knocked', t); + this.exposure = 0; + this.vel.x = this.vel.z = 0; + this.drop(t); + this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir } }); + return true; + } + + /** + * @param {number} dt fixed step, seconds + * @param {number} t storm time, seconds + * @param {object} input {x,z} camera-relative axes in -1..1, {run}, {camYaw} radians + * @param {object} wind contracts wind ({sample(pos,t)->Vector3}) or a plain {x,z} vector + */ + step(dt, t, input = {}, wind = null) { + const T = this.tune; + this.stateT += dt; + + // --- local wind, and how much of it is gust --- + let wx = 0, wz = 0; + if (wind) { + const v = typeof wind.sample === 'function' ? wind.sample(this.pos, t) : wind; + if (v) { wx = v.x || 0; wz = v.z || 0; } + } + const ws = Math.hypot(wx, wz); + this.windSpeed = ws; + this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1); + this.gust = Math.max(0, ws - this.windBase); + + // --- sustained extreme wind puts you down (same rule as a sail corner letting go) --- + if (ws > T.knockWind) this.exposure += dt; + else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed); + if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz); + + const st = STATES[this.state]; + + // --- movement --- + const slow = 1 - Math.min(T.slowMax, ws / T.slowRef); // prototype: rain + wind slow you + let wantX = 0, wantZ = 0; + if (!st.locked) { + const ix = input.x || 0, iz = input.z || 0; + const mag = Math.hypot(ix, iz); + if (mag > 1e-3) { + // camera-relative: at camYaw 0 three.js looks down -Z, so forward = (-sin, -cos). + const cy = input.camYaw || 0; + const sin = Math.sin(cy), cos = Math.cos(cy); + const nx = ix / mag, nz = iz / mag; + const dx = nx * cos - nz * sin; + const dz = -nx * sin - nz * cos; + const target = (input.run ? T.runSpeed : T.walkSpeed) * Math.min(1, mag) * slow; + wantX = dx * target; wantZ = dz * target; + this.facing = turnToward(this.facing, Math.atan2(dx, dz), T.turnRate * dt); + } + } + // approach the wanted velocity at a fixed accel (both directions — stopping is the same law) + const dvx = wantX - this.vel.x, dvz = wantZ - this.vel.z; + const dvm = Math.hypot(dvx, dvz); + const step = T.accel * dt; + if (dvm <= step || dvm < 1e-6) { this.vel.x = wantX; this.vel.z = wantZ; } + else { this.vel.x += dvx / dvm * step; this.vel.z += dvz / dvm * step; } + + // --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground --- + const grounded = this.state === 'knocked' || this.state === 'getup'; + if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) { + const a = T.shoveK * ws * ws; + this.shove.x += (wx / ws) * a * dt; + this.shove.z += (wz / ws) * a * dt; + } + const bleed = Math.exp(-T.shoveDamp * dt); + this.shove.x *= bleed; this.shove.z *= bleed; + + this.pos.x += (this.vel.x + this.shove.x) * dt; + this.pos.z += (this.vel.z + this.shove.z) * dt; + this.pos.y = this.groundAt(this.pos.x, this.pos.z); + + // --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND, + // which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. --- + const wantPitch = this.state === 'knocked' ? 1 + : this.state === 'getup' ? Math.max(0, 1 - this.stateT / (st.secs || 1)) + : 0; + const pstep = dt / T.pitchSecs; + this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1); + + // --- locomotion state from actual speed (so shove/slow can't desync the feet) --- + if (!st.locked) { + const sp = this.speed; + this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t); + } else if (st.secs && this.stateT >= st.secs && st.next) { + this.setState(st.next, t); + } + return this.state; + } +} diff --git a/web/world/js/tests/d.test.js b/web/world/js/tests/d.test.js index c4719a9..202a9e4 100644 --- a/web/world/js/tests/d.test.js +++ b/web/world/js/tests/d.test.js @@ -1,26 +1,295 @@ /** - * Lane D selftests — player state machine and interactions. + * Lane D selftests — player state machine, weather effects, interactions. (PLAN3D §5-D.5) * - * Lane D owns this file. Lane A pre-created it so adding your suite never means - * editing selftest.html. + * Everything asserted here is renderer-free: player.sim.js and interact.js import nothing, so this + * suite is pure logic driven at fixed dt, never rAF. (rAF being throttled in a hidden tab is not + * theoretical — this lane's dev harness froze mid-knockdown at stateT=0.333 until it was driven by + * a fixed loop, which is exactly the trap Lane A called out.) * - * The asserts PLAN3D §5-D asks for, once player.js lands: - * 1. anim state machine table test — every state reachable, none stuck - * (idle/walk/run + one-shot interact + knockdown → get-up). - * 2. interact radius respects the busy and carrying flags. - * - * Note for integration: main.js currently drives a placeholder capsule that - * satisfies the Player contract ({pos, carrying, busy, update}). When player.js - * lands, Lane A swaps the factory call in boot() and deletes the placeholder — - * you shouldn't need to touch main.js yourself. Ping THREADS.md when you're - * ready and Lane A will do the swap. - * - * Useful imports: - * import { FIXED_DT, createStubWind } from '../contracts.js'; - * import { assert, assertEq, fixedLoop } from '../testkit.js'; + * The rig half of player.js — bone binding, head-bone scale, clip retarget — can't be asserted + * headlessly; it needs a GL context and two GLB fetches. That is verified by hand in + * web/world/dev_player.html and written up in THREADS.md: head bone 1.715 m at fig scale 0.983, + * all six clips bound, Hips tracks correctly absent. */ +import { PlayerSim, STATES, TUNE } from '../player.sim.js'; +import { Interact, wireYardActions } from '../interact.js'; +import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js'; +import { FIXED_DT } from '../contracts.js'; + +const DT = FIXED_DT; + +/** Steady wind blowing +X at `speed` m/s. */ +const windX = (speed) => ({ x: speed, y: 0, z: 0 }); + +/** Drive the sim for `secs` at fixed dt. */ +const drive = (sim, secs, input = {}, wind = null, t0 = 0) => + fixedLoop(secs, DT, (dt, t) => sim.step(dt, t0 + t, input, wind)); /** @param {import('../testkit.js').Suite} t */ export default function run(t) { - t.skip('player.js not landed yet — Lane D'); + // ---------------------------------------------------------------- state machine table + t.test('state table: every state\'s clip exists in player_anims.glb', () => { + const clips = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction']); + for (const [name, st] of Object.entries(STATES)) { + assert(clips.has(st.clip), `state ${name} wants missing clip ${st.clip}`); + } + }); + + t.test('state table: no stuck states — every locked state drains to a free one', () => { + for (const [name, st] of Object.entries(STATES)) { + if (!st.locked) continue; + assert((st.next && st.secs > 0) || !!st.releasedBy, + `locked state ${name} has neither a timed exit nor an external releaser`); + if (st.next) assert(!!STATES[st.next], `state ${name}.next=${st.next} is not a state`); + } + for (const name of Object.keys(STATES)) { + let cur = name, hops = 0; + const seen = new Set(); + while (STATES[cur].locked && STATES[cur].next && hops < 16 && !seen.has(cur)) { + seen.add(cur); cur = STATES[cur].next; hops++; + } + assert(!STATES[cur].locked || !!STATES[cur].releasedBy, + `state ${name} chains into a dead end at ${cur}`); + } + }); + + // ---------------------------------------------------------------- locomotion + t.test('locomotion: idle -> walk -> run -> idle at the tuned speeds', () => { + const sim = new PlayerSim(); + assertEq(sim.state, 'idle', 'spawns idle'); + assert(!sim.busy, 'idle is not busy'); + + drive(sim, 1.0, { x: 0, z: 1, camYaw: 0 }); + assertEq(sim.state, 'walk', 'W walks'); + assertClose(sim.speed, TUNE.walkSpeed, 0.05, 'walk speed'); + + drive(sim, 1.0, { x: 0, z: 1, run: true, camYaw: 0 }); + assertEq(sim.state, 'run', 'shift runs'); + assertClose(sim.speed, TUNE.runSpeed, 0.05, 'run speed'); + + drive(sim, 1.0, {}); + assertEq(sim.state, 'idle', 'releasing input returns to idle'); + assertLess(sim.speed, 0.15, 'and stops'); + }); + + t.test('locomotion: movement is relative to camera.yaw', () => { + const a = new PlayerSim(); drive(a, 1.0, { x: 0, z: 1, camYaw: 0 }); + assertLess(a.pos.z, -0.5, 'at yaw 0, W drives -Z (three.js camera-forward)'); + assertClose(a.pos.x, 0, 1e-6, 'and not sideways'); + + const b = new PlayerSim(); drive(b, 1.0, { x: 0, z: 1, camYaw: Math.PI / 2 }); + assertLess(b.pos.x, -0.5, 'at yaw 90 deg, the same key drives -X'); + assertClose(b.pos.z, 0, 1e-6, 'and not forward'); + + assertClose(Math.abs(a.facing), Math.PI, 0.05, 'facing chases the movement direction'); + }); + + // ---------------------------------------------------------------- weather on the player + t.test('weather: wind slows you, capped at TUNE.slowMax', () => { + const calm = new PlayerSim(); drive(calm, 2, { x: 0, z: 1, camYaw: 0 }, windX(0)); + const blow = new PlayerSim(); drive(blow, 2, { x: 0, z: 1, camYaw: 0 }, windX(20)); + assertLess(blow.speed, calm.speed, 'wind slows you'); + + // Isolate the slow curve from the knockdown — in a real 1e4 m/s you are flat on your back + // inside half a second (which is correct, and is what this assert used to accidentally measure). + const gale = new PlayerSim({ tune: { knockWind: Infinity } }); + drive(gale, 2, { x: 0, z: 1, camYaw: 0 }, windX(1e4)); + assertClose(gale.speed, TUNE.walkSpeed * (1 - TUNE.slowMax), 0.05, + 'slow saturates at slowMax and never goes past it'); + }); + + t.test('weather: steady wind reads as baseline, not as a gust', () => { + const s = new PlayerSim(); + drive(s, 60, {}, windX(20)); + assertLess(s.gust, 1.0, 'the EMA learns a constant 20 m/s as base'); + assertLess(Math.hypot(s.shove.x, s.shove.z), 0.05, 'so it does not shove you forever'); + }); + + t.test('weather: a gust over the baseline shoves you downwind, and scales with speed squared', () => { + const s = new PlayerSim(); + drive(s, 30, {}, windX(6)); // learn a calm baseline + const x0 = s.pos.x; + drive(s, 1.6, {}, windX(22), 30); // 16 m/s over baseline -> past shoveGustMin + assert(s.pos.x - x0 > 0.05, `gust should shove downwind, dx=${(s.pos.x - x0).toFixed(3)}`); + + const push = (ws) => { + const p = new PlayerSim(); + drive(p, 30, {}, windX(2)); + const p0 = p.pos.x; + drive(p, 1.0, {}, windX(ws), 30); + return p.pos.x - p0; + }; + const p15 = push(15), p30 = push(30); + assert(p30 > p15 * 2.5, + `shove must grow faster than linearly with speed: ${p15.toFixed(3)} -> ${p30.toFixed(3)}`); + }); + + // ---------------------------------------------------------------- knockdown + t.test('knockdown: needs SUSTAINED overload, like a sail corner letting go', () => { + const brief = new PlayerSim(); + drive(brief, 0.3, {}, windX(TUNE.knockWind + 5)); + assert(brief.state !== 'knocked', 'a brief spike must not put you down'); + + const held = new PlayerSim(); + held.carrying = 'spare'; + drive(held, TUNE.knockSustain + 0.2, {}, windX(TUNE.knockWind + 5)); + assertEq(held.state, 'knocked', 'sustained overload does'); + assertEq(held.carrying, null, 'and drops what you were carrying'); + assert(held.knockDir.x > 0.99, 'and you fall downwind'); + }); + + t.test('knockdown: exposure bleeds off — flickering gusts never creep into one', () => { + const s = new PlayerSim(); + fixedLoop(10, DT, (dt, t) => { + const on = Math.round(t / DT) % 40 < 10; // 0.17 s on, 0.5 s off + s.step(dt, t, {}, windX(on ? TUNE.knockWind + 5 : 5)); + }); + assert(s.state !== 'knocked', + `flickering gusts must not accumulate (exposure=${s.exposure.toFixed(3)})`); + }); + + t.test('knockdown: knocked -> getup -> idle, unaided, and upright again', () => { + const s = new PlayerSim(); + s.knockdown(0); + assert(s.busy, 'knocked is locked'); + drive(s, 0.5, { x: 0, z: 1, camYaw: 0 }); + assertClose(s.pos.z, 0, 1e-9, 'knocked ignores movement input'); + assert(s.pitch > 0.99, 'body goes flat'); + drive(s, 1.1, {}); + assertEq(s.state, 'getup', 'knocked drains to getup on its own'); + drive(s, 1.4, {}); + assertEq(s.state, 'idle', 'getup drains to idle on its own'); + assertLess(s.pitch, 0.01, 'and the body is upright again'); + assert(!s.busy, 'player is free'); + }); + + // ---------------------------------------------------------------- determinism (PLAN3D §4) + t.test('determinism: two identical 50 s runs produce byte-equal traces', () => { + const trace = () => { + const s = new PlayerSim(); + const out = []; + fixedLoop(50, DT, (dt, tt) => { + const w = { x: 6 + 24 * Math.max(0, Math.sin(tt * 0.7)), y: 0, z: 3 * Math.cos(tt * 0.31) }; + s.step(dt, tt, { x: Math.sin(tt), z: Math.cos(tt * 0.5), run: tt % 4 < 2, camYaw: tt * 0.2 }, w); + out.push(`${s.state}|${s.pos.x.toFixed(9)}|${s.pos.z.toFixed(9)}|${s.pitch.toFixed(9)}`); + }); + return out.join(';'); + }; + assertEq(trace(), trace(), 'same inputs must give the same trace'); + }); + + // ---------------------------------------------------------------- interact + const mk = () => { + const sim = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + const it = new Interact(); + let done = 0; + it.register({ id: 'x', pos: { x: 0, y: 0, z: 0 }, radius: 1.5, holdSecs: 1, label: 'do it', + onDone: () => { done++; } }); + return { sim, it, done: () => done }; + }; + + t.test('interact: radius gates the action', () => { + const { sim, it, done } = mk(); + sim.pos.x = 5; + fixedLoop(2, DT, (dt, tt) => it.step(dt, tt, sim, true)); + assertEq(done(), 0, 'out of radius never fires'); + assert(!sim.busy, 'and never makes you busy'); + }); + + t.test('interact: hold to completion fires once and hands the player back', () => { + const { sim, it, done } = mk(); + fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true)); + assertEq(done(), 1, 'fires once'); + assert(!sim.busy && sim.state === 'idle', 'player released'); + assert(it.events.some((e) => e.type === 'done' && e.id === 'x'), 'emits a done event'); + }); + + t.test('interact: player is busy mid-hold, with a partial radial', () => { + const { sim, it } = mk(); + fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true)); + assert(sim.busy && sim.state === 'busy', 'busy while holding'); + assert(it.progress > 0.4 && it.progress < 0.6, `radial partway, got ${it.progress.toFixed(2)}`); + }); + + t.test('interact: one press, one action — a held key must not re-arm itself', () => { + const { sim, it, done } = mk(); + fixedLoop(6.7, DT, (dt, tt) => it.step(dt, tt, sim, true)); + assertEq(done(), 1, 'holding E for 6.7 s over a 1 s action still fires once'); + it.step(DT, 7, sim, false); + fixedLoop(70 * DT, DT, (dt, tt) => it.step(dt, 7 + tt, sim, true)); + assertEq(done(), 2, 'releasing re-arms it'); + }); + + t.test('interact: releasing or walking away cancels the hold', () => { + const a = mk(); + fixedLoop(30 * DT, DT, (dt, tt) => a.it.step(dt, tt, a.sim, true)); + a.it.step(DT, 0.5, a.sim, false); + assertEq(a.it.progress, 0, 'release cancels'); + assert(!a.sim.busy && a.sim.state === 'idle', 'and frees the player'); + + const b = mk(); + fixedLoop(30 * DT, DT, (dt, tt) => b.it.step(dt, tt, b.sim, true)); + b.sim.pos.x = 9; + b.it.step(DT, 0.5, b.sim, true); + assertEq(b.it.progress, 0, 'leaving the radius cancels'); + assertEq(b.done(), 0, 'without firing'); + }); + + t.test('interact: a knockdown mid-hold aborts without stomping the knocked state', () => { + const { sim, it, done } = mk(); + fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true)); + sim.knockdown(0.5); + it.step(DT, 0.51, sim, true); + assertEq(it.progress, 0, 'hold aborted'); + assertEq(done(), 0, 'action did not fire'); + assertEq(sim.state, 'knocked', 'and the abort did not overwrite the knocked state'); + }); + + t.test('interact: canUse() gates on the carrying flag', () => { + const sim = new PlayerSim(); + const it = new Interact(); + let fired = 0; + it.register({ id: 'gated', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5, + canUse: (p) => !p.carrying, onDone: () => { fired++; } }); + sim.carrying = 'spare'; + fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true)); + assertEq(fired, 0, 'hands full: gated'); + assert(!sim.busy, 'and never went busy'); + sim.carrying = null; + fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true)); + assertEq(fired, 1, 'fires once the gate opens'); + }); + + // ---------------------------------------------------------------- hands-full + wiring + t.test('hands-full rule: one item at a time', () => { + const sim = new PlayerSim(); + assertEq(sim.pickUp('spare'), true, 'first item accepted'); + assertEq(sim.pickUp('wrench'), false, 'second refused'); + assertEq(sim.carrying, 'spare', 'still holding the first'); + assertEq(sim.drop(), 'spare', 'drop returns it'); + assertEq(sim.carrying, null, 'hands empty'); + }); + + t.test('wireYardActions: duck-types against a half-landed world', () => { + const empty = new Interact(); + wireYardActions(empty, {}); + assertEq(empty.targets.size, 0, 'no sailRig yet (Lane B) -> no actions, no crash'); + + const it = new Interact(); + const corners = [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 }, load: 0 }]; + let repaired = -1; + wireYardActions(it, { + sailRig: { corners, repair: (i) => { repaired = i; }, trim: () => {} }, + world: { shedTable: { pos: { x: 10, y: 0, z: 0 } } }, + }); + assertEq(it.targets.size, 3, 're-rig + trim + spare table'); + + const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } }); + fixedLoop(3.3, DT, (dt, tt) => it.step(dt, tt, p, true)); + assertEq(repaired, -1, 're-rig refuses without a spare'); + p.carrying = 'spare'; + fixedLoop(3.3, DT, (dt, tt) => it.step(dt, 10 + tt, p, true)); + assertEq(repaired, 0, 're-rig fires with a spare'); + assertEq(p.carrying, null, 'and consumes it'); + }); }