Merge remote-tracking branch 'origin/lane/d'
# Conflicts: # THREADS.md
This commit is contained in:
commit
cd173d1ce0
102
THREADS.md
102
THREADS.md
@ -722,3 +722,105 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
produce, and so it never executed the path that was broken. The replacement drives a real overload
|
||||
failure and asserts the corner tears free and keeps moving. If your suite hand-builds state before
|
||||
asserting on it, it may be green over a dead code path.
|
||||
[D] 2026-07-17 — **SPRINT 2 part 1 on `lane/d`** — the player is now a body in a storm, not a camera
|
||||
target. Selftest **35 Lane D asserts, 0 fail** (was 20). All verified in the real yard, not just
|
||||
in asserts:
|
||||
· **`world.solids` collision** — the biggest gap at gate 1: the ped walked through the house.
|
||||
Now stops dead 0.30 m off the wall face (expected −9.70, measured −9.70), off trunks, posts and
|
||||
the fence, and slides along walls when you hit them at an angle. Injected as `opts.collide`
|
||||
the same way `groundAt` is, so player.sim.js stays zero-import and node-runnable.
|
||||
· **the M3 verbs are live**: carrying swaps locomotion to Carry/CarryIdle · an interaction names
|
||||
its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) via a new `clip` field on
|
||||
the interact spec · `StumbleBack` on a gust that breaks your stride · **`TakeCover` (hold C)**
|
||||
is now a real mechanic, not a pose — see below.
|
||||
· Table-driven throughout: STATES gained `carryClip`, and `clipFor(sim)` is exported so the
|
||||
selftest can assert what plays without a renderer.
|
||||
|
||||
[D] 2026-07-17 — 🛡️ **NEW MECHANIC — shelter (hold C), flagging it because it's a design addition.**
|
||||
SPRINT2 §Lane D.4 said "TakeCover as the storm shelter verb" and left the transition to me. It
|
||||
brace-locks you: knockWind ×2.0 and shove ×0.25 while held. Measured in the real yard: a **38 m/s
|
||||
gale floors you standing and does NOT while braced — let go in the same gale and you're down in
|
||||
half a second.** So the storm's answer to "the gusts are too strong to cross the yard" is now
|
||||
*wait one out, then move in the lull*, which is exactly the lull-as-repair-window language
|
||||
DESIGN.md §Wind already uses. It raises the bar, it doesn't remove it — a big enough gust still
|
||||
takes you off your feet, braced or not (asserted). You cannot brace from your back.
|
||||
|
||||
[D] 2026-07-17 — 📌 **CORRECTION, Lane A — `knockdown()` does NOT jam the state machine.** Your note
|
||||
says `knockdown(impact)` "jams ~40 into the state machine's start time and you never get up".
|
||||
Reproduced it exactly in the live game: `knockdown(40)` → knocked, then **getup at 1.38 s, idle at
|
||||
2.68 s. You get up.** `t` is only ever written into the event log; timing runs off `stateT`, which
|
||||
`setState` zeroes. The only real effect is cosmetic — polluted event timestamps. **Your wiring
|
||||
(`knockdown(windT, piece.vx, piece.vz)`) is right and better than the plain call** — falling the
|
||||
way the crate travelled is the good version — so nothing to change; I'm only correcting the record
|
||||
so nobody burns an hour hunting a state-machine bug that isn't there. Leaving the signature alone:
|
||||
it's correctly wired at the one call site that matters.
|
||||
(`player.pos` being `{x,y,z}` not `Vector3`: taking your offer to relax the contract wording. Making
|
||||
it real would mean either importing THREE into the zero-import sim — which is what makes it
|
||||
node-runnable and deterministic — or handing back a synced mirror whose writes silently don't move
|
||||
the player. Neither is worth a nominal type match.)
|
||||
|
||||
[D] 2026-07-17 — ❗ **BLOCKED ON LANE A — `world.shedTable`, and it gates the sprint's "done".** The §7
|
||||
scenario is *rig → carry a spare → repair mid-storm*, and there is nowhere to pick a spare up:
|
||||
`world.shedTable` is undefined, so `wireYardActions` self-skips the pickup and **nothing in the game
|
||||
can put a spare in the player's hands.** E shipped `shed_01_v1.glb` AND `shed_table_v1.glb` and
|
||||
they're on disk unused. All I need is world.js to place them and expose
|
||||
`world.shedTable = { pos }` (a `pickup_anchor` empty inside the GLB if E put one there, else the
|
||||
table's top-centre); ~1.5 m from the table's edge is reachable. Everything downstream of it is
|
||||
already wired and asserted. Yard dressing is your file, so I'm not touching it — shout if you'd
|
||||
rather I take it.
|
||||
|
||||
[D] 2026-07-17 — 👋 **LANE B — decision 4, exactly what I call, so you can land it without guessing.**
|
||||
I've hardened my side while waiting; `sail.js` already has `repairCorner(i, hw)` / `trimCorner(i,
|
||||
delta)` internally, so this should be three thin aliases:
|
||||
· `rig.repair(i)` — I gate on `corners[i].broken && carrying === 'spare'`, hold 2.5 s, then call
|
||||
it and consume the spare. Pick the hw yourself (the spare is untyped on my side for now).
|
||||
· `rig.trim(i, delta)` — I call `trim(i, +0.1)` after a 1.2 s hold. Plays `Crank`.
|
||||
· `rig.cornerPos(i)` — **live world position, fresh vector.** I resolve it every frame so a
|
||||
flogging corner's prompt tracks it; `corners[i].pos` is my fallback and doesn't exist today,
|
||||
so with neither, my prompts have no position and silently never appear (they fail safe, which
|
||||
is why the game doesn't crash right now — but it also means none of my repair UI is reachable
|
||||
until this lands).
|
||||
Also: your `attach()` replaces the corners array, per Lane A's warning. **My closures now read
|
||||
`sailRig.corners[i]` live by index instead of capturing the corner object**, so a re-rig can't
|
||||
strand them whether or not anyone re-wires. Asserted both ways (swap the array → the targets track
|
||||
the new objects). You don't have to call `wireYardActions` again after `attach()`, though it's
|
||||
harmless if you do — ids are stable so it replaces rather than stacks.
|
||||
|
||||
[D] 2026-07-17 — ✅ **THE §7 REPAIR LOOP RUNS END TO END — proven with the two missing seams stood in.**
|
||||
Rather than wait, I stubbed exactly what I asked A and B for, in the live game, and drove the whole
|
||||
scenario through the REAL sim + REAL interact + REAL rig:
|
||||
`targets: rerig_0..3, trim_0..3, spare_table`
|
||||
`corner 2 broken → cornerPos(2) = 5.3, 3.9, 8`
|
||||
`walk to shed table → arrived → prompt "take a spare" → hold → carrying=spare`
|
||||
`walk to blown corner → prompt "re-rig corner" (rerig_2) → hold 2.5 s`
|
||||
`→ corner2.broken=false, carrying=null` ← the spare was consumed, the corner is back
|
||||
**So Lane D's half of gate 3 is done and the only thing between us and a hand-playable repair is
|
||||
two tiny seams.** Nothing else of mine is waiting on anything.
|
||||
|
||||
[D] 2026-07-17 — 🔧 **LANE B — `cornerPos(i)`, written and proven against your sim. Three lines.** I had
|
||||
to build it to run the proof above, so here it is exactly as it worked, no guessing:
|
||||
```js
|
||||
/** Live world position of corner i — the anchor while pinned, the flogging node once blown. */
|
||||
cornerPos(i) {
|
||||
const ci = this.cornerIdx[i] * 3;
|
||||
return new THREE.Vector3(this.pos[ci], this.pos[ci + 1], this.pos[ci + 2]);
|
||||
}
|
||||
repair(i) { return this.repairCorner(i); }
|
||||
trim(i, delta) { return this.trimCorner(i, delta); }
|
||||
```
|
||||
Reading the NODE rather than the anchor is the whole point and costs nothing: `_pinCorners()`
|
||||
already pins unbroken corners onto `_anchorPos(c.anchor, t)` and leaves blown ones free, so one
|
||||
implementation gives me a static prompt on a live corner AND a prompt that chases a flogging one.
|
||||
Fresh vector per call per decision 4 — I call it every frame and don't retain it. (Note your
|
||||
corners carry `anchor` but no `pos`, so `corners[i].pos` — my documented fallback — is always
|
||||
undefined; `cornerPos` is the real path.)
|
||||
|
||||
[D] 2026-07-17 — 🔧 **LANE A — `world.shedTable`, one line, and it unblocks the sprint's "done".** What I
|
||||
stubbed to prove the loop was literally:
|
||||
```js
|
||||
world.shedTable = { pos: new THREE.Vector3(9, heightAt(9, 6) + 0.9, 6) }; // table-top centre
|
||||
```
|
||||
Anywhere reachable works — my pickup radius is 1.5 m off that point and the player walked to it
|
||||
fine. `shed_01_v1.glb` + `shed_table_v1.glb` are on disk from E and currently unused; if E baked a
|
||||
`pickup_anchor` empty, read that, else the table top is fine. That plus B's three lines above and
|
||||
the §7 scenario is hand-playable.
|
||||
|
||||
@ -30,12 +30,14 @@ export class Interact {
|
||||
* @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
|
||||
* @param {string} [spec.clip] verb played for the length of the hold ('Crank', 'PickUp', …).
|
||||
* Must name a clip in player_anims.glb; omitted means the busy state's default Idle.
|
||||
* @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,
|
||||
radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, clip: null, ...spec,
|
||||
};
|
||||
this.targets.set(target.id, target);
|
||||
return () => this.unregister(target.id);
|
||||
@ -70,6 +72,7 @@ export class Interact {
|
||||
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);
|
||||
player.busyClip = null;
|
||||
this.events.push({ type: 'cancel', id: this.active.id, t });
|
||||
this.active = null;
|
||||
this.progress = 0;
|
||||
@ -99,6 +102,7 @@ export class Interact {
|
||||
if (!this.active && holding && !this.latched && near && !player.busy) {
|
||||
this.active = near;
|
||||
this.progress = 0;
|
||||
player.busyClip = near.clip || null; // the verb: Crank at a turnbuckle, PickUp at the table
|
||||
player.setState('busy', t);
|
||||
}
|
||||
|
||||
@ -110,6 +114,7 @@ export class Interact {
|
||||
this.progress = 0;
|
||||
this.latched = true;
|
||||
player.setState('idle', t); // release busy FIRST — onDone may pickUp(), which refuses while busy
|
||||
player.busyClip = null; // ...and after it, so the carry clips win on the next frame
|
||||
if (done.onDone) done.onDone(player, t);
|
||||
this.events.push({ type: 'done', id: done.id, t });
|
||||
}
|
||||
@ -129,38 +134,54 @@ export class Interact {
|
||||
* 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.
|
||||
*
|
||||
* Every closure reads `sailRig.corners[i]` LIVE rather than capturing the corner object. Lane A's
|
||||
* THREADS note: `attach()` REPLACES the corners array, so a captured corner is a stale object the
|
||||
* sim no longer steps — the prompt would gate forever on a `broken` flag that can never change
|
||||
* again. Reading by index means a re-rig can't strand these targets whether or not we get re-wired.
|
||||
*
|
||||
* @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)
|
||||
* @param {object} deps {sailRig, world}
|
||||
* sailRig.corners -> [{anchorId, hw, load, broken}] (contracts.js, Lane B)
|
||||
* sailRig.repair(i) -> void (decision 4)
|
||||
* sailRig.trim(i,d) -> void (decision 4)
|
||||
* sailRig.cornerPos(i) -> Vector3 (decision 4 — live world position; a flogging corner moves)
|
||||
* world.shedTable -> {pos} (Lane A — until it lands, the pickup self-skips)
|
||||
*/
|
||||
export function wireYardActions(interact, deps = {}) {
|
||||
const { sailRig, world } = deps;
|
||||
const wired = [];
|
||||
const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
|
||||
// a flogging corner is MOVING — resolve position every frame, never once at wire time
|
||||
const posAt = (i) => () => {
|
||||
const c = cornerAt(i);
|
||||
if (!c) return null;
|
||||
return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
|
||||
};
|
||||
|
||||
if (sailRig && Array.isArray(sailRig.corners)) {
|
||||
sailRig.corners.forEach((corner, i) => {
|
||||
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)),
|
||||
pos: posAt(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); },
|
||||
clip: 'Crank',
|
||||
canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
|
||||
&& p.carrying === 'spare' && !!sailRig.repair,
|
||||
onDone: (p) => { p.carrying = null; 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)),
|
||||
pos: posAt(i),
|
||||
radius: 1.8,
|
||||
holdSecs: 1.2,
|
||||
label: 'tighten turnbuckle',
|
||||
canUse: () => !corner.broken && !!sailRig.trim,
|
||||
onDone: () => sailRig.trim && sailRig.trim(i, +0.1),
|
||||
clip: 'Crank',
|
||||
canUse: () => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim,
|
||||
onDone: () => sailRig.trim(i, +0.1),
|
||||
}));
|
||||
});
|
||||
}
|
||||
@ -172,6 +193,7 @@ export function wireYardActions(interact, deps = {}) {
|
||||
radius: 1.5,
|
||||
holdSecs: 0.6,
|
||||
label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
|
||||
clip: 'PickUp',
|
||||
canUse: (p) => !p.carrying, // hands-full rule
|
||||
onDone: (p, t) => p.pickUp('spare', t),
|
||||
}));
|
||||
|
||||
@ -15,9 +15,9 @@
|
||||
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';
|
||||
import { PlayerSim, STATES, TUNE, clipFor } from './player.sim.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE };
|
||||
export { PlayerSim, STATES, TUNE, clipFor };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
@ -56,6 +56,69 @@ const _loadGLTF = (loader, url) => new Promise((res, rej) =>
|
||||
|
||||
const UP = new THREE.Vector3(0, 1, 0);
|
||||
|
||||
const _clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
|
||||
/**
|
||||
* Build the player's collision test out of `world.solids` (contracts World).
|
||||
*
|
||||
* Shape of the problem, measured in the real yard rather than assumed:
|
||||
* · `fence` is a GROUP of 37 child meshes whose combined box is the whole 30×20 m yard — so one
|
||||
* box per entry in solids is useless. We flatten to leaf meshes and box each one.
|
||||
* · the house ROOF is a solid spanning y 2.99–3.21, i.e. entirely above a 1.72 m head. A flat
|
||||
* footprint test would wall off the eaves, so every box is filtered by vertical overlap with
|
||||
* the body and the roof simply drops out.
|
||||
* Solids are static, so the boxes are computed once. ~44 leaves, distance-pruned — no raycast per
|
||||
* frame. (Lane A's note: the ground is deliberately NOT in solids; heightAt covers it.)
|
||||
*
|
||||
* @param {object} world contracts World
|
||||
* @param {object} [opts] {radius} metres, the player's shoulder radius
|
||||
* @returns {(x:number,z:number,feetY:number,headY:number)=>{x:number,z:number}}
|
||||
*/
|
||||
export function makeSolidCollider(world, opts = {}) {
|
||||
const radius = opts.radius ?? 0.3;
|
||||
const boxes = [];
|
||||
const b = new THREE.Box3();
|
||||
for (const root of (world && world.solids) || []) {
|
||||
root.updateWorldMatrix(true, true);
|
||||
root.traverse((o) => {
|
||||
if (!o.isMesh) return;
|
||||
b.setFromObject(o);
|
||||
if (!isFinite(b.min.x)) return;
|
||||
boxes.push({ x0: b.min.x, x1: b.max.x, z0: b.min.z, z1: b.max.z, y0: b.min.y, y1: b.max.y });
|
||||
});
|
||||
}
|
||||
const out = { x: 0, z: 0 }; // scratch — copied by the caller immediately, never retained
|
||||
const r2 = radius * radius;
|
||||
|
||||
return function collide(x, z, feetY, headY) {
|
||||
out.x = x; out.z = z;
|
||||
for (let i = 0; i < boxes.length; i++) {
|
||||
const bx = boxes[i];
|
||||
if (bx.y1 <= feetY + 0.05 || bx.y0 >= headY) continue; // under the eaves / over a low wall
|
||||
// closest point on the box to the body centre, in XZ
|
||||
const cx = _clamp(out.x, bx.x0, bx.x1), cz = _clamp(out.z, bx.z0, bx.z1);
|
||||
const dx = out.x - cx, dz = out.z - cz;
|
||||
const d2 = dx * dx + dz * dz;
|
||||
if (d2 >= r2) continue; // clear
|
||||
if (d2 > 1e-10) { // outside: push along the normal
|
||||
const d = Math.sqrt(d2);
|
||||
out.x = cx + (dx / d) * radius;
|
||||
out.z = cz + (dz / d) * radius;
|
||||
} else {
|
||||
// centre is inside the box (spawned in a wall, or shoved through): eject through the nearest
|
||||
// face rather than picking an arbitrary axis, so you pop out the side you came in.
|
||||
const l = out.x - bx.x0, rr = bx.x1 - out.x, u = out.z - bx.z0, dn = bx.z1 - out.z;
|
||||
const m = Math.min(l, rr, u, dn);
|
||||
if (m === l) out.x = bx.x0 - radius;
|
||||
else if (m === rr) out.x = bx.x1 + radius;
|
||||
else if (m === u) out.z = bx.z0 - radius;
|
||||
else out.z = bx.z1 + radius;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
export class PlayerView {
|
||||
/**
|
||||
* @param {object} rig {scene, anims} — the character
|
||||
@ -127,7 +190,8 @@ export class PlayerView {
|
||||
/** 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);
|
||||
// clipFor, not st.clip: carrying swaps in Carry/CarryIdle, and an interaction names its own verb
|
||||
this.play(clipFor(sim), st.loop !== false);
|
||||
|
||||
this.root.position.set(sim.pos.x, sim.pos.y, sim.pos.z);
|
||||
|
||||
@ -202,9 +266,13 @@ export async function loadPlayer(scene, opts = {}) {
|
||||
* @returns {Promise<object>} satisfies checkContract('player', …)
|
||||
*/
|
||||
export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const height = opts.height || 1.72;
|
||||
const p = await loadPlayer(scene, {
|
||||
...opts,
|
||||
height,
|
||||
groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
|
||||
// built AFTER the world exists so the boxes capture E's real GLBs, not the graybox
|
||||
collide: opts.collide !== undefined ? opts.collide : makeSolidCollider(world, opts),
|
||||
start: opts.start || { x: 0, y: 0, z: 6 },
|
||||
});
|
||||
const keyboard = new KeyboardInput();
|
||||
@ -257,7 +325,11 @@ export class KeyboardInput {
|
||||
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 };
|
||||
return {
|
||||
x, z, camYaw,
|
||||
run: k.has('ShiftLeft') || k.has('ShiftRight'),
|
||||
shelter: k.has('KeyC'), // hold to brace — see STATES.shelter
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
@ -11,23 +11,39 @@
|
||||
|
||||
/**
|
||||
* 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
|
||||
* clip — clip name in player_anims.glb
|
||||
* carryClip — clip to use instead when the player has something in their hands
|
||||
* 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.
|
||||
* or names an external releaser. `busy` and `shelter` are the released ones — interact.js and the
|
||||
* shelter key each both ENTER and LEAVE their own state, so a dropped release can't strand you.
|
||||
*/
|
||||
export const STATES = {
|
||||
idle: { clip: 'Idle', locked: false, loop: true },
|
||||
walk: { clip: 'Walk', locked: false, loop: true },
|
||||
run: { clip: 'Run', locked: false, loop: true },
|
||||
idle: { clip: 'Idle', carryClip: 'CarryIdle', locked: false, loop: true },
|
||||
walk: { clip: 'Walk', carryClip: 'Carry', locked: false, loop: true },
|
||||
run: { clip: 'Run', carryClip: 'Carry', locked: false, loop: true },
|
||||
busy: { clip: 'Idle', locked: true, loop: true, releasedBy: 'interact' },
|
||||
shelter: { clip: 'TakeCover', locked: true, loop: true, releasedBy: 'input' },
|
||||
stumble: { clip: 'StumbleBack', locked: true, loop: false, secs: 0.8, next: 'idle' },
|
||||
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' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle),
|
||||
* and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) —
|
||||
* interact.js writes that into `sim.busyClip`. Everything else is the table's `clip`.
|
||||
* Kept here rather than in player.js so the selftest can assert it without a renderer.
|
||||
*/
|
||||
export function clipFor(sim) {
|
||||
const st = STATES[sim.state];
|
||||
if (sim.state === 'busy' && sim.busyClip) return sim.busyClip;
|
||||
if (sim.carrying && st.carryClip) return st.carryClip;
|
||||
return st.clip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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").
|
||||
@ -61,6 +77,16 @@ export const TUNE = {
|
||||
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)
|
||||
|
||||
// A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
|
||||
// storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
|
||||
stumbleGust: 17, // m/s over baseline → you lose your footing (but not your feet)
|
||||
stumbleCooldown: 3, // s — punctuation, not a stutter: one gust hold must not stumble you twice
|
||||
|
||||
// Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the
|
||||
// gusts are too strong to cross the yard" — wait one out, then move in the lull.
|
||||
shelterKnockMult: 2.0, // knockWind × this while braced — a gust that floors you standing won't
|
||||
shelterShoveMult: 0.25, // and it barely pushes you
|
||||
};
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
@ -78,6 +104,10 @@ 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 {function} [opts.collide] (x,z,feetY,headY) -> {x,z} pushed clear of world.solids.
|
||||
* Injected, not imported, for the same reason as groundAt: this file must stay renderer-free.
|
||||
* player.js#makeSolidCollider builds the real one out of world.solids.
|
||||
* @param {number} [opts.height] body height, metres — the collider's vertical span
|
||||
* @param {object} [opts.tune] overrides for TUNE
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
@ -90,6 +120,8 @@ export class PlayerSim {
|
||||
this.state = 'idle';
|
||||
this.stateT = 0;
|
||||
this.carrying = null; // contract: player.carrying — one item, hands-full rule
|
||||
this.busyClip = null; // interact.js names the verb for the current hold (Crank, PickUp…)
|
||||
this.stumbleCool = 0; // s until a gust may stumble you again
|
||||
this.events = []; // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD
|
||||
|
||||
this.exposure = 0; // s spent above knockWind
|
||||
@ -100,6 +132,8 @@ export class PlayerSim {
|
||||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||||
|
||||
this.groundAt = opts.groundAt || (() => 0);
|
||||
this.collide = opts.collide || null;
|
||||
this.bodyHeight = opts.height || 1.72;
|
||||
this.tune = { ...TUNE, ...(opts.tune || {}) };
|
||||
}
|
||||
|
||||
@ -168,6 +202,7 @@ export class PlayerSim {
|
||||
step(dt, t, input = {}, wind = null) {
|
||||
const T = this.tune;
|
||||
this.stateT += dt;
|
||||
this.stumbleCool = Math.max(0, this.stumbleCool - dt);
|
||||
|
||||
// --- local wind, and how much of it is gust ---
|
||||
let wx = 0, wz = 0;
|
||||
@ -180,11 +215,29 @@ export class PlayerSim {
|
||||
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;
|
||||
// --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you
|
||||
// even mid-gust. Refused while you're down — you can't brace from your back. ---
|
||||
const wantShelter = !!input.shelter;
|
||||
const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
|
||||
if (wantShelter && canShelter) this.setState('shelter', t);
|
||||
else if (!wantShelter && this.state === 'shelter') this.setState('idle', t);
|
||||
const braced = this.state === 'shelter';
|
||||
|
||||
// --- sustained extreme wind puts you down (same rule as a sail corner letting go).
|
||||
// Bracing raises the bar rather than removing it: a big enough gust still wins. ---
|
||||
const knockAt = braced ? T.knockWind * T.shelterKnockMult : T.knockWind;
|
||||
if (ws > knockAt) this.exposure += dt;
|
||||
else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
|
||||
if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);
|
||||
|
||||
// --- a gust below the knockdown bar can still break your stride ---
|
||||
if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0
|
||||
&& (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
|
||||
this.stumbleCool = T.stumbleCooldown;
|
||||
this.setState('stumble', t);
|
||||
this.vel.x = this.vel.z = 0;
|
||||
}
|
||||
|
||||
const st = STATES[this.state];
|
||||
|
||||
// --- movement ---
|
||||
@ -215,7 +268,7 @@ export class PlayerSim {
|
||||
// --- 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;
|
||||
const a = T.shoveK * ws * ws * (braced ? T.shelterShoveMult : 1);
|
||||
this.shove.x += (wx / ws) * a * dt;
|
||||
this.shove.z += (wz / ws) * a * dt;
|
||||
}
|
||||
@ -226,6 +279,15 @@ export class PlayerSim {
|
||||
this.pos.z += (this.vel.z + this.shove.z) * dt;
|
||||
this.pos.y = this.groundAt(this.pos.x, this.pos.z);
|
||||
|
||||
// Solids: push back out of anything we ended up inside. Pushout is perpendicular to the surface,
|
||||
// so walking into a wall at an angle keeps its tangential component and slides along it for free
|
||||
// — no separate slide pass. Velocity is deliberately NOT zeroed: the wind should still be able to
|
||||
// hold you against a fence, and the pushout wins over it every frame anyway.
|
||||
if (this.collide) {
|
||||
const r = this.collide(this.pos.x, this.pos.z, this.pos.y, this.pos.y + this.bodyHeight);
|
||||
if (r) { this.pos.x = r.x; this.pos.z = r.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
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
* 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 { PlayerSim, STATES, TUNE, clipFor } 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';
|
||||
@ -28,13 +28,36 @@ const drive = (sim, secs, input = {}, wind = null, t0 = 0) =>
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default function run(t) {
|
||||
// ---------------------------------------------------------------- state machine table
|
||||
// The 17 clips actually in player_anims.glb (integrator baked the M3 pack; names logged in THREADS).
|
||||
// Verified against the real GLB in-browser: SHADES.player.view.clipNames matches this exactly.
|
||||
const PACK = new Set(['Idle', 'Walk', 'Run', 'Falling', 'CrouchToStand', 'Reaction',
|
||||
'ClimbLadder', 'Crank', 'Dig', 'PickUp', 'Carry', 'CarryTurn', 'CarryIdle', 'StandUp',
|
||||
'TakeCover', 'StumbleBack', 'PlantSeeds']);
|
||||
|
||||
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}`);
|
||||
assert(PACK.has(st.clip), `state ${name} wants missing clip ${st.clip}`);
|
||||
if (st.carryClip) assert(PACK.has(st.carryClip), `state ${name} wants missing ${st.carryClip}`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('clipFor: carrying swaps the locomotion set, an interaction names its own verb', () => {
|
||||
const s = new PlayerSim();
|
||||
assertEq(clipFor(s), 'Idle', 'empty-handed idle');
|
||||
s.state = 'walk'; assertEq(clipFor(s), 'Walk');
|
||||
s.carrying = 'spare';
|
||||
assertEq(clipFor(s), 'Carry', 'carrying while walking');
|
||||
s.state = 'run'; assertEq(clipFor(s), 'Carry', 'no CarryRun clip exists — Carry covers it');
|
||||
s.state = 'idle'; assertEq(clipFor(s), 'CarryIdle', 'carrying while standing');
|
||||
s.state = 'busy'; s.busyClip = 'Crank';
|
||||
assertEq(clipFor(s), 'Crank', 'the verb wins over the carry set while busy');
|
||||
s.busyClip = null;
|
||||
assertEq(clipFor(s), 'Idle', 'busy with no named verb falls back to the table');
|
||||
// locked states have no carry variant — you drop what you held anyway
|
||||
s.carrying = 'spare'; s.state = 'knocked';
|
||||
assertEq(clipFor(s), 'Falling', 'knockdown always plays Falling');
|
||||
});
|
||||
|
||||
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;
|
||||
@ -163,6 +186,130 @@ export default function run(t) {
|
||||
assert(!s.busy, 'player is free');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- shelter (hold C)
|
||||
t.test('shelter: bracing survives a gust that floors you standing', () => {
|
||||
const gust = TUNE.knockWind + 8; // over the standing bar, under the braced one
|
||||
const standing = new PlayerSim();
|
||||
drive(standing, TUNE.knockSustain + 0.3, {}, windX(gust));
|
||||
assertEq(standing.state, 'knocked', 'standing, this gust floors you');
|
||||
|
||||
const braced = new PlayerSim();
|
||||
drive(braced, TUNE.knockSustain + 0.3, { shelter: true }, windX(gust));
|
||||
assertEq(braced.state, 'shelter', 'braced, the same gust does not');
|
||||
assert(braced.busy, 'shelter is locked — you cannot walk while braced');
|
||||
});
|
||||
|
||||
t.test('shelter: raises the bar, it does not remove it', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, TUNE.knockSustain + 0.2, { shelter: true }, windX(TUNE.knockWind * TUNE.shelterKnockMult + 5));
|
||||
assertEq(s.state, 'knocked', 'a big enough gust still takes you off your feet, braced or not');
|
||||
});
|
||||
|
||||
t.test('shelter: releasing the key always frees you, even mid-gust', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 1, { shelter: true }, windX(20));
|
||||
assertEq(s.state, 'shelter', 'braced');
|
||||
drive(s, 0.5, {}, windX(20)); // let go, wind still blowing
|
||||
assert(!s.busy && s.state === 'idle', 'released');
|
||||
});
|
||||
|
||||
t.test('shelter: cannot brace from your back', () => {
|
||||
const s = new PlayerSim();
|
||||
s.knockdown(0);
|
||||
drive(s, 0.3, { shelter: true });
|
||||
assertEq(s.state, 'knocked', 'holding C while down does not hijack the knockdown');
|
||||
});
|
||||
|
||||
t.test('shelter: the wind barely moves you while braced', () => {
|
||||
const push = (input) => {
|
||||
const p = new PlayerSim();
|
||||
drive(p, 30, input, windX(2)); // learn a calm baseline
|
||||
const x0 = p.pos.x;
|
||||
drive(p, 1.2, input, windX(24), 30);
|
||||
return Math.abs(p.pos.x - x0);
|
||||
};
|
||||
assertLess(push({ shelter: true }), push({}) * 0.5, 'bracing must cut the shove hard');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- stumble
|
||||
t.test('stumble: a gust below the knockdown bar still breaks your stride', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(3)); // calm baseline
|
||||
drive(s, 0.4, {}, windX(3 + TUNE.stumbleGust + 4), 30);
|
||||
assertEq(s.state, 'stumble', 'gust over stumbleGust but under knockWind');
|
||||
assert(s.busy, 'stumble is locked');
|
||||
drive(s, 1.0, {}, windX(3), 31);
|
||||
assertEq(s.state, 'idle', 'and it drains on its own');
|
||||
});
|
||||
|
||||
t.test('stumble: one gust hold must not stumble you twice', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(3));
|
||||
let stumbles = 0;
|
||||
const before = s.events.length;
|
||||
drive(s, 2.5, {}, windX(3 + TUNE.stumbleGust + 4), 30); // a full ~1.7 s hold and then some
|
||||
for (const e of s.events.slice(before)) if (e.type === 'state' && e.state === 'stumble') stumbles++;
|
||||
assertEq(stumbles, 1, 'stumbleCooldown makes it punctuation, not a stutter');
|
||||
});
|
||||
|
||||
t.test('stumble: bracing means you keep your feet', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, { shelter: true }, windX(3));
|
||||
drive(s, 0.5, { shelter: true }, windX(3 + TUNE.stumbleGust + 4), 30);
|
||||
assertEq(s.state, 'shelter', 'braced, the gust does not stumble you');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- solids collision
|
||||
t.test('collision: solids stop you, and the pushout slides you along them', () => {
|
||||
// one box: the yard's north wall, x -8..8, z -16..-10, waist high
|
||||
const wall = { x0: -8, x1: 8, z0: -16, z1: -10, y0: 0, y1: 3 };
|
||||
const R = 0.3;
|
||||
const collide = (x, z, feetY, headY) => {
|
||||
if (wall.y1 <= feetY + 0.05 || wall.y0 >= headY) return { x, z };
|
||||
const cx = Math.min(Math.max(x, wall.x0), wall.x1);
|
||||
const cz = Math.min(Math.max(z, wall.z0), wall.z1);
|
||||
const dx = x - cx, dz = z - cz, d2 = dx * dx + dz * dz;
|
||||
if (d2 >= R * R || d2 <= 1e-10) return { x, z };
|
||||
const d = Math.sqrt(d2);
|
||||
return { x: cx + dx / d * R, z: cz + dz / d * R };
|
||||
};
|
||||
|
||||
const s = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide });
|
||||
drive(s, 6, { x: 0, z: 1, run: true, camYaw: 0 }, null); // camYaw 0 → forward is -Z
|
||||
assert(s.pos.z >= -10 - 1e-6, `must not enter the wall, z=${s.pos.z.toFixed(3)}`);
|
||||
assertClose(s.pos.z, -10 + R, 0.02, 'stops exactly one body radius off the face');
|
||||
|
||||
// Diagonal into a LONG wall: blocked north, but must still slide east. The wall has to outrun
|
||||
// the player here — against the 16 m one above, a 4 s diagonal sprint rounds its east end and
|
||||
// gets past, which is correct behaviour and not what this assert is about.
|
||||
const long = { ...wall, x0: -100, x1: 100 };
|
||||
const collideLong = (x, z, feetY, headY) => {
|
||||
if (long.y1 <= feetY + 0.05 || long.y0 >= headY) return { x, z };
|
||||
const cx = Math.min(Math.max(x, long.x0), long.x1);
|
||||
const cz = Math.min(Math.max(z, long.z0), long.z1);
|
||||
const dx = x - cx, dz = z - cz, d2 = dx * dx + dz * dz;
|
||||
if (d2 >= R * R || d2 <= 1e-10) return { x, z };
|
||||
const d = Math.sqrt(d2);
|
||||
return { x: cx + dx / d * R, z: cz + dz / d * R };
|
||||
};
|
||||
const g = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide: collideLong });
|
||||
drive(g, 4, { x: 1, z: 1, run: true, camYaw: 0 }, null);
|
||||
assertClose(g.pos.z, -10 + R, 0.02, 'held off the wall');
|
||||
assert(g.pos.x > 2, `pushout must preserve the tangential slide, x=${g.pos.x.toFixed(2)}`);
|
||||
});
|
||||
|
||||
t.test('collision: you can walk under an overhang (eaves are above your head)', () => {
|
||||
// the real roof: y 2.99..3.21, reaching 0.4 m further into the yard than the wall below it
|
||||
const collide = (x, z, feetY, headY) => {
|
||||
const y0 = 2.99, y1 = 3.21;
|
||||
if (y1 <= feetY + 0.05 || y0 >= headY) return { x, z }; // filtered out for a 1.72 m body
|
||||
return { x, z: Math.max(z, -9.6 + 0.3) }; // would wall you off if it applied
|
||||
};
|
||||
const s = new PlayerSim({ start: { x: 0, y: 0, z: -5 }, collide, height: 1.72 });
|
||||
drive(s, 4, { x: 0, z: 1, run: true, camYaw: 0 }, null); // camYaw 0 → forward is -Z
|
||||
assert(s.pos.z < -9, `a 3 m eave must not block a 1.7 m person, z=${s.pos.z.toFixed(2)}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- determinism (PLAN3D §4)
|
||||
t.test('determinism: two identical 50 s runs produce byte-equal traces', () => {
|
||||
const trace = () => {
|
||||
@ -270,6 +417,61 @@ export default function run(t) {
|
||||
assertEq(sim.carrying, null, 'hands empty');
|
||||
});
|
||||
|
||||
t.test('interact: the action names the verb the player plays', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
it.register({ id: 'crank', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 1, clip: 'Crank' });
|
||||
fixedLoop(30 * DT, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(sim.busyClip, 'Crank', 'busyClip is set for the hold');
|
||||
assertEq(clipFor(sim), 'Crank', 'and that is what plays');
|
||||
it.step(DT, 1, sim, false);
|
||||
assertEq(sim.busyClip, null, 'cancelling clears the verb');
|
||||
assertEq(clipFor(sim), 'Idle', 'back to the table');
|
||||
});
|
||||
|
||||
t.test('interact: the verb is cleared on completion, so carry clips win afterwards', () => {
|
||||
const sim = new PlayerSim();
|
||||
const it = new Interact();
|
||||
it.register({ id: 'take', pos: { x: 0, y: 0, z: 0 }, radius: 2, holdSecs: 0.5, clip: 'PickUp',
|
||||
onDone: (p, tt) => p.pickUp('spare', tt) });
|
||||
fixedLoop(1, DT, (dt, tt) => it.step(dt, tt, sim, true));
|
||||
assertEq(sim.carrying, 'spare', 'picked it up');
|
||||
assertEq(sim.busyClip, null, 'verb cleared');
|
||||
assertEq(clipFor(sim), 'CarryIdle', 'and the player now reads as carrying');
|
||||
});
|
||||
|
||||
t.test('wireYardActions: reads corners LIVE, so attach() cannot strand the targets', () => {
|
||||
// Lane A's warning: attach() REPLACES the corners array. Capturing the corner object at wire
|
||||
// time would leave these gated on a `broken` flag nothing updates ever again.
|
||||
const rig = {
|
||||
corners: [{ anchorId: 'p1', broken: true, pos: { x: 0, y: 2, z: 0 } }],
|
||||
repair: () => {}, trim: () => {},
|
||||
cornerPos: (i) => rig.corners[i].pos,
|
||||
};
|
||||
const it = new Interact();
|
||||
wireYardActions(it, { sailRig: rig });
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
p.carrying = 'spare';
|
||||
assertEq(it.nearest(p).id, 'rerig_0', 'broken corner offers a re-rig');
|
||||
|
||||
// now do what attach() does: swap the whole array for fresh objects
|
||||
rig.corners = [{ anchorId: 'p1', broken: false, pos: { x: 0, y: 2, z: 0 } }];
|
||||
assertEq(it.nearest(p).id, 'trim_0', 'the NEW corner is unbroken → trim, not re-rig');
|
||||
rig.corners = [{ anchorId: 'p1', broken: true, pos: { x: 4, y: 2, z: 4 } }];
|
||||
assertEq(it.nearest(p), null, 'and it followed the corner when it moved out of range');
|
||||
});
|
||||
|
||||
t.test('wireYardActions: prompts track a moving (flogging) corner', () => {
|
||||
const corner = { anchorId: 'p1', broken: false, pos: { x: 0, y: 2, z: 0 } };
|
||||
const rig = { corners: [corner], repair: () => {}, trim: () => {}, cornerPos: (i) => rig.corners[i].pos };
|
||||
const it = new Interact();
|
||||
wireYardActions(it, { sailRig: rig });
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
assertEq(it.nearest(p).id, 'trim_0', 'in range at the start');
|
||||
corner.pos = { x: 9, y: 2, z: 9 }; // the corner blows away
|
||||
assertEq(it.nearest(p), null, 'prompt follows it out of range, not pinned to where it was');
|
||||
});
|
||||
|
||||
t.test('wireYardActions: duck-types against a half-landed world', () => {
|
||||
const empty = new Interact();
|
||||
wireYardActions(empty, {});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user