Lane D S18 gate 1.3: THE KNIFE — hold X at a cleat, and the verbs finally on the glass

DESIGN.md line 165's signature decision gets its verb. One hold, the whole
sail, at a cleat you can reach from the grass — because the corner taking the
$180 carport is bracket work and the ladder is a luxury a 5.9-second window
does not have. That is the mechanic, not a limitation: the ladder is for
repairs, the knife is for the seconds you actually have.

X and not a third hold-E target (interact.nearest picks by DISTANCE, and the
one input a player must never misfire is the irreversible one). A hold and not
a press, so the decision has a length and a gust can take it off you. And it
never writes player.busy: interact.js owns that state, and a second writer is
how a player gets stranded in a lock nothing releases.

The panel is the other half, and it is five sprints overdue. interact.step()
has always returned {target, progress, label, usable} "for hud.js to draw the
prompt + radial" — and nothing ever drew it. Every sentence this lane wrote
for the player's hands ("out of reach — needs the ladder", "push the water off
(169 kg)", "hands full") has never reached a player. Same disease as setFabric:
the code was right, the wire was missing, and no assert could see it because
the gap was on the glass. It lives in knife.js rather than player.js because
player.js statically imports GLTFLoader, so the suite has never been able to
reach it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 21:29:21 +10:00
parent 2baf54bd47
commit 008de38766
2 changed files with 379 additions and 2 deletions

336
web/world/js/knife.js Normal file
View File

@ -0,0 +1,336 @@
/**
* knife.js THE KNIFE. (Lane D, SPRINT18 gate 1.3)
*
* DESIGN.md line 165: *"knife (cut a sail loose — lose the sail, save the anchors)"*, and the
* signature decision under load: **release, cut, or ride it out.** B built the physics half
* (`rig.cutAway`); this is the verb the input, the reach rule, the read the decision is made off,
* and the panel that finally puts this lane's verbs on the glass (`createVerbPanel`, at the bottom).
*
* The panel lives HERE rather than in player.js for one reason and it is the suite: player.js
* statically imports GLTFLoader, which is why d.test.js has never been able to import it (it tests
* player.sim.js instead the same rule ladder.js and broom.js follow by loading their GLBs lazily).
* A prompt surface that only a browser can construct is a prompt surface with no asserts, which is
* precisely how it went five sprints unnoticed that nothing drew it.
*
* WHY IT IS ONE HOLD AND NOT FOUR
* The knife cuts the WHOLE SAIL, which is what the verb means in DESIGN.md and what B's
* `cutAway()` does with no arguments. The per-corner cut B also exposes is NOT this verb: DESIGN.md
* lists the **quick-release corner** ("installed in prep: deliberately depower a sail mid-storm")
* as its own tool, bought in the shop, and depowering one corner is that tool's job. One sprint,
* one verb; the shipped emergency is decided by the whole-sail cut and the corner version can
* arrive with the thing that sells it.
*
* WHERE YOU CAN CUT, AND WHY IT IS THE GROUND
* At the cleat, not at the corner. ladder.js's own header states the yard's rule: *"a sail post is
* tensioned from a cleat at its base, a tree anchor is a strop you throw"* while a fascia or
* carport bracket is worked at height. So the knife reaches a post or a tree from the grass, and it
* CANNOT reach a bracket corner.
*
* That is not a limitation, it is the mechanic, and the shipped emergency is the proof: the corner
* about to take the $180 carport is CB1, a bracket at y=2.36 that needs a ladder you have no time
* to fetch B measured the window at 5.9 s. **You cannot get to the corner that is failing, so you
* cut the sail at the one you can reach and the whole thing goes limp.** The ladder is for repairs
* (slow, two trips, both hands); the knife is for the seconds you actually have. They are
* complements, and neither is redundant.
*
* THE HOLD, AND WHAT IT DOES *NOT* TOUCH
* X, held for `cutSecs`, standing at a cleat. It is a hold and not a keypress on purpose: this is
* the most irreversible input in the game, and one tap of a key that spends the sail is a button,
* not a decision. Interruptible by walking out of reach, by releasing X, and by the storm putting
* you down mid-cut the same three ways a hold-E dies.
*
* It deliberately does NOT write `player.state`. interact.js's header states the invariant: *"this
* module both ENTERS and LEAVES the player's `busy` state. Nothing else writes it, so a dropped
* release cannot strand the player."* A second writer of `busy` is how a player gets stranded in a
* locked state with nothing to release them, so the knife instead REFUSES to start while the player
* is busy (you cannot cut mid-crank or mid-poke) and cancels itself if a locked state arrives while
* it is running. The cost is that there is no cut animation; filed rather than faked.
*
* Hands are NOT a gate, and that is a ruling. A knife is on your belt and cutting one line is a
* one-handed job but the real reason is that the alternative is worse: gating the game's
* signature decision behind dropping the spare would put an undiscoverable inventory step inside a
* 5.9-second window, and the player would read it as the game refusing rather than as a choice.
*/
import { needsLadder } from './ladder.js';
export const KNIFE_TUNE = {
/**
* How long the cut takes. Sits between the broom's poke (1.5 s) and the turnbuckle trim (1.2 s):
* a loaded sail cuts FAST that is why it is dangerous so this is not a long hold. It is long
* enough to be a decision you can change your mind about and short enough to fit the window B
* measured (cut at t=33 saves the carport; the corner blows at 35.9).
*/
cutSecs: 1.2,
/** How near the cleat/strop you must stand, m. The broom's `standRadius` is 2.0 for the same job. */
reach: 2.0,
};
/**
* Can a knife reach this corner from the grass?
*
* A live corner (there is nothing to cut on one that is already down) on an anchor whose work
* happens at the cloth rather than at a bracket `needsLadder` is the repo's one rule for that
* distinction and it reads the site's own `work` field, so a new yard follows with no code change.
*/
export const cuttable = (corner, anchor) =>
!!corner && !corner.broken && !needsLadder(anchor);
/**
* The cleat: the base of the post, the foot of the tree. Ground level at the anchor's x/z, which is
* where the line comes down to and where a person stands to work it.
*/
export const cleatOf = (anchor) => (anchor?.pos ? { x: anchor.pos.x, z: anchor.pos.z } : null);
/**
* What the cut is FOR, in one phrase, for the paperwork's `reason` field (B's payload carries it;
* their own example is 'the glasshouse'). The most expensive thing the live corners are still
* holding up because that is the sentence a player would say out loud.
*/
export function reasonFor(saves) {
if (!saves || !saves.length) return 'the steel';
const worst = saves.reduce((a, b) => (b.cost > a.cost ? b : a));
// The article, because this phrase is read INSIDE a sentence on the invoice and the collateral
// labels do not agree with each other about it: site JSON ships "the carport", "the gutter" and
// "garden gnome". Played it and the invoice said "cut loose at Q3 — 9s in, deliberate · for garden
// gnome", which reads like a typo rather than a reason.
return /^the /.test(worst.what) ? worst.what : `the ${worst.what}`;
}
/**
* @param {object} deps
* getRig() -> SailRig|null (live: rigSail() REPLACES the object)
* player -> PlayerSim
* getWorld() -> contracts World|null (anchors + collateral prices)
* getPhase() -> 'storm'| (optional; absent = "any time there is a sail")
* getGarden() -> {hp} (optional; absent = no cost line)
* collateralAtRisk(corners, world) -> {items,total} (optional; main.js injects ITS OWN walk, so
* the read and the invoice cannot disagree about what a cut saves. Absent = no saves line.)
* secsLeft() -> number (optional; how long the bed would be bare)
*/
export function createKnife(deps = {}) {
const tune = { ...KNIFE_TUNE, ...(deps.tune || {}) };
const player = deps.player;
const state = { progress: 0, cut: null };
const rigOf = () => (deps.getRig ? deps.getRig() : null);
const worldOf = () => (deps.getWorld ? deps.getWorld() : null);
const anchorOf = (c) => {
const w = worldOf();
return c?.anchor || (w && w.anchor ? w.anchor(c.anchorId) : null) || null;
};
/** Every corner still flying — what a cut would release, and what is still at risk if you don't. */
function liveCorners() {
const rig = rigOf();
if (!rig || !rig.rigged || !Array.isArray(rig.corners)) return [];
return rig.corners.filter((c) => !c.broken);
}
/** The nearest cleat within reach, or null. Ground only — you cannot cut from up a ladder. */
function target() {
if (!player || (player.climbY ?? 0) > 0.02) return null;
let best = null, bestD = Infinity;
for (const c of liveCorners()) {
const a = anchorOf(c);
if (!cuttable(c, a)) continue;
const cleat = cleatOf(a);
if (!cleat) continue;
const d = Math.hypot(cleat.x - player.pos.x, cleat.z - player.pos.z);
if (d <= tune.reach && d < bestD) { best = c; bestD = d; }
}
return best;
}
/** True when there is a sail to cut in a phase where cutting means something. */
function live() {
if (deps.getPhase && deps.getPhase() !== 'storm') return false;
return liveCorners().length > 0;
}
/**
* What the decision is made off and it is the whole of the HUD read, because "cut or ride it
* out" is unanswerable without both halves of the trade printed at once:
* saves the client's property the live corners are still holding up, priced through main.js's
* OWN collateral walk (injected), so this can never advertise a saving the invoice
* won't honour.
* costs the bed, bare for whatever is left of the storm. The knife's price, per B's ruling:
* the garden is the STAKE, not the wage.
*/
function read() {
if (!live()) return { state: 'none' };
const corners = liveCorners();
const w = worldOf();
const risk = (deps.collateralAtRisk && w) ? deps.collateralAtRisk(corners, w) : null;
const hp = deps.getGarden ? deps.getGarden()?.hp : null;
const secs = deps.secsLeft ? Math.max(0, Math.round(deps.secsLeft())) : null;
const at = target();
return {
state: state.cut ? 'cutting' : at ? 'ready' : 'unavailable',
at: at ? at.anchorId : null,
corners: corners.map((c) => c.anchorId),
progress: state.progress,
saves: risk ? risk.items : [],
savesTotal: risk ? risk.total : 0,
hp: hp == null ? null : Math.round(hp),
secsLeft: secs,
/**
* The refusal, and it has to name the REAL reason which is two different reasons.
*
* Played it and caught this in my own read: the line was one constant that always blamed the
* bracket, so on the swing lawn (a yard with no bracket corner in the quad) it told the player
* to fetch a ladder for a corner that does not exist. That is `needsLadder`'s own disease from
* SPRINT10 a rule keyed on the wrong thing, failing plausibly on the surface the rule is
* supposed to explain. So: is there a cleat to walk to, or is every live corner up a wall?
*/
whyNot: at ? null
: corners.some((c) => cuttable(c, anchorOf(c)))
? 'get to a post cleat or a tree strop'
: 'every corner left is bracket work — the knife cannot reach it',
};
}
/**
* @param {number} dt @param {number} t
* @param {boolean} holding is the knife key down this frame
* @returns {object} read() so the caller can draw the panel off one call
*/
function step(dt, t, holding) {
const at = target();
const locked = !!player && player.busy;
if (state.cut) {
// the three ways a cut dies, and they are the hold-E rules: let go, walk off, get put down
if (!holding || !at || at !== state.cut || locked) {
state.cut = null;
state.progress = 0;
}
} else if (holding && at && !locked && !state.latched) {
state.cut = at;
state.progress = 0;
}
if (!holding) state.latched = false;
if (state.cut) {
state.progress += dt / Math.max(1e-6, tune.cutSecs);
if (state.progress >= 1) {
const rig = rigOf();
const risk = read();
state.cut = null;
state.progress = 0;
// One press, one cut. Without the latch a held key re-arms the instant the sail is down and
// the next frame's `cutAway` refuses forever — a ticker full of "the sail is already down".
state.latched = true;
const res = rig && rig.cutAway ? rig.cutAway(null, reasonFor(risk.saves)) : { ok: false };
state.last = { t, ...res };
}
}
return read();
}
return {
tune,
read,
step,
target,
liveCorners,
get progress() { return state.progress; },
get cutting() { return !!state.cut; },
/** The last cutAway result, for a ticker or a test. */
get last() { return state.last ?? null; },
};
}
/**
* THE VERBS ON THE GLASS. (Lane D, SPRINT18 gate 1.3)
*
* **What I found building the knife, and it is five sprints old:** `interact.step()` returns
* `{target, progress, label, holding, usable}` "for hud.js to draw the prompt + radial" its own
* words and in the SHIPPED GAME nobody has ever drawn it. main.js calls
* `interact.step(dt, t, sim, keyboard.holding)` and throws the return value away; hud.js has no
* prompt element; the only renderer of a prompt in this repo is `dev_player.html`, a bench. So every
* sentence this lane has written for the player's hands *"out of reach — needs the ladder"*,
* *"push the water off (169 kg)"*, *"hands full"*, and the whole greyed-prompt surface I built for
* Lane A in SPRINT13 has never reached a player. The hold-E verbs have shipped with no prompt and
* no radial: you walk to a broken corner, hold a key you were told about on a help line, and the
* game says nothing until it is over. Same disease as `setFabric` (B, this sprint) and the F key
* before it the code was right, the wire was missing, and no assert could see the gap because the
* gap was on the glass.
*
* So this panel exists, and the knife is not the reason it exists the knife is just the verb that
* finally made it unignorable, because a cut with no radial is a keypress that eats your sail.
*
* It is D's own overlay rather than an edit to hud.js for two reasons: hud.js is Lane A's file and
* says so in line 2, and its own header already assigns *"the repair prompt, which Lane D already
* owns"* to this lane. z-index 9 UNDER #hud (10) and the cards (30), so a card always wins.
*/
export function createVerbPanel(opts = {}) {
if (typeof document === 'undefined') return { update() {}, dispose() {} }; // headless: no-op
const style = document.createElement('style');
style.textContent = `
#verbs { position:fixed; left:50%; bottom:40px; transform:translateX(-50%); z-index:9;
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; text-align:center;
text-shadow:0 1px 3px #000a; pointer-events:none; user-select:none; display:none; }
#verbs.on { display:block; }
#verbs .verb { background:#0d1418d9; border:1px solid #2c3a44; border-radius:6px;
padding:6px 12px; margin-top:5px; display:inline-block; min-width:200px; }
#verbs .key { font-weight:700; letter-spacing:.06em; }
#verbs .off { color:#8ba0ad; border-color:#243038; }
#verbs .bar { height:5px; background:#00000066; border-radius:3px; overflow:hidden; margin-top:5px; }
#verbs .bar i { display:block; height:100%; width:0; background:#ffd27a; }
/* The knife is the one irreversible input in the game. It gets the invoice's own red, and its two
consequence lines are coloured for what they mean: what you keep, what you spend. */
#verbs .knife { border-color:#7a2a22; background:#1a0f0dd9; }
#verbs .knife .key { color:#ff8f86; }
#verbs .knife .bar i { background:#ff5b4a; }
#verbs .saves { color:#6ee06e; }
#verbs .costs { color:#ffc24a; }`;
document.head.appendChild(style);
const root = document.createElement('div');
root.id = 'verbs';
document.body.appendChild(root);
let last = '';
const bar = (p) => `<div class="bar"><i style="width:${Math.round(Math.min(1, p) * 100)}%"></i></div>`;
/**
* @param {object|null} prompt interact.step()'s return
* @param {object|null} knife knife.read()
*/
function update(prompt, knife) {
const showable = !opts.getPhase || opts.getPhase() === 'storm' || opts.getPhase() === 'prep';
let html = '';
if (showable && prompt && prompt.target) {
html += `<div class="verb ${prompt.usable ? '' : 'off'}">`
+ `<span class="key">${prompt.usable ? '[E]' : '···'}</span> ${prompt.label || ''}`
+ `${prompt.holding ? bar(prompt.progress) : ''}</div>`;
}
if (showable && knife && knife.state !== 'none') {
const ready = knife.state === 'ready' || knife.state === 'cutting';
// The trade, both halves, always — "cut or ride it out" is unanswerable off one of them.
// "$N at risk" is the BOARD's own vocabulary for collateral, deliberately: the offer card
// priced this night at "$205 at risk" and the knife is the one move that takes that number off
// the table. Same words, same meaning, two cards apart. The items are named because a bare
// total is what B's gate 3 caught the SCORE IT card doing.
const saves = knife.saves?.length
? `<div class="saves">saves $${knife.savesTotal} at risk · ${
knife.saves.map((s) => s.what).join(', ')}</div>`
: '';
const costs = knife.hp == null ? ''
: `<div class="costs">costs the garden — ${knife.hp}% now, bare${
knife.secsLeft != null ? ` for ${knife.secsLeft}s` : ''}</div>`;
html += `<div class="verb knife ${ready ? '' : 'off'}">`
+ `<span class="key">${ready ? '[X]' : '···'}</span> ${
ready ? 'CUT THE SAIL LOOSE — hold' : `the knife: ${knife.whyNot}`}`
+ `${ready ? saves + costs : ''}`
+ `${knife.state === 'cutting' ? bar(knife.progress) : ''}</div>`;
}
if (html !== last) { last = html; root.innerHTML = html; }
root.classList.toggle('on', !!html);
}
return {
update,
get el() { return root; },
dispose() { root.remove(); style.remove(); },
};
}

View File

@ -18,8 +18,10 @@ import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
import { createLadder } from './ladder.js';
import { createBroom } from './broom.js';
import { createKnife, createVerbPanel, KNIFE_TUNE } from './knife.js';
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom };
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom,
createKnife, createVerbPanel, KNIFE_TUNE };
export const CHAR_URL = './models/player_01.glb';
export const ANIM_URL = './models/player_anims.glb';
@ -306,6 +308,25 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
const broom = (opts.broom === false || !opts.interact)
? null : createBroom(scene, world, opts.interact, sim, () => opts.interact.sailRig);
/**
* THE KNIFE (SPRINT18 gate 1.3). Same self-wiring as the ladder and the broom the rig is read
* LIVE off interact, because `rigSail()` replaces the object on every commit. Everything main.js
* has to supply is optional and injected as a GETTER, not a value: `game` and `garden` are
* declared in boot() BELOW the first `await loadSiteInto()` that builds the player, so a plain
* `{ game, garden }` would be a temporal-dead-zone throw at boot the trap A has documented twice
* in that file. A getter is read at frame time, when both exist.
*/
const knife = (opts.knife === false || !opts.interact) ? null : createKnife({
player: sim,
getRig: () => opts.interact.sailRig,
getWorld: () => world,
getPhase: opts.getPhase,
getGarden: opts.getGarden,
collateralAtRisk: opts.collateralAtRisk,
secsLeft: opts.secsLeft,
});
const verbs = opts.verbPanel === false ? null : createVerbPanel({ getPhase: opts.getPhase });
return {
get pos() { return sim.pos; },
get carrying() { return sim.carrying; },
@ -318,7 +339,10 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
sim.step(dt, t, input, opts.wind);
if (ladder) ladder.update(dt, t, input);
if (broom) broom.update(dt, t, input);
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
// The prompt state is KEPT now rather than dropped on the floor — see createVerbPanel.
const prompt = opts.interact ? opts.interact.step(dt, t, sim, keyboard.holding) : null;
const cut = knife ? knife.step(dt, t, keyboard.cutting) : null;
verbs?.update(prompt, cut);
view.sync(sim, dt);
},
@ -328,11 +352,16 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
view,
ladder,
broom,
knife,
verbs,
keyboard,
dispose() {
keyboard.dispose(); view.dispose();
if (ladder) ladder.dispose();
if (broom) broom.dispose();
// The panel is rebuilt with the player on every site change (loadSiteInto disposes the old
// one), so a leaked overlay would stack a second #verbs on the third night.
if (verbs) verbs.dispose();
},
};
}
@ -356,6 +385,18 @@ export class KeyboardInput {
get holding() { return this.keys.has('KeyE'); }
/**
* SPRINT18 THE KNIFE'S KEY. X, held.
*
* Its own key rather than a third hold-E target, because at a post base E already offers two
* things (re-rig, tighten) and `interact.nearest()` picks by DISTANCE a knife registered at the
* same position would race the repair for the same key, and the one input the player must never
* misfire is the irreversible one. X is free (WASD/arrows, shift, C brace, E hold, P, M, Enter,
* and prep's [ ] S F are the whole keyboard), and it is next to C on the row your left hand
* already lives on during a storm.
*/
get cutting() { return this.keys.has('KeyX'); }
/** @param {number} camYaw radians */
read(camYaw = 0) {
const k = this.keys;