diff --git a/web/world/js/knife.js b/web/world/js/knife.js new file mode 100644 index 0000000..2ec76d7 --- /dev/null +++ b/web/world/js/knife.js @@ -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) => `
`; + + /** + * @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 += `