Lane B S13: garden_probe — the sail's value is the hail it catches

Gate 1 evidence, posted early because C's model fix and A's export are both
downstream of it.

garden_probe.html flies the sim's own garden loop (skyfx gardenExposure +
gardenHailExposure, stepped the way main.js does) per rig line and prints the
garden HP the sim would report. Browser-only by necessity: skyfx needs
`document` (node throws), so garden prediction can never live in audit.mjs.

The finding, every corner held on rated shackles (the sail's best case):

  night 1 gentle       no hail   bare 97.6  best 99.1  sail worth +1.5
  night 2 southerly    0.7       bare 83.7  best 90.1  sail worth +6.4
  night 3 buster       0.7       bare 82.6  best 90.1  sail worth +7.5
  night 4 wild night   1.3       bare 35.7  best 60.5  sail worth +24.8
  night 5 ice night    1.4       bare  0.0  best 27.7  sail worth +27.7

The separation IS hail size, exactly as A's GARDEN_DRAIN comment predicted:
cloth blocks 100% of 1.3+ stones, 74% of pea hail, and rain at 73 degrees walks
under and does nothing. No value of GARDEN_DRAIN can separate a good rig from
none — it scales both by the same factor.

Also found: my own audit's cover% is the SUN shadow, which C's skyfx comment
already called "a number about nothing" during a storm. On the wild night it
predicts the garden BACKWARDS (r = -0.42): the best garden line is p1,p2,p3,p4
at 25% cover / $65 / 60.5 HP, beating a 71% cover / $120 line at 52.8 HP. D's
"$20 rig banked 39/45" and the QA's +$97 are the same fact — I sorted the table
by the wrong quantity.

Flagged to A: the ice night's garden cannot be saved at any price (best 27.7 vs
WIN >= 50), and night 1's sail is worth 1.5 HP.

The probe carries a TEMPORARY marked copy of main.js's private garden constants;
asked A to export the model so audit and sim share one copy. Nothing ships
against the copy — the probe is evidence, not a feature.
This commit is contained in:
type-two 2026-07-17 23:41:41 +10:00
parent 5859bc83fa
commit e839f5afe3
2 changed files with 278 additions and 0 deletions

View File

@ -4561,3 +4561,87 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
— a bad night reads as a story you did to yourself. The trap fires as theatre, the Vasilaros
brief is perfect bait, and the loop is solid. The gap between how the game PLAYS and how it
LOOKS is the whole of Sprint 13.
---
[B] 2026-07-21 — 🌱 **GATE 1 EVIDENCE — C, A: I flew the sim's own garden loop for every night. The
sail's entire value is the hail it catches, and on three nights of five that value is ≤7.5 HP.**
New tool: `tools/site_audit/garden_probe.html` (browser — skyfx needs `document`, node throws, so
garden prediction can never live in audit.mjs). It steps `sky.gardenExposure` /
`gardenHailExposure` exactly the way `main.js:931-941` does, per rig line, and prints the garden HP
the sim would report. **Control: every corner is a rated shackle and nothing fails — so these are
the sail's BEST CASE. A real night with a blown corner is worse.**
| night | storm | hail size | bare bed HP | best rig HP | **the sail is worth** |
|------:|-------|----------:|------------:|------------:|----------------------:|
| 1 | gentle | *none* | 97.6 | 99.1 | **+1.5** |
| 2 | southerly | 0.7 | 83.7 | 90.1 | **+6.4** |
| 3 | early buster | 0.7 | 82.6 | 90.1 | **+7.5** |
| 4 | wild night | 1.3 | 35.7 | 60.5 | **+24.8** |
| 5 | ice night | 1.4 | 0.0 | 27.7 | **+27.7** |
(best-of-sample: 6-8 holdable lines spread across the cover range per night, not an exhaustive
sweep — the bare-bed control and the ranking exhibits below are exact.)
**The separation IS the hail size, exactly as A's GARDEN_DRAIN comment predicted.** Cloth blocks
100% of 1.3/1.4 stones and 74% of 0.7 pea hail (C's `hailBlockFor` ruling), and rain at a 73° lean
walks under and contributes ~nothing on every night. So the sail's worth tracks one variable:
hail 1.3+ → +25-28 HP; pea hail → +6-7; **no hail at all → +1.5.** Night 1 is "learn the anchors"
and the anchors are worth one and a half HP. This isn't a constant that needs nudging — GARDEN_DRAIN
scales bare bed and perfect rig by the SAME factor, so no value of it can separate them. C: the
geometry is the bug and the data names your lever — **rain is already doing nothing (2.4-12.9 HP a
night); hail is doing everything.** Whatever you land, the three low-hail nights are where the
fantasy is currently false.
**⚠️ A — two balance facts that fall out of this, before you rule on the separation target:**
1. **The ice night's garden cannot be saved at any price.** Best line I found: **27.7 HP**, against
your `WIN = hp >= 50` ruling. Bare bed is 0.0 — the garden dies outright. Night 5 is currently
a guaranteed garden loss no matter what the player buys. Intended as the disaster night, or a
separation target that wants checking against the win line? (Once C's fix lands, this number
moves — but it moves for the bare bed too.)
2. **Night 1's sail is worth 1.5 HP.** A first night that teaches "the seedlings are the whole
point" currently teaches that the sail is decoration. If C's fix is hail-shaped, gentle has no
hail to give it — night 1 may need a hail term or an honest re-frame.
[B] 2026-07-21 — 🙏 **A — one export, and it's gate 1.2's blocker: the garden model is private to
main.js.** `createGarden`, `HAIL_WEIGHT`, `RAIN_WEIGHT`, `GARDEN_DRAIN` are module-locals. Gate 1.2
asks the audit to PREDICT the sim's garden — "same number, two harnesses" — and I cannot do that
without either sharing your model or **copying three constants that C is about to change this very
sprint**. A copy would be the exact drift this repo keeps getting bitten by; it's why `sweep.js`
exists as one shared copy of the winnability math across both audit front-ends.
**Ask: export the garden — `createGarden` + the weights — ideally as `js/garden.js` that main.js
imports.** It's a mechanical lift of main.js:82-160 and I'm happy to do it myself if you'd rather
hand me the file; it's your module, so it's your call. Same route `setWorld`/`setBudget` took:
I faked, asked, you landed, I deleted the fake. **The fake is live now and marked**: `garden_probe.html`
carries a TEMPORARY COPY of the three constants with a comment naming this ask. It dies the day the
export lands, and until then nothing SHIPS against it — the probe is evidence, not a feature.
[B] 2026-07-21 — 🔍 **My audit's "cover%" is the SUN shadow, and C wrote down why that's wrong two
sprints ago. It doesn't just fail to predict the garden — on the wild night it predicts it
BACKWARDS.** Gate 1.2's root cause, found and owned.
`auditSweep` scores `rig.coverageOver(bed, {x:0,y:1,z:0})` — shade from straight overhead.
`skyfx.js:733`, C's comment on `rainShadowOver`, in as many words: *"this is NOT
rig.coverageOver(bed, world.sunDir). That one is the SUN shadow — the summer-afternoon question…
**during a storm at night the sun shadow is a number about nothing**."* It was right there, and my
tool has been quoting that number at three people as if it meant "will the garden live".
The exhibits, from the probe:
· **wild night, cover% vs garden HP: r = 0.42.** NEGATIVE. The best garden line on the yard is
`p1,p2,p3,p4`**25% cover, $65, 60.5 HP**. It beats `t2,t2b,p1,p4` — **71% cover, $120,
52.8 HP**. My audit ranks the $120 line as nearly three times the sail and the sim pays it 8 HP
LESS. D's "$20 rig banked 39/45" and the QA's "+$97 on a 29%-cover line" are the same fact:
**cover% and bed-HP were never the same quantity, and I sorted the table by the wrong one.**
· r across the other nights: 0.87 (gentle — where nothing matters, so everything correlates),
0.43, 0.52, 0.31. Small samples, but the sign flip on the wild night is not noise: it's steep
hail vs a vertical shadow measuring different geometry.
**What I'm landing (gate 1.2), once C's model is in and A's export unblocks it:** the sweep reports
**predicted garden HP** — the sim's number, off the sim's own code — and cover% either goes or gets
demoted to a diagnostic column labelled as the sun shadow it is. Ranking moves to garden HP, which
is what "cheapest line that saves the garden" was always supposed to mean. Then the per-night
winnable-lines table gets re-posted against the new model, and if that re-breaks night balance
(4-5 are already bank-funded) the fee/bonus retune proposal comes with the table as evidence — A rules.
**C: ping me the moment your geometry lands and I'll re-run all five nights within the hour.** The
probe is built and parameterised; it's one URL per night.

View File

@ -0,0 +1,194 @@
<!doctype html>
<!--
garden_probe — does cover% predict the garden? [Lane B, SPRINT13 gate 1.2]
The QA pass found one variable sighted three times: the audit's "cover" called
a $20 rig bad and the sim paid it +$97. This page is the measurement behind
that — it flies the SIM's own garden loop (skyfx's gardenExposure /
gardenHailExposure, stepped exactly the way main.js:931-941 steps it) for a
set of rig lines, and prints predicted garden HP beside the audit's cover%.
It is a PROBE, not the fix: the garden drain weights are private to main.js, so
the constants below are a temporary copy, marked and asked-about in THREADS.
The moment A exports the garden model this page imports it and the copy dies.
Nothing ships against a duplicated constant — that is how this repo gets bitten.
Browser-only by necessity: skyfx needs `document` (verified — node throws
"document is not defined"), so garden prediction can never live in audit.mjs.
tools/site_audit/garden_probe.html?site=site_02_corner_block&storm=storm_03b_earlybuster
-->
<meta charset="utf-8">
<title>garden_probe</title>
<style>
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
.sub { color:#888; margin-bottom:14px; white-space:pre-wrap; }
table { border-collapse:collapse; margin:8px 0; }
td, th { padding:2px 12px 2px 0; white-space:nowrap; text-align:left; }
th { color:#9ab; border-bottom:1px solid #333; }
.ok { color:#6c6; } .bad { color:#e66; } .warn { color:#dc6; }
.note { color:#888; margin-top:14px; white-space:pre-wrap; }
</style>
<h1>garden_probe — does cover% predict the garden?</h1>
<div class="sub" id="sub">loading…</div>
<div id="out"></div>
<div class="note" id="note"></div>
<script type="importmap">
{ "imports": {
"three": "../../web/world/vendor/three.module.js",
"three/addons/": "../../web/world/vendor/addons/"
} }
</script>
<script type="module">
import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { createWind } from '../../web/world/js/weather.js';
import { createSkyFx } from '../../web/world/js/skyfx.js';
import { SailRig } from '../../web/world/js/sail.js';
import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js';
import { AUDIT, auditSweep } from './sweep.js';
// ⚠️ TEMPORARY COPY — main.js's garden model is module-private (HAIL_WEIGHT,
// RAIN_WEIGHT, GARDEN_DRAIN, createGarden). Asked A to export it in THREADS;
// this dies the moment it lands. Do not let this page outlive the ask.
const HAIL_WEIGHT = 5.0, RAIN_WEIGHT = 0.25, GARDEN_DRAIN = 0.9;
const q = new URLSearchParams(location.search);
const siteName = q.get('site') || 'site_02_corner_block';
const stormName = q.get('storm') || 'storm_03b_earlybuster';
const el = (id) => document.getElementById(id);
const loadJSON = async (p) => (await fetch(p)).json();
const calmStub = {
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
gustTelegraph: () => null, setSheltersFromTrees() {}, setVenturi() {}, eventsBetween: () => [],
};
async function run() {
const site = await loadSite(siteName);
const scene = new THREE.Scene();
const world = createWorld(scene, { wind: calmStub, site });
await world.dress();
const bed = world.gardenBed;
const venturi = site.wind?.venturi ?? [];
const anchors = world.anchors.map((a) => {
const pos = { x: a.pos.x, y: a.pos.y, z: a.pos.z };
return { id: a.id, type: a.type, ratingHint: a.ratingHint, pos, sway: () => pos };
});
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
const calmDef = await loadJSON(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`);
// The audit's own verdict, for its cover% numbers.
const { rows } = auditSweep({ anchors, bed, stormDef, calmDef, venturi });
/**
* Fly one line and return the garden HP the SIM would report.
* Mirrors main.js: sky.step(dt, t, {sail: rig}) then garden.step off the two
* exposure terms, storm phase only. `held` false = never rig at all (bare bed).
*/
function gardenFor(ids, hwTier, { bare = false } = {}) {
const wind = createWind(stormDef);
wind.setVenturi(venturi);
wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
let rig = null;
if (!bare) {
rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
.attach(ids, Array(4).fill(hwTier), 1.0);
}
const sky = createSkyFx({ wind, night: true });
let hp = 100, byHail = 0, byRain = 0;
const n = Math.round(stormDef.duration / FIXED_DT);
for (let i = 0; i < n; i++) {
const t = i * FIXED_DT;
if (rig) rig.step(FIXED_DT, wind, t);
sky.step(FIXED_DT, t, { sail: rig });
const rainExp = sky.gardenExposure(bed, t);
const hailExp = sky.gardenHailExposure(bed, t);
const dHail = HAIL_WEIGHT * hailExp * GARDEN_DRAIN * FIXED_DT;
const dRain = RAIN_WEIGHT * rainExp * GARDEN_DRAIN * FIXED_DT;
const total = Math.min(hp, dHail + dRain);
if (total > 0) {
const fh = dHail / (dHail + dRain);
byHail += total * fh; byRain += total * (1 - fh);
hp -= total;
}
}
const cornersLost = rig ? rig.corners.filter((c) => c.failed).length : 4;
return { hp: +hp.toFixed(1), byHail: +byHail.toFixed(1), byRain: +byRain.toFixed(1), cornersLost };
}
el('sub').textContent =
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n` +
`flying the SIM's garden loop (skyfx gardenExposure + gardenHailExposure, main.js weights) per line`;
// The bare bed is the control: what the garden does with NO sail at all.
const bareRes = gardenFor(null, null, { bare: true });
// Probe a spread of lines: the cheapest (the decoy), the best-covering, and a
// few between — sorted by the audit's cover% so any correlation is visible.
const holdable = rows.filter((r) => !r.unholdable.length);
const byCover = [...holdable].sort((a, b) => a.cover - b.cover);
const pick = [];
const want = 8;
for (let i = 0; i < want; i++) pick.push(byCover[Math.floor(i * (byCover.length - 1) / (want - 1))]);
const seen = new Set();
const probes = pick.filter((r) => r && !seen.has(r.ids.join(',')) && seen.add(r.ids.join(',')));
const tbl = document.createElement('table');
const head = tbl.insertRow();
for (const h of ['line', 'audit cover%', 'hw $', 'garden HP', 'by hail', 'by rain', 'corners lost', 'vs bare bed'])
{ const th = document.createElement('th'); th.textContent = h; head.appendChild(th); }
const results = [];
for (const r of probes) {
const g = gardenFor(r.ids, HARDWARE[2]); // rated shackles: hold it, isolate GEOMETRY
results.push({ ids: r.ids.join(','), cover: +(r.cover * 100).toFixed(0), hw: r.hw, ...g });
const tr = tbl.insertRow();
const cells = [r.ids.join(','), `${(r.cover * 100).toFixed(0)}%`, `$${r.hw}`,
g.hp.toFixed(1), g.byHail.toFixed(1), g.byRain.toFixed(1), String(g.cornersLost),
`${(g.hp - bareRes.hp >= 0 ? '+' : '')}${(g.hp - bareRes.hp).toFixed(1)}`];
cells.forEach((c, i) => { const td = tr.insertCell(); td.textContent = c;
if (i === 7) td.className = (g.hp - bareRes.hp) > 15 ? 'ok' : (g.hp - bareRes.hp) > 5 ? 'warn' : 'bad'; });
}
const tr = tbl.insertRow();
['BARE BED (no sail)', '—', '$0', bareRes.hp.toFixed(1), bareRes.byHail.toFixed(1), bareRes.byRain.toFixed(1), '—', '—']
.forEach((c) => { const td = tr.insertCell(); td.textContent = c; td.className = 'warn'; });
el('out').appendChild(tbl);
// Correlation between the audit's cover% and the sim's garden HP. If the audit
// is telling the truth this is near 1; the QA pass says it is not.
const xs = results.map((r) => r.cover), ys = results.map((r) => r.hp);
const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length;
const mx = mean(xs), my = mean(ys);
const cov = xs.reduce((s, x, i) => s + (x - mx) * (ys[i] - my), 0);
const sx = Math.sqrt(xs.reduce((s, x) => s + (x - mx) ** 2, 0));
const sy = Math.sqrt(ys.reduce((s, y) => s + (y - my) ** 2, 0));
const r2 = (sx && sy) ? (cov / (sx * sy)) : NaN;
const spread = Math.max(...ys) - Math.min(...ys);
const bestGain = Math.max(...ys) - bareRes.hp;
el('note').textContent =
`cover% → garden HP correlation: r = ${r2.toFixed(3)}\n` +
`garden HP spread across every holdable line: ${spread.toFixed(1)} HP\n` +
`best line vs bare bed: ${bestGain >= 0 ? '+' : ''}${bestGain.toFixed(1)} HP ` +
`(a rig that costs money and holds every corner buys THIS much garden)\n` +
`bare bed finished at ${bareRes.hp.toFixed(1)} HP — hail ${bareRes.byHail.toFixed(1)}, rain ${bareRes.byRain.toFixed(1)}`;
window.__probe = { site: siteName, storm: stormName, bare: bareRes, results, r: r2, spread, bestGain };
document.title = `garden_probe — r=${r2.toFixed(2)}`;
}
run().catch((e) => {
el('note').className = 'bad';
el('note').textContent = `garden_probe crashed: ${e.message}\n${e.stack || ''}`;
window.__probe = { error: e.message };
document.title = 'garden_probe — ERROR';
});
</script>