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:
parent
62005c8fd1
commit
e576f5cb67
@ -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]],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user