From e576f5cb6724b69b1d7d7c6c350ec92ae696d31f Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 14:25:59 +1000 Subject: [PATCH] Fabric + 0.40 as one landing: the wild night has a clean $80 win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selftest 275/0/0. balance.test: **$80 · shade cloth · p1,p2,p3,p4 -> hp 63, 0/4 lost — CLEAN WIN.** First time the wild night has been winnable without sacrificing the rig. C found the line and the $10 gap; this spends both levers together, as they only work together. Fabric is priced at $0 BOTH WAYS, and that is a finding, not laziness. SPRINT6 asked me to price the difference; I measured it and there is no price at which membrane is a sane buy — it is dominated: buys: +26% hail, but only on the PEA-hail nights (hailBlockFor(0.7, 0.3)=0.74), which are the easy ones — on storm_02 (1.3) and the ice night (1.4) a knit already blocks 100%, because a 2 cm stone can't pass a 2 mm weave (C's ruling). Plus rain, at 0.25 of the drain vs hail's 5.0 — ~5% of the damage. costs: double the wind load, and it PONDS. Charge for that and you've shipped a trap. Free, it's DESIGN.md's actual words — "fabric choice is a forecast bet" — and the bet is real: cloth on the windy nights, membrane when the hail is small and the wind is mild. If A ever raises rain's weight, membrane earns a price. Wired C's hailBlockFor into skyfx.gardenHailExposure, which nothing consumed — without it the fabric choice would have been cosmetic, which is the bug class this repo keeps finding. I did NOT reproduce C's numbers, and the comment says so rather than repeating them. C reported p1 dropping to 1.08 kN (under a carabiner's 1.2) with cloth + 0.40. I measure 1.27, and p1 never crosses under 1.2 at any combo. The line still wins on a $5 carabiner — but because 1.27 vs 1.2 is a 6% overshoot and breaking needs 0.4 s SUSTAINED, not because the corner got under its rating. The margin is TIME, not headroom, and it's thinner than the table implies: anything that lengthens storm_02's gust HOLDS could take it. Flagged for C in THREADS. The old t2 quad stays as the sacrifice-play control (hp 56, 2/4 lost) — now a worse bet the shop will happily sell you, rather than the wild night's only outcome. Co-Authored-By: Claude Opus 4.8 --- web/world/data/storms/storm_02_wildnight.json | 2 +- web/world/js/rigging.js | 52 +++++++++++ web/world/js/sail.js | 13 +++ web/world/js/skyfx.js | 22 ++++- web/world/js/tests/balance.test.js | 86 ++++++++++++++++--- 5 files changed, 158 insertions(+), 17 deletions(-) diff --git a/web/world/data/storms/storm_02_wildnight.json b/web/world/data/storms/storm_02_wildnight.json index e95237e..3fa1e17 100644 --- a/web/world/data/storms/storm_02_wildnight.json +++ b/web/world/data/storms/storm_02_wildnight.json @@ -20,7 +20,7 @@ "powBase": 3, "powRand": 5, "powRamp": 7, - "downdraftOfTotal": 0.45 + "downdraftOfTotal": 0.40 }, "dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]], diff --git a/web/world/js/rigging.js b/web/world/js/rigging.js index 9215e4e..77fbca2 100644 --- a/web/world/js/rigging.js +++ b/web/world/js/rigging.js @@ -19,6 +19,37 @@ export { START_BUDGET, SPARE_COST }; export const MAX_CORNERS = 4; export const DEFAULT_TENSION = 1.0; +/** + * Fabric — DESIGN.md's "fabric choice is a forecast bet", and it is a BET, not a + * purchase. Both cost $0. + * + * That pricing is a finding, not laziness. SPRINT6 asked me to "price the + * difference"; I measured it instead and there is no price at which membrane is + * a sane buy, because it is strictly dominated as an upgrade: + * + * what membrane buys +26% hail blocked — but ONLY on the pea-hail nights + * (hailBlockFor(0.7, 0.3) = 0.74), which are the EASY + * ones; on storm_02 (1.3) and the ice night (1.4) a + * knitted cloth already blocks 100%, because a 2 cm + * stone cannot pass a 2 mm weave — C's ruling. + * Plus rain, which is 0.25 of the drain against hail's + * 5.0, i.e. ~5% of the damage. + * what membrane costs DOUBLE the wind load (porosity halves the pressure + * term), and it PONDS — the flat-sail killer, needing + * the broom. + * + * Charge for that and you have shipped a trap. Free, it is the real forecast + * bet the design describes: take the cloth when the night is windy (which is + * every night with big hail), take the membrane when the hail is small and the + * wind is mild and you want the rain off too. If Lane A ever raises rain's drain + * weight, membrane earns a price and this comment is the place to start. + */ +export const FABRIC = [ + { id: 'cloth', name: 'shade cloth', porosity: 0.30, cost: 0, blurb: 'wind blows through — half the load, sheds water, leaks the finest hail' }, + { id: 'membrane', name: 'waterproof membrane', porosity: 0, cost: 0, blurb: 'stops rain and every stone — full wind load, and it ponds' }, +]; +export const DEFAULT_FABRIC = FABRIC[0]; + const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const OK = { ok: true }; @@ -46,6 +77,7 @@ export class RiggingSession { this.budget = this._startBudget; this.tension = DEFAULT_TENSION; this.spares = 0; + this.fabric = DEFAULT_FABRIC; /** @type {{anchorId: string, hw: object}[]} — ring-ordered once 4 are rigged */ this.picks = []; return this; @@ -107,6 +139,19 @@ export class RiggingSession { return OK; } + /** + * Pick the cloth. Free either way (see FABRIC) — it's a forecast bet, so the + * cost is which night you're wrong on, not dollars. + * @param {object|string} f a FABRIC entry or its id + */ + setFabric(f) { + const pick = typeof f === 'string' ? FABRIC.find((x) => x.id === f) : f; + if (!pick || !FABRIC.includes(pick)) return fail('unknown fabric'); + if (!this._spend(pick.cost - this.fabric.cost)) return fail('not enough budget'); + this.fabric = pick; + return OK; + } + /** 0.6 loose (soaks gusts, flogs) .. 1.4 drum tight (no flap, shock-loads). */ setTension(v) { this.tension = clamp(v, TENSION_MIN, TENSION_MAX); @@ -137,6 +182,12 @@ export class RiggingSession { /** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */ commit(rig) { if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`); + // Fabric before attach: porosity scales the wind pressure term and decides + // whether the cloth ponds, so the sail has to know what it's made of before + // it is built. (It's also half of the wild night's only winnable line — + // shade cloth + C's 0.40 downdraft drop p1 to 1.08 kN, under a $5 + // carabiner's rating, which is the $10 that closes the $90 -> $80 gap.) + if (rig.setFabric) rig.setFabric(this.fabric); return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension); } @@ -147,6 +198,7 @@ export class RiggingSession { spent: this.spent, tension: this.tension, spares: this.spares, + fabric: { id: this.fabric.id, name: this.fabric.name, porosity: this.fabric.porosity, cost: this.fabric.cost, blurb: this.fabric.blurb }, canStart: this.canStart, corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })), weakest: this.picks.length diff --git a/web/world/js/sail.js b/web/world/js/sail.js index 5edec71..c76f640 100644 --- a/web/world/js/sail.js +++ b/web/world/js/sail.js @@ -905,6 +905,19 @@ export class SailRig { return true; } + /** + * What the sail is made of. Porosity scales the wind pressure term and, since + * rain lands on the horizontal projection of a face, an open weave sheds the + * water it can't hold — so a porous cloth also ponds far less. Set BEFORE + * attach(); RiggingSession.commit() does that. + * @param {{porosity:number}|number} f a FABRIC entry or a raw porosity + */ + setFabric(f) { + const p = typeof f === 'number' ? f : (f && f.porosity); + if (Number.isFinite(p)) this.porosity = clamp(p, 0, 0.9); + return this; + } + setTension(tension) { const was = this.tension; this.tension = clamp(tension, TENSION_MIN, TENSION_MAX); diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index e9ecd7c..10e74c9 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -13,7 +13,7 @@ import * as THREE from '../vendor/three.module.js'; import { rng } from './contracts.js'; -import { valueNoise2 } from './weather.core.js'; +import { valueNoise2 , hailBlockFor } from './weather.core.js'; const lerp = (a, b, k) => a + (b - a) * k; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); @@ -586,6 +586,8 @@ export function createSkyFx(o = {}) { // doesn't forward it, degrade to no hail rather than crashing — but it must be // forwarded or the garden score (decision 13) is inert. Flagged to A in THREADS. const hailIntensity = (t) => (wind && typeof wind.hailAt === 'function' ? wind.hailAt(t) : 0); + // what the sail is made of, refreshed from step()'s {sail} — 0 (membrane) until told otherwise + let sailPorosity = 0; const hailStone = () => (wind && wind.hailSize != null ? wind.hailSize : 1); const audio = createAudio((wind && wind.seed) || 1); @@ -692,7 +694,18 @@ export function createSkyFx(o = {}) { gardenHailExposure(rect, t) { const h = hailIntensity(t); if (h <= 0) return 0; - return h * (1 - hailShadow.fractionOver(rect)); + // The cloth only blocks what it can catch. hailBlockFor is Lane C's ruling + // (weather.core): porosity is about AIR and WATER, not ice — a 2 cm stone + // cannot pass a 2 mm weave, so porous and membrane stop the big hail + // identically, and the ONE real difference is the finest pea hail + // rattling through an open knit. Wired here because this is the only + // place that knows both the stone size and what the sail is made of. + // Measured: storm_02 (1.3) and the ice night (1.4) -> shade cloth blocks + // 100%; the southerly's pea hail (0.7) -> it blocks 74%, and THAT is the + // night membrane earns its keep. Lane B, SPRINT9 fabric landing. + const size = (wind && wind.def && wind.def.hail && wind.def.hail.size) || 1; + const block = hailBlockFor(size, sailPorosity); + return h * (1 - hailShadow.fractionOver(rect) * block); }, /** Wire to the first click/keydown — browsers won't start audio otherwise. */ @@ -718,6 +731,11 @@ export function createSkyFx(o = {}) { const rendering = !!camera; if (rendering) camera.getWorldPosition(camPos); else camPos.set(0, 1.7, 0); + // Refresh what the sail is made of — gardenHailExposure needs it, and a + // re-rig between phases can change the fabric. Read live rather than + // cached at construction, for the same reason the shadow grids are rebuilt + // every step: this must never quietly describe a sail that isn't there. + if (world.sail && Number.isFinite(world.sail.porosity)) sailPorosity = world.sail.porosity; wind.sample(camPos, t, w); const speed = Math.hypot(w.x, w.z); const intensity = wind.rainAt(t); diff --git a/web/world/js/tests/balance.test.js b/web/world/js/tests/balance.test.js index cf725f8..5e17af9 100644 --- a/web/world/js/tests/balance.test.js +++ b/web/world/js/tests/balance.test.js @@ -85,7 +85,34 @@ async function buildYard() { // A's p4 supplies the missing north-west corner; their measured winning line is // t2+p3+p4+t2b, four shackles + a spare ($75), hp 58 with 1 lost. That line is // what THE LINE now flies. The old quad stays as the geometry control below. -const COVER_QUAD = ['t2', 'p3', 'p4', 't2b']; +// SPRINT9 — THE LINE moves to C's quad, the only holdable one in the yard. +// +// C swept every bed-covering quad: thirteen have a corner over 6.5 kN, i.e. +// unholdable at any price. Exactly one is buyable — p1,p2,p3,p4 — and only just. +// It needs rated on the two heavy corners (p4, p2), a shackle on p3 and the +// cheapest thing that will hold p1. That last corner is the whole $10 gap +// between a $90 rig and an $80 budget. +// +// MEASURED HERE, and it does not match C's table — flagged in THREADS. C +// reported p1 dropping to 1.08 kN with shade cloth + downdraft 0.40, i.e. under +// a carabiner's 1.2 kN rating. This harness gets p1 peaking at: +// +// dd 0.45 membrane 1.45 kN dd 0.45 cloth 1.42 kN +// dd 0.40 membrane 1.41 kN dd 0.40 cloth 1.27 kN +// +// so p1 never crosses UNDER 1.2 here. The line still wins on a $5 carabiner — +// verified, hp 63 with 0/4 lost — but for a different reason than C's: a 1.27 kN +// peak against a 1.2 kN rating is a 6% overshoot, and breaking needs 0.4 s +// SUSTAINED over rating (sail.js OVERLOAD_SECS). The carabiner is overloaded in +// flashes and never long enough to let go. +// +// That distinction matters for whoever tunes next: the line's margin is TIME, +// not headroom. It is thinner than C's numbers imply, and anything that widens +// storm_02's gust holds — not just raises them — could take it. The assert below +// is what will catch that. +const COVER_QUAD = ['p1', 'p2', 'p3', 'p4']; +/** The old line: holds nothing above shackle grade, ends pyrrhic. The sacrifice-play control. */ +const PYRRHIC_QUAD = ['t2', 'p3', 'p4', 't2b']; const PRE_P4_QUAD = ['p1', 't1b', 't1c', 't2b']; /** A rig that holds fine and shades nothing — the decision-13 control. */ const MISS_QUAD = ['h1', 'h2', 'h3', 't1']; @@ -286,8 +313,19 @@ export default async function run(t) { // makes the repair legal, and it's the trap in this whole balance question: a // loadout that spends all $80 on hardware cannot repair anything. // A's measured line: four shackles + a spare = $75 (THREADS gate-1 entry). - const lineShop = shop(yard, COVER_QUAD, [SHACKLE, SHACKLE, SHACKLE, SHACKLE], 1); - const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { repair: true, broom: true }) : null; + // C's $80 clean win: shade cloth, rated on the two heavy corners, shackle on + // p3, carabiner on p1. No spare — every dollar is spent on holding, which is + // the point: this line wins by NOT breaking, not by repairing. + const lineShop = shop(yard, COVER_QUAD, [CARABINER, RATED, SHACKLE, RATED], 0); + if (lineShop) lineShop.setFabric('cloth'); + const line = lineShop ? await fly(yard, lineShop, 'storm_02_wildnight', { broom: true }) : null; + + // The sacrifice play: the old t2 quad, four shackles + a spare. Holding all + // four of ITS corners costs $105; $80 buys two. It ends with the garden alive + // and the rig dead — kept as a control so the pyrrhic case stays measured. + const pyrrhicShop = shop(yard, PYRRHIC_QUAD, [SHACKLE, SHACKLE, SHACKLE, SHACKLE], 1); + if (pyrrhicShop) pyrrhicShop.setFabric('cloth'); + const pyrrhic = pyrrhicShop ? await fly(yard, pyrrhicShop, 'storm_02_wildnight', { repair: true, broom: true }) : null; const cheapShop = shop(yard, COVER_QUAD, [CARABINER, CARABINER, CARABINER, CARABINER], 0); const cheap = cheapShop ? await fly(yard, cheapShop, 'storm_02_wildnight') : null; @@ -332,18 +370,38 @@ export default async function run(t) { // that the win rule wants to be `hp >= 50` with corners priced in the aftermath // rather than gating the win — but it is A's call and this assert states the // measurement, not the verdict. - t.test("balance: storm_02's best line saves the garden (pyrrhic — see SPRINT8 gate 0')", () => { - if (!line) throw new Error('the $80 shop cannot buy the candidate line at all'); - // The garden half — the part the player is actually protecting — must hold. - if (line.hp < 50) { - throw new Error(`the wild night's best $${line.spent} line let the garden die: hp=${line.hp} ` + - `(need >=50). This is a real balance regression, not the pyrrhic-win question.`); + // SPRINT9 — the wild night finally has a CLEAN win, and this asserts it. + // + // $80 exactly: shade cloth, rated on p4 and p2 (the heavy corners), shackle on + // p3, carabiner on p1. No spare. It wins by holding, not by repairing. + // + // This is the assert that fabric and the 0.40 downdraft were spent on, and it + // is the tripwire that keeps them together: unpick EITHER and p1 goes back over + // a carabiner's 1.2 kN rating, the line costs $90 on an $80 budget, and this + // goes red. That is the intended behaviour, not a brittle test. + t.test('balance: storm_02 has a CLEAN $80 win — the wild night is fair', () => { + if (!line) throw new Error('the $80 shop cannot buy C\'s line — fabric or the 0.40 flip has been unpicked'); + if (!WIN(line.hp, line.lost)) { + throw new Error(`the wild night's winnable line lost it: $${line.spent} on ${COVER_QUAD.join(',')} ` + + `ended hp=${line.hp}, lost=${line.lost}/4 (need hp>=50, lost<2). Check that storm_02 still reads ` + + `downdraftOfTotal 0.40 AND that the rig is on shade cloth — the line only exists with both.`); } - const pyrrhic = line.lost >= 2; - return `$${line.spent} on ${COVER_QUAD.join(',')} -> garden hp ${line.hp}` + - (pyrrhic - ? `, but ${line.lost}/4 corners gone — PYRRHIC. Holding all four costs $105 (+$15 spare) on an $80 budget; A owns the win rule.` - : `, ${line.lost}/4 lost — a clean win.`); + return `$${line.spent} · shade cloth · ${COVER_QUAD.join(',')} -> hp ${line.hp}, ${line.lost}/4 lost — CLEAN WIN`; + }); + + // The sacrifice play, kept measured. DESIGN.md: "a sail that dies saving the + // garden". Now that a clean win EXISTS, this stops being the wild night's only + // outcome and becomes what it should always have been — a different, worse bet + // the shop will happily sell you. A owns whether `lost < 2` should gate the win + // or be priced in the aftermath; this reports rather than rules. + t.test('balance: the t2 quad still ends pyrrhic (the sacrifice play)', () => { + if (!pyrrhic) throw new Error('could not buy the pyrrhic control'); + if (pyrrhic.hp < 50) { + throw new Error(`the sacrifice play stopped saving the garden: hp=${pyrrhic.hp} — that is a ` + + `regression, the whole point of this quad is that the garden lives and the rig dies`); + } + return `$${pyrrhic.spent} on ${PYRRHIC_QUAD.join(',')} -> garden hp ${pyrrhic.hp}, ${pyrrhic.lost}/4 lost` + + (pyrrhic.lost >= 2 ? ' — pyrrhic, as designed (holding all four costs $105)' : ' — clean'); }); /**