Fabric + 0.40 as one landing: the wild night has a clean $80 win

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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 14:25:59 +10:00
parent 62005c8fd1
commit e576f5cb67
5 changed files with 158 additions and 17 deletions

View File

@ -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]],

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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');
});
/**