/** * 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) => `
`; /** * @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 += `
` + `${prompt.usable ? '[E]' : '···'} ${prompt.label || ''}` + `${prompt.holding ? bar(prompt.progress) : ''}
`; } 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 ? `
saves $${knife.savesTotal} at risk · ${ knife.saves.map((s) => s.what).join(', ')}
` : ''; const costs = knife.hp == null ? '' : `
costs the garden — ${knife.hp}% now, bare${ knife.secsLeft != null ? ` for ${knife.secsLeft}s` : ''}
`; html += `
` + `${ready ? '[X]' : '···'} ${ ready ? 'CUT THE SAIL LOOSE — hold' : `the knife: ${knife.whyNot}`}` + `${ready ? saves + costs : ''}` + `${knife.state === 'cutting' ? bar(knife.progress) : ''}
`; } if (html !== last) { last = html; root.innerHTML = html; } root.classList.toggle('on', !!html); } return { update, get el() { return root; }, dispose() { root.remove(); style.remove(); }, }; }