Compare commits
No commits in common. "03c6cd1c0994a185d4c195bf5f96ba80a10151c3" and "d2b9a31363d22942e632b91ee31dfad65d4fd035" have entirely different histories.
03c6cd1c09
...
d2b9a31363
1069
THREADS.md
1069
THREADS.md
File diff suppressed because it is too large
Load Diff
@ -1,82 +0,0 @@
|
||||
# Wind clips — the Mixamo candidates, and why they're rejected
|
||||
|
||||
Lane E, Sprint 13 gate 2.4 ("wind clips for the player — lean / stagger / brace,
|
||||
staged by wind band, through the Mixamo pipeline").
|
||||
|
||||
**Outcome: no clip shipped. The Mixamo pipeline cannot supply a wind lean, and the
|
||||
lean was never going to be a clip in the first place.** Both halves of that are
|
||||
measured, and the evidence is in this folder. Full argument in THREADS [E] 2026-07-17/18.
|
||||
|
||||
## 1. Mixamo has no wind animations
|
||||
|
||||
Searched the real API (`fetch.cjs search`, on ultra), not assumed:
|
||||
|
||||
| term | what comes back |
|
||||
|---|---|
|
||||
| `brace` | eleven **Braced Hang** ledge-climbing clips |
|
||||
| `wind` | Standing Idle 04, Catwalk Walk Turn 180 |
|
||||
| `shiver` | nothing, 0 results |
|
||||
| `stagger` | three walks (Swagger Walk, Walking, Walking) |
|
||||
| `lean` | Leaning On A Wall, Leaning, One Shoulder Lean |
|
||||
| `push` | Pushing, Push Up, Button Pushing |
|
||||
|
||||
The factory's own header already recorded half of this in Sprint 3 — *"Hammering /
|
||||
Sweeping Floor / **Bracing** don't exist on Mixamo — skipped."* This is the case
|
||||
the asset-pipeline skill names directly: *truly bespoke clips don't exist on
|
||||
Mixamo — hand-author, don't keep searching.*
|
||||
|
||||
## 2. The two real candidates, rendered and rejected
|
||||
|
||||
`skinshot.py` films a skinned Mixamo FBX twice: **as authored**, and **as the game
|
||||
shows it** (Hips rotation cleared, which is what `player.js:_rotOnly` does at load).
|
||||
The second row is the honest one.
|
||||
|
||||
- **`rejected_Pushing.png`** — not a wind lean. A deep squat shoving something heavy
|
||||
along the ground, standing up at the end. It reads as *moving a couch*. The
|
||||
forward angle I hoped to borrow is a squat, not a lean.
|
||||
- **`rejected_Leaning.png`** — a casual weight-on-the-back-foot lean, the "propped
|
||||
against a bar" pose. Hands slack, no tension, near-identical across all five
|
||||
frames. A person waiting for a bus, not one in a gale.
|
||||
|
||||
Neither is close enough to fix by retiming. Shipping either would have passed every
|
||||
check we have — they load, they're metre-scale, their nodes survive — and been wrong
|
||||
on screen. That's the bike rule: **the verify can't answer "is this the right shape."**
|
||||
|
||||
## 3. The useful technical result
|
||||
|
||||
In both strips the raw and stripped rows are **near-identical**, which is worth
|
||||
knowing: these clips carry their lean in the **spine**, and spine rotation survives
|
||||
`_rotOnly` intact. So a hand-authored posture (spine curl, shoulder hunch, arm up)
|
||||
*would* survive the loader. What cannot survive is a lean authored into the **hips** —
|
||||
`_rotOnly` drops `Hips.quaternion` from every clip (`player.js:53`, applied `:239`).
|
||||
Measured on the shipped pack: 17 clips, 195 channels each, **64 survive**.
|
||||
|
||||
Which is why the lean belongs in `player.sim.js` as a root pitch aimed at
|
||||
`wind.dirAt(t)` — the wind's bearing swings (the change; site_02's venturi) and a
|
||||
canned clip has one fixed lean axis. Same trick as the knockdown and `climbY`.
|
||||
|
||||
## Reproducing
|
||||
|
||||
```sh
|
||||
# on ultra (the FBX libraries and the Mixamo login live there; JING5 has neither)
|
||||
mkdir -p /tmp/shadeswind && cd /tmp/shadeswind
|
||||
cp ~/Documents/mixamo-fetch/fetch.cjs .
|
||||
printf 'Pushing\nLeaning\n' > wishlist.txt
|
||||
SKIN=1 node fetch.cjs "X Bot" # SKIN=1: needs a mesh to judge a silhouette
|
||||
/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \
|
||||
-P skinshot.py -- /tmp/shadeswind/out/Pushing.fbx Pushing
|
||||
```
|
||||
|
||||
Notes for whoever next reaches for this pipeline:
|
||||
- `fetch.cjs` reads its token from a **logged-in Brave on ultra via CDP** (port 9222).
|
||||
Nobody types a password; the session is already there. It 429s after a handful of
|
||||
exports — space them out.
|
||||
- `resolveChar` defaults to `Grandma_mixamo`, which is not currently uploaded to the
|
||||
account. **`X Bot`** is a Mixamo stock rig and works; for anim-only export the source
|
||||
rig is irrelevant anyway, since `_rotOnly` keeps rotations only.
|
||||
- Anim-only FBX (`SKIN` unset) has **no mesh** and renders nothing. Judging a
|
||||
silhouette needs `SKIN=1`.
|
||||
- Don't retarget onto `Hum_M_1_mixamo.fbx` to preview: its armature carries FBX's
|
||||
0.01 unit scale and the clip's action doesn't, so the body collapses to ~3 cm.
|
||||
That's the same "peds cannot round-trip through Blender" trap `build_player_anims.py`
|
||||
documents.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 234 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 315 KiB |
@ -1,84 +0,0 @@
|
||||
"""Filmstrip a SKINNED Mixamo FBX, twice: as-authored, and as the game shows it
|
||||
(Hips rotation cleared — player.js:_rotOnly).
|
||||
|
||||
blender -b --factory-startup -P skinshot.py -- <skinned.fbx> <label>
|
||||
|
||||
Skinned export carries its own mesh at its own scale, so there's no cross-rig
|
||||
retarget and no unit mismatch — the reason the Hum_M_1 route collapsed.
|
||||
|
||||
Camera is fitted to the MEASURED bbox of the animated body rather than guessed,
|
||||
because guessing framing is how the last three renders came out flat grey.
|
||||
"""
|
||||
import bpy, sys, os, math
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
SRC, LABEL = argv[0], argv[1]
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=SRC)
|
||||
|
||||
arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
|
||||
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
||||
act = arm.animation_data.action if arm.animation_data else None
|
||||
hips = next((b for b in arm.pose.bones if b.name.lower().endswith("hips")), None)
|
||||
f0, f1 = (int(act.frame_range[0]), int(act.frame_range[1])) if act else (1, 1)
|
||||
print(f" arm={arm.name} meshes={len(meshes)} act={act.name if act else None} frames={f0}..{f1} hips={hips.name if hips else None}")
|
||||
|
||||
sc = bpy.context.scene
|
||||
N = 5
|
||||
frames = [int(f0 + (f1 - f0) * i / (N - 1)) for i in range(N)] if f1 > f0 else [f0] * N
|
||||
|
||||
|
||||
def world_pts(frame):
|
||||
sc.frame_set(frame)
|
||||
bpy.context.view_layer.update()
|
||||
dg = bpy.context.evaluated_depsgraph_get()
|
||||
pts = []
|
||||
for m in meshes:
|
||||
ev = m.evaluated_get(dg)
|
||||
pts += [ev.matrix_world @ Vector(c) for c in ev.bound_box]
|
||||
return pts
|
||||
|
||||
|
||||
# --- measure the whole clip, then frame it once so all tiles share a scale.
|
||||
allp = []
|
||||
for fr in frames:
|
||||
allp += world_pts(fr)
|
||||
minx, maxx = min(p.x for p in allp), max(p.x for p in allp)
|
||||
miny, maxy = min(p.y for p in allp), max(p.y for p in allp)
|
||||
minz, maxz = min(p.z for p in allp), max(p.z for p in allp)
|
||||
cx, cy, cz = (minx + maxx) / 2, (miny + maxy) / 2, (minz + maxz) / 2
|
||||
span = max(maxz - minz, maxy - miny, 0.1)
|
||||
print(f" clip bbox X[{minx:.2f},{maxx:.2f}] Y[{miny:.2f},{maxy:.2f}] Z[{minz:.2f},{maxz:.2f}] span={span:.2f}")
|
||||
|
||||
cam_d = bpy.data.cameras.new("cam"); cam_d.type = 'ORTHO'
|
||||
cam_d.ortho_scale = span * 1.5
|
||||
cam = bpy.data.objects.new("cam", cam_d); bpy.context.collection.objects.link(cam)
|
||||
cam.rotation_mode = 'XYZ'
|
||||
cam.location = (cx + span * 6, cy, cz)
|
||||
cam.rotation_euler = (Vector((cx, cy, cz)) - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
sc.camera = cam
|
||||
print(f" cam {tuple(round(v,2) for v in cam.location)} ortho={cam_d.ortho_scale:.2f}")
|
||||
|
||||
sc.render.engine = 'BLENDER_WORKBENCH'
|
||||
sc.render.resolution_x, sc.render.resolution_y = 300, 460
|
||||
sc.render.image_settings.file_format = 'PNG'
|
||||
sc.display.shading.light = 'FLAT'
|
||||
sc.display.shading.color_type = 'SINGLE'
|
||||
sc.display.shading.single_color = (0.85, 0.32, 0.28)
|
||||
if sc.world is None:
|
||||
sc.world = bpy.data.worlds.new("W")
|
||||
sc.world.color = (0.42, 0.45, 0.48)
|
||||
|
||||
for mode in ("raw", "stripped"):
|
||||
for i, fr in enumerate(frames):
|
||||
sc.frame_set(fr)
|
||||
if mode == "stripped" and hips:
|
||||
hips.rotation_mode = 'QUATERNION'
|
||||
hips.rotation_quaternion = (1, 0, 0, 0)
|
||||
bpy.context.view_layer.update()
|
||||
sc.render.filepath = f"/tmp/shadeswind/tile_{LABEL}_{mode}_{i}.png"
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
print("DONE " + LABEL)
|
||||
@ -1,39 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>garden bench — where does the protection go?</title>
|
||||
<style>
|
||||
body { background:#11161a; color:#dde5ea; font:13px/1.45 ui-monospace, Menlo, monospace; padding:18px; }
|
||||
h1 { font-size:15px; color:#7ee0ff; margin:0 0 4px; }
|
||||
p.sub { color:#8ba0ad; margin:0 0 14px; }
|
||||
pre { white-space:pre; margin:0; }
|
||||
.sep { color:#ffb1ab; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>GARDEN BENCH — Lane C, Sprint 13 gate 1</h1>
|
||||
<p class="sub">Real yard, real rig, real skyfx exposure, main.js's exact drain. Held honest quad vs bare bed, both yards, five storms. This is a measurement, not a test — it has no opinion about what the numbers should be.</p>
|
||||
<pre id="out">running… (builds two yards and flies ten storms twice — give it a few seconds)</pre>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "../../web/world/vendor/three.module.js",
|
||||
"three/addons/": "../../web/world/vendor/addons/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import { runBench } from './bench.js';
|
||||
const el = document.getElementById('out');
|
||||
const lines = [];
|
||||
const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
|
||||
el.textContent = '';
|
||||
try {
|
||||
await runBench(log);
|
||||
document.title = 'garden bench — done';
|
||||
} catch (e) {
|
||||
log('\n\nBENCH THREW: ' + (e && e.stack || e));
|
||||
document.title = 'garden bench — THREW';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,267 +0,0 @@
|
||||
/**
|
||||
* garden_bench — WHERE DOES THE PROTECTION ACTUALLY GO? [Lane C, SPRINT13 gate 1]
|
||||
*
|
||||
* The QA pass found one variable sighted three times: the sim's garden outcome
|
||||
* barely responds to whether a sail is up (sail DEAD at t=3 on the funnelled
|
||||
* buster still finished 83%). A's GARDEN_DRAIN comment names the suspect and
|
||||
* says the lever is the shadow GEOMETRY, not the constant. This bench is the
|
||||
* measurement that has to come before any fix.
|
||||
*
|
||||
* It drives the REAL chain — real site JSON, real world.js yard, real SailRig,
|
||||
* real skyfx exposure helpers, main.js's exact drain arithmetic — because a
|
||||
* bench that reimplements the drain measures a copy and proves nothing (the
|
||||
* lesson balance.test.js paid for twice: the fictional yard, and the missing
|
||||
* camera).
|
||||
*
|
||||
* For each yard × storm it flies two conditions and reports where they differ:
|
||||
* HELD — an honest bed-covering quad on RATED steel, re-repaired every step
|
||||
* so a corner failure can never contaminate a GEOMETRY measurement
|
||||
* BARE — no sail at all, the floor
|
||||
* ...and buckets rain/hail shadow-over-bed by wind speed, which is the actual
|
||||
* question: protection is not one number, it decays as the wind builds.
|
||||
*
|
||||
* Deliberately a browser tool: the scoring chain needs `document`. Run it at
|
||||
* tools/garden_bench/bench.html; it prints a table and dumps JSON to console.
|
||||
*/
|
||||
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { RiggingSession } from '../../web/world/js/rigging.js';
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { createSkyFx } from '../../web/world/js/skyfx.js';
|
||||
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
|
||||
// main.js's exact numbers. Duplicated ONLY so a divergence names itself; the
|
||||
// bench asserts they still match main.js at the bottom of the report.
|
||||
const GARDEN_DRAIN = 0.9;
|
||||
const W_HAIL = 5.0;
|
||||
const W_RAIN = 0.25;
|
||||
|
||||
export const STORMS = [
|
||||
'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly',
|
||||
'storm_03b_earlybuster', 'storm_02b_icenight',
|
||||
];
|
||||
|
||||
/**
|
||||
* The honest bed-covering line in each yard — NOT the carport trap, NOT a rig
|
||||
* that misses. backyard's is balance.test's COVER_QUAD (C's sweep, the only
|
||||
* buyable one). site_02's is the site JSON's own _design note: the honest three
|
||||
* posts + the gum tree, 58% cover at 32.6 m².
|
||||
*/
|
||||
export const YARDS = [
|
||||
{ site: 'backyard_01', quad: ['p1', 'p2', 'p3', 'p4'] },
|
||||
{ site: 'site_02_corner_block', quad: ['q1', 'q2', 'q3', 'tr1'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* The real yard, read from world.js — never a copied anchor table, and never a
|
||||
* FROZEN one. Two landmines this builder now closes (SPRINT13, both caught by
|
||||
* D replaying through the real UI):
|
||||
* 1. The SITE def rides along because its wind block is half the yard's
|
||||
* weather: the venturi lives HERE, not in any storm def. Measured without
|
||||
* it, site_02 is a different, easier yard (third harness to learn this;
|
||||
* windForSite is the fix and the story).
|
||||
* 2. The anchors are world.anchors THEMSELVES. The old remap to a static
|
||||
* `sway: () => pos` froze the gum tree — a rig on a tree eats the tree's
|
||||
* sway as dynamic load, and stripping it under-read every tr1 corner.
|
||||
* The world is built on a re-pointable wind proxy (main.js-router style)
|
||||
* so the sway closures sample whichever storm is currently flying.
|
||||
*/
|
||||
async function buildYard(siteId) {
|
||||
const scene = new THREE.Scene();
|
||||
let current = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [],
|
||||
};
|
||||
const proxy = {
|
||||
sample: (p, t, o) => current.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p, t) => current.speedAt(p, t),
|
||||
rainAt: (t) => current.rainAt(t),
|
||||
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => current.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => current.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
get seed() { return current.seed ?? 1; },
|
||||
get def() { return current.def ?? {}; },
|
||||
};
|
||||
const siteDef = await loadSite(siteId);
|
||||
const world = createWorld(scene, { wind: proxy, site: siteDef });
|
||||
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
|
||||
const anchors = world.anchors;
|
||||
return {
|
||||
anchors, bed: world.gardenBed, ids: anchors.map((a) => a.id),
|
||||
sunDir: world.sunDir, heightAt: world.heightAt,
|
||||
siteDef,
|
||||
use: (w) => { current = w; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fly one condition and record the geometry as a time series.
|
||||
* @param {'held'|'bare'} mode
|
||||
*/
|
||||
async function fly(yard, stormName, mode) {
|
||||
const def = await loadStorm(stormName);
|
||||
// windForSite, NOT createWind + storm-def venturi. The old line here read
|
||||
// `def.wind.venturi` off the STORM def, where a venturi never lives — it
|
||||
// LOOKED right and never fired, so every site_02 number this bench produced
|
||||
// before 2026-07-18 was flown with the funnel OFF (D caught it replaying the
|
||||
// $80 line through the real UI: 91.5 FULL became 39.8 TATTERED). Same bug
|
||||
// B's site_audit shipped in Sprint 11. The helper carries the story.
|
||||
const wind = windForSite(def, yard.siteDef, yard.anchors);
|
||||
yard.use(wind); // point the yard's tree-sway closures at this flight's wind
|
||||
|
||||
let rig = null;
|
||||
if (mode === 'held') {
|
||||
const s = new RiggingSession({ anchors: yard.anchors, budget: 9999 });
|
||||
for (const id of yard.quad) {
|
||||
const r = s.rig(id);
|
||||
if (!r.ok) return { skipped: `cannot rig ${id}: ${r.reason ?? 'refused'}` };
|
||||
}
|
||||
// RATED on every corner: this is a geometry bench, so buy the strongest
|
||||
// steel in the game and ignore the $80 budget on purpose. Money is B's
|
||||
// question; this one is "where does the shadow land".
|
||||
for (const id of yard.quad) { const r = s.setHardware(id, RATED);
|
||||
if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} — flying hardware it did not buy`); }
|
||||
s.setTension(1.0);
|
||||
rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
|
||||
}
|
||||
|
||||
// NO calm settle (correction, 2026-07-18): in the real game the rig does not
|
||||
// exist before commit, and commit → attach → advance() → storm land in one
|
||||
// keypress (sail.js attach, B's clock-reset comment). The attach transient
|
||||
// rides the storm's own calm opening exactly as it does for a player — and
|
||||
// because SailRig samples wind at its INTERNAL clock, a 12 s settle here
|
||||
// skewed every storm sample 12 s off the authored curve.
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
|
||||
camera.position.set(0, 2, 8);
|
||||
const sky = createSkyFx({ wind, night: true, camera });
|
||||
|
||||
const bed = yard.bed;
|
||||
const probe = new THREE.Vector3(bed.x, 1.7, bed.z);
|
||||
let hp = 100, byHail = 0, byRain = 0, repairs = 0;
|
||||
const rows = [];
|
||||
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t = i * FIXED_DT;
|
||||
if (rig) {
|
||||
rig.step(FIXED_DT, wind, t);
|
||||
// HELD means held. A corner that lets go turns this into a hardware
|
||||
// measurement instead of a geometry one, so put it straight back.
|
||||
for (let k = 0; k < rig.corners.length; k++) {
|
||||
if (rig.corners[k].broken) { rig.repairCorner(k, RATED); repairs++; }
|
||||
}
|
||||
}
|
||||
sky.step(FIXED_DT, t, rig ? { sail: rig } : {});
|
||||
|
||||
const hailExp = sky.gardenHailExposure(bed, t);
|
||||
const rainExp = sky.gardenExposure(bed, t);
|
||||
// main.js's exact drain
|
||||
const dHail = W_HAIL * hailExp * GARDEN_DRAIN * FIXED_DT;
|
||||
const dRain = W_RAIN * rainExp * GARDEN_DRAIN * FIXED_DT;
|
||||
const total = Math.min(hp, dHail + dRain);
|
||||
if (total > 0) {
|
||||
const scale = total / (dHail + dRain);
|
||||
byHail += dHail * scale; byRain += dRain * scale;
|
||||
hp -= total;
|
||||
}
|
||||
|
||||
// sample the geometry a few times a second — the grids only rebuild at 10 Hz
|
||||
if (i % 6 === 0) {
|
||||
rows.push({
|
||||
t,
|
||||
speed: wind.speedAt(probe, t),
|
||||
rainShadow: sky.rainShadowOver(bed),
|
||||
hailShadow: sky.hailShadowOver(bed),
|
||||
rainExp, hailExp,
|
||||
hail: sky.hailAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
sky.dispose?.();
|
||||
return {
|
||||
hp: Math.round(hp * 10) / 10,
|
||||
byHail: Math.round(byHail * 10) / 10,
|
||||
byRain: Math.round(byRain * 10) / 10,
|
||||
repairs,
|
||||
rows,
|
||||
// the SUN coverage — reported for context only. It is emphatically not the
|
||||
// rain/hail geometry, and the gap between them is half of what this bench
|
||||
// exists to show.
|
||||
cover: rig ? rig.coverageOver(bed, yard.sunDir, yard.heightAt) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bucket a time series by wind speed — protection is not one number. */
|
||||
function byWind(rows) {
|
||||
const buckets = [[0, 10], [10, 15], [15, 20], [20, 25], [25, 99]];
|
||||
return buckets.map(([lo, hi]) => {
|
||||
const r = rows.filter((x) => x.speed >= lo && x.speed < hi);
|
||||
if (!r.length) return { band: `${lo}-${hi}`, n: 0 };
|
||||
const mean = (f) => r.reduce((a, b) => a + f(b), 0) / r.length;
|
||||
return {
|
||||
band: `${lo}-${hi}`,
|
||||
n: r.length,
|
||||
kmh: `${Math.round(lo * 3.6)}-${Math.round(hi * 3.6)}`,
|
||||
rainShadow: mean((x) => x.rainShadow),
|
||||
hailShadow: mean((x) => x.hailShadow),
|
||||
};
|
||||
}).filter((b) => b.n > 0);
|
||||
}
|
||||
|
||||
const f2 = (v) => (v == null ? ' — ' : v.toFixed(2).padStart(6));
|
||||
const f1 = (v) => (v == null ? ' — ' : v.toFixed(1).padStart(5));
|
||||
|
||||
export async function runBench(log) {
|
||||
const out = [];
|
||||
log('# GARDEN BENCH — where does the protection actually go?');
|
||||
log('# HELD = honest bed-covering quad, RATED steel, re-repaired every step (geometry only)');
|
||||
log('# BARE = no sail. Drain is main.js exact: hail×5.0 + rain×0.25, ×0.9.\n');
|
||||
|
||||
for (const y of YARDS) {
|
||||
const yard = await buildYard(y.site);
|
||||
// Print the funnel in the header, B's audit.html pattern: a reader must be
|
||||
// able to SEE which wind these numbers were flown in.
|
||||
const vl = yard.siteDef.wind?.venturi ?? [];
|
||||
const vtxt = vl.length
|
||||
? vl.map((v) => `throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ')
|
||||
: 'none';
|
||||
log(`\n## ${y.site} bed ${yard.bed.w}×${yard.bed.d} at (${yard.bed.x}, ${yard.bed.z}) line ${y.quad.join('+')} venturi: ${vtxt}`);
|
||||
|
||||
for (const storm of STORMS) {
|
||||
const held = await fly({ ...yard, quad: y.quad }, storm, 'held');
|
||||
if (held.skipped) { log(` ${storm}: SKIPPED — ${held.skipped}`); continue; }
|
||||
const bare = await fly({ ...yard, quad: y.quad }, storm, 'bare');
|
||||
const sep = bare.hp === 0 && held.hp === 0 ? 0 : held.hp - bare.hp;
|
||||
out.push({ site: y.site, storm, held, bare, sep });
|
||||
|
||||
log(`\n ### ${storm} cover ${(held.cover * 100).toFixed(0)}% repairs ${held.repairs}`);
|
||||
log(` GARDEN HP held ${f1(held.hp)} bare ${f1(bare.hp)} SEPARATION ${f1(sep)}`);
|
||||
log(` held damage: hail ${f1(held.byHail)} rain ${f1(held.byRain)}`);
|
||||
log(` bare damage: hail ${f1(bare.byHail)} rain ${f1(bare.byRain)}`);
|
||||
log(' wind band km/h rainShadow(held) hailShadow(held)');
|
||||
for (const b of byWind(held.rows)) {
|
||||
log(` ${b.band.padStart(6)} m/s ${b.kmh.padStart(8)} ${f2(b.rainShadow)} ${f2(b.hailShadow)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log('\n\n# THE HEADLINE');
|
||||
log('# site storm held bare sep');
|
||||
for (const r of out) {
|
||||
log(`# ${r.site.padEnd(26)} ${r.storm.padEnd(22)} ${f1(r.held.hp)} ${f1(r.bare.hp)} ${f1(r.sep)}`);
|
||||
}
|
||||
console.log('GARDEN_BENCH_JSON', JSON.stringify(out.map((r) => ({
|
||||
site: r.site, storm: r.storm, held: r.held.hp, bare: r.bare.hp, sep: r.sep,
|
||||
heldHail: r.held.byHail, heldRain: r.held.byRain,
|
||||
bareHail: r.bare.byHail, bareRain: r.bare.byRain,
|
||||
cover: r.held.cover,
|
||||
}))));
|
||||
return out;
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>shadow probe</title>
|
||||
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
|
||||
<body>
|
||||
<pre id="out">running…</pre>
|
||||
<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 { RiggingSession } from '../../web/world/js/rigging.js';
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { RainShadow } from '../../web/world/js/skyfx.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
|
||||
const [,, RATED] = HARDWARE;
|
||||
const el = document.getElementById('out');
|
||||
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
|
||||
el.textContent = '';
|
||||
|
||||
// TWO HARNESSES, ONE QUANTITY. rig.coverageOver(rect, straight-up sun) is the
|
||||
// sail's true vertical projection over the bed, computed by raycast. And
|
||||
// RainShadow.fractionOver(rect) with straight-down rain is the SAME quantity,
|
||||
// computed by rasterising the sail's triangles into a grid. They must agree.
|
||||
// If they don't, the shadow grid — which is what scores every rig — is lying.
|
||||
try {
|
||||
const scene = new THREE.Scene();
|
||||
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
|
||||
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
|
||||
const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') });
|
||||
if (world.dress) { try { await world.dress(); } catch {} }
|
||||
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, pos, sway:()=>pos }; });
|
||||
const bed = world.gardenBed;
|
||||
log(`bed: centre (${bed.x}, ${bed.z}) size ${bed.w} x ${bed.d}\n`);
|
||||
|
||||
const s = new RiggingSession({ anchors, budget: 9999 });
|
||||
for (const id of ['p1','p2','p3','p4']) s.rig(id);
|
||||
for (const id of ['p1','p2','p3','p4']) { const r = s.setHardware(id, RATED);
|
||||
if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason}`); }
|
||||
s.setTension(1.0);
|
||||
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
|
||||
// settle in dead air so the cloth is in steady state and nothing is moving
|
||||
for (let i = 0; i < Math.round(12/FIXED_DT); i++) rig.step(FIXED_DT, calm, 0);
|
||||
|
||||
const corners = ['p1','p2','p3','p4'].map((id) => anchors.find((a)=>a.id===id));
|
||||
log('quad corners:');
|
||||
for (const c of corners) log(` ${c.id.padEnd(3)} (${c.pos.x.toFixed(2)}, ${c.pos.y.toFixed(2)}, ${c.pos.z.toFixed(2)})`);
|
||||
|
||||
// where is the cloth, really?
|
||||
let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9,minY=1e9,maxY=-1e9;
|
||||
for (let i=0;i<rig.pos.length;i+=3){
|
||||
minX=Math.min(minX,rig.pos[i]); maxX=Math.max(maxX,rig.pos[i]);
|
||||
minY=Math.min(minY,rig.pos[i+1]); maxY=Math.max(maxY,rig.pos[i+1]);
|
||||
minZ=Math.min(minZ,rig.pos[i+2]); maxZ=Math.max(maxZ,rig.pos[i+2]);
|
||||
}
|
||||
log(`\ncloth bbox: x ${minX.toFixed(2)}..${maxX.toFixed(2)} y ${minY.toFixed(2)}..${maxY.toFixed(2)} z ${minZ.toFixed(2)}..${maxZ.toFixed(2)}`);
|
||||
log(`bed spans: x ${(bed.x-bed.w/2).toFixed(2)}..${(bed.x+bed.w/2).toFixed(2)} z ${(bed.z-bed.d/2).toFixed(2)}..${(bed.z+bed.d/2).toFixed(2)}`);
|
||||
log(`tris: ${rig.tris.length/3} nodes: ${rig.pos.length/3}`);
|
||||
|
||||
// harness 1: raycast toward a sun straight overhead
|
||||
const cov = rig.coverageOver(bed, { x:0, y:1, z:0 }, null);
|
||||
// harness 2: rasterise down a straight-down rain vector
|
||||
const sh = new RainShadow({ groundY: 0 });
|
||||
sh.update(rig, 0, -1, 0);
|
||||
const frac = sh.fractionOver(bed);
|
||||
|
||||
log(`\n--- THE SAME QUANTITY, TWO WAYS (vertical) ---`);
|
||||
log(` rig.coverageOver(bed, straight-up sun) = ${cov.toFixed(4)}`);
|
||||
log(` RainShadow.fractionOver(bed), rain down = ${frac.toFixed(4)}`);
|
||||
log(` live=${sh.live} difference = ${Math.abs(cov-frac).toFixed(4)}`);
|
||||
|
||||
// how much of the grid did the rasteriser actually fill, vs the cloth's footprint?
|
||||
let filled = 0; for (let i=0;i<sh.ceil.length;i++) if (sh.ceil[i]>0) filled++;
|
||||
const cellA = (sh.half*2/sh.n)**2;
|
||||
log(`\n grid cells filled: ${filled} => ${(filled*cellA).toFixed(1)} m2 of ground shadowed`);
|
||||
log(` cloth footprint bbox area: ${((maxX-minX)*(maxZ-minZ)).toFixed(1)} m2`);
|
||||
|
||||
// sample the bed on a fine lattice both ways to see WHERE they differ
|
||||
log(`\n--- bed lattice: 1 = shadowed, . = open (rows = z, cols = x) ---`);
|
||||
for (let j=0;j<9;j++){
|
||||
let rowS='', rowC='';
|
||||
for (let i=0;i<13;i++){
|
||||
const x = bed.x + ((i+0.5)/13 - 0.5)*bed.w;
|
||||
const z = bed.z + ((j+0.5)/9 - 0.5)*bed.d;
|
||||
const c = sh._idx(x,z);
|
||||
rowS += (c>=0 && sh.ceil[c]>0) ? '1' : '.';
|
||||
rowC += rig.coverageOver({x, z, w:0.001, d:0.001}, {x:0,y:1,z:0}, null) > 0.5 ? '1' : '.';
|
||||
}
|
||||
log(` grid ${rowS} raycast ${rowC}`);
|
||||
}
|
||||
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
|
||||
document.title = 'probe done';
|
||||
</script>
|
||||
</body></html>
|
||||
@ -1,79 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>shadow probe 2 — synthetic</title>
|
||||
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
|
||||
<body>
|
||||
<pre id="out">running…</pre>
|
||||
<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 { RiggingSession } from '../../web/world/js/rigging.js';
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { RainShadow } from '../../web/world/js/skyfx.js';
|
||||
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
|
||||
const [,, RATED] = HARDWARE;
|
||||
const el = document.getElementById('out');
|
||||
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
|
||||
el.textContent = '';
|
||||
|
||||
// ISOLATE THE RASTERISER FROM THE LEVEL.
|
||||
// probe.html showed the real yard's best quad sits over only 25% of the bed, so
|
||||
// its shadow numbers can't tell a rasteriser bug from an anchor-placement fact.
|
||||
// So: a SYNTHETIC square of anchors dead over the bed, at 4 m. This sail
|
||||
// unambiguously covers the whole bed. Grid and raycast must BOTH read ~1.0.
|
||||
// Anything less is the shadow model losing cloth it is standing under.
|
||||
try {
|
||||
const bed = { x: 0, z: 0, w: 6, d: 4 };
|
||||
const mk = (id, x, z) => { const pos = { x, y: 4, z }; return { id, type: 'post', pos, sway: () => pos }; };
|
||||
// a generous 10x8 square around a 6x4 bed — no edge effects, no excuses
|
||||
const anchors = [mk('s1', -5, -4), mk('s2', 5, -4), mk('s3', 5, 4), mk('s4', -5, 4)];
|
||||
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
|
||||
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
|
||||
|
||||
for (const gridN of [10, 16]) {
|
||||
const s = new RiggingSession({ anchors, budget: 9999 });
|
||||
for (const a of anchors) s.rig(a.id);
|
||||
for (const a of anchors) { const r = s.setHardware(a.id, RATED);
|
||||
if (!r.ok) throw new Error(`setHardware ${a.id} refused: ${r.reason}`); }
|
||||
s.setTension(1.0);
|
||||
const rig = s.commit(new SailRig({ anchors, gridN }));
|
||||
for (let i = 0; i < Math.round(12/FIXED_DT); i++) rig.step(FIXED_DT, calm, 0);
|
||||
|
||||
let minY=1e9,maxY=-1e9;
|
||||
for (let i=1;i<rig.pos.length;i+=3){ minY=Math.min(minY,rig.pos[i]); maxY=Math.max(maxY,rig.pos[i]); }
|
||||
|
||||
const cov = rig.coverageOver(bed, { x:0, y:1, z:0 }, null);
|
||||
const sh = new RainShadow({ groundY: 0 });
|
||||
sh.update(rig, 0, -1, 0);
|
||||
const frac = sh.fractionOver(bed);
|
||||
|
||||
log(`\n=== gridN ${gridN} (${rig.tris.length/3} tris, ${rig.pos.length/3} nodes) cloth y ${minY.toFixed(2)}..${maxY.toFixed(2)} ===`);
|
||||
log(` a 10x4 sail sitting dead over a 6x4 bed. Truth for both numbers is 1.00.`);
|
||||
log(` rig.coverageOver(bed, straight up) = ${cov.toFixed(4)} ${cov > 0.99 ? 'ok' : '<-- LOSES CLOTH'}`);
|
||||
log(` RainShadow.fractionOver(bed), down = ${frac.toFixed(4)} ${frac > 0.99 ? 'ok' : '<-- LOSES CLOTH'}`);
|
||||
|
||||
// where are the holes? print the grid cells across the bed's footprint
|
||||
log(' grid cells over the bed (1 = shadowed):');
|
||||
for (let j=0;j<7;j++){
|
||||
let row='';
|
||||
for (let i=0;i<15;i++){
|
||||
const x = bed.x + ((i+0.5)/15 - 0.5)*bed.w;
|
||||
const z = bed.z + ((j+0.5)/7 - 0.5)*bed.d;
|
||||
const c = sh._idx(x,z);
|
||||
row += (c>=0 && sh.ceil[c]>0) ? '1' : '.';
|
||||
}
|
||||
log(` ${row}`);
|
||||
}
|
||||
let filled=0; for (let i=0;i<sh.ceil.length;i++) if (sh.ceil[i]>0) filled++;
|
||||
const cellA = (sh.half*2/sh.n)**2;
|
||||
log(` cells filled ${filled} => ${(filled*cellA).toFixed(1)} m2 shadowed; the sail is 10x8 = 80 m2`);
|
||||
}
|
||||
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
|
||||
document.title = 'probe2 done';
|
||||
</script>
|
||||
</body></html>
|
||||
@ -1,135 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>probe3 — does the covering line hold?</title>
|
||||
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
|
||||
<body>
|
||||
<pre id="out">running…</pre>
|
||||
<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 { RiggingSession } from '../../web/world/js/rigging.js';
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { createSkyFx } from '../../web/world/js/skyfx.js';
|
||||
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const el = document.getElementById('out');
|
||||
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
|
||||
el.textContent = '';
|
||||
|
||||
// THE DECIDING QUESTION.
|
||||
// sweep.html found quads that cover 100% of the bed in BOTH yards, while the
|
||||
// canonical line covers 25%. B's Sprint-9 sweep says every bed-covering quad has
|
||||
// a corner over 6.5 kN — unholdable even on RATED, the best steel in the game —
|
||||
// and that p1..p4 is the ONLY buyable line. If that's true, then "coverage" and
|
||||
// "holdability" are anti-correlated by construction, the affordable rig can only
|
||||
// ever shade a quarter of the bed, and NO shadow-model change can make the
|
||||
// garden respond to rigging. That is a very different bug from the one the
|
||||
// sprint chartered, so it has to be measured, not inherited.
|
||||
//
|
||||
// Fly the covering lines. Report peak corner load per corner and the garden.
|
||||
const GARDEN_DRAIN = 0.9, W_HAIL = 5.0, W_RAIN = 0.25;
|
||||
|
||||
async function fly(world, ids, stormName, hw, siteDef, use) {
|
||||
const def = await loadStorm(stormName);
|
||||
const anchors = world.anchors; // REAL anchors: trees sway, hints ride along
|
||||
const bed = world.gardenBed;
|
||||
// windForSite: the SITE's venturi + shelters, as main.js wires them. Before
|
||||
// 2026-07-18 this probe (a) never set the venturi — every site_02 row was
|
||||
// flown with the funnel OFF — and (b) remapped anchors to a static
|
||||
// `sway: () => pos`, freezing the gum tree and dropping ratingHint. See
|
||||
// probe4's correction note and the THREADS postscript; backyard post-only
|
||||
// lines (p1..p4) were unaffected by either, which is why they matched D's
|
||||
// UI replays to the decimal while the tr1 lines lied.
|
||||
const wind = windForSite(def, siteDef, anchors);
|
||||
use(wind);
|
||||
|
||||
const s = new RiggingSession({ anchors, budget: 9999 });
|
||||
for (const id of ids) if (!s.rig(id).ok) return null;
|
||||
for (const id of ids) { const r = s.setHardware(id, hw);
|
||||
if (!r.ok) throw new Error(`setHardware ${id} refused: ${r.reason} — flying hardware it did not buy`); }
|
||||
s.setTension(1.0);
|
||||
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
|
||||
// no calm settle: commit → attach → storm is one keypress in the real game
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
|
||||
camera.position.set(0, 2, 8);
|
||||
const sky = createSkyFx({ wind, night: true, camera });
|
||||
const peak = ids.map(() => 0);
|
||||
let hp = 100, lost = 0, hsum = 0, hn = 0, brokeAt = null;
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
for (let i=0;i<steps;i++){
|
||||
const t = i * FIXED_DT;
|
||||
rig.step(FIXED_DT, wind, t);
|
||||
sky.step(FIXED_DT, t, { sail: rig });
|
||||
for (let k=0;k<rig.corners.length;k++) if ((rig.corners[k].load||0) > peak[k]) peak[k] = rig.corners[k].load;
|
||||
if (brokeAt === null && rig.corners.some((c)=>c.broken)) brokeAt = t;
|
||||
// the hail shadow WHILE it is hailing is the only one that scores
|
||||
if (sky.hailAmount > 0) { hsum += sky.hailShadowOver(bed); hn++; }
|
||||
const ex = W_HAIL*sky.gardenHailExposure(bed,t) + W_RAIN*sky.gardenExposure(bed,t);
|
||||
if (ex > 0) hp = Math.max(0, hp - GARDEN_DRAIN*ex*FIXED_DT);
|
||||
}
|
||||
lost = rig.corners.filter((c)=>c.broken).length;
|
||||
sky.dispose?.();
|
||||
// Peaks are indexed by rig.corners — the RING order commit() reordered the
|
||||
// picks into — so label them with corners[k].anchorId, NOT the input order.
|
||||
// (Found at re-verify: p1..p4 flies as p4,p2,p3,p1; an input-order label
|
||||
// attributes each corner's load to the wrong anchor.)
|
||||
return { hp: Math.round(hp*10)/10, lost, peak: peak.map((p)=>p/1000),
|
||||
cornerIds: rig.corners.map((c)=>c.anchorId),
|
||||
hailShadow: hn ? hsum/hn : 0, brokeAt };
|
||||
}
|
||||
|
||||
try {
|
||||
for (const [siteId, lineSet] of [
|
||||
['backyard_01', { cover100: ['h1','t1','t2b','p2'], cover100b: ['h1','h3','p1','p2'], canonical: ['p1','p2','p3','p4'] }],
|
||||
['site_02_corner_block', { posts: ['q1','q2','q3','q4'], cover100: ['tr1','q1','q3','q4'], canonical: ['q1','q2','q3','tr1'] }],
|
||||
]) {
|
||||
const scene = new THREE.Scene();
|
||||
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
|
||||
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
|
||||
const siteDef = await loadSite(siteId);
|
||||
// re-pointable wind, main.js-router style: sway closures capture the proxy
|
||||
let current = calm;
|
||||
const proxy = {
|
||||
sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p,t) => current.speedAt(p, t),
|
||||
rainAt: (t) => current.rainAt(t),
|
||||
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => current.gustTelegraph(t),
|
||||
eventsBetween: (a,b) => current.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
get seed() { return current.seed ?? 1; },
|
||||
get def() { return current.def ?? {}; },
|
||||
};
|
||||
const use = (w) => { current = w; };
|
||||
const world = createWorld(scene, { wind: proxy, site: siteDef });
|
||||
if (world.dress) { try { await world.dress(); } catch {} }
|
||||
const bed = world.gardenBed;
|
||||
const anchors = world.anchors;
|
||||
const vl = siteDef.wind?.venturi ?? [];
|
||||
const vtxt = vl.length ? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ') : 'none';
|
||||
log(`\n\n######## ${siteId} (RATED = 6.5 kN is the best steel in the game, $30/corner) venturi: ${vtxt}`);
|
||||
for (const [label, ids] of Object.entries(lineSet)) {
|
||||
for (const storm of ['storm_02_wildnight', 'storm_02b_icenight', 'storm_03b_earlybuster']) {
|
||||
const r = await fly(world, ids, storm, RATED, siteDef, use);
|
||||
if (!r) { log(` ${label} ${ids.join('+')}: REFUSED`); continue; }
|
||||
const over = r.peak.map((p,i)=>`${r.cornerIds[i]} ${p.toFixed(2)}`).join(' ');
|
||||
const bare = { storm_02_wildnight: 35.7, storm_02b_icenight: 0, storm_03b_earlybuster: 82.6 }[storm];
|
||||
log(` ${label.padEnd(10)} ${ids.join('+').padEnd(18)} ${storm.padEnd(22)} hp ${String(r.hp).padStart(5)}`
|
||||
+ ` (bare ${bare}, sep ${(r.hp-bare).toFixed(1).padStart(5)}) lost ${r.lost}/4`
|
||||
+ `${r.brokeAt!==null?` @t=${r.brokeAt.toFixed(0)}`:' '} hailShadow ${r.hailShadow.toFixed(2)} peak kN: ${over}`);
|
||||
}
|
||||
log('');
|
||||
}
|
||||
}
|
||||
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
|
||||
document.title = 'probe3 done';
|
||||
</script>
|
||||
</body></html>
|
||||
@ -1,153 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>probe4 — the $80 line that saves the garden</title>
|
||||
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
|
||||
<body>
|
||||
<pre id="out">running…</pre>
|
||||
<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 { RiggingSession } from '../../web/world/js/rigging.js';
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { createSkyFx } from '../../web/world/js/skyfx.js';
|
||||
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { FIXED_DT, HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const el = document.getElementById('out');
|
||||
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
|
||||
el.textContent = '';
|
||||
|
||||
// THE PAYOFF — and the correction (2026-07-18).
|
||||
// The first run of this probe read the $80 line tr1+q1+q3+q4 at 91.5 FULL on
|
||||
// the wild night and called it the headline. TWO harness landmines, both mine:
|
||||
// 1. No venturi — the funnel lives in the SITE def, and this probe never set
|
||||
// it (bench.js read it off the STORM def, where it never lives). Third
|
||||
// harness to make B's Sprint-11 mistake.
|
||||
// 2. Static anchors — the remap `sway: () => pos` stripped tr1's TREE sway,
|
||||
// the dynamic load a rig on a gum tree has to eat (DESIGN.md's whole
|
||||
// warning about tree anchors). The backyard bench numbers matched D's UI
|
||||
// replays to the decimal BECAUSE p1..p4 are all posts; any tr1 line was
|
||||
// flown against a tree that never moved.
|
||||
// With both fixed the probe agrees with D's live UI replays in direction and
|
||||
// closely in number (buster peaks within ~15%); on the wild night the line's
|
||||
// cheapest corner rides AT its rating (q4 carabiner ~1.2 kN) — a knife edge
|
||||
// the real game falls off (D: q3+q4 break t≈45, 39.8 TATTERED). Where this
|
||||
// probe and D's UI disagree on which side of that edge a corner lands, D's
|
||||
// UI number is canonical. What survives: the line holds the BUSTER — site_02's
|
||||
// actual shipped night — with room (D live: 97.9 FULL, 0/4).
|
||||
async function fly(world, plan, stormName, siteDef, use) {
|
||||
const def = await loadStorm(stormName);
|
||||
const anchors = world.anchors; // REAL anchors — tr1 sways
|
||||
const bed = world.gardenBed;
|
||||
const wind = windForSite(def, siteDef, anchors);
|
||||
use(wind); // point the world's sway closures at the flight wind
|
||||
|
||||
// THE REAL SHOP, at the REAL budget. Every refusal is loud.
|
||||
const s = new RiggingSession({ anchors }); // budget = START_BUDGET
|
||||
for (const [id] of plan) {
|
||||
const r = s.rig(id);
|
||||
if (!r.ok) return { err: `rig ${id}: ${r.reason}` };
|
||||
}
|
||||
for (const [id, hw] of plan) {
|
||||
const r = s.setHardware(id, hw);
|
||||
if (!r.ok) return { err: `setHardware ${id} ${hw.name}: ${r.reason}` };
|
||||
}
|
||||
s.setTension(1.0);
|
||||
const spent = START_BUDGET - s.budget;
|
||||
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
|
||||
// NO calm settle: in the real game the rig does not exist until commit, and
|
||||
// commit → attach → advance() → storm land in one keypress (B's clock-reset
|
||||
// comment, sail.js attach). The attach transient rides the storm's own calm
|
||||
// opening, exactly as it does for a player.
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
|
||||
camera.position.set(0, 2, 8);
|
||||
const sky = createSkyFx({ wind, night: true, camera });
|
||||
const peak = plan.map(() => 0);
|
||||
let hp = 100, hsum = 0, hn = 0;
|
||||
const steps = Math.round(def.duration / FIXED_DT);
|
||||
for (let i=0;i<steps;i++){
|
||||
const t = i * FIXED_DT;
|
||||
rig.step(FIXED_DT, wind, t);
|
||||
sky.step(FIXED_DT, t, { sail: rig });
|
||||
for (let k=0;k<rig.corners.length;k++) if ((rig.corners[k].load||0) > peak[k]) peak[k] = rig.corners[k].load;
|
||||
if (sky.hailAmount > 0) { hsum += sky.hailShadowOver(bed); hn++; }
|
||||
const ex = 5.0*sky.gardenHailExposure(bed,t) + 0.25*sky.gardenExposure(bed,t);
|
||||
if (ex > 0) hp = Math.max(0, hp - 0.9*ex*FIXED_DT);
|
||||
}
|
||||
const lost = rig.corners.filter((c)=>c.broken).length;
|
||||
sky.dispose?.();
|
||||
// Peaks are indexed by rig.corners — the RING order commit() reordered the
|
||||
// picks into — so label them with corners[k].anchorId, NOT the plan order.
|
||||
// (Found at re-verify: the $80 line flies as q1,tr1,q3,q4 — a plan-order
|
||||
// label swaps the two RATED corners' loads. q3/q4 were unaffected.)
|
||||
return { hp: Math.round(hp*10)/10, lost, spent, peak: peak.map((p)=>p/1000),
|
||||
cornerIds: rig.corners.map((c)=>c.anchorId), hailShadow: hn?hsum/hn:0 };
|
||||
}
|
||||
|
||||
// main.js's own win rule
|
||||
const WIN = (hp, lost) => hp >= 50 && lost < 2;
|
||||
const state = (hp) => hp > 66 ? 'FULL' : hp > 33 ? 'tattered' : 'DEAD';
|
||||
|
||||
try {
|
||||
const scene = new THREE.Scene();
|
||||
// A re-pointable wind, the way main.js's router serves world.js: the world's
|
||||
// tree-sway closures capture THIS object, and fly() re-points it per storm.
|
||||
// Building the world on a dead stub and remapping anchors to static
|
||||
// `sway: () => pos` was landmine #2 — it froze the gum tree.
|
||||
let current = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
|
||||
rainMmPerHour:()=>0, gustTelegraph:()=>null, eventsBetween:()=>[] };
|
||||
const proxy = {
|
||||
sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p,t) => current.speedAt(p, t),
|
||||
rainAt: (t) => current.rainAt(t),
|
||||
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => current.gustTelegraph(t),
|
||||
eventsBetween: (a,b) => current.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
get seed() { return current.seed ?? 1; },
|
||||
get def() { return current.def ?? {}; },
|
||||
};
|
||||
const use = (w) => { current = w; };
|
||||
const siteDef = await loadSite('site_02_corner_block');
|
||||
const world = createWorld(scene, { wind: proxy, site: siteDef });
|
||||
await world.dress(); // NOT optional: undressed, the carport trap does not exist
|
||||
const bed = world.gardenBed;
|
||||
|
||||
const PLANS = {
|
||||
'THE $80 LINE tr1=rated q1=rated q3=shackle q4=carabiner':
|
||||
[['tr1',RATED],['q1',RATED],['q3',SHACKLE],['q4',CARABINER]],
|
||||
'all-shackle tr1+q1+q3+q4 ($60)':
|
||||
[['tr1',SHACKLE],['q1',SHACKLE],['q3',SHACKLE],['q4',SHACKLE]],
|
||||
'canonical q1+q2+q3+tr1, best steel $80 can buy':
|
||||
[['q1',RATED],['q2',SHACKLE],['q3',CARABINER],['tr1',RATED]],
|
||||
};
|
||||
const vl = siteDef.wind?.venturi ?? [];
|
||||
const vtxt = vl.length
|
||||
? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ') : 'none';
|
||||
log(`site_02_corner_block budget $${START_BUDGET} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) venturi: ${vtxt}`);
|
||||
// bare-bed refs are funnel-independent (no sail = exposure is the storm's own
|
||||
// rain/hail timeline, wind never enters it); D confirmed 82.6 live on the buster
|
||||
log(`bare bed for reference: wildnight 35.7 (DEAD) icenight 0.0 (DEAD) buster 82.6 (FULL)\n`);
|
||||
for (const [label, plan] of Object.entries(PLANS)) {
|
||||
log(`\n## ${label}`);
|
||||
for (const storm of ['storm_02_wildnight','storm_02b_icenight','storm_03b_earlybuster']) {
|
||||
const r = await fly(world, plan, storm, siteDef, use);
|
||||
if (r.err) { log(` ${storm.padEnd(22)} SHOP REFUSED: ${r.err}`); continue; }
|
||||
const bare = { storm_02_wildnight:35.7, storm_02b_icenight:0, storm_03b_earlybuster:82.6 }[storm];
|
||||
log(` ${storm.padEnd(22)} spent $${String(r.spent).padStart(3)} hp ${String(r.hp).padStart(5)} ${state(r.hp).padEnd(8)}`
|
||||
+ ` (bare ${String(bare).padStart(4)} ${state(bare)}) sep ${(r.hp-bare).toFixed(1).padStart(5)}`
|
||||
+ ` lost ${r.lost}/4 hailShadow ${r.hailShadow.toFixed(2)} ${WIN(r.hp,r.lost)?'WIN ':'LOSS'}`
|
||||
+ ` peak kN: ${r.peak.map((p,i)=>`${r.cornerIds[i]} ${p.toFixed(2)}`).join(' ')}`);
|
||||
}
|
||||
}
|
||||
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
|
||||
document.title = 'probe4 done';
|
||||
</script>
|
||||
</body></html>
|
||||
@ -1,132 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>quad sweep — can ANY line cover the bed?</title>
|
||||
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
|
||||
<body>
|
||||
<pre id="out">running…</pre>
|
||||
<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 { RiggingSession } from '../../web/world/js/rigging.js';
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { RainShadow } from '../../web/world/js/skyfx.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
|
||||
const [,, RATED] = HARDWARE;
|
||||
const el = document.getElementById('out');
|
||||
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
|
||||
el.textContent = '';
|
||||
|
||||
// THE QUESTION probe.html forces: the yard's canonical "best bed-covering quad"
|
||||
// sits over 25% of the bed vertically, and hail falls vertically — so the sail
|
||||
// can never stop more than a quarter of what kills the garden. Is that the
|
||||
// LEVEL's ceiling, or did the canonical line just pick badly?
|
||||
//
|
||||
// Sweep every 4-anchor combination. Stage 1 is a cheap polygon-overlap proxy
|
||||
// (the cloth lives roughly inside its anchors' hull), stage 2 flies the real
|
||||
// cloth + real shadow grid for the best candidates. Cheap-then-real, because
|
||||
// C(n,4) settles would take an hour and the proxy only has to RANK.
|
||||
const clip = (poly, rect) => {
|
||||
// Sutherland–Hodgman against the bed rect, then shoelace. Exact for convex.
|
||||
const [x0, x1] = [rect.x - rect.w/2, rect.x + rect.w/2];
|
||||
const [z0, z1] = [rect.z - rect.d/2, rect.z + rect.d/2];
|
||||
const edges = [
|
||||
(p) => p.x >= x0, (p) => p.x <= x1, (p) => p.z >= z0, (p) => p.z <= z1,
|
||||
];
|
||||
const isect = [
|
||||
(a, b) => ({ x: x0, z: a.z + (b.z - a.z) * (x0 - a.x) / (b.x - a.x) }),
|
||||
(a, b) => ({ x: x1, z: a.z + (b.z - a.z) * (x1 - a.x) / (b.x - a.x) }),
|
||||
(a, b) => ({ x: a.x + (b.x - a.x) * (z0 - a.z) / (b.z - a.z), z: z0 }),
|
||||
(a, b) => ({ x: a.x + (b.x - a.x) * (z1 - a.z) / (b.z - a.z), z: z1 }),
|
||||
];
|
||||
let out = poly;
|
||||
for (let e = 0; e < 4; e++) {
|
||||
const inp = out; out = [];
|
||||
for (let i = 0; i < inp.length; i++) {
|
||||
const a = inp[i], b = inp[(i + 1) % inp.length];
|
||||
const ain = edges[e](a), bin = edges[e](b);
|
||||
if (ain && bin) out.push(b);
|
||||
else if (ain && !bin) out.push(isect[e](a, b));
|
||||
else if (!ain && bin) { out.push(isect[e](a, b)); out.push(b); }
|
||||
}
|
||||
if (!out.length) return 0;
|
||||
}
|
||||
let s = 0;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const a = out[i], b = out[(i + 1) % out.length];
|
||||
s += a.x * b.z - b.x * a.z;
|
||||
}
|
||||
return Math.abs(s) / 2;
|
||||
};
|
||||
// convex hull (the cloth spans its anchors' hull, so order the quad sanely)
|
||||
const hull = (pts) => {
|
||||
const p = pts.slice().sort((a, b) => a.x - b.x || a.z - b.z);
|
||||
const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
|
||||
const lo = []; for (const q of p) { while (lo.length >= 2 && cross(lo[lo.length-2], lo[lo.length-1], q) <= 0) lo.pop(); lo.push(q); }
|
||||
const up = []; for (const q of p.slice().reverse()) { while (up.length >= 2 && cross(up[up.length-2], up[up.length-1], q) <= 0) up.pop(); up.push(q); }
|
||||
lo.pop(); up.pop(); return lo.concat(up);
|
||||
};
|
||||
|
||||
try {
|
||||
for (const siteId of ['backyard_01', 'site_02_corner_block']) {
|
||||
const scene = new THREE.Scene();
|
||||
const calm = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
|
||||
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
|
||||
const world = createWorld(scene, { wind: calm, site: await loadSite(siteId) });
|
||||
if (world.dress) { try { await world.dress(); } catch {} }
|
||||
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, pos, sway:()=>pos }; });
|
||||
const bed = world.gardenBed;
|
||||
const bedArea = bed.w * bed.d;
|
||||
log(`\n\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) = ${bedArea} m2 ${anchors.length} anchors`);
|
||||
|
||||
// stage 1: rank every 4-combo by hull-over-bed overlap
|
||||
const cands = [];
|
||||
const n = anchors.length;
|
||||
for (let a=0;a<n;a++) for (let b=a+1;b<n;b++) for (let c=b+1;c<n;c++) for (let d=c+1;d<n;d++) {
|
||||
const q = [anchors[a], anchors[b], anchors[c], anchors[d]];
|
||||
const h = hull(q.map((x) => ({ x: x.pos.x, z: x.pos.z })));
|
||||
if (h.length < 3) continue;
|
||||
const over = clip(h, bed) / bedArea;
|
||||
cands.push({ ids: q.map((x)=>x.id), over });
|
||||
}
|
||||
cands.sort((p, q) => q.over - p.over);
|
||||
log(` swept ${cands.length} quads. Top 8 by hull-over-bed (proxy):`);
|
||||
for (const c of cands.slice(0, 8)) log(` ${c.ids.join('+').padEnd(24)} hull covers ${(c.over*100).toFixed(0)}% of bed`);
|
||||
|
||||
// stage 2: fly the real cloth for the best few + the canonical line
|
||||
const canonical = siteId === 'backyard_01' ? ['p1','p2','p3','p4'] : ['q1','q2','q3','tr1'];
|
||||
const tryList = [];
|
||||
for (const c of cands.slice(0, 5)) tryList.push(c.ids);
|
||||
if (!tryList.some((t) => t.join()===canonical.join())) tryList.push(canonical);
|
||||
|
||||
log(`\n REAL cloth + REAL shadow grid (settled 12 s, dead air, vertical rain):`);
|
||||
log(` line hull% coverageOver(up) RainShadow.fractionOver`);
|
||||
for (const ids of tryList) {
|
||||
const s = new RiggingSession({ anchors, budget: 9999 });
|
||||
let ok = true;
|
||||
for (const id of ids) if (!s.rig(id).ok) { ok = false; break; }
|
||||
if (!ok) { log(` ${ids.join('+').padEnd(24)} REFUSED by the shop`); continue; }
|
||||
for (const id of ids) { const r = s.setHardware(id, RATED);
|
||||
if (!r.ok) throw new Error(`setHardware ${id} RATED refused: ${r.reason} — the bench is flying hardware it did not buy`); }
|
||||
s.setTension(1.0);
|
||||
const rig = s.commit(new SailRig({ anchors, gridN: 10 }));
|
||||
for (let i=0;i<Math.round(12/FIXED_DT);i++) rig.step(FIXED_DT, calm, 0);
|
||||
const cov = rig.coverageOver(bed, {x:0,y:1,z:0}, null);
|
||||
const sh = new RainShadow({ groundY: 0 }); sh.update(rig, 0, -1, 0);
|
||||
const frac = sh.fractionOver(bed);
|
||||
const hp = hull(ids.map((id)=>{const a=anchors.find((z)=>z.id===id);return {x:a.pos.x,z:a.pos.z};}));
|
||||
const over = clip(hp, bed)/bedArea;
|
||||
const mark = ids.join()===canonical.join() ? ' <-- the canonical line' : '';
|
||||
log(` ${ids.join('+').padEnd(24)} ${(over*100).toFixed(0).padStart(4)}% ${cov.toFixed(3).padStart(12)} ${frac.toFixed(3).padStart(18)}${mark}`);
|
||||
}
|
||||
}
|
||||
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
|
||||
document.title = 'sweep done';
|
||||
</script>
|
||||
</body></html>
|
||||
@ -1,125 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>sweep2 — hold AND cover?</title>
|
||||
<style>body{background:#11161a;color:#dde5ea;font:13px/1.5 ui-monospace,Menlo,monospace;padding:18px}pre{white-space:pre}</style></head>
|
||||
<body>
|
||||
<pre id="out">running…</pre>
|
||||
<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 { RiggingSession } from '../../web/world/js/rigging.js';
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { RainShadow } from '../../web/world/js/skyfx.js';
|
||||
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js';
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const el = document.getElementById('out');
|
||||
const lines = []; const log = (s) => { lines.push(s); el.textContent = lines.join('\n'); };
|
||||
el.textContent = '';
|
||||
|
||||
// THE PARETO QUESTION, and the whole of gate 1.
|
||||
//
|
||||
// probe2 proved the shadow model is sound (a sail over the bed reads 1.000 by
|
||||
// raycast AND by the grid). probe3 found the variable: _checkFailure compares
|
||||
// load against hw.rating * anchor.ratingHint, and the HOUSE fascia's hint is
|
||||
// 0.35 — so a $30 RATED shackle is worth 2.3 kN on the house and 6.5 kN on a
|
||||
// post. Every quad that covers the bed hangs off the house.
|
||||
//
|
||||
// So: sweep every 4-anchor line on the BEST steel in the game, settle it the way
|
||||
// prep does, and ask both questions at once — does it HOLD, and does it COVER?
|
||||
// A quad that covers 100% while tearing itself apart in prep covers nothing.
|
||||
// This is the table the garden's whole design rests on and nobody has ever
|
||||
// printed it.
|
||||
try {
|
||||
for (const siteId of ['backyard_01', 'site_02_corner_block']) {
|
||||
const scene = new THREE.Scene();
|
||||
const zero = { sample:(p,t,o)=>(o||new THREE.Vector3()).set(0,0,0), speedAt:()=>0, rainAt:()=>0,
|
||||
rainMmPerHour:()=>0, gustTelegraph:()=>null, setSheltersFromTrees(){}, eventsBetween:()=>[] };
|
||||
const siteDef = await loadSite(siteId);
|
||||
// re-pointable wind, main.js-router style: the anchors are world.anchors
|
||||
// THEMSELVES, so tree sway is live — the old static remap froze the gum
|
||||
// tree and under-read every tr1 corner (correction, 2026-07-18).
|
||||
let current = zero;
|
||||
const proxy = {
|
||||
sample: (p,t,o) => current.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p,t) => current.speedAt(p, t),
|
||||
rainAt: (t) => current.rainAt(t),
|
||||
rainMmPerHour: (t) => (current.rainMmPerHour ? current.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => current.gustTelegraph(t),
|
||||
eventsBetween: (a,b) => current.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
get seed() { return current.seed ?? 1; },
|
||||
get def() { return current.def ?? {}; },
|
||||
};
|
||||
const world = createWorld(scene, { wind: proxy, site: siteDef });
|
||||
if (world.dress) { try { await world.dress(); } catch {} }
|
||||
const anchors = world.anchors;
|
||||
const bed = world.gardenBed;
|
||||
const vl = siteDef.wind?.venturi ?? [];
|
||||
const vtxt = vl.length ? vl.map((v)=>`throat (${v.x},${v.z}) gain ${v.gain} r${v.radius}`).join(' · ') : 'none';
|
||||
log(`\n\n######## ${siteId} bed ${bed.w}x${bed.d} @ (${bed.x}, ${bed.z}) venturi: ${vtxt}`);
|
||||
log(` anchors and what the BEST steel ($30 RATED, 6.5 kN) is actually worth on them:`);
|
||||
for (const a of anchors) {
|
||||
log(` ${a.id.padEnd(5)} ${String(a.type).padEnd(8)} hint ${(a.ratingHint ?? 1).toFixed(2)} -> ${((a.ratingHint ?? 1) * 6.5).toFixed(2)} kN`);
|
||||
}
|
||||
|
||||
// prep's own wind: main.js hands the rig the calm day outside a storm.
|
||||
// windForSite so the SITE's venturi flies too — before 2026-07-18 this
|
||||
// sweep settled site_02's quads with the funnel OFF (the third harness to
|
||||
// make B's Sprint-11 mistake; see windForSite's comment). The funnel does
|
||||
// not switch off for prep.
|
||||
const calmWind = windForSite(await loadStorm('storm_01_gentle'), siteDef, anchors);
|
||||
current = calmWind; // the tree-sway closures fly the same calm day
|
||||
|
||||
const rows = [];
|
||||
const n = anchors.length;
|
||||
for (let a=0;a<n;a++) for (let b=a+1;b<n;b++) for (let c=b+1;c<n;c++) for (let d=c+1;d<n;d++) {
|
||||
const ids = [anchors[a].id, anchors[b].id, anchors[c].id, anchors[d].id];
|
||||
const s = new RiggingSession({ anchors, budget: 9999 });
|
||||
let ok = true;
|
||||
for (const id of ids) if (!s.rig(id).ok) { ok = false; break; }
|
||||
if (!ok) continue;
|
||||
for (const id of ids) { const r = s.setHardware(id, RATED);
|
||||
if (!r.ok) throw new Error(`setHardware ${id} RATED refused: ${r.reason} — the bench is flying hardware it did not buy`); }
|
||||
s.setTension(1.0);
|
||||
let rig;
|
||||
try { rig = s.commit(new SailRig({ anchors, gridN: 10 })); } catch { continue; }
|
||||
// settle exactly like prep: calm day, running clock, 12 s
|
||||
let prepT = 0, peak = 0;
|
||||
for (let i=0;i<Math.round(12/FIXED_DT);i++){
|
||||
rig.step(FIXED_DT, calmWind, prepT % 3); prepT += FIXED_DT;
|
||||
for (const co of rig.corners) if ((co.load||0) > peak) peak = co.load;
|
||||
}
|
||||
const broke = rig.corners.filter((co)=>co.broken).length;
|
||||
const cov = rig.coverageOver(bed, {x:0,y:1,z:0}, null); // vertical: what hail sees
|
||||
const sh = new RainShadow({ groundY: 0 }); sh.update(rig, 0, -1, 0);
|
||||
const frac = sh.fractionOver(bed);
|
||||
rows.push({ ids, broke, cov, frac, peak: peak/1000 });
|
||||
}
|
||||
|
||||
const survived = rows.filter((r) => r.broke === 0);
|
||||
log(`\n ${rows.length} quads swept on RATED steel. ${survived.length} survive PREP on the calm day.`);
|
||||
log(`\n --- best COVER among quads that actually HOLD (this is the real ceiling) ---`);
|
||||
log(` line vert.cover shadow peak kN broke`);
|
||||
for (const r of survived.sort((p,q)=>q.cov-p.cov).slice(0,10)) {
|
||||
log(` ${r.ids.join('+').padEnd(22)} ${(r.cov*100).toFixed(0).padStart(7)}% ${r.frac.toFixed(3).padStart(6)} ${r.peak.toFixed(2).padStart(7)} ${r.broke}`);
|
||||
}
|
||||
log(`\n --- best COVER overall, holding or not (what the sprint assumed was available) ---`);
|
||||
log(` line vert.cover shadow peak kN broke`);
|
||||
for (const r of rows.slice().sort((p,q)=>q.cov-p.cov).slice(0,6)) {
|
||||
log(` ${r.ids.join('+').padEnd(22)} ${(r.cov*100).toFixed(0).padStart(7)}% ${r.frac.toFixed(3).padStart(6)} ${r.peak.toFixed(2).padStart(7)} ${r.broke}${r.broke?' <-- tears in PREP':''}`);
|
||||
}
|
||||
const canonical = siteId === 'backyard_01' ? 'p1+p2+p3+p4' : 'q1+q2+q3+tr1';
|
||||
const cn = rows.find((r)=>r.ids.join('+')===canonical);
|
||||
if (cn) log(`\n the canonical line ${canonical}: cover ${(cn.cov*100).toFixed(0)}% shadow ${cn.frac.toFixed(3)} peak ${cn.peak.toFixed(2)} kN broke ${cn.broke}`);
|
||||
}
|
||||
} catch (e) { log('THREW: ' + (e && e.stack || e)); }
|
||||
document.title = 'sweep2 done';
|
||||
</script>
|
||||
</body></html>
|
||||
@ -1,7 +1,6 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool;
|
||||
SPRINT13 gate 1.2 — the audit predicts the GARDEN now]
|
||||
site_audit, browser front-end. [Lane B, SPRINT10 — the gate-2 tool]
|
||||
|
||||
The node front-end (audit.mjs) is fast but blind: it cannot dress, so it only
|
||||
sees posts + a hand-kept snapshot, and it REFUSES dress-source site JSON rather
|
||||
@ -12,17 +11,8 @@
|
||||
it. Then it runs the SAME sweep.js the node tool runs. This is the only way to
|
||||
audit a site with a carport (site_02) before it ships.
|
||||
|
||||
SPRINT13: winnability was never the whole question — the audit's static
|
||||
"cover%" predicted the wild night's garden BACKWARDS (r = −0.42) and told
|
||||
three people a $20 rig was fine. Every affordable line is now FLOWN through
|
||||
the real scoring chain (gardenfly.js: attach → settle → skyfx exposure →
|
||||
garden.js) with the SITE's venturi live, and the verdict reports the garden
|
||||
state the sim would invoice — FULL / tattered / dead — plus the site's
|
||||
pinned `separation` target (A's gate-1.4 block in the site JSON), judged on
|
||||
the block's own storm. cover% stays as a geometry diagnostic only.
|
||||
|
||||
tools/site_audit/audit.html?site=backyard_01
|
||||
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03b_earlybuster
|
||||
tools/site_audit/audit.html?site=site_02_corner_block&storm=storm_03_southerly
|
||||
|
||||
Served from the repo root (server.py), so the relative imports resolve.
|
||||
-->
|
||||
@ -55,7 +45,6 @@ import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
import { flyGarden, flySeparation } from './gardenfly.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const siteName = q.get('site') || 'backyard_01';
|
||||
@ -65,39 +54,29 @@ const el = (id) => document.getElementById(id);
|
||||
const loadJSON = async (path) => (await fetch(path)).json();
|
||||
|
||||
async function run() {
|
||||
// Build the site the way the game does — data in, dressed yard out — on a
|
||||
// RE-POINTABLE wind proxy (C's bench pattern), so the audit can fly
|
||||
// world.anchors THEMSELVES. The old version here remapped every anchor to a
|
||||
// static `sway: () => pos`, which froze the gum tree — the same landmine
|
||||
// that made C's bench under-read q4 by 0.22 kN on the $80 line (a tree rig
|
||||
// eats the tree's sway as dynamic load). Backyard posts never noticed;
|
||||
// every tr1/t1/t2 line was optimistic.
|
||||
// Build the site the way the game does — data in, dressed yard out.
|
||||
const site = await loadSite(siteName);
|
||||
const scene = new THREE.Scene();
|
||||
let currentWind = {
|
||||
const calmStub = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [],
|
||||
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||
};
|
||||
const windProxy = {
|
||||
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p, t) => currentWind.speedAt(p, t),
|
||||
rainAt: (t) => currentWind.rainAt(t),
|
||||
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => currentWind.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
};
|
||||
const use = (w) => { currentWind = w; };
|
||||
const world = createWorld(scene, { wind: windProxy, site });
|
||||
const world = createWorld(scene, { wind: calmStub, site });
|
||||
let dressed = false;
|
||||
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* fall through, flagged below */ } }
|
||||
|
||||
// world.anchors, LIVE — sway closures and ratingHint intact. The sweep and
|
||||
// every garden flight below re-point the proxy at their own wind via `use`.
|
||||
const anchors = world.anchors;
|
||||
// world.anchors are the REAL dressed positions — freeze sway like balance.test.
|
||||
// ratingHint rides along (SPRINT12): the sweep prices hardware against
|
||||
// rating × hint, and dropping it here would audit a yard where the fascia
|
||||
// and the carport beam are honest steel — the exact lie the wiring killed.
|
||||
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`);
|
||||
|
||||
const vlist = site.wind?.venturi ?? [];
|
||||
const vtxt = vlist.length
|
||||
@ -110,86 +89,23 @@ async function run() {
|
||||
`venturi: ${vtxt}\n` +
|
||||
`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}`;
|
||||
|
||||
// The venturi rides in on the SITE def — windForSite (C's shared builder,
|
||||
// inside sweep.js and gardenfly.js) is the ONE door site wind comes through
|
||||
// now. Three harnesses independently mis-built it; there is no fourth copy.
|
||||
const bed = world.gardenBed;
|
||||
const { cands, rows, verdict, winners, marginalWinners } =
|
||||
auditSweep({ anchors, bed, stormDef, siteDef: site, use });
|
||||
// The venturi is the SITE's, not the storm's — main.js:424 sets it on the wind
|
||||
// at every site load, so the audit must too or the corner block flies with its
|
||||
// funnel switched off (an easier yard than ships).
|
||||
const venturi = site.wind?.venturi ?? [];
|
||||
const { cands, rows, verdict, winners } = auditSweep({ anchors, bed: world.gardenBed, stormDef, calmDef, venturi });
|
||||
|
||||
// ── SPRINT13: fly the garden for every line the budget can actually buy ──
|
||||
// The scoring quantity is the FLOWN outcome (gardenfly.js — real attach,
|
||||
// real skyfx exposure, real garden.js), not static cover%. Marginal lines
|
||||
// fly too, deliberately: they are the trap the margin rule exists to name.
|
||||
const bare = flyGarden({ anchors, bed, stormDef, siteDef: site, use });
|
||||
const FLY_CAP = 12;
|
||||
const flown = new Map();
|
||||
for (const r of [...winners, ...marginalWinners].slice(0, FLY_CAP)) {
|
||||
// clean winners fly the CLEAN tiers (what the audit recommends buying);
|
||||
// marginal winners fly the knife-edge tiers (the trap, priced as sold).
|
||||
const tierBy = new Map(r.tiers.map((c) => [c.id, r.clean ? c.cleanTier : c.tier])); // ring-ordered; align by anchorId
|
||||
flown.set(r.ids.join(','), flyGarden({
|
||||
anchors, bed, stormDef, siteDef: site, use,
|
||||
ids: r.ids, hw: r.ids.map((id) => tierBy.get(id)),
|
||||
}));
|
||||
}
|
||||
const skipped = Math.max(0, winners.length + marginalWinners.length - FLY_CAP);
|
||||
|
||||
// best GARDEN line the budget buys — the audit's answer to "what should I
|
||||
// rig", which used to be "the cheapest line that holds" and is now "the line
|
||||
// that saves the garden" — cheapest on ties, and never a marginal one (a
|
||||
// marginal flight is C's 91.9-FULL illusion; it gets reported, not sold).
|
||||
const rowBy = (key) => rows.find((w) => w.ids.join(',') === key);
|
||||
const pickBest = (entries) => entries.reduce((best, [key, g]) => {
|
||||
if (!best) return { key, g };
|
||||
if (g.hp > best.g.hp + 0.05) return { key, g };
|
||||
if (Math.abs(g.hp - best.g.hp) <= 0.05 && (rowBy(key)?.hw ?? 1e9) < (rowBy(best.key)?.hw ?? 1e9)) return { key, g };
|
||||
return best;
|
||||
}, null);
|
||||
const cleanFlown = [...flown.entries()].filter(([k, g]) => !g.marginal.length && rowBy(k)?.clean);
|
||||
const bestGarden = pickBest(cleanFlown);
|
||||
const bestMarginal = pickBest([...flown.entries()].filter(([k]) => !rowBy(k)?.clean));
|
||||
|
||||
// ── the site's pinned separation target (A's gate-1.4 ruling), judged on the
|
||||
// block's OWN storm — the target is site data, not a per-run choice.
|
||||
let sep = null, sepStormName = null;
|
||||
if (site.separation) {
|
||||
sepStormName = site.separation.stormKey;
|
||||
const sepStorm = sepStormName === stormName ? stormDef
|
||||
: await loadJSON(`../../web/world/data/storms/${sepStormName}.json`);
|
||||
sep = flySeparation({ anchors, bed, separation: site.separation, stormDef: sepStorm, siteDef: site, use });
|
||||
}
|
||||
|
||||
// render rows — cover% is a DIAGNOSTIC column now (static overhead projection
|
||||
// of the taut attach shape); the flown garden column is what scores.
|
||||
// render rows
|
||||
const tbl = document.createElement('table');
|
||||
const hd = tbl.insertRow();
|
||||
for (const h of ['line', 'm²', 'vert cover (static)', 'holds?', 'garden (flown)', 'corners: peak → tier']) {
|
||||
const td = hd.insertCell(); td.textContent = h; td.className = 'corner';
|
||||
}
|
||||
for (const r of rows) {
|
||||
const tr = tbl.insertRow();
|
||||
tr.insertCell().textContent = `${r.pinned ? '📌 ' : ''}${r.ids.join(',')}`;
|
||||
tr.insertCell().textContent = r.ids.join(',');
|
||||
tr.insertCell().textContent = `${r.area.toFixed(0)} m²`;
|
||||
tr.insertCell().textContent = `${(r.cover * 100).toFixed(0)}%`;
|
||||
tr.insertCell().textContent = `cover ${(r.cover * 100).toFixed(0)}%`;
|
||||
const mk = tr.insertCell();
|
||||
if (r.unholdable.length) { mk.textContent = '✗ unholdable'; mk.className = 'bad'; }
|
||||
else if (!r.affordable) { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
|
||||
else if (!r.clean) {
|
||||
mk.textContent = `⚠ $${r.hw} MARGINAL (${r.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}%`).join(' ')})` +
|
||||
` — clean ${r.cleanHw != null ? `$${r.cleanHw} > $${START_BUDGET}` : 'over the shop ceiling'}`;
|
||||
mk.className = 'warn';
|
||||
} else {
|
||||
mk.textContent = `✓ $${r.cleanHw} clean${r.hw < r.cleanHw ? ` (holds at $${r.hw})` : ''}`;
|
||||
mk.className = 'ok';
|
||||
}
|
||||
const gd = tr.insertCell();
|
||||
const g = flown.get(r.ids.join(','));
|
||||
if (g) {
|
||||
gd.textContent = `${g.hp} ${g.state.toUpperCase()}${g.cornersLost ? ` (${g.cornersLost} lost)` : ''}` +
|
||||
`${g.marginal.length ? ` ⚠ ${g.marginal.join(',')} inside margin` : ''}`;
|
||||
gd.className = (g.marginal.length || g.state === 'tattered') ? 'warn' : g.state === 'full' ? 'ok' : 'bad';
|
||||
} else { gd.textContent = '—'; gd.className = 'corner'; }
|
||||
else if (r.hw <= START_BUDGET) { mk.textContent = `✓ $${r.hw}`; mk.className = 'ok'; }
|
||||
else { mk.textContent = `✗ $${r.hw} > $${START_BUDGET}`; mk.className = 'warn'; }
|
||||
const cs = tr.insertCell(); cs.className = 'corner';
|
||||
cs.innerHTML = r.tiers.map((c) =>
|
||||
`${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : '<span class="none">NONE</span>'}`).join(' ');
|
||||
@ -198,79 +114,26 @@ async function run() {
|
||||
|
||||
// verdict
|
||||
const v = el('verdict');
|
||||
const gardenLine = () => {
|
||||
const bg = bestGarden ? rowBy(bestGarden.key) : null;
|
||||
return `\n— the garden (flown, funnel ${vlist.length ? 'ON' : 'n/a'}): bare bed ${bare.hp} ${bare.state.toUpperCase()}` +
|
||||
(bestGarden
|
||||
? ` · best clean buyable ${bg.ids.join(',')} ($${bg.cleanHw}) → ${bestGarden.g.hp} ${bestGarden.g.state.toUpperCase()}` +
|
||||
`${bestGarden.g.cornersLost ? ` (${bestGarden.g.cornersLost} corner(s) lost)` : ''}` +
|
||||
` · the sail is worth ${(bestGarden.g.hp - bare.hp) >= 0 ? '+' : ''}${(bestGarden.g.hp - bare.hp).toFixed(1)} HP`
|
||||
: ' · NO clean buyable line to fly') +
|
||||
(bestMarginal && (!bestGarden || bestMarginal.g.hp > bestGarden.g.hp)
|
||||
? `\n ⚠ best MARGINAL line ${bestMarginal.key} reads ${bestMarginal.g.hp} ${bestMarginal.g.state.toUpperCase()} on paper — ` +
|
||||
`inside the 15% break-in-game band, treat as ${bestMarginal.g.hp > bare.hp ? 'the trap, not the answer' : 'dead'}`
|
||||
: '') +
|
||||
(skipped ? `\n (${skipped} affordable line(s) beyond the ${FLY_CAP}-flight cap not flown)` : '');
|
||||
};
|
||||
const sepLine = () => {
|
||||
if (!sep) return `\n— separation target: none pinned in the site JSON (A's gate-1.4 block) — nothing to judge against.`;
|
||||
const s = site.separation;
|
||||
return `\n— separation target (${sepStormName}): ` +
|
||||
`held ${sep.held.hp} ${sep.held.state.toUpperCase()} ($${sep.held.cost}, ${sep.held.cornersLost}/4 lost` +
|
||||
`) vs pinned >${s.heldMustExceed}` +
|
||||
` · bare ${sep.bare.hp} vs pinned <${s.bareMustLoseBelow}` +
|
||||
` → ${sep.ok ? 'MEETS the target' : `FAILS (${[!sep.heldOk && 'held not FULL', !sep.heldWin && 'held does not WIN', !sep.bareOk && 'bare does not lose'].filter(Boolean).join(', ')})`}` +
|
||||
(sep.ok && !sep.heldClean
|
||||
? `\n ⚠ but the pinned line is KNIFE-EDGED: ${sep.held.marginal.map((id) => {
|
||||
const p = sep.held.peaks.find((x) => x.id === id);
|
||||
return `${id} ${(p.headroom * 100).toFixed(0)}% headroom (${p.peakN}/${p.effN} N)`;
|
||||
}).join(', ')} — inside the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band. ` +
|
||||
`Flagged to A/D in THREADS, not overruled here: the margin rule's cause is unfound and this yard's ` +
|
||||
`bench has matched the UI to the decimal — but nobody has PLAYED this line yet.`
|
||||
: '');
|
||||
};
|
||||
if (verdict.code === 'no-cover') {
|
||||
v.className = 'verdict fail';
|
||||
v.textContent = `✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.\n` +
|
||||
`The site cannot be rigged. It needs an anchor near the bed before it ships.`;
|
||||
} else if (verdict.code === 'marginal-only') {
|
||||
const b = verdict.best;
|
||||
v.className = 'verdict fail';
|
||||
v.textContent = `✗ MARGINAL ONLY — the budget's ${marginalWinners.length} affordable line(s) all carry a corner inside ` +
|
||||
`the ${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (best: ${b.ids.join(',')} at $${b.hw}, ` +
|
||||
`${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')}; ` +
|
||||
`clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).\n` +
|
||||
`On the bench it holds; in the real game it is D's 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.` +
|
||||
gardenLine() + sepLine();
|
||||
} else if (!verdict.ok) {
|
||||
const b = verdict.best;
|
||||
v.className = 'verdict fail';
|
||||
v.textContent = `✗ FAIL — no affordable holding line. Cheapest is ${b.ids.join(',')} at $${b.hw} on a $${START_BUDGET} budget` +
|
||||
(b.unholdable.length ? `, and ${b.unholdable.map((c) => c.id).join('/')} is over the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.') +
|
||||
`\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.` +
|
||||
gardenLine() + sepLine();
|
||||
`\nThe SPRINT6 p1=7.4 kN failure. Move an anchor (E's standing offer), or the site ships unwinnable.`;
|
||||
} else {
|
||||
const w = verdict.best;
|
||||
const wTotal = w.cleanHw + AUDIT.SPARE_COST;
|
||||
v.className = (sep && !sep.ok) ? 'verdict fail' : 'verdict pass';
|
||||
v.textContent = `${(sep && !sep.ok) ? '✗' : '✓'} winnability: ${winners.length} clean affordable line(s)` +
|
||||
`${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}; cheapest clean ${w.ids.join(',')} — $${w.cleanHw} hardware` +
|
||||
`${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}.` +
|
||||
gardenLine() + sepLine();
|
||||
v.className = 'verdict pass';
|
||||
v.textContent = `✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.`;
|
||||
}
|
||||
// a machine-readable line, so this page can also be driven headless-in-browser
|
||||
window.__audit = {
|
||||
site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length,
|
||||
cands: cands.length, verdict,
|
||||
winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover, garden: flown.get(w.ids.join(',')) ?? null })),
|
||||
marginalWinners: marginalWinners.map((w) => ({ ids: w.ids, hw: w.hw,
|
||||
marginal: w.marginal.map((c) => ({ id: c.id, headroom: c.headroom })),
|
||||
garden: flown.get(w.ids.join(',')) ?? null })),
|
||||
bare, bestGarden: bestGarden ? { ids: bestGarden.key, ...bestGarden.g } : null,
|
||||
bestMarginal: bestMarginal ? { ids: bestMarginal.key, ...bestMarginal.g } : null,
|
||||
separation: sep ? { storm: sepStormName, ...sep } : null,
|
||||
};
|
||||
document.title = `site_audit — ${verdict.ok ? 'PASS' : verdict.code.toUpperCase()}${sep ? ` · sep ${sep.ok ? 'MEETS' : 'FAILS'}` : ''}`;
|
||||
window.__audit = { site: siteName, storm: stormName, dressed, venturi: vlist, anchors: anchors.length, cands: cands.length, verdict, winners: winners.map((w) => ({ ids: w.ids, hw: w.hw, cover: w.cover })) };
|
||||
document.title = `site_audit — ${verdict.ok ? 'PASS' : 'FAIL'}`;
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
|
||||
@ -16,19 +16,11 @@
|
||||
* balance decision disguised as art, and it should be audited the minute it's
|
||||
* drawn, not the sprint after it ships.
|
||||
*
|
||||
* What it does NOT do: score the garden — and SPRINT13 made the garden the
|
||||
* scoring quantity (the audit's old static cover% predicted the wild night's
|
||||
* garden BACKWARDS, r = −0.42). skyfx needs `document`, so garden truth lives
|
||||
* in the BROWSER front-end: audit.html flies every affordable line through
|
||||
* gardenfly.js (real attach → skyfx exposure → garden.js) and judges the
|
||||
* site's pinned `separation` block. This node tool remains the fast
|
||||
* winnability check — peaks, pricing, the margin rule — and it TELLS you
|
||||
* where the garden verdict is rather than pretending it has one.
|
||||
*
|
||||
* Known blindness beyond dressing (stated, not hidden): node hands the sweep
|
||||
* STATIC anchors, so tree sway — real dynamic load on any tr1/t1/t2 line,
|
||||
* C's landmine 2 — is frozen here. Post lines are exact; tree lines read
|
||||
* optimistic. The browser front-end flies world.anchors live.
|
||||
* What it does NOT do: score the garden. Winnability is decided before any of
|
||||
* that — by whether a quad exists that (a) covers the bed and (b) has peak
|
||||
* corner loads the budget can hold. The garden drain only decides how BADLY you
|
||||
* lose a site that was already unwinnable. That's also why this runs in node:
|
||||
* no skyfx, no document, no browser, ~20 s per site.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@ -102,15 +94,11 @@ const BACKYARD_01 = {
|
||||
['p2', 'post', 4.308797385647288, 3.958677942376306, 6.463196078470932, 1.0],
|
||||
['p3', 'post', 0, 3.954237880742151, 7.556692403840262, 1.0],
|
||||
['p4', 'post', -3.721247340646687, 4.000586139378515, -1.3954677527425075, 1.0],
|
||||
// SPRINT13: p5, the clothesline post — A's gate-1 anchor (the ruling that
|
||||
// gave the backyard a FULL-capable wild-night line). h 2.6 in the JSON,
|
||||
// raked like every post; verified live like every post.
|
||||
['p5', 'post', 5.840064309022781, 2.591333620780842, -2.1236597487355566, 1.0],
|
||||
].map(([id, type, x, y, z, ratingHint]) => ({ id, type, ratingHint, pos: { x, y, z } })),
|
||||
};
|
||||
|
||||
/** Anchors dress() never touches — so live world.js IS authoritative for these. */
|
||||
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4', 'p5']);
|
||||
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);
|
||||
|
||||
/**
|
||||
* Re-derive the posts from live world.js and fail loudly if the dump drifted.
|
||||
@ -199,9 +187,7 @@ async function loadSite(path) {
|
||||
}));
|
||||
// A resolved export still owes us the site's funnel — the venturi is site data,
|
||||
// not storm data, and a sweep without it flies an easier yard than ships.
|
||||
// The separation block rides along so the tool can at least PRINT the pin.
|
||||
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors,
|
||||
venturi: j.wind?.venturi ?? [], separation: j.separation ?? null };
|
||||
return { name: j.name || path, bed: j.gardenBed || j.bed, anchors, venturi: j.wind?.venturi ?? [] };
|
||||
}
|
||||
|
||||
const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));
|
||||
@ -210,6 +196,7 @@ async function main() {
|
||||
const site = await loadSite(sitePath);
|
||||
const anchors = withSway(site.anchors);
|
||||
const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8'));
|
||||
const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`, import.meta.url), 'utf8'));
|
||||
|
||||
console.log(`\nsite_audit — ${site.name}`);
|
||||
if (site.dumped) {
|
||||
@ -224,9 +211,6 @@ async function main() {
|
||||
console.log(` posts verified against live world.js ✓ ` +
|
||||
`dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`);
|
||||
console.log(` (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
|
||||
// the pinned separation block is site JSON — read it so the dump path can print the pin
|
||||
const j = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
|
||||
site.separation = j.separation ?? null;
|
||||
}
|
||||
console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
|
||||
const venturi = site.venturi ?? [];
|
||||
@ -234,11 +218,7 @@ async function main() {
|
||||
console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);
|
||||
|
||||
// The sweep itself is shared with the browser front-end — see sweep.js.
|
||||
// siteDef carries the venturi AND the pinned separation recipe (the pin
|
||||
// sweeps regardless of the band — see sweep.js).
|
||||
const { cands, rows, winners, marginalWinners, verdict } =
|
||||
auditSweep({ anchors, bed: site.bed, stormDef: def,
|
||||
siteDef: { wind: { venturi }, separation: site.separation ?? null } });
|
||||
const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef, venturi });
|
||||
|
||||
if (verdict.code === 'no-cover') {
|
||||
console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);
|
||||
@ -246,33 +226,14 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${cands.length} quad(s) in band shading the bed (cover% is the STATIC vertical projection — a`);
|
||||
console.log(`geometry diagnostic; the garden verdict is flown in audit.html via gardenfly.js):\n`);
|
||||
console.log(`${cands.length} quad(s) in band shading the bed:\n`);
|
||||
for (const r of rows) {
|
||||
const cs = r.tiers.map((c) => `${c.id}${c.hint !== 1 ? `×${c.hint}` : ''} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join(' ');
|
||||
const mark = r.unholdable.length ? '✗ unholdable'
|
||||
: !r.affordable ? `✗ $${r.hw} > $${START_BUDGET}`
|
||||
: !r.clean ? `⚠ $${r.hw} MARGINAL(${r.marginal.map((c) => c.id).join(',')})`
|
||||
: `✓ $${r.cleanHw} clean`;
|
||||
console.log(` ${((r.pinned ? '📌' : '') + r.ids.join(',')).padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(22)} ${cs}`);
|
||||
}
|
||||
if (site.separation) {
|
||||
console.log(`\nseparation target pinned (${site.separation.stormKey}): ` +
|
||||
site.separation.line.map((l) => `${l.anchor} ${l.hw}`).join(' + ') +
|
||||
` — held >${site.separation.heldMustExceed} & WIN, bare <${site.separation.bareMustLoseBelow}.` +
|
||||
`\n Judged in the BROWSER front-end (garden needs skyfx): tools/site_audit/audit.html?site=...`);
|
||||
const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `✓ $${r.hw}` : `✗ $${r.hw} > $${START_BUDGET}`;
|
||||
console.log(` ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m² cover ${(r.cover * 100).toFixed(0).padStart(3)}% ${mark.padEnd(14)} ${cs}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (verdict.code === 'marginal-only') {
|
||||
const b = verdict.best;
|
||||
console.log(`✗ MARGINAL ONLY — every affordable line carries a corner inside the ` +
|
||||
`${(AUDIT.MARGIN * 100).toFixed(0)}% break-in-game band (C's residual rule). Best: ${b.ids.join(',')} at $${b.hw}` +
|
||||
` (${b.marginal.map((c) => `${c.id} ${(c.headroom * 100).toFixed(0)}% headroom`).join(', ')};` +
|
||||
` clean would need ${b.cleanHw != null ? `$${b.cleanHw}` : 'steel over the shop ceiling'}).`);
|
||||
console.log(` On this bench it holds; in the real game it is the 39.8-TATTERED wild night. No clean line at $${START_BUDGET}.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!verdict.ok) {
|
||||
const best = verdict.best;
|
||||
console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
|
||||
@ -281,12 +242,9 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
const w = verdict.best;
|
||||
const wTotal = w.cleanHw + AUDIT.SPARE_COST;
|
||||
console.log(`✓ PASS — ${winners.length} clean affordable line(s)` +
|
||||
`${marginalWinners.length ? ` (+${marginalWinners.length} marginal, flagged above)` : ''}. ` +
|
||||
`Best clean: ${w.ids.join(',')} — $${w.cleanHw} hardware` +
|
||||
`${wTotal <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${wTotal}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% static cover. Garden verdict: audit.html.\n`);
|
||||
console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
|
||||
`${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
|
||||
`, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); });
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
<!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%.
|
||||
|
||||
SPRINT13: the garden model is A's js/garden.js now (the export this page's
|
||||
first version asked for), so the probe flies the SAME createGarden main.js
|
||||
flies — the temporary constant copy died the day the export landed, per the
|
||||
fake → ask → landed → delete-the-fake protocol.
|
||||
|
||||
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 { HARDWARE } from '../../web/world/js/contracts.js';
|
||||
import { auditSweep } from './sweep.js';
|
||||
import { flyGarden } from './gardenfly.js';
|
||||
|
||||
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();
|
||||
|
||||
async function run() {
|
||||
// The yard on a re-pointable wind proxy (C's bench pattern) so world.anchors
|
||||
// fly THEMSELVES — a static sway remap froze the gum tree and under-read
|
||||
// every tree corner (C's landmine 2; q4 1.02 frozen vs 1.24 live).
|
||||
const site = await loadSite(siteName);
|
||||
const scene = new THREE.Scene();
|
||||
let currentWind = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [],
|
||||
};
|
||||
const windProxy = {
|
||||
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p, t) => currentWind.speedAt(p, t),
|
||||
rainAt: (t) => currentWind.rainAt(t),
|
||||
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => currentWind.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
};
|
||||
const use = (w) => { currentWind = w; };
|
||||
const world = createWorld(scene, { wind: windProxy, site });
|
||||
await world.dress();
|
||||
const bed = world.gardenBed;
|
||||
const anchors = world.anchors;
|
||||
const venturi = site.wind?.venturi ?? [];
|
||||
|
||||
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||
|
||||
// The audit's own sweep, for its (diagnostic) cover% numbers.
|
||||
const { rows } = auditSweep({ anchors, bed, stormDef, siteDef: site, use });
|
||||
|
||||
/** Fly one line through gardenfly — THE audit scoring chain, no local copy. */
|
||||
const gardenFor = (ids, hwTier, { bare = false } = {}) =>
|
||||
flyGarden({ anchors, bed, stormDef, siteDef: site, use,
|
||||
ids: bare ? null : ids, hw: hwTier });
|
||||
|
||||
el('sub').textContent =
|
||||
`${site.name} · ${stormName} (${stormDef.duration}s) · bed ${bed.w}x${bed.d} @ (${bed.x},${bed.z})\n` +
|
||||
`venturi: ${venturi.length ? venturi.map((v) => `(${v.x},${v.z}) gain ${v.gain}`).join(' · ') : 'none'} · ` +
|
||||
`flying gardenfly.js — the sim's own chain (windForSite → attach → skyfx → garden.js), live anchors`;
|
||||
|
||||
// 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% (static)', 'hw $', 'garden (flown)', '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.state.toUpperCase()}${g.marginal.length ? ` ⚠ ${g.marginal.join(',')}` : ''}`,
|
||||
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.state.toUpperCase()}`,
|
||||
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>
|
||||
@ -1,175 +0,0 @@
|
||||
/**
|
||||
* gardenfly.js — fly a rig line through the REAL scoring chain and return the
|
||||
* garden verdict the sim would hand the invoice. [Lane B, SPRINT13 gate 1.2]
|
||||
*
|
||||
* This module exists because the audit's old ranking quantity — static cover%
|
||||
* of the taut attach-shape — predicted the wild night's garden BACKWARDS
|
||||
* (r = −0.42, THREADS [B] gate-1 evidence): it told three people a $20 rig was
|
||||
* bad while the sim paid it +$97. The quantity that scores is the FLOWN
|
||||
* vertical (hail) shadow through the storm: hail is 5.0 against rain's 0.25
|
||||
* (garden.js), stones fall near-vertical, and a sail either blocks the hail
|
||||
* over the bed or blocks nothing that matters. Only flying the real chain
|
||||
* measures that, so this is the audit's scoring engine now; cover% is demoted
|
||||
* to a geometry diagnostic in both front-ends.
|
||||
*
|
||||
* THE chain, one copy of it, shared by audit.html and garden_probe.html:
|
||||
*
|
||||
* windForSite(stormDef, siteDef, anchors) (C's shared builder —
|
||||
* venturi + tree shelters, byte-for-byte main.js's site-load wiring)
|
||||
* → SailRig.attach(ids, hw, tension) (the real attach)
|
||||
* → rig.step + sky.step(dt, t, {sail: rig}) (main.js's exact call)
|
||||
* → createGarden(...).step(dt, hailExp, rainExp) (A's garden.js, no copy)
|
||||
*
|
||||
* NO calm settle, deliberately: in the real game the rig does not exist before
|
||||
* commit, and commit→attach→storm is one keypress (the rig.t fix's own words),
|
||||
* so the attach transient flies under STORM wind and counts. C measured the
|
||||
* phantom settle's skew and removed it from the bench the same day.
|
||||
*
|
||||
* Landmines this file exists to never re-arm (each earned this sprint):
|
||||
*
|
||||
* · D's skipped-attach trap (skyfx.js:803): a harness that never runs
|
||||
* attach() scores EVERY rig as bare — the bare-bed constant lies politely.
|
||||
* flyGarden throws if the rig it built has no shadow mesh.
|
||||
* · The venturi lives in the SITE def, not the storm def. THREE harnesses
|
||||
* shipped that bug (my Sprint-11 audit, C's bench, D's first harness half);
|
||||
* windForSite is the one door now, and the selftest mutation-checks that
|
||||
* the funnel reaches this module's wind.
|
||||
* · The FROZEN TREE (C's landmine 2): remapping anchors to `sway: () => pos`
|
||||
* freezes the gum tree whose sway is the dynamic load a tr1 rig eats —
|
||||
* q4 read 1.02 frozen vs 1.24 live. Callers hand this module
|
||||
* world.anchors THEMSELVES and re-point the world's wind proxy via `use`
|
||||
* so the sway closures sample the storm that is actually flying.
|
||||
* · THE MARGIN RULE (C's residual): even corrected, benches under-read the
|
||||
* real UI's peaks by ~5–15% on site_02 (cause unfound, in the pool). Until
|
||||
* it is found, any corner within 15% of its effective rating is scored
|
||||
* "breaks in the game" — a flight that holds on paper with q4 at 1.24 vs
|
||||
* 1.2 is D's 39.8 TATTERED in play, not the bench's 91.9 FULL. flyGarden
|
||||
* reports `marginal` and callers must not print PASS over it.
|
||||
*
|
||||
* Per-corner peaks are labelled from `corners[k].anchorId`, never input order —
|
||||
* C's label-permutation warning (attach reorders picks into ring order).
|
||||
*
|
||||
* Browser-only by necessity: skyfx needs `document` (node throws), which is
|
||||
* why audit.mjs (node) prints winnability and POINTS HERE for garden truth.
|
||||
*/
|
||||
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { windForSite } from '../../web/world/js/weather.js';
|
||||
import { createSkyFx } from '../../web/world/js/skyfx.js';
|
||||
import { createGarden } from '../../web/world/js/garden.js';
|
||||
import { HARDWARE, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
import { AUDIT } from './sweep.js';
|
||||
|
||||
// The margin rule's knob is AUDIT.MARGIN — sweep.js holds the ONE copy and
|
||||
// both audit engines read it (a local copy here would be the exact drift this
|
||||
// sprint spent itself killing).
|
||||
|
||||
/** Shop hardware by its contracts.js name ("rated shackle") — throws on a
|
||||
* stranger, because a typo'd tier silently becoming a carabiner is exactly
|
||||
* the setHardware() lesson from gate 3. */
|
||||
export function hardwareByName(name) {
|
||||
const hw = HARDWARE.find((h) => h.name === name);
|
||||
if (!hw) throw new Error(`unknown hardware "${name}" — the shop sells: ${HARDWARE.map((h) => h.name).join(', ')}`);
|
||||
return hw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fly one line (or a bare bed) and return the garden verdict.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {Array} o.anchors world.anchors THEMSELVES (live sway), or resolved
|
||||
* {id, type, pos, sway, ratingHint} for synthetic yards
|
||||
* @param {object} o.bed garden bed rect {x, z, w, d}
|
||||
* @param {object} o.stormDef storm JSON to fly
|
||||
* @param {object} [o.siteDef] site JSON — its wind.venturi is half the yard's
|
||||
* weather; omit ONLY for a site with no funnel
|
||||
* @param {function} [o.use] yard's wind-proxy re-pointer (C's bench pattern):
|
||||
* called with this flight's wind so world.anchors'
|
||||
* tree-sway closures sample the storm actually flying
|
||||
* @param {Array} [o.ids] 4 anchor ids; null/omitted = bare bed (the control)
|
||||
* @param {Array|object} [o.hw] hardware per pick (aligned with ids), or one tier for all 4
|
||||
* @param {number} [o.tension]
|
||||
* @returns {{ hp, state, byHail, byRain, cornersLost, peaks, marginal, cost }}
|
||||
*/
|
||||
export function flyGarden({ anchors, bed, stormDef, siteDef = null, use = null, ids = null, hw = null, tension = 1.0 }) {
|
||||
const wind = windForSite(stormDef, siteDef, anchors);
|
||||
use?.(wind);
|
||||
|
||||
let rig = null, cost = 0;
|
||||
if (ids) {
|
||||
const hwArr = Array.isArray(hw) ? hw : Array(4).fill(hw ?? hardwareByName('rated shackle'));
|
||||
cost = hwArr.reduce((s, h) => s + (h.cost || 0), 0);
|
||||
// shade cloth (porosity 0.30): the fabric a competent player flies — same as sweep.js
|
||||
rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 }).attach(ids, hwArr, tension);
|
||||
// D's skipped-attach trap: a rig without its shadow mesh scores as a bare
|
||||
// bed and the number LOOKS plausible. Refuse to fly it.
|
||||
if (!rig.rigged || !rig.pos || !rig.tris || !rig.tris.length) {
|
||||
throw new Error('flyGarden: rig is not attached (no shadow mesh) — a bypassed attach scores ' +
|
||||
'every rig as bare (skyfx.js:803, D\'s landmine). Fix the harness, do not trust the number.');
|
||||
}
|
||||
}
|
||||
|
||||
const sky = createSkyFx({ wind, night: true });
|
||||
const garden = createGarden({ setPlants() {} }); // THE model — garden.js, main.js's own
|
||||
|
||||
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 }); // main.js's exact third param
|
||||
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
|
||||
}
|
||||
sky.dispose?.();
|
||||
|
||||
// labelled from the rig's OWN anchorId — attach reorders picks into ring
|
||||
// order, and a peak quoted against input order swaps corners (C's warning).
|
||||
const peaks = rig ? rig.corners.map((c) => {
|
||||
const eff = c.hw.rating * (c.anchor.ratingHint ?? 1);
|
||||
return { id: c.anchorId, peakN: Math.round(c.peakLoad), effN: Math.round(eff),
|
||||
headroom: +(1 - c.peakLoad / eff).toFixed(3) };
|
||||
}) : [];
|
||||
|
||||
return {
|
||||
hp: +garden.hp.toFixed(1),
|
||||
state: garden.state, // 'full' | 'tattered' | 'dead' — garden.js's own thresholds
|
||||
byHail: +garden.damage.hail.toFixed(1),
|
||||
byRain: +garden.damage.rain.toFixed(1),
|
||||
cornersLost: rig ? rig.corners.filter((c) => c.broken).length : 4,
|
||||
peaks,
|
||||
// C's margin rule: corners that held on paper but sit within MARGIN of
|
||||
// their effective rating — in the real game these break. A prediction
|
||||
// carrying names here is NOT a clean hold, whatever the hp says.
|
||||
marginal: peaks.filter((p) => !rig.corners.find((c) => c.anchorId === p.id).broken && p.headroom < AUDIT.MARGIN)
|
||||
.map((p) => p.id),
|
||||
cost,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge a site against its pinned `separation` block (A's gate-1.4 ruling,
|
||||
* backyard_01.json): fly the pinned recipe AND a bare bed on the block's own
|
||||
* storm, and answer the only question the target asks — does the best line the
|
||||
* budget buys read FULL and WIN, and does a bare bed LOSE the night.
|
||||
*
|
||||
* `heldWin` is main.js's win rule (hp >= 50 && corners lost < 2) — EXACTLY the
|
||||
* rule, so this stays in lockstep with a.test's pin of the same block.
|
||||
* `heldClean` is the margin rule's opinion, kept SEPARATE from `ok` on
|
||||
* purpose: the pinned target is A's ruling and this module doesn't get to
|
||||
* overrule it with a working rule whose cause is still unfound — but a pinned
|
||||
* line carrying a corner inside the 15% band is a knife edge the front-end
|
||||
* must SAY (it's a flag for A/D, not a verdict).
|
||||
*
|
||||
* @returns {{ held, bare, heldOk, heldWin, heldClean, bareOk, ok }}
|
||||
*/
|
||||
export function flySeparation({ anchors, bed, separation, stormDef, siteDef = null, use = null }) {
|
||||
const ids = separation.line.map((l) => l.anchor);
|
||||
const hw = separation.line.map((l) => hardwareByName(l.hw));
|
||||
const tension = separation.tension ?? 1.0;
|
||||
const held = flyGarden({ anchors, bed, stormDef, siteDef, use, ids, hw, tension });
|
||||
const bare = flyGarden({ anchors, bed, stormDef, siteDef, use });
|
||||
const heldOk = held.hp > separation.heldMustExceed;
|
||||
const heldWin = held.hp >= 50 && held.cornersLost < 2;
|
||||
const heldClean = held.marginal.length === 0;
|
||||
const bareOk = bare.hp < separation.bareMustLoseBelow;
|
||||
return { held, bare, heldOk, heldWin, heldClean, bareOk, ok: heldOk && heldWin && bareOk };
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
/**
|
||||
* gardenfly.selftest.js — the audit's garden engine audits itself. [Lane B, SPRINT13]
|
||||
*
|
||||
* Browser-only (skyfx needs `document`), so unlike sweep.selftest.js this is an
|
||||
* ASYNC factory: b.test.js awaits it and registers the returned [name, fn]
|
||||
* pairs. The flights happen here, once; the tests assert on captured results —
|
||||
* the same Suite-cannot-await shape a.test.js's pinned-separation flight uses.
|
||||
*
|
||||
* What it pins, and why each can fail:
|
||||
*
|
||||
* 1. TWO HARNESSES, ONE PIN. a.test flies backyard_01's `separation` block
|
||||
* through the RiggingSession shop path; this flies the SAME block through
|
||||
* the audit's own chain (gardenfly: windForSite, direct attach, named
|
||||
* hardware). Both must land on A's ruling — held FULL and winning, bare
|
||||
* losing. If a retune moves the wild night, both go red together and the
|
||||
* argument happens in THREADS, not in a drifted tool.
|
||||
*
|
||||
* 2. THE VENTURI REACHES THE FLOWN WIND. Three harnesses shipped the same
|
||||
* bug (site_audit SPRINT11, C's bench, D's first harness): the funnel
|
||||
* lives in the SITE def and a harness that doesn't wire it flies an
|
||||
* easier yard than ships. Same synthetic-yard trick as sweep.selftest:
|
||||
* starve gardenfly of the siteDef's venturi and this goes red.
|
||||
*
|
||||
* 3. The shop has no mystery tier: hardwareByName throws on a stranger
|
||||
* rather than quietly handing back nothing (the setHardware lesson).
|
||||
*/
|
||||
|
||||
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||
import { loadStorm, windForSite } from '../../web/world/js/weather.js';
|
||||
import { SailRig } from '../../web/world/js/sail.js';
|
||||
import { createSkyFx } from '../../web/world/js/skyfx.js';
|
||||
import { createGarden } from '../../web/world/js/garden.js';
|
||||
import { FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
import { flyGarden, flySeparation, hardwareByName } from './gardenfly.js';
|
||||
|
||||
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
||||
|
||||
/** sweep.selftest's synthetic yard: one quad, storm dead along +Z, funnel on it. */
|
||||
const SYN_ANCHORS = [
|
||||
{ id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
|
||||
{ id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
|
||||
{ id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
|
||||
{ id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
|
||||
].map((a) => ({ ...a, sway: () => a.pos }));
|
||||
const SYN_BED = { x: 0, z: 0, w: 4, d: 4 };
|
||||
const SYN_STORM = { id: 'gardenfly_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
|
||||
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 } };
|
||||
const SYN_FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
|
||||
|
||||
export async function buildGardenflyTests() {
|
||||
// The real yard, the way the game builds it — on a re-pointable wind proxy
|
||||
// (C's bench pattern) so world.anchors fly THEMSELVES with live sway (a
|
||||
// static remap froze the gum tree, C's landmine 2). Fail LOUD if dress
|
||||
// fails — an undressed yard has no fascia hint, and a garden number
|
||||
// measured on it is about a different game (C's bare-specifier gun).
|
||||
const site = await loadSite('backyard_01');
|
||||
let currentWind = {
|
||||
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||
gustTelegraph: () => null, eventsBetween: () => [],
|
||||
};
|
||||
const windProxy = {
|
||||
sample: (p, t, o) => currentWind.sample(p, t, o || new THREE.Vector3()),
|
||||
speedAt: (p, t) => currentWind.speedAt(p, t),
|
||||
rainAt: (t) => currentWind.rainAt(t),
|
||||
rainMmPerHour: (t) => (currentWind.rainMmPerHour ? currentWind.rainMmPerHour(t) : 0),
|
||||
gustTelegraph: (t) => currentWind.gustTelegraph(t),
|
||||
eventsBetween: (a, b) => currentWind.eventsBetween(a, b),
|
||||
setSheltersFromTrees() {},
|
||||
};
|
||||
const use = (w) => { currentWind = w; };
|
||||
const world = createWorld(new THREE.Scene(), { wind: windProxy, site });
|
||||
await world.dress();
|
||||
|
||||
let sepRun = null, skewRun = null, sepErr = null;
|
||||
if (site.separation) {
|
||||
try {
|
||||
const stormDef = await loadStorm(site.separation.stormKey);
|
||||
sepRun = flySeparation({
|
||||
anchors: world.anchors, bed: world.gardenBed,
|
||||
separation: site.separation, stormDef, siteDef: site, use,
|
||||
});
|
||||
// The OTHER harness's chain, reproduced on purpose: a.test settles 12 s
|
||||
// on the calm day before the storm, and SailRig samples wind at its
|
||||
// INTERNAL clock — so its storm flies 12 s off the authored curve
|
||||
// against the sky's t. Measured (2026-07-18, tmp probe → THREADS): the
|
||||
// skew is worth +4.6 HP on the pinned line (63.8 game-true vs 68.4
|
||||
// skewed; A's 68.3 to within 0.1, peaks to the digit). This flight
|
||||
// exists so BOTH harnesses are pinned from one file: if either chain
|
||||
// drifts, the agreement asserts below go red and name which one.
|
||||
const calmDef = await loadStorm('storm_01_gentle');
|
||||
const sep = site.separation;
|
||||
const wind = windForSite(stormDef, site, world.anchors);
|
||||
const calm = windForSite(calmDef, site, world.anchors);
|
||||
use(calm);
|
||||
const rig = new SailRig({ anchors: world.anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(sep.line.map((l) => l.anchor), sep.line.map((l) => hardwareByName(l.hw)), sep.tension ?? 1);
|
||||
for (let i = 0, n = Math.round(12 / FIXED_DT); i < n; i++) rig.step(FIXED_DT, calm, (i * FIXED_DT) % 3);
|
||||
use(wind);
|
||||
const sky = createSkyFx({ wind, night: true });
|
||||
const garden = createGarden({ setPlants() {} });
|
||||
const bed = world.gardenBed;
|
||||
for (let i = 0, n = Math.round(stormDef.duration / FIXED_DT); i < n; i++) {
|
||||
const t = i * FIXED_DT;
|
||||
rig.step(FIXED_DT, wind, t);
|
||||
sky.step(FIXED_DT, t, { sail: rig });
|
||||
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t), sky.gardenExposure(bed, t));
|
||||
}
|
||||
sky.dispose?.();
|
||||
skewRun = { hp: +garden.hp.toFixed(1), lost: rig.corners.filter((c) => c.broken).length };
|
||||
} catch (err) { sepErr = String((err && err.stack) || err); }
|
||||
}
|
||||
|
||||
let venRun = null, venErr = null;
|
||||
try {
|
||||
const fly = (venturi) => flyGarden({
|
||||
anchors: SYN_ANCHORS, bed: SYN_BED, stormDef: SYN_STORM,
|
||||
siteDef: { wind: { venturi } },
|
||||
ids: ['a1', 'a2', 'a3', 'a4'], hw: hardwareByName('rated shackle'),
|
||||
});
|
||||
venRun = { bare: fly([]), funnelled: fly(SYN_FUNNEL) };
|
||||
} catch (err) { venErr = String((err && err.stack) || err); }
|
||||
|
||||
return [
|
||||
['gardenfly: the pinned separation line, flown in BOTH chains — the 12s-skew disagreement, pinned', () => {
|
||||
// ⚠ THE STATE OF PLAY (2026-07-18, [B] THREADS, receipts in the entry):
|
||||
// the pinned recipe HOLDS and rigging matters by a full state's width —
|
||||
// but the >66 FULL half of the target is only met in a.test's chain,
|
||||
// whose 12 s calm settle runs the storm 12 s off the authored curve
|
||||
// (SailRig samples wind at its INTERNAL clock). The game-true chain
|
||||
// (commit→attach→storm at t=0, this module) reads ~63.8 TATTERED.
|
||||
// Ruled numbers are A's; the game is D's to play; this test pins BOTH
|
||||
// measurements so any drift in either chain goes red and names itself.
|
||||
// When A re-rules (fix a.test's harness, restate the threshold, or
|
||||
// re-steel the recipe), the constants below move WITH the ruling.
|
||||
if (!site.separation) throw new Error('backyard_01 lost its separation block — the audit has no target to read');
|
||||
if (sepErr) throw new Error(`separation flight died: ${sepErr}`);
|
||||
const s = site.separation;
|
||||
assert(sepRun.held.cost <= 80, `the pinned recipe costs $${sepRun.held.cost} — must be night-1 buyable`);
|
||||
assert(sepRun.held.cornersLost === 0, `pinned line lost ${sepRun.held.cornersLost}/4 corners — it must HOLD the wild night`);
|
||||
assert(sepRun.bareOk, `bare bed ${sepRun.bare.hp} vs pinned <${s.bareMustLoseBelow} — bare must LOSE the night`);
|
||||
assert(sepRun.held.hp - sepRun.bare.hp > 25,
|
||||
`separation ${(sepRun.held.hp - sepRun.bare.hp).toFixed(1)} — rigging must matter by a full state's width`);
|
||||
// RESOLVED at SPRINT13 integration, per this assert's own instruction: A
|
||||
// re-ruled heldMustExceed 66 → 60 on the game-true number (their THREADS
|
||||
// entry, "restated, not nudged"), so the two-chain disagreement this used
|
||||
// to pin is closed and the pin is asserted directly. B pre-authorized the
|
||||
// edit in the message above; A verified 357/0/0 on a scratch merge first.
|
||||
assert(sepRun.heldOk,
|
||||
`game-true chain reads ${sepRun.held.hp} vs pinned >${s.heldMustExceed} — the held bed must ` +
|
||||
`clear the site's separation block for the RIGHT reason (no settle skew)`);
|
||||
assert(skewRun.lost === 0 && skewRun.hp > s.heldMustExceed,
|
||||
`a.test's settle-skewed chain reads ${skewRun.hp} — measured 68.4 at landing (a.test's own 68.3). ` +
|
||||
`If this dropped, a.test's pin is about to go red too: the wild night itself moved`);
|
||||
assert(sepRun.ok === (sepRun.heldOk && sepRun.heldWin && sepRun.bareOk),
|
||||
'flySeparation.ok must agree with its own parts');
|
||||
}],
|
||||
['gardenfly: the SITE venturi reaches the flown wind (the bench landmine, third time charmed)', () => {
|
||||
if (venErr) throw new Error(`venturi flight died: ${venErr}`);
|
||||
const { bare, funnelled } = venRun;
|
||||
assert(bare.peaks.length === 4 && funnelled.peaks.length === 4, 'both flights fly 4 corners');
|
||||
for (const b of bare.peaks) {
|
||||
// align by anchorId — peaks are ring-ordered, never input-ordered (C's warning)
|
||||
const f = funnelled.peaks.find((p) => p.id === b.id);
|
||||
assert(f.peakN > b.peakN,
|
||||
`corner ${b.id}: funnelled peak ${f.peakN} N is not above bare ${b.peakN} N — ` +
|
||||
`the siteDef's venturi is not reaching gardenfly's wind (windForSite must receive the SITE def)`);
|
||||
}
|
||||
}],
|
||||
['gardenfly: unknown hardware names throw — the shop has no mystery tier', () => {
|
||||
let threw = false;
|
||||
try { hardwareByName('titanium dream'); } catch { threw = true; }
|
||||
assert(threw, 'hardwareByName("titanium dream") must throw, not hand back undefined');
|
||||
assert(hardwareByName('rated shackle').rating === 6500, 'and the real names still resolve');
|
||||
}],
|
||||
];
|
||||
}
|
||||
@ -7,44 +7,25 @@
|
||||
* differ in how they GET the anchors and how they PRINT the result. A tool built
|
||||
* to catch reimplemented-formula drift must not carry two copies of its own math.
|
||||
*
|
||||
* Pure given its inputs: hand it anchors (world.anchors themselves, ideally —
|
||||
* live sway matters, see below), the bed rect, the storm def and the SITE def,
|
||||
* and it flies the same attach→storm chain the game flies and returns ranked
|
||||
* rows + a verdict. No I/O, no process, no DOM.
|
||||
*
|
||||
* SPRINT13: winnability rows carry the margin rule now (AUDIT.MARGIN) — a
|
||||
* corner priced inside 15% of its effective rating holds on this bench and
|
||||
* "breaks in the game" (C's residual, cause unfound), so every row prices
|
||||
* twice: `hw` (holds) and `cleanHw` (holds with margin), and only clean lines
|
||||
* are winners. The garden side of the audit lives in gardenfly.js (browser).
|
||||
* Pure given its inputs: hand it resolved anchors (each {id, type, pos, sway}),
|
||||
* the bed rect, the storm + calm-day defs, and the site's venturi list. It flies
|
||||
* the same settle+storm the game flies and returns ranked rows + a verdict.
|
||||
* No I/O, no process, no DOM.
|
||||
*/
|
||||
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
import { windForSite } from '../../web/world/js/weather.js';
|
||||
import { createWind } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
|
||||
/** Audit knobs, in one place so both front-ends and any future site agree. */
|
||||
export const AUDIT = {
|
||||
BAND: { lo: 18, hi: 45 }, // A's a.test rigging band, m²
|
||||
MIN_COVER: 0.25, // A's "shades the bed" bar
|
||||
SETTLE_S: 12, // D's settle — a player plays through prep
|
||||
PRE_GUST_S: 3, // hold the settle inside gentle's pre-gust window (balance.test)
|
||||
SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
|
||||
/**
|
||||
* C's margin rule (SPRINT13 correction): even a corrected bench under-reads
|
||||
* the real UI's peaks by ~5–15% on site_02 (cause unfound, in the pool).
|
||||
* Until it's found, a corner priced within this fraction of its effective
|
||||
* rating "breaks in the game" — the bench held q4 at 1.24-vs-1.2 and read
|
||||
* 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED.
|
||||
* A line with a marginal corner is flagged, never sold as a clean PASS.
|
||||
*/
|
||||
MARGIN: 0.15,
|
||||
CALM_STORM: 'storm_01_gentle',
|
||||
};
|
||||
// SPRINT13: SETTLE_S / PRE_GUST_S / CALM_STORM are GONE, with the phantom
|
||||
// settle they parameterised. In the real game the rig does not exist before
|
||||
// commit — commit→attach→storm is one keypress (the rig.t fix's own words) —
|
||||
// so the attach transient flies under STORM wind and its loads count. The old
|
||||
// 12 s calm settle + resetPeaks measured a chain the game never flies, and
|
||||
// also skewed every storm sample 12 s off the authored curve (C measured both
|
||||
// on the bench and removed its copy the same day).
|
||||
|
||||
/** Ground-plane area, shoelace over the ring — the same formula a.test uses. */
|
||||
export function areaOf(q) {
|
||||
@ -71,28 +52,20 @@ export const tierFor = (peakN, ratingHint = 1) =>
|
||||
/**
|
||||
* Sweep every bed-covering quad and price the cheapest holding line.
|
||||
* @param {object} o
|
||||
* @param {Array} o.anchors world.anchors THEMSELVES where possible (their
|
||||
* sway closures carry the tree's real movement — a
|
||||
* static remap froze the gum tree and under-read
|
||||
* every tr1 corner, C's landmine 2), or resolved
|
||||
* { id, type, pos, sway, ratingHint } for synthetic
|
||||
* yards. Omit ratingHint and the anchor is priced
|
||||
* at full hardware strength (hint 1), which is a
|
||||
* LIE for any GLB-dressed anchor (fascia 0.35,
|
||||
* @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, sway,
|
||||
* ratingHint } — omit ratingHint and the anchor is
|
||||
* priced at full hardware strength (hint 1), which
|
||||
* is a LIE for any GLB-dressed anchor (fascia 0.35,
|
||||
* carport beam 0.22). Hand this the dressed truth.
|
||||
* @param {object} o.bed garden bed rect { x, z, w, d }
|
||||
* @param {object} o.stormDef the storm JSON to fly
|
||||
* @param {object} [o.siteDef] the SITE json — its wind.venturi is half the
|
||||
* yard's weather (three harnesses learned this the
|
||||
* hard way); preferred over `venturi`
|
||||
* @param {Array} [o.venturi] bare funnel list, for synthetic yards with no
|
||||
* site JSON (sweep.selftest). Ignored if siteDef given.
|
||||
* @param {function} [o.use] the yard's wind-proxy re-pointer (C's bench
|
||||
* pattern) — called with the sweep's wind so live
|
||||
* tree-sway closures sample the storm being flown
|
||||
* @returns {{ cands, rows, winners, marginalWinners, verdict:{ ok, code, best } }}
|
||||
* @param {object} o.calmDef the calm-day JSON to settle on (storm_01_gentle)
|
||||
* @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi).
|
||||
* Omitted = no funnel, which is backyard_01's truth
|
||||
* and a LIE on the corner block. See below.
|
||||
* @returns {{ cands, rows, winners, verdict:{ ok:boolean, code:string, best } }}
|
||||
*/
|
||||
export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [], use = null }) {
|
||||
export function auditSweep({ anchors, bed, stormDef, calmDef, venturi = [] }) {
|
||||
// 1. every quad, in the rigging band, that shades the bed
|
||||
const cands = [];
|
||||
for (let a = 0; a < anchors.length; a++) for (let b = a + 1; b < anchors.length; b++)
|
||||
@ -108,93 +81,61 @@ export function auditSweep({ anchors, bed, stormDef, siteDef = null, venturi = [
|
||||
if (cover >= AUDIT.MIN_COVER) cands.push({ ids: q.map((x) => x.id), area, cover });
|
||||
}
|
||||
|
||||
// The site's PINNED separation line sweeps regardless of the band: A's
|
||||
// gate-1.4 recipe (p1+p2+p3+p5) is 45.9 m² against the band's 45 — the band
|
||||
// is an audit heuristic calibrated before p5 existed, and an audit that
|
||||
// cannot see the site's own ruled-best line is auditing a different yard.
|
||||
// Flagged `pinned` so front-ends can say why it's there; band-vs-pin
|
||||
// reconciliation is A's (posted in THREADS).
|
||||
const pinIds = siteDef?.separation?.line?.map((l) => l.anchor);
|
||||
if (pinIds && !cands.some((c) => pinIds.every((id) => c.ids.includes(id)))) {
|
||||
const q = pinIds.map((id) => anchors.find((a) => a.id === id)).filter(Boolean);
|
||||
if (q.length === 4) {
|
||||
try {
|
||||
const rig = new SailRig({ anchors, gridN: 10 }).attach(pinIds, Array(4).fill(HARDWARE[2]), 1.0);
|
||||
cands.push({ ids: pinIds, area: areaOf(q), cover: rig.coverageOver(bed, { x: 0, y: 1, z: 0 }), pinned: true });
|
||||
} catch { /* a pin naming unriggable anchors will fail loudly in a.test; nothing to add here */ }
|
||||
}
|
||||
}
|
||||
if (!cands.length) return { cands, rows: [], winners: [], verdict: { ok: false, code: 'no-cover', best: null } };
|
||||
|
||||
if (!cands.length) return { cands, rows: [], winners: [], marginalWinners: [], verdict: { ok: false, code: 'no-cover', best: null } };
|
||||
|
||||
// 2. peak corner loads, flown the way the GAME flies them. Every clause here
|
||||
// is a bug some harness shipped:
|
||||
// · windForSite — the one shared wind builder (C's helper): venturi from
|
||||
// the SITE def + tree shelters, byte-for-byte main.js's site-load
|
||||
// wiring. THREE harnesses independently mis-built site wind before it
|
||||
// existed (this tool's Sprint-11 funnel-off audit, C's bench reading
|
||||
// def.wind.venturi off the STORM def, D's first garden harness) — a
|
||||
// fourth copy of the wiring is how there's a fifth bug.
|
||||
// · `use` re-points the caller's world-wind proxy so LIVE tree-sway
|
||||
// closures sample this sweep's storm (frozen sway under-read q4 by
|
||||
// 0.22 kN on the $80 line — C's landmine 2).
|
||||
// · NO calm settle, NO resetPeaks: commit→attach→storm is one keypress
|
||||
// in the real game, so the attach transient flies under storm wind
|
||||
// and its loads count (cheap steel genuinely dies "at the settle" —
|
||||
// that's the storm's opening seconds, not a separate phase).
|
||||
const wind = windForSite(stormDef, siteDef ?? { wind: { venturi } }, anchors);
|
||||
use?.(wind);
|
||||
// 2. peak corner loads, settled the way the game settles, on the real storm.
|
||||
// Every clause here is a bug the first draft shipped:
|
||||
// · createWind + setSheltersFromTrees — trees knock a hole downwind.
|
||||
// · settle on the CALM day on a RUNNING clock (main.js's prep), not storm
|
||||
// wind frozen at t=0 (that read 1.94 kN of standing load vs the true 0.40).
|
||||
// · resetPeaks() at entry — peakLoad is peak-since-ATTACH otherwise, folding
|
||||
// the settle transient into the storm peak.
|
||||
// · setVenturi — the SITE's funnel, and the bug this clause exists for.
|
||||
// A venturi lives in the site JSON, not the storm, so a sweep built only
|
||||
// from stormDef silently drops it: main.js:424 calls setVenturi on the
|
||||
// wind at every site load, and this tool did not. On the corner block —
|
||||
// whose entire weather personality IS the funnel — that under-reports
|
||||
// every corner load and hands back an easier yard than the one that
|
||||
// ships. A false PASS, which is the SPRINT6 trap wearing the other face:
|
||||
// there the tool called a good site unriggable, here it would call a
|
||||
// mean site cheap. Both are the tool lying about a yard it can't see.
|
||||
const trees = anchors.filter((a) => a.type === 'tree');
|
||||
const wind = createWind(stormDef);
|
||||
wind.setVenturi(venturi);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setVenturi(venturi); // the gap doesn't switch off for the settle
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
|
||||
const rows = [];
|
||||
for (const cnd of cands) {
|
||||
// shade cloth (porosity 0.30): the fabric a competent player takes into a windy night
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: 0.30 })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0, n = Math.round(AUDIT.SETTLE_S / FIXED_DT); i < n; i++) {
|
||||
rig.step(FIXED_DT, calmWind, (i * FIXED_DT) % AUDIT.PRE_GUST_S);
|
||||
}
|
||||
rig.resetPeaks(); // ← the storm starts HERE
|
||||
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
// Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
|
||||
// resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
|
||||
//
|
||||
// TWO prices per corner (C's margin rule):
|
||||
// `tier` cheapest hardware that HOLDS the measured peak — what the
|
||||
// old audit sold, and what a player gambling the knife edge
|
||||
// actually buys;
|
||||
// `cleanTier` cheapest hardware that holds it WITH ≥ MARGIN headroom —
|
||||
// the price the margin rule trusts (demand ÷ (1 − MARGIN)).
|
||||
// A corner whose `tier` sits inside the margin band is `marginal`: it
|
||||
// holds on this bench and "breaks in the game" (the residual's working
|
||||
// rule). The row's clean price is what closing that gap costs.
|
||||
const tiers = rig.corners.map((c) => {
|
||||
const hint = c.anchor.ratingHint ?? 1;
|
||||
const tier = tierFor(c.peakLoad, hint);
|
||||
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
|
||||
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
|
||||
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
|
||||
});
|
||||
const tiers = rig.corners.map((c) => ({
|
||||
id: c.anchorId, peak: c.peakLoad, hint: c.anchor.ratingHint ?? 1,
|
||||
tier: tierFor(c.peakLoad, c.anchor.ratingHint ?? 1),
|
||||
}));
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
const cleanHw = tiers.every((c) => c.cleanTier)
|
||||
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
|
||||
rows.push({ ...cnd, tiers, unholdable, marginal, hw, cleanHw,
|
||||
total: hw + AUDIT.SPARE_COST,
|
||||
affordable: !unholdable.length && hw <= START_BUDGET,
|
||||
clean: cleanHw != null && cleanHw <= START_BUDGET });
|
||||
rows.push({ ...cnd, tiers, unholdable, hw, total: hw + AUDIT.SPARE_COST,
|
||||
affordable: !unholdable.length && hw <= START_BUDGET });
|
||||
}
|
||||
rows.sort((a, b) => (a.affordable === b.affordable ? a.hw - b.hw : a.affordable ? -1 : 1));
|
||||
|
||||
// winners are lines the budget can buy at the CLEAN price (≥15% headroom on
|
||||
// every corner); a line only affordable on knife-edge steel is the wild-night
|
||||
// 91.9-FULL illusion and gets its own bucket + verdict code so no front-end
|
||||
// can print PASS over it by accident.
|
||||
const winners = rows.filter((r) => r.clean).sort((a, b) => a.cleanHw - b.cleanHw);
|
||||
const marginalWinners = rows.filter((r) => r.affordable && !r.clean);
|
||||
const winners = rows.filter((r) => r.affordable);
|
||||
return {
|
||||
cands, rows, winners, marginalWinners,
|
||||
cands, rows, winners,
|
||||
verdict: winners.length
|
||||
? { ok: true, code: 'pass', best: winners[0] }
|
||||
: marginalWinners.length
|
||||
? { ok: false, code: 'marginal-only', best: marginalWinners[0] }
|
||||
: { ok: false, code: 'unaffordable', best: rows[0] },
|
||||
: { ok: false, code: 'unaffordable', best: rows[0] },
|
||||
};
|
||||
}
|
||||
|
||||
@ -23,8 +23,7 @@
|
||||
* collapse to the same peak loads and the strict inequality goes red.
|
||||
*/
|
||||
|
||||
import { AUDIT, auditSweep } from './sweep.js';
|
||||
import { HARDWARE } from '../../web/world/js/contracts.js';
|
||||
import { auditSweep } from './sweep.js';
|
||||
|
||||
const TESTS = [];
|
||||
const test = (name, fn) => TESTS.push([name, fn]);
|
||||
@ -50,12 +49,13 @@ const STORM = {
|
||||
id: 'sweep_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
|
||||
gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
|
||||
};
|
||||
const CALM = { id: 'sweep_selftest_calm', duration: 8, dir: Math.PI / 2, base: 3, gusts: null };
|
||||
|
||||
/** Centred on the bed, wide enough to swallow it, aligned with the storm. */
|
||||
const FUNNEL = [{ x: 0, z: 0, axis: Math.PI / 2, gain: 2.0, radius: 12, sharp: 1 }];
|
||||
|
||||
const peaksOf = (venturi) => {
|
||||
const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, venturi });
|
||||
const { rows } = auditSweep({ anchors: ANCHORS, bed: BED, stormDef: STORM, calmDef: CALM, venturi });
|
||||
assert(rows.length > 0, 'sweep selftest yard produced no candidate quad — fix the fixture, not the test');
|
||||
return rows[0].tiers.map((c) => c.peak);
|
||||
};
|
||||
@ -97,7 +97,7 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo
|
||||
// every PEAK stays byte-identical (the hint is a pricing fact, not physics —
|
||||
// the sweep flies unbreakable audit hardware). Written to fail if the hint
|
||||
// is dropped from sweep.js's tierFor call: the two runs collapse into one.
|
||||
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] }).rows[0];
|
||||
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, calmDef: CALM, venturi: [] }).rows[0];
|
||||
const honest = sweep(ANCHORS);
|
||||
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: 0.01, sway: a.sway } : a)));
|
||||
|
||||
@ -119,34 +119,4 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo
|
||||
'an over-ceiling corner must land in unholdable, or the verdict lies');
|
||||
});
|
||||
|
||||
test('the margin rule: a corner inside 15% of its effective rating is not a clean win', () => {
|
||||
// SPRINT13, C's residual: even a corrected bench under-reads the real UI's
|
||||
// peaks by ~5-15%, so "any corner within ~15% of rating breaks in the game".
|
||||
// The sweep must therefore refuse to call a knife-edge line a winner — the
|
||||
// wild-night 91.9-FULL-with-q4-at-1.24-vs-1.2 headline died of exactly this
|
||||
// (D's UI: 39.8 TATTERED). Craft a hint that leaves the best steel ~8%
|
||||
// headroom on a1's measured peak: still priced, still "holds" on paper,
|
||||
// and the verdict must refuse to bless it. Delete the marginal logic from
|
||||
// sweep.js and this goes red on the verdict code.
|
||||
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] });
|
||||
const honest = sweep(ANCHORS);
|
||||
const a1h = honest.rows[0].tiers.find((c) => c.id === 'a1');
|
||||
assert(a1h.tier, 'fixture drift: a1 must be holdable at hint 1');
|
||||
assert(honest.verdict.ok && honest.verdict.code === 'pass',
|
||||
'fixture drift: the honest yard must be a clean pass or this test asserts nothing');
|
||||
|
||||
const RATED = HARDWARE.at(-1);
|
||||
const hint = a1h.peak / (RATED.rating * 0.92); // → best steel holds a1 with 8% headroom
|
||||
const lied = sweep(ANCHORS.map((a) => (a.id === 'a1' ? { ...a, ratingHint: hint, sway: a.sway } : a)));
|
||||
const row = lied.rows[0];
|
||||
const a1 = row.tiers.find((c) => c.id === 'a1');
|
||||
assert(a1.tier === RATED, `a1 at the crafted hint must price to the rated shackle, got ${a1.tier?.name}`);
|
||||
assert(a1.headroom > 0 && a1.headroom < AUDIT.MARGIN,
|
||||
`fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN})`);
|
||||
assert(row.marginal.some((c) => c.id === 'a1'), 'a1 must land in row.marginal');
|
||||
assert(row.affordable && !row.clean, 'the line is affordable but must NOT be clean');
|
||||
assert(!lied.verdict.ok && lied.verdict.code === 'marginal-only',
|
||||
`the only affordable line is marginal — verdict must say so, got ${lied.verdict.code}/${lied.verdict.ok}`);
|
||||
});
|
||||
|
||||
export const SWEEP_TESTS = TESTS;
|
||||
|
||||
@ -34,7 +34,6 @@ import * as THREE from './vendor/three.module.js';
|
||||
import { FIXED_DT } from './js/contracts.js';
|
||||
import { loadStorm, createWind } from './js/weather.js';
|
||||
import { createSkyFx } from './js/skyfx.js';
|
||||
import { createDebris } from './js/debris.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const stormName = q.get('storm') || 'storm_03b_earlybuster';
|
||||
@ -66,16 +65,9 @@ slab.position.set(0, 1.5, -11);
|
||||
scene.add(slab);
|
||||
|
||||
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
|
||||
// gate 2.3: the viewer flies the ambient-leaf layer too — the sky stack and
|
||||
// the yard's tell belong in the same eyeball test
|
||||
const debris = createDebris({ wind, scene });
|
||||
addEventListener('pointerdown', () => sky.unlockAudio(), { once: false });
|
||||
|
||||
let t = Math.max(0, +q.get('t') || 0), paused = false, last = performance.now(), acc = 0;
|
||||
// Warm the ambient layer to the scrub point: leaves carry a few seconds of
|
||||
// state (speed EMA + travel), so a viewer that spawns them cold at ?t=50
|
||||
// shows an empty sky the real t=50 would not.
|
||||
for (let wt = Math.max(0, t - 8); wt < t; wt += FIXED_DT) debris.step(FIXED_DT, wt, {});
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space') paused = !paused;
|
||||
if (e.key === ']') t = Math.min(def.duration, t + 5);
|
||||
@ -94,13 +86,13 @@ function frame(now) {
|
||||
const wall = Math.min(0.1, (now - last) / 1000); last = now;
|
||||
if (!paused && t < def.duration) {
|
||||
acc += wall;
|
||||
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); debris.step(FIXED_DT, t, {}); }
|
||||
while (acc >= FIXED_DT) { acc -= FIXED_DT; t += FIXED_DT; sky.step(FIXED_DT, t, {}); }
|
||||
}
|
||||
camera.rotation.set(0, yaw, 0, 'YXZ');
|
||||
const ch = (def.events || []).find((e) => e.type === 'windchange');
|
||||
hud.textContent = `${stormName} t=${t.toFixed(1)}s / ${def.duration}s${paused ? ' ⏸' : ''}\n`
|
||||
+ `wind ${wind.speedAt(camera.position, t).toFixed(1)} m/s rain ${wind.rainAt(t).toFixed(2)} `
|
||||
+ `front ${sky.changeFront.toFixed(2)} leaves ${debris.leafCount}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
|
||||
+ `front ${sky.changeFront.toFixed(2)}${ch ? ` (change at t=${ch.t})` : ' (no change)'}\n`
|
||||
+ `space pause · [ ] scrub · ←→ turn · click for audio`;
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
@ -52,66 +52,6 @@ export function createDebris(o = {}) {
|
||||
const w = new THREE.Vector3();
|
||||
const probe = new THREE.Vector3();
|
||||
|
||||
// ---------------------------------------------------------------- leaves
|
||||
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3"
|
||||
// — the event-driven crates are the drama, but nothing SELLS the gale
|
||||
// between events. A handful of leaves streaming with the wind does, from
|
||||
// 30 km/h up. Numbers that matter: LEAF_START is 8.3 m/s on purpose — the
|
||||
// same threshold D keys the player's lean on (their table, lane/d), so the
|
||||
// yard and the body start telling the same story at the same speed. Count
|
||||
// stays single digits (cap 7); these are a tell, not a particle system.
|
||||
//
|
||||
// Deterministic: own seeded rng (so the leaf pool never shifts the piece
|
||||
// rng sequence), distance accumulated over the fixed-dt stream, flutter
|
||||
// pure in t. Same (dt, t) stream in, same leaves out.
|
||||
const LEAF_START = 8.3; // m/s = 30 km/h; matches D's lean threshold
|
||||
const LEAF_MAX = 7; // "a handful" — single digits, capped
|
||||
const LEAF_SPAN = 26; // m of downwind run before a leaf recycles
|
||||
const lrand = rng(((wind && wind.seed) || 1) ^ 0x1eaf);
|
||||
const leafGeo = new THREE.PlaneGeometry(0.16, 0.09);
|
||||
const leafMat = new THREE.MeshLambertMaterial({ color: 0x8a7a3f, side: THREE.DoubleSide });
|
||||
const leafMeshes = [];
|
||||
const leafSeed = [];
|
||||
for (let i = 0; i < LEAF_MAX; i++) {
|
||||
const m = new THREE.Mesh(leafGeo, leafMat);
|
||||
m.visible = false;
|
||||
if (scene) scene.add(m);
|
||||
leafMeshes.push(m);
|
||||
leafSeed.push({
|
||||
lat: lrand() * 16 - 8, // lane across the wind, m
|
||||
base: 0.35 + lrand() * 1.5, // ride height, m
|
||||
off: lrand() * LEAF_SPAN, // where on the loop it starts
|
||||
fl: 1.6 + lrand() * 1.6, // flutter frequency
|
||||
ph: lrand() * 6.283,
|
||||
});
|
||||
}
|
||||
let leafDist = 0; // m travelled downwind, accumulated over the dt stream
|
||||
let leafEma = 0; // ~2.5 s smoothed speed, so the count doesn't strobe
|
||||
|
||||
function stepLeaves(dt, t) {
|
||||
probe.set(0, 1.6, 0);
|
||||
wind.sample(probe, t, w);
|
||||
const sp = Math.hypot(w.x, w.z);
|
||||
leafEma += (sp - leafEma) * Math.min(1, dt / 2.5);
|
||||
const n = leafEma < LEAF_START ? 0
|
||||
: Math.min(LEAF_MAX, 1 + Math.floor((leafEma - LEAF_START) / 1.5));
|
||||
leafDist += sp * 0.85 * dt; // leaves ride a little under the wind
|
||||
const inv = sp > 1e-4 ? 1 / sp : 0;
|
||||
const dx = w.x * inv, dz = w.z * inv;
|
||||
for (let i = 0; i < LEAF_MAX; i++) {
|
||||
const m = leafMeshes[i], s = leafSeed[i];
|
||||
const vis = i < n && inv > 0;
|
||||
m.visible = vis;
|
||||
if (!vis) continue;
|
||||
const along = ((leafDist + s.off + i * (LEAF_SPAN / LEAF_MAX)) % LEAF_SPAN) - LEAF_SPAN / 2;
|
||||
const lat = s.lat + Math.sin(t * 0.9 + s.ph) * 1.1;
|
||||
const x = dx * along - dz * lat;
|
||||
const z = dz * along + dx * lat;
|
||||
m.position.set(x, groundAt(x, z) + s.base + Math.sin(t * s.fl + s.ph) * 0.3, z);
|
||||
m.rotation.set(t * s.fl, s.ph + t * 2.1, t * 1.3 + s.ph);
|
||||
}
|
||||
}
|
||||
|
||||
/** Graybox stand-in so a missing GLB can't break Lane A's merge. */
|
||||
function placeholder(spec) {
|
||||
const g = new THREE.BoxGeometry(spec.r * 1.8, spec.r * 1.8, spec.r * 1.8);
|
||||
@ -168,11 +108,6 @@ export function createDebris(o = {}) {
|
||||
const debris = {
|
||||
get pieces() { return pieces; },
|
||||
|
||||
/** How many ambient leaves are flying right now (gate 2.3). 0 in a calm. */
|
||||
get leafCount() { let n = 0; for (const m of leafMeshes) if (m.visible) n++; return n; },
|
||||
/** Positions of the flying leaves — for asserts and D's judging. */
|
||||
get leaves() { return leafMeshes.filter((m) => m.visible).map((m) => m.position); },
|
||||
|
||||
/** Lane E's GLBs, once they land. name -> Object3D template. */
|
||||
setModels(map) { Object.assign(models, map); return debris; },
|
||||
|
||||
@ -190,7 +125,6 @@ export function createDebris(o = {}) {
|
||||
for (const ev of wind.eventsBetween(t - dt, t)) {
|
||||
if (ev.type === 'debris') spawn(ev, t);
|
||||
}
|
||||
stepLeaves(dt, t); // gate 2.3 — the ambient tell
|
||||
}
|
||||
|
||||
const player = world.player;
|
||||
@ -285,10 +219,6 @@ export function createDebris(o = {}) {
|
||||
clear() {
|
||||
for (const p of pieces) despawn(p);
|
||||
pieces.length = 0;
|
||||
// leaves are a pooled ambient layer, not spawned pieces: hide and rewind
|
||||
// so the next night's stream starts from the same state every time
|
||||
for (const m of leafMeshes) m.visible = false;
|
||||
leafDist = 0; leafEma = 0;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -373,18 +373,6 @@ export function createHud(d) {
|
||||
const quad = new THREE.PlaneGeometry(1, 1);
|
||||
quad.translate(0.5, 0, 0); // pivot on the left edge, so scale.x grows rightward
|
||||
const BAR_W = 0.9, BAR_H = 0.1;
|
||||
/**
|
||||
* When two corner bars count as "on top of each other", in clip space (NDC
|
||||
* spans -1..1 across the viewport), and how far apart to stack them.
|
||||
*
|
||||
* Measured off the collision the QA pass actually hit — CB1/CB2 on the carport
|
||||
* beam, viewed from where a player stands to rig them. A bar is ~0.9 world
|
||||
* units wide and scaled to hold constant screen size, so these are roughly
|
||||
* "one bar wide, one line tall" and don't need to track the bar's dimensions.
|
||||
* Y is the tighter of the two on purpose: labels stack vertically, so a pair
|
||||
* side-by-side is already readable and only a vertical overlap is mush.
|
||||
*/
|
||||
const BAR_NDC_X = 0.10, BAR_NDC_Y = 0.045, BAR_STACK_Y = 0.34;
|
||||
|
||||
const bars = [];
|
||||
function ensureBars(n) {
|
||||
@ -442,14 +430,6 @@ export function createHud(d) {
|
||||
const _p = new THREE.Vector3();
|
||||
const _q = new THREE.Quaternion();
|
||||
const _cam = new THREE.Vector3();
|
||||
// Corner-bar de-collision (SPRINT13). Scratch + a per-frame record of where
|
||||
// each bar landed in clip space, so bars that typeset on top of each other can
|
||||
// be stacked instead. Pooled rather than reallocated — this runs every frame —
|
||||
// and each entry carries `live`, because a corner that has no position this
|
||||
// frame must not leave last frame's ghost for the others to stack against.
|
||||
const _ndc = new THREE.Vector3();
|
||||
const _placed = [];
|
||||
const placedSlot = (i) => (_placed[i] || (_placed[i] = { x: 0, y: 0, live: false }));
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
const barColor = (frac, broken) => (broken ? BAR_DEAD : frac > 0.85 ? BAR_HOT : frac > 0.55 ? BAR_WARN : BAR_OK);
|
||||
@ -622,7 +602,7 @@ export function createHud(d) {
|
||||
const c = d.rig.corners[i];
|
||||
const b = bars[i];
|
||||
const p = cornerPos(i);
|
||||
if (!p) { b.holder.visible = false; placedSlot(i).live = false; continue; }
|
||||
if (!p) { b.holder.visible = false; continue; }
|
||||
|
||||
_p.set(p.x, p.y, p.z);
|
||||
b.holder.position.copy(_p);
|
||||
@ -635,43 +615,7 @@ export function createHud(d) {
|
||||
// out there rather than in a corner of the screen. Clamped so it doesn't
|
||||
// balloon when you walk right up to a corner to repair it.
|
||||
const dist = _p.distanceTo(_cam);
|
||||
const scale = Math.max(0.75, Math.min(3.2, dist / 7));
|
||||
b.holder.scale.setScalar(scale);
|
||||
|
||||
/**
|
||||
* Stack bars that would typeset on top of each other. [B's flag, SPRINT13]
|
||||
*
|
||||
* The QA pass: "CB1/CB2 died together and typeset on top of each other".
|
||||
* The carport's two beam anchors are 2.82 m apart and the sail hangs
|
||||
* between them, so from most of the yard their bars land in the same
|
||||
* handful of pixels — and they are exactly the pair most likely to blow
|
||||
* together, because they share a beam and a 0.22 ratingHint. The one
|
||||
* moment the HUD has to be legible ("both beam corners went, that's why
|
||||
* the sail is on the lawn") was the one moment it turned to mush.
|
||||
*
|
||||
* Screen space, not world space: whether two labels collide is a fact
|
||||
* about the camera, and two corners a metre apart are unreadable from
|
||||
* the fence and perfectly clear from underneath. Compared at the anchor
|
||||
* point rather than after nudging, so the test is order-independent and
|
||||
* a stack of three can't chase itself up the screen. Deterministic —
|
||||
* corner index order, no clock — and O(n²) on n=4.
|
||||
*
|
||||
* The offset rides `scale` so the gap is constant on screen at any range,
|
||||
* the same reason the bar itself is scaled.
|
||||
*/
|
||||
_ndc.copy(b.holder.position).project(d.camera);
|
||||
const onScreen = _ndc.z < 1; // behind the camera projects to nonsense
|
||||
let clash = 0;
|
||||
if (onScreen) {
|
||||
for (let k = 0; k < i; k++) {
|
||||
const o = _placed[k];
|
||||
if (!o || !o.live) continue;
|
||||
if (Math.abs(_ndc.x - o.x) < BAR_NDC_X && Math.abs(_ndc.y - o.y) < BAR_NDC_Y) clash++;
|
||||
}
|
||||
}
|
||||
const slot = placedSlot(i);
|
||||
slot.x = _ndc.x; slot.y = _ndc.y; slot.live = onScreen;
|
||||
if (clash) b.holder.position.y += clash * BAR_STACK_Y * scale;
|
||||
b.holder.scale.setScalar(Math.max(0.75, Math.min(3.2, dist / 7)));
|
||||
|
||||
// SPRINT12 — B's flag, actioned: the bar reads the EFFECTIVE rating,
|
||||
// rating × ratingHint, because that's the number sail.js fails on now.
|
||||
|
||||
@ -69,22 +69,7 @@ export const needsLadder = (anchor) => {
|
||||
return anchor.type === 'house'; // site_01's shape
|
||||
};
|
||||
|
||||
/**
|
||||
* What to call the thing the ladder serves, in the player's own words.
|
||||
*
|
||||
* SPRINT13 pool (the integrator's QA pass): the place label hardcoded "the fascia" — true on
|
||||
* backyard_01, a lie on the corner block, where the bracket is a carport beam. Keyed on `type`,
|
||||
* which has been a CHECKED enum since Sprint 11 (`validateSite` rejects an unknown one), so a new
|
||||
* site can only arrive here with a type somebody typed on purpose.
|
||||
*
|
||||
* The fallback names the MECHANISM, not a guessed structure: an unmapped bracket reads "the bracket
|
||||
* needs the ladder", which is vague but never false. Same instinct as `needsLadder` itself — the
|
||||
* thing that broke here was a rule keyed on site_01's spelling of the idea instead of the idea.
|
||||
*/
|
||||
const BRACKET_NOUN = { house: 'the fascia', carport: 'the carport bracket' };
|
||||
const bracketNoun = (a) => BRACKET_NOUN[a?.type] ?? 'the bracket';
|
||||
|
||||
/** Where the player stands to work a bracket anchor: out from the wall, at the anchor's x. */
|
||||
/** Where the player stands to work a fascia anchor: out from the wall, at the anchor's x. */
|
||||
const STAND_OFF = 0.9; // m clear of the wall face
|
||||
const PLACE_RANGE = 2.6; // m — how close you must be to a fascia anchor to plant the ladder
|
||||
const RUNG_CLEAR = 0.55; // m — feet this far below the top rung, so the fascia is at chest height
|
||||
@ -110,8 +95,6 @@ export function createLadder(scene, world, interact, player) {
|
||||
base: new THREE.Vector3(),
|
||||
topY: 2.9, // overwritten from the GLB's ladder_top node
|
||||
view: null,
|
||||
dropped: false, // lying in the grass where it was lost (see the reconcile in update)
|
||||
dropYaw: 0,
|
||||
};
|
||||
|
||||
// Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
|
||||
@ -151,11 +134,6 @@ export function createLadder(scene, world, interact, player) {
|
||||
// planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
|
||||
// top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
|
||||
state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
|
||||
} else if (state.dropped) {
|
||||
// Dropped, not stowed: it lies FLAT where it fell. The stowed pose is a slouch against the
|
||||
// shed wall, and a slouch in open grass reads as a ladder leaning on thin air — which looks
|
||||
// like a bug at exactly the moment the player is hunting for the thing.
|
||||
state.view.rotation.set(Math.PI / 2, state.dropYaw, 0);
|
||||
} else {
|
||||
state.view.rotation.set(0, 0.6, 0.22); // stowed: slouched against the shed
|
||||
}
|
||||
@ -191,7 +169,6 @@ export function createLadder(scene, world, interact, player) {
|
||||
onDone: (p, t) => {
|
||||
state.carried = true;
|
||||
state.placedAt = null;
|
||||
state.dropped = false;
|
||||
p.pickUp('ladder', t);
|
||||
syncView();
|
||||
},
|
||||
@ -208,19 +185,15 @@ export function createLadder(scene, world, interact, player) {
|
||||
clip: 'PickUp',
|
||||
// Reads as an offer when you can, and as a reason when you can't — interact.visible() now
|
||||
// shows unusable targets greyed, and a label written only as an offer explains nothing there.
|
||||
// Standing under a blown bracket holding a spare, "the fascia needs the ladder" is the single
|
||||
// most useful sentence in the game — so it has to name the RIGHT structure, or it's the single
|
||||
// most confusing one. It said "fascia" in the corner block until SPRINT13.
|
||||
// ...and it must not lie about WHERE. "it's by the shed" is true until the wind takes it out
|
||||
// of your hands, and then it's the game sending you to the wrong end of the yard mid-storm.
|
||||
// Standing under a blown fascia bracket holding a spare, "the fascia needs the ladder" is the
|
||||
// single most useful sentence in the game.
|
||||
label: (p) => (p.carrying === 'ladder'
|
||||
? `set the ladder under ${a.id.toUpperCase()}`
|
||||
: `${bracketNoun(a)} needs the ladder — ${state.dropped ? 'you dropped it out there' : "it's by the shed"}`),
|
||||
? `set the ladder under ${a.id}`
|
||||
: 'the fascia needs the ladder — it\'s by the shed'),
|
||||
canUse: (p) => p.carrying === 'ladder',
|
||||
onDone: (p, t) => {
|
||||
state.carried = false;
|
||||
state.placedAt = a.id;
|
||||
state.dropped = false;
|
||||
state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
|
||||
a.pos.z + STAND_OFF);
|
||||
p.carrying = null;
|
||||
@ -266,29 +239,6 @@ export function createLadder(scene, world, interact, player) {
|
||||
* player.js calls this each frame from the same input it reads for movement.
|
||||
*/
|
||||
update(dt, t, input) {
|
||||
// SPRINT13 pool — "the dropped spare's whereabouts are unknown" is the small half of this.
|
||||
// The player can lose the ladder without telling us: knockdown() calls drop() (player.sim.js),
|
||||
// which nulls `carrying` and pushes an event nothing here listens for. state.carried stayed
|
||||
// true forever, and every consequence of that is silent:
|
||||
// · syncView hides the mesh (`visible = !carried`) → the ladder is invisible
|
||||
// · ladder_take.pos() returns null while carried → nothing to walk to
|
||||
// · ladder_take.canUse needs !state.carried → it can never be picked up again
|
||||
// Blown over mid-carry and the ladder left the game, while ladder_place went on saying "it's
|
||||
// by the shed". It wasn't. On the corner block that silently ends the site's whole thesis:
|
||||
// cb1/cb2 are bracket work, and bracket work needs a ladder that no longer exists.
|
||||
// The player's hands are the one source of truth, so reconcile against them every frame and
|
||||
// let the ladder FALL where it was lost — the same instinct as STATES.shelter, which enters
|
||||
// and leaves itself so a dropped release can't strand you. Fetching it is now a cost you can
|
||||
// see and walk to, which is what the mechanic wanted from the knockdown in the first place.
|
||||
if (state.carried && player.carrying !== 'ladder') {
|
||||
state.carried = false;
|
||||
state.placedAt = null;
|
||||
state.dropped = true;
|
||||
state.dropYaw = player.facing ?? 0;
|
||||
state.base.set(player.pos.x,
|
||||
world.heightAt ? world.heightAt(player.pos.x, player.pos.z) : 0,
|
||||
player.pos.z);
|
||||
}
|
||||
if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
|
||||
syncView();
|
||||
},
|
||||
|
||||
@ -172,8 +172,6 @@ export class PlayerView {
|
||||
this._axis = new THREE.Vector3();
|
||||
this._qYaw = new THREE.Quaternion();
|
||||
this._qPitch = new THREE.Quaternion();
|
||||
this._leanAxis = new THREE.Vector3();
|
||||
this._qLean = new THREE.Quaternion();
|
||||
}
|
||||
|
||||
/** @returns {string[]} clip names that bound to at least one bone */
|
||||
@ -210,16 +208,6 @@ export class PlayerView {
|
||||
if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
|
||||
this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
|
||||
this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
|
||||
} else if (sim.lean > 1e-3) {
|
||||
// Wind lean (SPRINT13): the same foot-pivot tip as the knockdown, but small and toward the
|
||||
// wind's downwind bearing (leanDir) rather than the fall direction. The sim suppresses lean
|
||||
// whenever pitch > 0, so these two never fight — this is the else branch by construction, not
|
||||
// by luck. leanMaxRad is the whole tip; a knockdown's is π/2, so a full lean is ~a third of a
|
||||
// fall. Yaw still applies underneath, so you lean while facing wherever you're walking.
|
||||
this._leanAxis.set(sim.leanDir.z, 0, -sim.leanDir.x);
|
||||
if (this._leanAxis.lengthSq() < 1e-8) this._leanAxis.set(1, 0, 0);
|
||||
this._qLean.setFromAxisAngle(this._leanAxis.normalize(), sim.lean * sim.leanMaxRad);
|
||||
this.root.quaternion.copy(this._qLean).multiply(this._qYaw);
|
||||
} else {
|
||||
this.root.quaternion.copy(this._qYaw);
|
||||
}
|
||||
|
||||
@ -100,21 +100,6 @@ export const TUNE = {
|
||||
knockBleed: 2, // exposure drains this many × faster than it fills
|
||||
pitchSecs: 0.35, // s for the body to swing down / back up (view reads sim.pitch)
|
||||
|
||||
// Wind lean (SPRINT13 gate 2.4). The QA pass: "the player stands bolt upright and unbothered" at
|
||||
// 65 km/h. E rendered the two Mixamo lean candidates and rejected both on screen (a couch-shove
|
||||
// and a bus-stop slouch), and proved the deeper reason a clip can't do this at all: _rotOnly drops
|
||||
// Hips.quaternion from every clip at load (player.js:44), so a baked lean is discarded — and a
|
||||
// clip's lean axis is fixed while the wind's bearing swings. So the lean is the ROOT, same machine
|
||||
// as the knockdown pitch: the sim owns an angle and a direction, the view tips the body by it.
|
||||
// Keyed on windBase (the ~4 s EMA), NOT raw windSpeed — measured: raw strobes the posture 8-18×/min
|
||||
// across the storms, windBase settles it to 0-2 changes per 90 s. Sustained wind = posture; gust is
|
||||
// already the stumble/knock event. Bands land on the storm rating ladder (D's THREADS table): night
|
||||
// 1 upright all night, 2-3 lean, 4-5 in the gale.
|
||||
leanWindMin: 8.3, // m/s windBase (30 km/h — the speed C's leaves start at) before you lean at all
|
||||
leanWindFull: 22, // m/s windBase for a full lean — past the wildnight median, shy of its 25 peak
|
||||
leanMaxRad: 0.30, // rad (~17°) of torso tip at full lean. A brace-into-it, not a fall (pitch is 0.5π)
|
||||
leanSecs: 0.6, // s to reach/leave the target lean — slow, so a lull visibly lets you straighten
|
||||
|
||||
// A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
|
||||
// storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
|
||||
// RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the
|
||||
@ -179,8 +164,6 @@ export class PlayerSim {
|
||||
this.gust = 0; // windSpeed - windBase, clamped ≥0
|
||||
this.pitch = 0; // 0 upright … 1 flat on the ground
|
||||
this.knockDir = { x: 0, z: 1 }; // which way the body went down
|
||||
this.lean = 0; // 0 upright … 1 full wind-lean (view reads this + leanDir)
|
||||
this.leanDir = { x: 0, z: 1 }; // unit downwind — the way the wind is trying to push you
|
||||
|
||||
this.climbY = 0; // m above the ground; >0 means you're up a ladder
|
||||
this.climbTarget = 0; // where ladder.js asked you to be
|
||||
@ -197,9 +180,6 @@ export class PlayerSim {
|
||||
get clip() { return STATES[this.state].clip; }
|
||||
get speed() { return Math.hypot(this.vel.x, this.vel.z); }
|
||||
|
||||
/** Radians of torso tip at a full wind-lean. Surfaced so PlayerView reads it, not TUNE. */
|
||||
get leanMaxRad() { return this.tune.leanMaxRad; }
|
||||
|
||||
/** How high this person's hands get right now. The gate on reaching a fascia bracket. */
|
||||
get reachY() { return this.pos.y + this.climbY + this.tune.reach; }
|
||||
|
||||
@ -401,22 +381,6 @@ export class PlayerSim {
|
||||
const pstep = dt / T.pitchSecs;
|
||||
this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);
|
||||
|
||||
// --- wind lean: the sim owns it exactly like pitch, so the view stays a pure reader and the lean
|
||||
// is deterministic (dt,t). Off windBase, not ws, so a gust punches the stumble/knock event
|
||||
// while the posture holds. Suppressed where a lean would fight another root pose: on your back
|
||||
// (pitch owns the body), braced (TakeCover is its own hunch), or up the ladder (you're pinned
|
||||
// to the rungs, not standing in the wind). It eases to 0 there rather than snapping. ---
|
||||
const leanOK = this.pitch < 1e-3 && this.state !== 'shelter' && !up;
|
||||
const leanSpan = Math.max(1e-3, T.leanWindFull - T.leanWindMin);
|
||||
const wantLean = leanOK
|
||||
? clamp((this.windBase - T.leanWindMin) / leanSpan, 0, 1)
|
||||
: 0;
|
||||
const lstep = dt / T.leanSecs;
|
||||
this.lean = clamp(this.lean + clamp(wantLean - this.lean, -lstep, lstep), 0, 1);
|
||||
// downwind is where the wind blows TOWARD; track it only while there's a wind to read, so a lull
|
||||
// leaves the last bearing rather than snapping leanDir to a default and twisting the body.
|
||||
if (ws > 0.1) { const inv = 1 / ws; this.leanDir.x = wx * inv; this.leanDir.z = wz * inv; }
|
||||
|
||||
// --- locomotion state from actual speed (so shove/slow can't desync the feet).
|
||||
// `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
|
||||
// this guard the speed check would immediately re-state you to idle and drop you off. ---
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
* the bottom for the seam it will plug into.
|
||||
*/
|
||||
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST, FIXED_DT } from './contracts.js';
|
||||
import { SailRig, orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
import { HARDWARE, START_BUDGET, SPARE_COST } from './contracts.js';
|
||||
import { orderRing, TENSION_MIN, TENSION_MAX } from './sail.js';
|
||||
|
||||
export { START_BUDGET, SPARE_COST };
|
||||
export const MAX_CORNERS = 4;
|
||||
@ -161,34 +161,10 @@ export class RiggingSession {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put specific hardware on a rigged corner, paying the difference.
|
||||
*
|
||||
* THROWS on hardware that isn't in the shop, where every other failure here
|
||||
* returns {ok:false}. The split is deliberate and it is about who made the
|
||||
* mistake: 'not rigged' and 'not enough budget' are things a PLAYER does, and
|
||||
* the UI tickers the reason. Hardware that isn't in HARDWARE is not reachable
|
||||
* by any click — the picking UI only ever cycles the shop's own list — so it
|
||||
* can only mean a caller passed the wrong thing, and returning {ok:false} for
|
||||
* that just lets a bug walk.
|
||||
*
|
||||
* It already had: balance.test's own comment records "setHardware fails
|
||||
* silently, cheap hardware stays on, p2/p4 tear off" — a loadout that quietly
|
||||
* kept its carabiners and then reported a cascade as if it were a finding.
|
||||
* The SPRINT12 QA pass hit the same edge from the other side. A wrong tier is
|
||||
* invisible in the result (the sim just runs, cheaper), so this is exactly the
|
||||
* class of mistake that must not be ignorable.
|
||||
*/
|
||||
setHardware(anchorId, hw) {
|
||||
if (!HARDWARE.includes(hw)) {
|
||||
throw new TypeError(
|
||||
`setHardware("${anchorId}", ${JSON.stringify(hw?.name ?? hw)}) — not a shop item. ` +
|
||||
`Pass a HARDWARE entry (${HARDWARE.map((h) => h.name).join(' / ')}), not a name or a copy: ` +
|
||||
'the shop compares by identity. A returned {ok:false} here would leave the old ' +
|
||||
'hardware on the corner and the sim would run cheaper without telling anyone.');
|
||||
}
|
||||
const p = this.pickOf(anchorId);
|
||||
if (!p) return fail('not rigged');
|
||||
if (!HARDWARE.includes(hw)) return fail('unknown hardware');
|
||||
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
|
||||
p.hw = hw;
|
||||
return OK;
|
||||
@ -213,7 +189,7 @@ export class RiggingSession {
|
||||
return this.tension;
|
||||
}
|
||||
|
||||
/** Buy (or sell back) spares in prep. `spares` is the count on the shed table. */
|
||||
/** Spares are what Lane D's hold-E re-rig consumes mid-storm. */
|
||||
setSpares(n) {
|
||||
n = Math.max(0, Math.floor(n));
|
||||
const delta = (n - this.spares) * SPARE_COST;
|
||||
@ -222,38 +198,6 @@ export class RiggingSession {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many spares are still on the shed table. [D's ask, SPRINT13]
|
||||
*
|
||||
* The read half of the seam D found: interact.js gated the table pickup on
|
||||
* `!player.carrying` alone and never looked at the count, so a player who
|
||||
* bought ZERO walked off with unlimited free shackles mid-storm — the whole
|
||||
* "limited hands, two trips" economy the ladder's cost is balanced against was
|
||||
* free on the spare side, live on the public deploy.
|
||||
*/
|
||||
get sparesRemaining() { return this.spares; }
|
||||
|
||||
/**
|
||||
* Take one spare off the table. [D's ask, SPRINT13]
|
||||
*
|
||||
* The consume half. `spares` IS the count on the table, not a purchased total
|
||||
* held elsewhere, so taking one decrements it — and that quietly fixes a
|
||||
* SECOND bug for free: main.js:720 already refunds `session.spares * SPARE_COST`
|
||||
* as "a spare you never had to use", so before this you were refunded for
|
||||
* spares you'd already spent. Decrementing here makes that line honest with no
|
||||
* change to main.js. reset() zeroes the count between nights, so nothing
|
||||
* carries.
|
||||
*
|
||||
* interact.js calls `canUse: () => !p.carrying && session.sparesRemaining > 0`
|
||||
* and `onDone: () => session.takeSpare()` once A threads the session into
|
||||
* wireYardActions — D has the interact side ready.
|
||||
*/
|
||||
takeSpare() {
|
||||
if (this.spares <= 0) return fail('no spares left on the table');
|
||||
this.spares -= 1;
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ring-order the picks by angle around their ground-plane centroid, so corner
|
||||
* i of the cloth grid always maps to a neighbouring anchor. Without it,
|
||||
@ -288,66 +232,6 @@ export class RiggingSession {
|
||||
return p.hw.rating * (a?.ratingHint ?? 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* What each corner carries in STILL AIR at the current tension, newtons.
|
||||
* Null until four corners are picked — a quad is the only thing that has a load.
|
||||
*
|
||||
* D's ask, SPRINT13 ("the first lesson always costs ~$60"): the tension dial
|
||||
* has a static price and the panel never showed it. At 1.4 the standing load
|
||||
* alone is ~1.4-1.5 kN/corner on a 46 m² quad, which is over every tier the
|
||||
* shop sells except a rated shackle on an honest anchor — so a drum-tight sail
|
||||
* rips corners off during PREP, on the calm day, before a breath of wind. That
|
||||
* reads fair when you can see it coming and daylight robbery when you can't.
|
||||
*
|
||||
* Still air, not "calm day": this is the number the DIAL owns. Gravity and
|
||||
* tension only — no gusts, no forecast, nothing that could be mistaken for a
|
||||
* prediction about tonight. The storm multiplies this by ~3-8x and the panel
|
||||
* says "standing" for exactly that reason.
|
||||
*
|
||||
* Cheap enough to call on a click: 15.6 ms of settle on a 10×10 grid (measured),
|
||||
* and the result is cached by the caller on (picks, tension, fabric) — hardware
|
||||
* doesn't move the cloth, so cycling tiers, the most-clicked action in prep,
|
||||
* never recomputes.
|
||||
*
|
||||
* 4 s of settle, not 2: the cloth arrives at rest through a damped oscillation,
|
||||
* and at low tension 2 s still reads ~4% high on the way down (0.195 vs 0.188
|
||||
* kN converged). 4 s is inside 1% everywhere measured and costs 8 ms more, once.
|
||||
*
|
||||
* Cross-checked against the game the honest way: on the corner block's real
|
||||
* 46 m² carport quad this reports a worst corner of 1.41 kN at tension 1.4 —
|
||||
* D measured "~1.4-1.5 kN/corner at 46 m²" in play, from the other side.
|
||||
*/
|
||||
standingLoads({ settle = 4.0 } = {}) {
|
||||
if (this.picks.length !== MAX_CORNERS) return null;
|
||||
const rig = new SailRig({ anchors: this.anchors, gridN: 10, porosity: this.fabric.porosity });
|
||||
/**
|
||||
* UNBREAKABLE hardware, deliberately — and this is the whole subtlety.
|
||||
*
|
||||
* The preview measures the LOAD, which is a fact about geometry and the
|
||||
* dial; it is not a rehearsal of tonight. Attach it with the player's real
|
||||
* tiers and at tension 1.4 the corners genuinely rip during this very
|
||||
* settle (D measured it: three go at t≈0.5 on the calm day) — and a broken
|
||||
* corner carries nothing, so the panel would print a serene `0.00 kN` for
|
||||
* the exact corner about to take the night down. The first version of this
|
||||
* did that and the selftest below caught it.
|
||||
*
|
||||
* So: measure the load unbroken, then let the PANEL compare it against the
|
||||
* corner's real effective rating and say "RIPS AT REST". The number stays
|
||||
* honest and the warning lands where the player can act on it.
|
||||
*/
|
||||
const UNBREAKABLE = { name: 'preview', cost: 0, rating: Infinity };
|
||||
rig.attach(this.picks.map((p) => p.anchorId), Array(MAX_CORNERS).fill(UNBREAKABLE), this.tension);
|
||||
// Still air. `sample` must honour the out-param — sail.js reuses one vector.
|
||||
const stillAir = {
|
||||
sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }),
|
||||
rainMmPerHour: () => 0,
|
||||
};
|
||||
for (let i = 0, n = Math.round(settle / FIXED_DT); i < n; i++) rig.step(FIXED_DT, stillAir, i * FIXED_DT);
|
||||
const out = {};
|
||||
for (const c of rig.corners) out[c.anchorId] = c.load;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Everything the HUD needs to draw the prep panel, in one read. */
|
||||
get summary() {
|
||||
return {
|
||||
@ -357,16 +241,7 @@ export class RiggingSession {
|
||||
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,
|
||||
// `effRating` rides along with the bare one: D's panel-honesty gap — the
|
||||
// weak-link arrow reasoned on rating × ratingHint while the printed kN was
|
||||
// the bare steel, so a cold player saw four identical 1.2s and one
|
||||
// unexplained arrow. The panel prints the effective number now; the bare
|
||||
// rating stays for anyone who wants to show the steel's own spec.
|
||||
corners: this.picks.map((p) => ({
|
||||
anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost,
|
||||
effRating: this._effRating(p),
|
||||
hint: this.anchors.find((x) => x.id === p.anchorId)?.ratingHint ?? 1,
|
||||
})),
|
||||
corners: this.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost })),
|
||||
// The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's
|
||||
// ratingHint, the same product sail.js fails a corner on (SPRINT12, A's
|
||||
// ruling). D's #7: this used to reduce on hw.rating alone with strict <,
|
||||
@ -532,63 +407,23 @@ export async function createRiggingUI({
|
||||
return tri(p[0], p[1], p[2]) + tri(p[0], p[2], p[3]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standing load per corner, cached on (picks, tension). [D's ask, SPRINT13]
|
||||
*
|
||||
* The cloth doesn't care what steel hangs off it, so cycling hardware — the
|
||||
* most-clicked action in prep — never recomputes. Only closing the quad or
|
||||
* moving the dial does, which is exactly when the number changes.
|
||||
*/
|
||||
let standingKey = null, standingCache = null;
|
||||
function standing() {
|
||||
const key = `${session.picks.map((p) => p.anchorId).join(',')}|${session.tension.toFixed(2)}|${session.fabric.id}`;
|
||||
if (key !== standingKey) { standingKey = key; standingCache = session.standingLoads(); }
|
||||
return standingCache;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!el) return;
|
||||
const s = session.summary;
|
||||
const load = standing();
|
||||
// padEnd(4), not 3: the backyard's ids are all 2-3 chars, but the corner
|
||||
// block ships `tr1b` and the column broke the moment a second site existed.
|
||||
const rows = world.anchors.map((a) => {
|
||||
const pick = session.pickOf(a.id);
|
||||
if (!pick) return ` ${a.id.padEnd(4)} ${a.type.padEnd(6)} —`;
|
||||
const weak = s.weakest === a.id && session.picks.length > 1 ? ' <- weak link' : '';
|
||||
// The EFFECTIVE rating, not the steel's spec — D's panel-honesty gap: the
|
||||
// arrow reasoned on rating × hint while this column printed the bare kN,
|
||||
// so a carport beam and a house post both read "1.2 kN" and only one of
|
||||
// them was telling the truth. This is the number sail.js fails on.
|
||||
const corner = s.corners.find((c) => c.anchorId === a.id);
|
||||
const eff = (corner.effRating / 1000).toFixed(1);
|
||||
// "1.2×0.22" — the hint is shown as the discount it is, so the arrow has
|
||||
// a reason on the same line instead of being folded into a smaller number
|
||||
// the player can't account for.
|
||||
const hint = corner.hint !== 1 ? `×${corner.hint}` : '';
|
||||
const st = load?.[a.id] != null ? `${(load[a.id] / 1000).toFixed(2)}` : '—';
|
||||
const over = load?.[a.id] != null && load[a.id] > corner.effRating;
|
||||
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${st.padStart(4)}/${eff.padEnd(4)}kN${hint.padEnd(6)} $${pick.hw.cost}` +
|
||||
`${over ? ' !! RIPS AT REST' : weak}`;
|
||||
return ` ${a.id.padEnd(4)} ${pick.hw.name.padEnd(14)} ${(pick.hw.rating / 1000).toFixed(1)} kN $${pick.hw.cost}${weak}`;
|
||||
});
|
||||
const area = quadArea();
|
||||
// D, SPRINT13: "the panel never shows standing kN at the chosen tension, so
|
||||
// the first lesson always costs ~$60". At 1.4 the standing load alone is
|
||||
// over most of the shop. Said as a per-corner average because that's the
|
||||
// number the DIAL owns; the per-corner column above is where you find which
|
||||
// one is about to let go.
|
||||
const vals = load ? Object.values(load) : [];
|
||||
const avg = vals.length ? vals.reduce((x, y) => x + y, 0) / vals.length : 0;
|
||||
const worst = vals.length ? Math.max(...vals) : 0;
|
||||
const standingLine = vals.length
|
||||
? `standing ≈${(avg / 1000).toFixed(2)} kN/corner at this tension (worst ${(worst / 1000).toFixed(2)}) — before any wind`
|
||||
: null;
|
||||
el.textContent = [
|
||||
`PREP — rig four corners $${s.budget} left`,
|
||||
`tension ${s.tension.toFixed(2)} spare x${s.spares}${area ? ` sail ${area.toFixed(0)} m2` : ''}`,
|
||||
'',
|
||||
...rows,
|
||||
...(standingLine ? ['', standingLine] : []),
|
||||
'',
|
||||
s.canStart ? 'ENTER to start the storm' : `pick ${MAX_CORNERS - session.picks.length} more corner(s)`,
|
||||
'click anchor: rig / cycle hw shift-click: remove',
|
||||
|
||||
@ -156,99 +156,6 @@ test('setAnchors moves the session to a new yard and drops the old picks', () =>
|
||||
return 'session followed the site; stale picks did not';
|
||||
});
|
||||
|
||||
test('setHardware THROWS on hardware the shop does not sell', () => {
|
||||
// SPRINT13, QA pass: it returned {ok:false} and callers that didn't check
|
||||
// sailed on with the old tier still hanging. balance.test's own comment
|
||||
// records the bite — "setHardware fails silently, cheap hardware stays on,
|
||||
// p2/p4 tear off" — a cascade reported as a finding. A wrong tier is
|
||||
// invisible in the result, so this one must not be ignorable.
|
||||
const s = session();
|
||||
s.rig('h1');
|
||||
let threw = null;
|
||||
try { s.setHardware('h1', { name: 'titanium', cost: 0, rating: 99999 }); }
|
||||
catch (e) { threw = e; }
|
||||
assert(threw, 'a hardware object that is not in HARDWARE was accepted silently');
|
||||
assert(threw instanceof TypeError, `expected a TypeError, got ${threw?.constructor?.name}`);
|
||||
assert(s.pickOf('h1').hw === CARABINER, 'the refused tier must not have been applied');
|
||||
// A LOOKALIKE is the real trap: same shape, same numbers, wrong identity.
|
||||
let threw2 = null;
|
||||
try { s.setHardware('h1', { ...CARABINER }); } catch (e) { threw2 = e; }
|
||||
assert(threw2, 'a copy of a real HARDWARE entry was accepted — the shop compares by identity');
|
||||
// and the player-reachable failures stay {ok:false}, not throws
|
||||
assert(s.setHardware('p3', RATED).ok === false, 'an unrigged corner should fail, not throw');
|
||||
return 'unknown tiers throw; player-reachable failures still return {ok:false}';
|
||||
});
|
||||
|
||||
test('standing load: the tension dial has a price, and the panel can see it', () => {
|
||||
// D, SPRINT13: "the panel never shows standing kN at the chosen tension, so
|
||||
// the first lesson always costs ~$60" — at 1.4 the standing load ALONE rips
|
||||
// corners off during prep, on the calm day. This is the number that makes
|
||||
// that visible before the money is spent.
|
||||
const s = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) s.rig(id);
|
||||
|
||||
s.setTension(0.7);
|
||||
const loose = s.standingLoads();
|
||||
s.setTension(1.4);
|
||||
const tight = s.standingLoads();
|
||||
|
||||
assert(loose && tight, 'a closed quad must have standing loads');
|
||||
assert(Object.keys(tight).length === 4, `expected 4 corners, got ${Object.keys(tight).length}`);
|
||||
for (const id of Object.keys(tight)) {
|
||||
assert(Number.isFinite(tight[id]) && tight[id] > 0, `${id}: standing load ${tight[id]} is not a real load`);
|
||||
// The whole point: drum-tight costs more at rest than loose does. If this
|
||||
// ever ties, the dial is decoration and D's $60 lesson teaches nothing.
|
||||
assert(tight[id] > loose[id],
|
||||
`${id}: standing load at tension 1.4 (${tight[id].toFixed(0)} N) is not above tension 0.7 ` +
|
||||
`(${loose[id].toFixed(0)} N) — the dial has no static price, so the prep readout is a lie`);
|
||||
}
|
||||
// still air means still air: no wind, no gust, nothing that could be mistaken
|
||||
// for a forecast. Deterministic — same picks and dial, same number, always.
|
||||
s.setTension(1.4);
|
||||
const again = s.standingLoads();
|
||||
for (const id of Object.keys(tight)) {
|
||||
assert(Math.abs(again[id] - tight[id]) < 1e-9, `${id}: standing load is not deterministic`);
|
||||
}
|
||||
const avg = Object.values(tight).reduce((a, b) => a + b, 0) / 4;
|
||||
return `tension 0.7 -> 1.4 lifts the standing load to ≈${(avg / 1000).toFixed(2)} kN/corner, repeatably`;
|
||||
});
|
||||
|
||||
test('standing load is null until the quad is closed', () => {
|
||||
const s = session();
|
||||
assert(s.standingLoads() === null, 'no picks should have no standing load');
|
||||
for (const id of ['h1', 'h3', 'p1']) s.rig(id);
|
||||
assert(s.standingLoads() === null, 'three corners is not a sail — there is no load to read');
|
||||
s.rig('p2');
|
||||
assert(s.standingLoads() !== null, 'four corners is a sail and must report');
|
||||
return 'null until four corners, then real';
|
||||
});
|
||||
|
||||
test('spares are consumable and finite — you cannot take one you did not buy', () => {
|
||||
// D's live-storm find, SPRINT13: interact.js handed out unlimited free spares
|
||||
// because it never read the count. This is the count's half of the fix — a
|
||||
// spare economy that can actually run dry.
|
||||
const s = session();
|
||||
assert(s.sparesRemaining === 0, 'a fresh session has no spares on the table');
|
||||
assert(s.takeSpare().ok === false, 'took a spare that was never bought — the QA bug, in one line');
|
||||
|
||||
s.setSpares(2);
|
||||
assert(s.sparesRemaining === 2, `bought 2, table shows ${s.sparesRemaining}`);
|
||||
assert(s.takeSpare().ok, 'first spare should come off the table');
|
||||
assert(s.takeSpare().ok, 'second spare should come off the table');
|
||||
assert(s.sparesRemaining === 0, `table should be empty, shows ${s.sparesRemaining}`);
|
||||
const dry = s.takeSpare();
|
||||
assert(!dry.ok && /no spares/.test(dry.reason), `a third take must fail, got ${JSON.stringify(dry)}`);
|
||||
|
||||
// The refund half: main.js pays back "a spare you never had to use" as
|
||||
// session.spares × SPARE_COST. A consumed spare must not still be refunded —
|
||||
// so a taken spare has to leave the count, not just flip a used flag.
|
||||
const s2 = session();
|
||||
s2.setSpares(3);
|
||||
s2.takeSpare();
|
||||
assert(s2.spares === 2, `after taking 1 of 3, refundable count must be 2, is ${s2.spares}`);
|
||||
return 'spares run dry, and a used spare is no longer refundable';
|
||||
});
|
||||
|
||||
test('picks come back ring-ordered however you click them', () => {
|
||||
const s = session();
|
||||
// deliberately crossing order: two diagonals first
|
||||
|
||||
@ -185,39 +185,6 @@ export class SailRig {
|
||||
|
||||
const ring = orderRing(picked);
|
||||
this.tension = clamp(tension, TENSION_MIN, TENSION_MAX);
|
||||
|
||||
/**
|
||||
* A new sail is a new clock. [SPRINT13 — D's pool nit, and it wasn't a nit]
|
||||
*
|
||||
* `this.t` is the sim's OWN fixed-step clock: step() burns ragged frame dt
|
||||
* into SIM_DT chunks and samples the wind at this.t, which is the whole
|
||||
* reason a 144 Hz browser and a fast-forwarded selftest produce byte-equal
|
||||
* traces. But it was only ever zeroed in the constructor, and main.js builds
|
||||
* ONE SailRig at boot and re-attach()es it every night — while step() runs
|
||||
* every frame in EVERY phase (storm, aftermath, the forecast card) the
|
||||
* moment `rigged` is true.
|
||||
*
|
||||
* So night 2 opened its storm with the clock wherever night 1's aftermath
|
||||
* left it, and sampled the storm curve from there. Measured on the wild
|
||||
* night, p1..p4 with rated shackles:
|
||||
*
|
||||
* clock reset (as authored) p4 3.65 kN p2 2.78 p3 1.62 p1 0.98
|
||||
* carried t=110 (night 2) p4 2.60 kN p2 1.92 p3 0.81 p1 0.54
|
||||
*
|
||||
* Every night after the first flew a storm 30-50% weaker than the one C
|
||||
* authored — past the end of its own baseCurve, so it read the tail breeze
|
||||
* flat, with no build and no peak. Worse, the offset was however long the
|
||||
* player spent reading the invoice, so it wasn't even the same wrong storm
|
||||
* twice: a determinism break in a repo whose whole sim is built on not
|
||||
* having one.
|
||||
*
|
||||
* Zeroed here rather than in main.js because attach() is the one door every
|
||||
* caller comes through (boot, the picking adapter, balance.test, the audit),
|
||||
* and commit → attach → game.advance() → storm all land in a single keypress,
|
||||
* so t=0 at attach IS t=0 at the first storm frame.
|
||||
*/
|
||||
this.t = 0;
|
||||
this._acc = 0;
|
||||
this.corners = ring.map((a) => ({
|
||||
anchorId: a.id,
|
||||
anchor: a,
|
||||
|
||||
@ -317,47 +317,6 @@ test('determinism: variable frame dt matches fixed dt', () => {
|
||||
return 'ragged frame times converge on the fixed-dt trace';
|
||||
});
|
||||
|
||||
test('re-attach resets the clock: night 2 flies the same storm as night 1', () => {
|
||||
// SPRINT13. main.js builds ONE SailRig at boot and re-attach()es it every
|
||||
// night, and step() runs in EVERY phase once rigged — so the clock ran on
|
||||
// through the aftermath and the forecast card, and night 2 opened its storm
|
||||
// wherever night 1 left off, sampling the curve past its own end. The offset
|
||||
// was however long the player read the invoice for, so it wasn't even the
|
||||
// same wrong storm twice.
|
||||
//
|
||||
// Asserted as the PROPERTY that broke rather than `t === 0`: two identical
|
||||
// nights on one rig must produce identical load traces. That stays true if
|
||||
// the clock is ever reset somewhere else, and it goes red the moment the
|
||||
// reset leaves attach() — which is the mutation-check (delete `this.t = 0`
|
||||
// from attach and the second trace diverges on sample 0).
|
||||
const trace = (r) => {
|
||||
const w = makeStubWind({ seed: 7, stormLen: 30 });
|
||||
const out = [];
|
||||
runStorm(r, w, 30, (rr) => { for (const c of rr.corners) out.push(c.load); });
|
||||
return out;
|
||||
};
|
||||
const r = rig(HEIGHTS_HYPAR);
|
||||
const night1 = trace(r);
|
||||
|
||||
// the aftermath: the sail is still up and still being stepped while the
|
||||
// player reads their invoice. This is the frame budget that used to poison
|
||||
// the next night.
|
||||
const calm = makeStubWind({ seed: 7, stormLen: 30 });
|
||||
for (let i = 0; i < Math.round(20 / SIM_DT); i++) r.step(SIM_DT, calm, i * SIM_DT);
|
||||
|
||||
r.attach(ALL_IDS, Array(4).fill(UNBREAKABLE), 1.0); // night 2, same rig object
|
||||
const night2 = trace(r);
|
||||
|
||||
assert(night1.length === night2.length, `night 1 traced ${night1.length} samples, night 2 ${night2.length}`);
|
||||
for (let i = 0; i < night1.length; i++) {
|
||||
assert(night1[i] === night2[i],
|
||||
`night 2 diverged from night 1 at sample ${i}: ${night1[i]} vs ${night2[i]} — ` +
|
||||
'the rig carried its clock across attach(), so night 2 is flying a different storm ' +
|
||||
'(and one that depends on how long the aftermath was on screen)');
|
||||
}
|
||||
return `${night1.length} load samples identical across a re-attach — same storm both nights`;
|
||||
});
|
||||
|
||||
test('tension dial changes load (drum tight shock-loads)', () => {
|
||||
const w = constantWind({ x: 0, y: 0, z: 20 });
|
||||
const loosePeak = runStorm(rig(HEIGHTS_HYPAR, { tension: 0.7 }), w, 8);
|
||||
|
||||
@ -23,7 +23,6 @@ const STORM_SKY = new THREE.Color(0x2a2f3a);
|
||||
const NIGHT_SKY = new THREE.Color(0x11141c);
|
||||
const WHITE = new THREE.Color(0xffffff);
|
||||
const FLASH_COL = new THREE.Color(0xdfe8ff);
|
||||
const SUN_STORM = new THREE.Color(0xc4cedb); // the noon disc gone slate (gate 2.1)
|
||||
|
||||
// ------------------------------------------------------------ rain shadow
|
||||
/**
|
||||
@ -191,11 +190,6 @@ function createHail(opts) {
|
||||
step(dt, camPos, vel, intensity, size, shadow) {
|
||||
const n = Math.floor(max * clamp01(intensity));
|
||||
mesh.count = n;
|
||||
// D's aftermath find (S13): count:0 with frustumCulled off and depthWrite
|
||||
// off still draws instance 0's identity matrix — a ~5 cm always-on-top
|
||||
// white sliver at the world origin, QA's "ghost near the shed table".
|
||||
// visible is the honest gate; count alone is not.
|
||||
mesh.visible = n > 0;
|
||||
if (n === 0) return;
|
||||
const s = 0.6 + size * 0.9; // bigger stones read bigger
|
||||
m.makeScale(s, s, s);
|
||||
@ -348,11 +342,8 @@ function cloudTexture(size = 256, seed = 7) {
|
||||
// ---------------------------------------------------------------- audio
|
||||
// Synthesized layers. WebAudio won't start until a gesture (browser rule), so
|
||||
// everything is built lazily on unlock() and silently absent before it.
|
||||
const MASTER_GAIN = 0.55;
|
||||
|
||||
function createAudio(seed = 1) {
|
||||
let ctx = null, master = null;
|
||||
let muted = false;
|
||||
let windGain, windFilter, windHowl, howlGain;
|
||||
let rainGain, rainFilter;
|
||||
let gustGain, gustFilter;
|
||||
@ -389,15 +380,6 @@ function createAudio(seed = 1) {
|
||||
/** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph
|
||||
* got built — a suspended context is still silent, so the HUD reports this. */
|
||||
get state() { return ctx ? ctx.state : 'none'; },
|
||||
/** A's M key (gate 3.3). A flag, not just a gain write, so muting works
|
||||
* before unlock() has built the graph and survives it. */
|
||||
setMute(on) {
|
||||
muted = !!on;
|
||||
if (master) master.gain.value = muted ? 0 : MASTER_GAIN;
|
||||
},
|
||||
get muted() { return muted; },
|
||||
/** Real bus gain, for asserts — null until unlock() builds the graph. */
|
||||
get masterGain() { return master ? master.gain.value : null; },
|
||||
/** Current layer gains — for the HUD and for asserting the bed tracks wind. */
|
||||
levels() {
|
||||
if (!started) return null;
|
||||
@ -417,9 +399,7 @@ function createAudio(seed = 1) {
|
||||
ctx = new AC();
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
master = ctx.createGain();
|
||||
// honour a mute that landed BEFORE the first gesture built the graph —
|
||||
// M on the splash screen must not be forgotten by the unlock
|
||||
master.gain.value = muted ? 0 : MASTER_GAIN;
|
||||
master.gain.value = 0.55;
|
||||
master.connect(ctx.destination);
|
||||
noiseBuf = noiseBuffer(ctx);
|
||||
|
||||
@ -661,25 +641,15 @@ export function createSkyFx(o = {}) {
|
||||
// fixed post-change instant — def + seed in, same wall out, every run.
|
||||
const FRONT_LEAD = 12; // s before the change the wall starts rising
|
||||
const FRONT_FADE = 8; // s after the swing completes for it to clear
|
||||
const FRONT_ARC = 2.6; // radians of horizon the wall spans (~149°)
|
||||
const FRONT_ARC = 2.2; // radians of horizon the wall spans (~126°)
|
||||
const FRONT_MAX_OP = 0.85;
|
||||
const frontEvents = (def.events || [])
|
||||
.filter((e) => e.type === 'windchange' && Number.isFinite(e.t))
|
||||
.map((e) => ({ t0: e.t, over: e.over ?? 6, az: null }));
|
||||
// Band from ~34° above the horizon down to ~12° BELOW it, centred (in local
|
||||
// space) on azimuth π: SphereGeometry's (x,z) = (-cosφ, sinφ)·sinθ, so φ=0
|
||||
// sits at atan2(z,x) = π. rotation.y = π − A then carries the centre to A.
|
||||
//
|
||||
// SPRINT13 gate 2.2 — why the band overshoots the equator: the old band
|
||||
// stopped AT the horizontal plane through the camera, but from in-yard eye
|
||||
// height the ground's true horizon sits ~0.6° BELOW that plane (the dip),
|
||||
// so a sliver of bright sky showed under the wall's base and the bank read
|
||||
// as a floating slab — the QA sighting, reproduced on dev_skyfx before this
|
||||
// fix. Overshooting to −12° buries the base behind the ground from any eye
|
||||
// height that matters (it survives scale.y's 0.3 floor: −37 m compressed to
|
||||
// −11 m is still −3.7° elevation, below the dip). The ground plane occludes
|
||||
// the overshoot by depth, so nothing is drawn twice.
|
||||
const frontGeo = new THREE.SphereGeometry(170, 24, 10, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.27);
|
||||
// Band from ~34° above the horizon down to it, centred (in local space) on
|
||||
// azimuth π: SphereGeometry's (x,z) = (-cosφ, sinφ)·sinθ, so φ=0 sits at
|
||||
// atan2(z,x) = π. rotation.y = π − A then carries the centre to azimuth A.
|
||||
const frontGeo = new THREE.SphereGeometry(170, 24, 8, -FRONT_ARC / 2, FRONT_ARC, Math.PI * 0.30, Math.PI * 0.20);
|
||||
{
|
||||
// Soft edges via vertex alpha, or the band reads as a floating rectangle
|
||||
// (checked by eye on dev_skyfx.html — the hard phi/theta cut was exactly
|
||||
@ -687,15 +657,11 @@ export function createSkyFx(o = {}) {
|
||||
// the arc ends and across the top, so it sits ON the horizon like a bank
|
||||
// of weather instead of hanging in the sky like a screen.
|
||||
// Sphere uv: x runs 0..1 across the arc, y is 1 at the band top, 0 at the
|
||||
// (now sub-horizon) bottom edge.
|
||||
// The 1.6 exponent is gate 2.2's other half: 0.75 left the outer tenth of
|
||||
// the arc at ~0.36 alpha, which from in-yard eye height is the "hard
|
||||
// vertical seam" the QA saw at the bank's ends. 1.6 holds the same solid
|
||||
// core (the arc is wider to compensate) but takes the ends to <0.12.
|
||||
// horizon edge.
|
||||
const uv = frontGeo.attributes.uv;
|
||||
const rgba = new Float32Array(uv.count * 4);
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 1.6);
|
||||
const across = Math.pow(Math.sin(Math.PI * uv.getX(i)), 0.75);
|
||||
const up = 1 - smoothstep(0.45, 1, uv.getY(i));
|
||||
rgba[i * 4] = 1; rgba[i * 4 + 1] = 1; rgba[i * 4 + 2] = 1;
|
||||
rgba[i * 4 + 3] = across * up;
|
||||
@ -729,8 +695,6 @@ export function createSkyFx(o = {}) {
|
||||
fogFar: scene && scene.fog ? scene.fog.far : 0,
|
||||
sun: sun ? sun.intensity : 0,
|
||||
hemi: hemi ? hemi.intensity : 0,
|
||||
sunColor: sun ? sun.color.clone() : null,
|
||||
sunRadius: sun && sun.shadow ? sun.shadow.radius : 1,
|
||||
};
|
||||
const baseSky = (scene && scene.background && scene.background.isColor)
|
||||
? scene.background.clone() : CALM_SKY.clone();
|
||||
@ -742,20 +706,16 @@ export function createSkyFx(o = {}) {
|
||||
}
|
||||
|
||||
let flash = 0; // decaying lightning brightness
|
||||
let gradeNow = 0; // the storm grade, gate 2.1 — pure in t, see step()
|
||||
let flashQueue = []; // {at, power} — double-strike
|
||||
let lastTelegraph = null;
|
||||
const camPos = new THREE.Vector3();
|
||||
const w = new THREE.Vector3();
|
||||
|
||||
const fx = {
|
||||
rain, hail, audio, dome, shadow, hailShadow, front: frontMesh,
|
||||
rain, audio, dome, shadow, hailShadow, front: frontMesh,
|
||||
get flash() { return flash; },
|
||||
/** 0..1 hail intensity right now — for the HUD ("HAIL" banner) and asserts. */
|
||||
get hailAmount() { return hailAmt; },
|
||||
/** 0..1 — the storm grade driving sky/sun/shadow-softness (gate 2.1).
|
||||
* storminess with a floor under the author's darkness dial; pure in t. */
|
||||
get stormGrade() { return gradeNow; },
|
||||
/** 0..1 — how far risen the change-front wall is (gate 3.4). Pure in t. */
|
||||
get changeFront() { return frontLevel; },
|
||||
/** Azimuth (radians, XZ) the front stands in — the quarter the new wind
|
||||
@ -832,16 +792,6 @@ export function createSkyFx(o = {}) {
|
||||
/** Wire to the first click/keydown — browsers won't start audio otherwise. */
|
||||
unlockAudio() { audio.unlock(); },
|
||||
|
||||
/**
|
||||
* A's M key (gate 3.3): mute the whole audio bus. main.js feature-detects
|
||||
* this (`typeof sky.setMute === 'function'`) and only advertises M once it
|
||||
* exists — so this method appearing is what lights the key up. Safe at any
|
||||
* time: before unlock it sets a flag the unlock honours, after unlock it
|
||||
* writes the master gain directly.
|
||||
*/
|
||||
setMute(on) { audio.setMute(on); },
|
||||
get muted() { return audio.muted; },
|
||||
|
||||
/**
|
||||
* @param {number} dt
|
||||
* @param {number} t storm time
|
||||
@ -871,16 +821,6 @@ export function createSkyFx(o = {}) {
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const intensity = wind.rainAt(t);
|
||||
const storminess = clamp01(Math.max(intensity, speed / 26));
|
||||
// SPRINT13 gate 2.1 — the STORM GRADE. The QA pass played the southerly at
|
||||
// 65 km/h under a noon-blue sky, and the arithmetic says why: everything
|
||||
// below multiplied the weather by the author's palette (`darkness`), so a
|
||||
// darkness-0.5 storm at full blow only ever moved 0.35 toward slate — the
|
||||
// author's dial could zero the weather's say. The grade gives the weather
|
||||
// a floor: at full storminess even a darkness-0 sky goes half way to
|
||||
// slate, while the wild night (0.94) keeps reading as the night it is.
|
||||
// Same curve the rain already uses (storminess IS max(rain, wind)), pure
|
||||
// in t, computed above the render gate so a test can read it headless.
|
||||
gradeNow = storminess * lerp(0.5, 1, darkness);
|
||||
|
||||
// --- events: lightning + the ticker ---
|
||||
for (const ev of wind.eventsBetween(t - dt, t)) {
|
||||
@ -973,7 +913,7 @@ export function createSkyFx(o = {}) {
|
||||
if (!rendering) return;
|
||||
|
||||
// --- sky ---
|
||||
skyCol.copy(baseSky).lerp(target, gradeNow);
|
||||
skyCol.copy(baseSky).lerp(target, storminess * darkness);
|
||||
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
|
||||
if (scene) {
|
||||
if (scene.fog) {
|
||||
@ -982,18 +922,8 @@ export function createSkyFx(o = {}) {
|
||||
scene.fog.far = lerp(160, 55, storminess);
|
||||
}
|
||||
}
|
||||
if (sun) {
|
||||
sun.intensity = lerp(original.sun, original.sun * 0.12, gradeNow) + flash * 2.2;
|
||||
// The sun loses its noon edge two ways as the grade builds: the warm
|
||||
// disc goes slate (colour), and the shadows go soft (radius — the
|
||||
// renderer is PCFSoft, so radius is real blur, not a no-op). Softness
|
||||
// keys on STORMINESS, not the graded value: overcast is overcast even
|
||||
// under a light-palette storm, and razor-sharp noon shadows at 65 km/h
|
||||
// were the QA sighting this whole block answers.
|
||||
if (original.sunColor) sun.color.copy(original.sunColor).lerp(SUN_STORM, gradeNow * 0.85);
|
||||
if (sun.shadow) sun.shadow.radius = lerp(original.sunRadius, 7, storminess);
|
||||
}
|
||||
if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, gradeNow) + flash * 1.2;
|
||||
if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2;
|
||||
if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2;
|
||||
|
||||
dome.position.copy(camPos);
|
||||
dome.material.opacity = storminess * 0.85;
|
||||
@ -1007,7 +937,7 @@ export function createSkyFx(o = {}) {
|
||||
// and the cloud texture is baked near-white. The crush is what makes night
|
||||
// look like night. It stops at 0.78 on purpose — the yard has no lights in
|
||||
// it yet, and a storm you can't see isn't a storm, it's a black screen.
|
||||
const nightAmt = gradeNow;
|
||||
const nightAmt = storminess * darkness;
|
||||
dome.material.color.copy(WHITE)
|
||||
.lerp(target, nightAmt * 0.92)
|
||||
.multiplyScalar(1 - 0.78 * nightAmt);
|
||||
@ -1085,11 +1015,7 @@ export function createSkyFx(o = {}) {
|
||||
original.fog.far = original.fogFar;
|
||||
}
|
||||
}
|
||||
if (sun) {
|
||||
sun.intensity = original.sun;
|
||||
if (original.sunColor) sun.color.copy(original.sunColor);
|
||||
if (sun.shadow) sun.shadow.radius = original.sunRadius;
|
||||
}
|
||||
if (sun) sun.intensity = original.sun;
|
||||
if (hemi) hemi.intensity = original.hemi;
|
||||
rain.dispose();
|
||||
hail.dispose();
|
||||
|
||||
@ -27,19 +27,12 @@
|
||||
import { SAIL_TESTS } from '../sail.selftest.js';
|
||||
import { RIGGING_TESTS } from '../rigging.selftest.js';
|
||||
import { SWEEP_TESTS } from '../../../../tools/site_audit/sweep.selftest.js';
|
||||
import { buildGardenflyTests } from '../../../../tools/site_audit/gardenfly.selftest.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
export default async function run(t) {
|
||||
export default function run(t) {
|
||||
for (const [name, fn] of SAIL_TESTS) t.test(name, fn);
|
||||
for (const [name, fn] of RIGGING_TESTS) t.test(`rigging: ${name}`, fn);
|
||||
// site_audit's own sweep. SPRINT11: the tool was flying site_02 with the
|
||||
// venturi switched off — the auditor needed an auditor. See sweep.selftest.js.
|
||||
for (const [name, fn] of SWEEP_TESTS) t.test(`site_audit: ${name}`, fn);
|
||||
// SPRINT13: the audit's garden engine (gardenfly.js) — browser-only, so the
|
||||
// flights run here in the async prelude and the tests assert on the results
|
||||
// (Suite.test cannot await; a.test's pinned-separation flight is the
|
||||
// precedent). run() is async now — runAll awaits it.
|
||||
const gardenflyTests = await buildGardenflyTests();
|
||||
for (const [name, fn] of gardenflyTests) t.test(`site_audit: ${name}`, fn);
|
||||
}
|
||||
|
||||
@ -28,11 +28,6 @@ import { SailRig } from '../sail.js';
|
||||
import { createSkyFx } from '../skyfx.js';
|
||||
import { createWind, loadStorm } from '../weather.js';
|
||||
import { HARDWARE, FIXED_DT, START_BUDGET } from '../contracts.js';
|
||||
// SPRINT13: the garden weights come from garden.js — the SAME module main.js
|
||||
// flies — not from a local copy. The old `const GARDEN_DRAIN = 0.9 // main.js`
|
||||
// here was the drift A's export exists to kill: a retune would have landed in
|
||||
// the game and left this suite green against last sprint's numbers.
|
||||
import { GARDEN_DRAIN, HAIL_WEIGHT as W_HAIL, RAIN_WEIGHT as W_RAIN } from '../garden.js';
|
||||
|
||||
const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
|
||||
@ -40,6 +35,9 @@ const [CARABINER, SHACKLE, RATED] = HARDWARE;
|
||||
const WIN = (hp, lost) => hp >= 50 && lost < 2;
|
||||
/** main.js's CALM_STORM — what wind.use() hands the rig outside a storm, and so what settles it. */
|
||||
const CALM_STORM = 'storm_01_gentle';
|
||||
const GARDEN_DRAIN = 0.9; // main.js
|
||||
const W_HAIL = 5.0; // main.js, decision 13 — integration weights
|
||||
const W_RAIN = 0.25;
|
||||
|
||||
/**
|
||||
* The yard is READ FROM world.js, never copied.
|
||||
|
||||
@ -16,8 +16,7 @@
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
|
||||
import { loadStorm, createWind, windForSite, forecastLines, forecastFor, leadFor } from '../weather.js';
|
||||
import { loadSite } from '../world.js';
|
||||
import { loadStorm, createWind, forecastLines, forecastFor, leadFor } from '../weather.js';
|
||||
import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { SailRig, HARDWARE } from '../sail.js';
|
||||
@ -137,112 +136,6 @@ export default async function run(t) {
|
||||
'clear() replaced the pieces array instead of emptying it — B holds a reference');
|
||||
});
|
||||
|
||||
// SPRINT13 gate 2.3 — ambient leaves. QA: "debris reads 0 through night 3";
|
||||
// the crates are event drama, the leaves are the continuous tell. Threshold
|
||||
// 8.3 m/s (30 km/h) matches D's lean threshold on purpose — the yard and the
|
||||
// body start telling the same story at the same speed. Count stays single
|
||||
// digits, they stream WITH the wind, and the layer is deterministic.
|
||||
t.test('ambient leaves: none in a calm, a streaming handful from ~30 km/h', () => {
|
||||
const stub = (sp) => ({
|
||||
seed: 5,
|
||||
sample: (p, t2, o) => o.set(sp, 0, 0),
|
||||
eventsBetween: () => [],
|
||||
});
|
||||
const calm = createDebris({ wind: stub(5) });
|
||||
fixedLoop(8, FIXED_DT, (dt, time) => calm.step(dt, time, {}));
|
||||
assert(calm.leafCount === 0,
|
||||
`${calm.leafCount} leaves flying at 18 km/h — below the threshold the yard is still`);
|
||||
|
||||
const gale = createDebris({ wind: stub(13) });
|
||||
fixedLoop(8, FIXED_DT, (dt, time) => gale.step(dt, time, {}));
|
||||
assert(gale.leafCount > 0, 'no leaves at 47 km/h — the gale has no ambient tell');
|
||||
assert(gale.leafCount <= 9, `${gale.leafCount} leaves — the plan says single digits`);
|
||||
|
||||
// they stream WITH the wind (+x here): over one step every leaf either
|
||||
// moves downwind or recycles ~a span upwind; none drifts against it
|
||||
const before = gale.leaves.map((pos) => pos.x);
|
||||
gale.step(FIXED_DT, 8, {});
|
||||
const after = gale.leaves.map((pos) => pos.x);
|
||||
assert(before.length === after.length, 'leaf count strobed across one step');
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const d = after[i] - before[i];
|
||||
assert(d > 0 || d < -10, `leaf ${i} moved ${d.toFixed(3)} m against a +x wind`);
|
||||
}
|
||||
|
||||
// deterministic: same seed, same (dt, t) stream, same leaves — bit for bit
|
||||
const a = createDebris({ wind: stub(13) });
|
||||
const b = createDebris({ wind: stub(13) });
|
||||
fixedLoop(5, FIXED_DT, (dt, time) => { a.step(dt, time, {}); b.step(dt, time, {}); });
|
||||
assert(a.leafCount === b.leafCount && a.leaves.every((pos, i) =>
|
||||
pos.x === b.leaves[i].x && pos.y === b.leaves[i].y && pos.z === b.leaves[i].z),
|
||||
'two identical (dt, t) streams produced different leaves');
|
||||
|
||||
// clear() rewinds the layer — next night starts from the same state
|
||||
gale.clear();
|
||||
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
|
||||
});
|
||||
|
||||
// SPRINT13, the third time this repo learned it: the venturi lives in the
|
||||
// SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in
|
||||
// Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again
|
||||
// in Sprint 13 — `def.wind.venturi` off a storm def LOOKS right and never
|
||||
// fires, and the funnel-off bench called the $80 line 91.5 FULL on a night
|
||||
// the real game scores it 39.8 TATTERED (D's live replay). windForSite is
|
||||
// the shared builder that makes the mistake unmakeable; this assert is
|
||||
// B's strictly-raises pin lifted onto it, with the REAL shipped funnel:
|
||||
// drop the setVenturi line and the two winds collapse to equal reads.
|
||||
const site02 = await loadSite('site_02_corner_block'); // awaited here: Suite.test() is sync
|
||||
t.test('windForSite flies the SITE venturi — the shipped funnel strictly raises the throat wind', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const site = site02;
|
||||
const vl = site.wind?.venturi ?? [];
|
||||
assert(vl.length > 0, "site_02 lost its venturi — this test (and the yard's weather) proves nothing");
|
||||
const bare = createWind(def); // storm def alone — the trap itself
|
||||
const funnelled = windForSite(def, site);
|
||||
const throat = new THREE.Vector3(vl[0].x, 1.5, vl[0].z);
|
||||
const a = new THREE.Vector3(), b = new THREE.Vector3();
|
||||
let bareMean = 0, funMean = 0, n = 0;
|
||||
for (let time = 20; time <= 80; time += 0.5) {
|
||||
bare.sample(throat, time, a);
|
||||
funnelled.sample(throat, time, b);
|
||||
bareMean += Math.hypot(a.x, a.z); funMean += Math.hypot(b.x, b.z); n++;
|
||||
}
|
||||
bareMean /= n; funMean /= n;
|
||||
assert(bareMean > 3, `the storm never blew at the throat (${bareMean.toFixed(1)} m/s) — vacuous`);
|
||||
assert(funMean > bareMean * 1.1,
|
||||
`throat wind ${funMean.toFixed(2)} vs bare ${bareMean.toFixed(2)} m/s — the site's venturi is not `
|
||||
+ 'reaching windForSite\'s wind (it must call setVenturi the way main.js does at every site load)');
|
||||
});
|
||||
|
||||
// D's aftermath find (S13, lane/d): the hail InstancedMesh at count:0 with
|
||||
// frustum culling and depthWrite both off still draws instance 0's identity
|
||||
// matrix — a ~5 cm always-on-top white sliver at the origin, the QA's "ghost
|
||||
// near the shed table". The gate is mesh.visible, not count. This flies the
|
||||
// wild night and demands the pool is invisible whenever no hail is falling
|
||||
// and visible whenever it is — both directions, whole storm.
|
||||
t.test("hail pool: invisible when nothing falls (D's aftermath sliver)", () => {
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const sky = createSkyFx({ scene, camera, wind });
|
||||
let sawHail = false, sawCalm = false;
|
||||
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||
sky.step(dt, time, {});
|
||||
// the mesh draws floor(1300 × intensity) stones, so a burst's first
|
||||
// millisecond can honestly show zero — demand visibility only once the
|
||||
// intensity buys at least one stone, and invisibility only at exactly 0
|
||||
if (sky.hailAmount >= 1 / 1300) {
|
||||
sawHail = true;
|
||||
assert(sky.hail.mesh.visible, `hail falling at t=${time.toFixed(1)} but the pool is hidden`);
|
||||
} else if (sky.hailAmount === 0) {
|
||||
sawCalm = true;
|
||||
assert(!sky.hail.mesh.visible, `no hail at t=${time.toFixed(1)} but the pool still draws — the sliver`);
|
||||
}
|
||||
});
|
||||
assert(sawHail && sawCalm, 'storm never exercised both states — this test proves nothing');
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
// Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path.
|
||||
// They verified sun/hemi restore exactly and spotted that fog didn't; this pins
|
||||
// both. The vacuity guards matter — a restore test where nothing ever moved is
|
||||
@ -279,86 +172,6 @@ export default async function run(t) {
|
||||
`skyfx left ${scene.children.length - before.children} object(s) in the scene`);
|
||||
});
|
||||
|
||||
// SPRINT13 gate 3.3 — A's M key. main.js feature-detects sky.setMute and only
|
||||
// advertises M once it exists, so this contract is what lights the key up.
|
||||
// The pre-unlock case is the one that bites: M pressed on the splash screen,
|
||||
// BEFORE the first gesture builds the audio graph, must survive the unlock.
|
||||
t.test('M-mute: setMute silences the bus, and a pre-unlock mute survives unlock', () => {
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const sky = createSkyFx({ wind }); // headless — the bus needs no scene
|
||||
assert(typeof sky.setMute === 'function', "no setMute — A's M key has nothing to call");
|
||||
assert(sky.muted === false, 'a fresh sky must not start muted');
|
||||
sky.setMute(true); // before unlock — the splash case
|
||||
assert(sky.muted === true, 'setMute(true) did not take');
|
||||
sky.unlockAudio();
|
||||
if (sky.audio.ready) { // no AudioContext = nothing to silence
|
||||
assert(sky.audio.masterGain === 0,
|
||||
`unlock forgot a pre-unlock mute: master at ${sky.audio.masterGain}`);
|
||||
sky.setMute(false);
|
||||
assert(sky.audio.masterGain > 0, 'unmute left the master gain at 0');
|
||||
}
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
// SPRINT13 gate 2.1 — the storm grade. The QA sighting: 65 km/h + driving
|
||||
// rain under a noon-blue sky with razor midday shadows. Cause: sky and sun
|
||||
// both multiplied the weather by the author's palette (`darkness`, 0.5 on
|
||||
// the southerly), so the dial could zero the weather's say — and nothing
|
||||
// touched shadow softness at all. Pins: the grade floors the darkness dial,
|
||||
// the sun dims/goes slate/softens, and dispose hands all of it back.
|
||||
t.test('storm grade: a mid-darkness gale dims the sun, softens shadows, restores on dispose', () => {
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x9fc4e8);
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
const sun = new THREE.DirectionalLight(0xfff2dc, 2.0);
|
||||
const before = { sun: sun.intensity, color: sun.color.getHex(), radius: sun.shadow.radius };
|
||||
// a darkness-0.5 storm blowing 18 m/s (65 km/h) in full rain — the QA scene
|
||||
const gale = {
|
||||
seed: 1, def: { sky: { darkness: 0.5 } },
|
||||
sample: (p, t2, o) => o.set(18, 0, 0),
|
||||
speedAt: () => 18, rainAt: () => 1, rainMmPerHour: () => 30,
|
||||
gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {},
|
||||
};
|
||||
const sky = createSkyFx({ scene, camera, wind: gale, sun });
|
||||
fixedLoop(10, FIXED_DT, (dt, time) => sky.step(dt, time, {}));
|
||||
assert(sky.stormGrade > 0.7,
|
||||
`grade ${sky.stormGrade} — the darkness dial still zeroes the weather (the old formula reads 0.50 here)`);
|
||||
assert(sun.intensity < before.sun * 0.5,
|
||||
`sun at ${sun.intensity.toFixed(2)} of ${before.sun} — still noon at 65 km/h`);
|
||||
assert(sun.shadow.radius > 4,
|
||||
`shadow radius ${sun.shadow.radius} — razor-sharp midday shadows in a gale`);
|
||||
assert(sun.color.getHex() !== before.color, 'the sun disc never lost its noon warmth');
|
||||
sky.dispose();
|
||||
assert(sun.intensity === before.sun && sun.color.getHex() === before.color
|
||||
&& sun.shadow.radius === before.radius,
|
||||
'dispose did not hand the sun back exactly (intensity / colour / shadow radius)');
|
||||
});
|
||||
|
||||
// SPRINT13 gate 2.2 — the wall's edges, judged from in-yard eye height.
|
||||
// Two QA sightings, both geometry: a sliver of bright sky under the bank's
|
||||
// base (it stopped AT the camera's horizontal plane, above the ground's true
|
||||
// horizon), and a hard vertical seam at the arc ends (0.36 alpha at the
|
||||
// outer tenth). The band now overshoots the equator and the end fade is
|
||||
// steeper; these pins are what "grounded" and "feathered" mean in numbers.
|
||||
t.test('the change-front wall grounds below the horizon and feathers its ends', () => {
|
||||
const wind = createWind(storms.storm_03_southerly);
|
||||
const sky = createSkyFx({ wind });
|
||||
const geo = sky.front.geometry;
|
||||
geo.computeBoundingBox();
|
||||
assert(geo.boundingBox.min.y < -20,
|
||||
`front base at y=${geo.boundingBox.min.y.toFixed(1)} — a band stopping at eye level floats a sky sliver under the wall`);
|
||||
const uv = geo.attributes.uv, col = geo.attributes.color;
|
||||
assert(col && col.itemSize === 4, 'front lost its vertex alpha — the edges are hard cuts again');
|
||||
let edgeMax = 0;
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
const u = uv.getX(i);
|
||||
if (u <= 0.09 || u >= 0.91) edgeMax = Math.max(edgeMax, col.getW(i));
|
||||
}
|
||||
assert(edgeMax < 0.2,
|
||||
`arc-end alpha ${edgeMax.toFixed(2)} — the vertical seam QA saw at the bank's ends`);
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
// --- SPRINT2 §Lane C.3: rain has to stop at the cloth ---
|
||||
// Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing
|
||||
// under test is the projection, and a flat panel makes the right answer
|
||||
|
||||
@ -16,7 +16,7 @@ import { Interact, wireYardActions } from '../interact.js';
|
||||
import { createBroom, BROOM_TUNE } from '../broom.js';
|
||||
// The REAL rule, not the fakeLadder's hand-copied duplicate of it — a rule the suite re-implements
|
||||
// is a rule the suite cannot catch drifting. (ladder.js is headless-importable for this reason.)
|
||||
import { needsLadder as realNeedsLadder, createLadder } from '../ladder.js';
|
||||
import { needsLadder as realNeedsLadder } from '../ladder.js';
|
||||
import { assert, assertEq, assertClose, assertLess, fixedLoop } from '../testkit.js';
|
||||
import { FIXED_DT } from '../contracts.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
@ -175,54 +175,6 @@ export default async function run(t) {
|
||||
`shove must grow faster than linearly with speed: ${p15.toFixed(3)} -> ${p30.toFixed(3)}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- wind lean (SPRINT13 gate 2.4)
|
||||
t.test('lean: calm leaves you upright, wind tips you, and it scales with the sustained speed', () => {
|
||||
const calm = new PlayerSim();
|
||||
drive(calm, 20, {}, windX(TUNE.leanWindMin - 3)); // below the threshold, all storm
|
||||
assertLess(calm.lean, 1e-3, 'under leanWindMin you stand up straight');
|
||||
|
||||
const mid = new PlayerSim();
|
||||
drive(mid, 40, {}, windX((TUNE.leanWindMin + TUNE.leanWindFull) / 2));
|
||||
assert(mid.lean > 0.35 && mid.lean < 0.65, `mid wind is a partial lean, got ${mid.lean.toFixed(2)}`);
|
||||
|
||||
const gale = new PlayerSim();
|
||||
drive(gale, 40, {}, windX(TUNE.leanWindFull + 6));
|
||||
assertClose(gale.lean, 1, 1e-2, 'past leanWindFull you are fully into it');
|
||||
assert(gale.lean >= mid.lean, 'more sustained wind is more lean');
|
||||
});
|
||||
|
||||
t.test('lean: keys on the SUSTAINED base, not the gust — a single gust does not snap you over', () => {
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(4)); // learn a calm baseline: no lean
|
||||
const before = s.lean;
|
||||
drive(s, 1.2, {}, windX(TUNE.leanWindFull + 10), 30); // one big gust, shorter than leanSecs momentum
|
||||
// windBase barely moves in 1.2 s (baseTrack ~4 s memory), so the posture must not jump to full.
|
||||
assertLess(s.lean, 0.5, `a brief gust is a stumble, not a lean — got ${s.lean.toFixed(2)} from ${before.toFixed(2)}`);
|
||||
});
|
||||
|
||||
t.test('lean: it points downwind, and it is suppressed on your back, braced, or up a ladder', () => {
|
||||
// downwind: wind blowing +X should tip leanDir toward +X.
|
||||
const s = new PlayerSim();
|
||||
drive(s, 30, {}, windX(TUNE.leanWindFull + 5));
|
||||
assert(s.lean > 0.5, 'leaning');
|
||||
assertClose(s.leanDir.x, 1, 1e-6, 'leanDir points the way the wind blows (x)');
|
||||
assertClose(s.leanDir.z, 0, 1e-6, 'and not sideways (z)');
|
||||
|
||||
// braced: holding C must zero the lean — TakeCover is its own pose.
|
||||
const braced = new PlayerSim();
|
||||
drive(braced, 30, { shelter: true }, windX(TUNE.leanWindFull + 5));
|
||||
assertEq(braced.state, 'shelter', 'C braces');
|
||||
assertLess(braced.lean, 1e-2, 'and a brace is not a lean');
|
||||
|
||||
// knocked down: pitch owns the body, lean eases out.
|
||||
const floored = new PlayerSim();
|
||||
drive(floored, 30, {}, windX(TUNE.leanWindFull + 5));
|
||||
assert(floored.lean > 0.5, 'leaning before the fall');
|
||||
floored.knockdown(0, 1, 0);
|
||||
drive(floored, 1, {}, windX(TUNE.leanWindFull + 5));
|
||||
assertLess(floored.lean, 1e-2, 'flat on your back is not a lean');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- knockdown
|
||||
t.test('knockdown: needs SUSTAINED overload, like a sail corner letting go', () => {
|
||||
const brief = new PlayerSim();
|
||||
@ -496,74 +448,6 @@ export default async function run(t) {
|
||||
'nor a tree limb — that is a strop you throw');
|
||||
});
|
||||
|
||||
// --- SPRINT13 pool: the real createLadder, not the fake. These two pin the things the QA pass
|
||||
// found by playing, and both were silent — no error, no red, just a mechanic that stopped.
|
||||
// (createLadder is scene-free with scene=null: the view is the only THREE it needs.)
|
||||
const realLadderWorld = () => ({
|
||||
anchors: [
|
||||
{ id: 'h2', type: 'house', work: 'bracket', pos: { x: 0, y: 2.48, z: -9.95 } },
|
||||
{ id: 'cb1', type: 'carport', work: 'bracket', pos: { x: -8.4, y: 2.29, z: -3 } },
|
||||
],
|
||||
shedTable: { pos: { x: 9, y: 0.9, z: 6 } },
|
||||
heightAt: () => 0,
|
||||
});
|
||||
|
||||
t.test('ladder: a knockdown mid-carry drops it in the grass — it does not leave the game', () => {
|
||||
const world = realLadderWorld();
|
||||
const interact = new Interact();
|
||||
const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } });
|
||||
const ladder = createLadder(null, world, interact, player);
|
||||
const takeTarget = [...interact.targets.values()].find((x) => x.id === 'ladder_take');
|
||||
|
||||
// in your hands, walking it out to the bracket — through the target's OWN onDone, because
|
||||
// player.pickUp alone fills your hands without telling the ladder, which is not a state the
|
||||
// game can produce and would test the fixture rather than the code.
|
||||
takeTarget.onDone(player, 0);
|
||||
ladder.update(DT, 0, {});
|
||||
assert(ladder.carried, 'carried');
|
||||
assertEq(player.carrying, 'ladder', 'and the hands agree');
|
||||
assertEq(takeTarget.pos(), null, 'nothing to walk to while it is in your hands');
|
||||
|
||||
// the wind takes you over at (4, 2). knockdown() -> drop() nulls carrying, and before SPRINT13
|
||||
// nothing told the ladder: it stayed "carried" by nobody, invisible and un-takeable forever.
|
||||
player.knockdown(0, 1, 0);
|
||||
assertEq(player.carrying, null, 'the knockdown took it out of your hands');
|
||||
ladder.update(DT, 0, {});
|
||||
|
||||
assert(!ladder.carried, 'the ladder knows it is no longer being carried');
|
||||
const p = takeTarget.pos();
|
||||
assert(p, 'and it is somewhere you can walk to');
|
||||
assertClose(p.x, 4, 1e-6, 'it fell where you did (x)');
|
||||
assertClose(p.z, 2, 1e-6, 'it fell where you did (z)');
|
||||
assert(takeTarget.canUse({ carrying: null, climbY: 0 }),
|
||||
'and you can pick it up again — this is the assert that fails if the reconcile is reverted');
|
||||
});
|
||||
|
||||
t.test('ladder: the place prompt names the structure it is under, and where the ladder is', () => {
|
||||
const world = realLadderWorld();
|
||||
const interact = new Interact();
|
||||
const player = new PlayerSim({ start: { x: 4, y: 0, z: 2 } });
|
||||
const ladder = createLadder(null, world, interact, player);
|
||||
const placeAt = (id) => [...interact.targets.values()].find((x) => x.id === `ladder_place_${id}`);
|
||||
const empty = { carrying: null };
|
||||
|
||||
// QA pass: this said "the fascia" while you stood under a carport beam.
|
||||
assert(/fascia/.test(interact.labelOf(placeAt('h2'), empty)), 'the house bracket IS the fascia');
|
||||
assert(/carport/.test(interact.labelOf(placeAt('cb1'), empty)),
|
||||
`a carport beam is not a fascia — got "${interact.labelOf(placeAt('cb1'), empty)}"`);
|
||||
assert(!/fascia/.test(interact.labelOf(placeAt('cb1'), empty)),
|
||||
'and it must not call it one');
|
||||
|
||||
// ...and it must not lie about where the ladder is once the wind has taken it.
|
||||
assert(/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)), 'stowed: it is by the shed');
|
||||
[...interact.targets.values()].find((x) => x.id === 'ladder_take').onDone(player, 0);
|
||||
ladder.update(DT, 0, {});
|
||||
player.knockdown(0, 1, 0);
|
||||
ladder.update(DT, 0, {});
|
||||
assert(!/by the shed/.test(interact.labelOf(placeAt('cb1'), empty)),
|
||||
'dropped: the shed is exactly where it is NOT');
|
||||
});
|
||||
|
||||
t.test('ladder: keys on the MECHANISM a site declares, not on the anchor type', () => {
|
||||
// SPRINT10 gate 1. The failure mode is the point: when needsLadder says false, interact's
|
||||
// canReach returns TRUE UNCONDITIONALLY — so a mis-keyed anchor doesn't error, it silently
|
||||
|
||||
@ -112,33 +112,6 @@ export async function loadStorm(name, dir = STORM_DIR) {
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wind a YARD actually flies — storm def + the SITE's local effects,
|
||||
* wired exactly as main.js does at every site load (setVenturi from
|
||||
* siteDef.wind.venturi, tree shelters from the anchors). SPRINT13:
|
||||
*
|
||||
* THREE harnesses have now measured site_02 with the funnel switched OFF by
|
||||
* building wind from the storm def alone — B's site_audit (Sprint 11, their
|
||||
* sweep.selftest tells the story), C's garden_bench (Sprint 13, where it
|
||||
* flipped the $80-line headline: 91.5 FULL funnel-off vs 39.8 TATTERED in
|
||||
* D's live replay), and probe4, which never attempted it. The venturi lives
|
||||
* in the SITE json, not the storm, so `def.wind.venturi` off a storm def
|
||||
* LOOKS right and never fires. Any tool measuring a yard builds its wind
|
||||
* HERE, or it is measuring a yard that doesn't ship. The strictly-raises
|
||||
* assert in c.test.js goes red if the setVenturi line is ever dropped.
|
||||
*
|
||||
* @param {object} stormDef parsed storm JSON
|
||||
* @param {object} [siteDef] parsed site JSON (loadSite) — its wind.venturi list
|
||||
* @param {Array} [anchors] world anchors; trees become wind shelters
|
||||
* @param {object} [opts] forwarded to createWind ({seed})
|
||||
*/
|
||||
export function windForSite(stormDef, siteDef, anchors = [], opts = {}) {
|
||||
const wind = createWind(stormDef, opts);
|
||||
wind.setVenturi(siteDef?.wind?.venturi ?? []);
|
||||
wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
|
||||
return wind;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} def parsed storm JSON
|
||||
* @param {object} [opts] {seed} — same seed + same def = same storm, every run
|
||||
|
||||
Loading…
Reference in New Issue
Block a user