Merge remote-tracking branch 'origin/lane/c'
# Conflicts: # THREADS.md # web/world/js/hud.js
This commit is contained in:
commit
a398d152b1
38
THREADS.md
38
THREADS.md
@ -2473,3 +2473,41 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
or "won't do"): **grass_atlas — seven sprints, 0 refs** (I'd bin it), the screenshot POST, and
|
||||
`moon.png` if the night sky ever wants it.
|
||||
Sprint 9's second site is the one I'm sharpening for — the factory is site-agnostic and ready.
|
||||
[C] 2026-07-18 — 🎯 **GATE 0' — THE WILD NIGHT IS WINNABLE AT $80. B ran out of budget before this sweep;
|
||||
here it is.** B's concrete next step was "enumerate bed-covering quads ≥0.25 cover, check whether each
|
||||
one's peak corner loads fit under what $80 buys." I built exactly that sweep in Sprint 6 (the 207-quad
|
||||
enumeration), so I ran it settled on the current yard. **299 quads cover ≥25% of the bed. Of the 14
|
||||
smallest, THIRTEEN have a corner over 6.5 kN — unholdable at any price.** Exactly one is holdable:
|
||||
```
|
||||
p1,p2,p3,p4 settled peak/corner p4 6.11 · p2 4.84 · p3 2.67 · p1 1.49 kN
|
||||
```
|
||||
That needs rated on p4+p2 ($30+$30), shackle on p3 ($15), shackle on p1 ($15) = **$90.** Measured
|
||||
through the real drain, settled, no repair: **hp 52, 1 lost — a WIN. It is the first winnable line
|
||||
anyone has found on the wild night.** The blocker was never physics; it's a **$10 price gap.**
|
||||
**And two levers that do nothing apart CLOSE it together.** Sprint 6 I declined 0.40 because alone it
|
||||
buys ~5% and moves no hardware tier — still true. B's shade cloth alone doesn't move a tier either.
|
||||
But stacked they drop p1 **1.49 → 1.08 kN, under the carabiner's 1.2 rating**, swapping its shackle
|
||||
($15) for a carabiner ($5):
|
||||
```
|
||||
dd .45 membrane p4 6.11 · p2 4.84 · p3 2.67 · p1 1.49 → $90
|
||||
dd .40 membrane p4 5.57 · p2 4.48 · p3 2.43 · p1 1.33 → $90
|
||||
dd .45 porous 0.3 p4 5.16 · p2 4.10 · p3 2.21 · p1 1.21 → $90
|
||||
dd .40 porous 0.3 p4 4.77 · p2 3.82 · p3 2.03 · p1 1.08 → $80 ✅
|
||||
```
|
||||
Scored end-to-end, settled: **porous · dd 0.40 · rated p4/p2 · shackle p3 · carabiner p1 · $80 → hp 52,
|
||||
1 lost. WIN.** This is the exact condition I said in Sprint 6 I'd spend 0.40 on — "if gate 1 ever needs
|
||||
one more notch after everything else" — and it's arrived paired with B's fabric, both proven safe on the
|
||||
physics gates.
|
||||
**What I am NOT doing: flipping storm_02 to 0.40 myself.** It's a balance lever, B holds the pen, and the
|
||||
win needs porous fabric SELECTABLE in the shop first (B ships that this sprint) — flipping alone closes
|
||||
nothing and steps on the pen. Say go and it's a one-line data edit; I'll pair it with B's fabric UI.
|
||||
**Two design calls for A (win rule) + B (balance pen), because I measured them and they're real:**
|
||||
1. `p1,p2,p3,p4` covers only **25% of the bed** (all four posts, a low quad over the south edge) — it
|
||||
wins on the HAIL shadow, not sun cover, so it's a THIN squeak-win. On the hardest night that reads
|
||||
right to me, but it's your feel call.
|
||||
2. **It wins WITHOUT a repair or the broom.** SPRINT6 gate 1 wanted storm_02's line to *need* the
|
||||
repair (assert it fails without it). This line survives outright. Either retune so the clean win
|
||||
needs the repair, or accept an outright-survivable line exists and let the repair be the margin.
|
||||
Both are yours; I bring the numbers. balance.test's asserted line (`t2,p3,p4,t2b`) is a genuine loser
|
||||
(t2 4.4–4.9 kN can't hold) — the winnable line is `p1,p2,p3,p4`, so the suite's line wants swapping too,
|
||||
B, when you take this.
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import { STORM_LEN } from './contracts.js';
|
||||
import { forecastLines } from './weather.js';
|
||||
|
||||
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
const kmh = (ms) => ms * 3.6;
|
||||
@ -354,6 +355,7 @@ export function createHud(d) {
|
||||
* @param {{key:string, def:object}[]} storms
|
||||
* @param {(key:string) => void} onPick
|
||||
*/
|
||||
<<<<<<< HEAD
|
||||
showForecast({ key, def }, wk, onGo) {
|
||||
const peak = Math.max(...def.baseCurve.map((p) => p[1]));
|
||||
const gustPeak = peak + (def.gusts?.powBase ?? 0) + (def.gusts?.powRamp ?? 0);
|
||||
@ -370,6 +372,23 @@ export function createHud(d) {
|
||||
const held = done && wk.log[i]?.won;
|
||||
return `<span class="pip ${now ? 'now' : done ? (held ? 'held' : 'lost') : ''}">${
|
||||
now ? '◆' : done ? (held ? '●' : '○') : '·'}</span>`;
|
||||
=======
|
||||
showForecast(storms, onPick) {
|
||||
// Numbers via Lane C's forecastLines(def, lead): MEASURED rather than
|
||||
// estimated (the old inline `baseCurve peak + powBase + powRamp` read 30
|
||||
// m/s for storm_02, which really gusts to 32.3), and banded by how far out
|
||||
// the night is. `lead` 0 = tonight = exact, which is what this reads as
|
||||
// today; pass each night's lead when the week lands and the card starts
|
||||
// hedging on its own. Same wording as before — only the numbers changed.
|
||||
const rows = storms.map(({ key, def, lead = 0 }) => {
|
||||
const f = forecastLines(def, lead);
|
||||
return `<button class="storm" data-key="${key}">
|
||||
<div class="name">${f.name || key.replace(/_/g, ' ').toUpperCase()}${f.night ? ' · NIGHT' : ''}</div>
|
||||
<div class="stat">${f.wind}</div>
|
||||
<div class="stat">${f.rain}</div>
|
||||
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
|
||||
</button>`;
|
||||
>>>>>>> origin/lane/c
|
||||
}).join('');
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
|
||||
@ -702,10 +702,22 @@ export function createSkyFx(o = {}) {
|
||||
* @param {number} dt
|
||||
* @param {number} t storm time
|
||||
* @param {object} [world] {sail} — duck-typed, for creak/flog
|
||||
*
|
||||
* Runs headless. The camera is a RENDERING concern and must never gate the
|
||||
* shadow grids — see the note at the physics/view split below.
|
||||
*/
|
||||
step(dt, t, world = {}) {
|
||||
if (!camera) return;
|
||||
camera.getWorldPosition(camPos);
|
||||
// A camera means "there is a view to place things in", not "the weather is
|
||||
// real". This used to read `if (!camera) return;` at the top, which meant a
|
||||
// harness with no camera silently skipped the shadow rebuild and then
|
||||
// scored every rig as if the sail did not exist — hp 36 was the bare-bed
|
||||
// constant, and it cost gate 0 two sprints of four lanes' time before A
|
||||
// found it. The grids are physics; only the view and the audio are the
|
||||
// camera's business. Fall back to the yard centre so the wind still has a
|
||||
// place to be sampled.
|
||||
const rendering = !!camera;
|
||||
if (rendering) camera.getWorldPosition(camPos);
|
||||
else camPos.set(0, 1.7, 0);
|
||||
wind.sample(camPos, t, w);
|
||||
const speed = Math.hypot(w.x, w.z);
|
||||
const intensity = wind.rainAt(t);
|
||||
@ -753,6 +765,37 @@ export function createSkyFx(o = {}) {
|
||||
flash *= Math.max(0, 1 - dt * 7);
|
||||
if (flash < 0.004) flash = 0;
|
||||
|
||||
// --- PHYSICS: the shadow grids. Camera or not, always. ---
|
||||
// Everything that scores a rig — gardenExposure, gardenHailExposure,
|
||||
// rainShadowOver, hailShadowOver — reads these grids. They are what the
|
||||
// sail DOES, so they rebuild before any view work and above the render
|
||||
// gate. Rebuilt a few times a second, not every frame: the cloth moves
|
||||
// slowly next to the weather, and this is the only part that costs.
|
||||
shadowTick -= dt;
|
||||
if (shadowTick <= 0) {
|
||||
shadowTick = 0.1;
|
||||
rainVelocity(w, intensity, rainDir);
|
||||
const rl = rainDir.length() || 1;
|
||||
shadow.update(world.sail, rainDir.x / rl, rainDir.y / rl, rainDir.z / rl);
|
||||
}
|
||||
hailAmt = hailIntensity(t);
|
||||
const stone = hailStone();
|
||||
// The hail shadow follows the STEEP hail vector, not the wind. It barely
|
||||
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
|
||||
hailTick -= dt;
|
||||
if (hailTick <= 0) {
|
||||
hailTick = 0.12;
|
||||
hailWind.set(w.x, 0, w.z);
|
||||
hailVelocity(hailWind, hailDir);
|
||||
const hl = hailDir.length() || 1;
|
||||
hailShadow.update(world.sail, hailDir.x / hl, hailDir.y / hl, hailDir.z / hl);
|
||||
}
|
||||
|
||||
// Past here is the VIEW and the NOISE — drops to place, a dome to tint, a
|
||||
// gale to hear. All of it needs somewhere to stand. A headless harness has
|
||||
// the numbers it came for and can stop here.
|
||||
if (!rendering) return;
|
||||
|
||||
// --- sky ---
|
||||
skyCol.copy(baseSky).lerp(target, storminess * darkness);
|
||||
if (flash > 0) skyCol.lerp(FLASH_COL, Math.min(0.85, flash));
|
||||
@ -787,31 +830,8 @@ export function createSkyFx(o = {}) {
|
||||
domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1;
|
||||
domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1;
|
||||
|
||||
// --- rain ---
|
||||
// Rebuild the shadow a few times a second, not every frame: the cloth
|
||||
// moves slowly next to the rain, and this is the only part that costs.
|
||||
shadowTick -= dt;
|
||||
if (shadowTick <= 0) {
|
||||
shadowTick = 0.1;
|
||||
rainVelocity(w, intensity, rainDir);
|
||||
const len = rainDir.length() || 1;
|
||||
shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len);
|
||||
}
|
||||
// --- rain + hail drops (the grids they read were built above) ---
|
||||
rain.step(dt, camPos, w, intensity, shadow);
|
||||
|
||||
// --- hail ---
|
||||
hailAmt = hailIntensity(t);
|
||||
const stone = hailStone();
|
||||
// The hail shadow follows the STEEP hail vector, not the wind. It barely
|
||||
// moves, so rebuild it slowly. A tenth of a second is fine; hail rides it.
|
||||
hailTick -= dt;
|
||||
if (hailTick <= 0) {
|
||||
hailTick = 0.12;
|
||||
hailWind.set(w.x, 0, w.z);
|
||||
hailVelocity(hailWind, hailDir);
|
||||
const len = hailDir.length() || 1;
|
||||
hailShadow.update(world.sail, hailDir.x / len, hailDir.y / len, hailDir.z / len);
|
||||
}
|
||||
hail.step(dt, camPos, hailDir, hailAmt, stone, hailShadow);
|
||||
// how much of the hail the sail overhead is catching, for the drum sound —
|
||||
// sample right above the player/camera so it's "is it drumming over ME"
|
||||
|
||||
@ -16,7 +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 } from '../weather.js';
|
||||
import { loadStorm, createWind, forecastLines } from '../weather.js';
|
||||
import { createDebris } from '../debris.js';
|
||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||
import { SailRig, HARDWARE } from '../sail.js';
|
||||
@ -344,6 +344,76 @@ export default async function run(t) {
|
||||
sky.dispose();
|
||||
});
|
||||
|
||||
// The trap that cost gate 0 two sprints, asserted so it cannot come back.
|
||||
// skyfx.step() used to open `if (!camera) return`, so a headless harness
|
||||
// skipped the shadow rebuild and scored every rig as if the sail weren't
|
||||
// there. A camera is a view, not a weather system: the grids are physics and
|
||||
// must build without one. If someone re-adds an early return, this goes red.
|
||||
t.test('skyfx shelters the bed with NO camera — the grids are physics, not view', () => {
|
||||
const bed = { x: 0, z: 0, w: 4, d: 3 };
|
||||
const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };
|
||||
|
||||
const run = (withCamera) => {
|
||||
const scene = new THREE.Scene();
|
||||
const wind = createWind(storms.storm_02_wildnight);
|
||||
const sky = createSkyFx(withCamera
|
||||
? { scene, camera: new THREE.PerspectiveCamera(), wind }
|
||||
: { scene, wind }); // headless: no camera at all
|
||||
fixedLoop(2, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
|
||||
const out = {
|
||||
hailShadow: sky.hailShadowOver(bed),
|
||||
rainShadow: sky.rainShadowOver(bed),
|
||||
hailExp: sky.gardenHailExposure(bed, 56),
|
||||
hail: sky.hailAmount,
|
||||
};
|
||||
sky.dispose();
|
||||
return out;
|
||||
};
|
||||
|
||||
const headless = run(false);
|
||||
const viewed = run(true);
|
||||
assert(headless.hailShadow > 0.9,
|
||||
`no camera → hail shadow ${headless.hailShadow.toFixed(2)} — the grid did not build, the sail is invisible to scoring`);
|
||||
assert(headless.hail > 0.5, 'no camera → hail intensity was not tracked');
|
||||
assert(headless.hailExp < 0.1,
|
||||
`no camera → bed reads ${headless.hailExp.toFixed(2)} exposed under a panel — this is the hp-36 bare-bed bug`);
|
||||
// and a camera must not CHANGE the physics, only add the view
|
||||
assert(Math.abs(headless.hailShadow - viewed.hailShadow) < 1e-9,
|
||||
`the camera changed the hail shadow: ${headless.hailShadow} vs ${viewed.hailShadow}`);
|
||||
assert(Math.abs(headless.rainShadow - viewed.rainShadow) < 1e-9,
|
||||
`the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`);
|
||||
});
|
||||
|
||||
// The card's copy, not just the model — this is what a player actually reads.
|
||||
t.test('forecastLines: tonight is exact, a week out hedges, never a bare NaN', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
|
||||
const tonight = forecastLines(def, 0);
|
||||
assert(tonight.name === 'WILD NIGHT', `name reads "${tonight.name}"`);
|
||||
assert(tonight.night === true, 'the wild night should read as a NIGHT');
|
||||
assert(!/–/.test(tonight.wind), `tonight's wind should be exact, got "${tonight.wind}"`);
|
||||
assert(tonight.confidence === '', 'tonight should not print a confidence line — 100% is noise');
|
||||
assert(/hail likely/.test(tonight.rain), `tonight should call the hail: "${tonight.rain}"`);
|
||||
|
||||
const far = forecastLines(def, 1);
|
||||
assert(/–/.test(far.wind), `a week out should hedge with a range, got "${far.wind}"`);
|
||||
assert(/confidence/.test(far.confidence), 'a distant forecast should state its confidence');
|
||||
|
||||
// no NaN/undefined can reach the card, for any storm at any lead
|
||||
for (const [name, d] of Object.entries(storms)) {
|
||||
for (const lead of [0, 0.5, 1]) {
|
||||
const f = forecastLines(d, lead);
|
||||
for (const k of ['name', 'wind', 'rain', 'confidence']) {
|
||||
assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
|
||||
`${name} @lead ${lead}: ${k} reads "${f[k]}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// a hail-free storm must not advertise hail
|
||||
assert(!/hail/.test(forecastLines(storms.storm_01_gentle, 0).rain),
|
||||
'the gentle storm advertised hail it does not have');
|
||||
});
|
||||
|
||||
t.test('every storm in data/storms/ loads and validates', () => {
|
||||
// loadStorm throws on invalid, so reaching here with all of them is the pass
|
||||
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
|
||||
|
||||
@ -17,6 +17,58 @@ import {
|
||||
|
||||
export { GUST, validateStorm, RAIN_TIME_COMPRESSION, hailBlockFor, stormStats, forecastFor };
|
||||
|
||||
const kmh = (ms) => ms * 3.6;
|
||||
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}–${fmt(b.hi)}`);
|
||||
|
||||
/**
|
||||
* The forecast card's two stat lines, already worded — DESIGN.md's partial
|
||||
* information made visible. Lane A owns the card; this owns the numbers, so the
|
||||
* card stays one call instead of re-deriving the storm inline.
|
||||
*
|
||||
* `lead` is how far out the night is: 0 = tonight (exact numbers, reads exactly
|
||||
* as the card always has), 1 = the far end of the week (wide bands, low
|
||||
* confidence). The bands ALWAYS contain the truth — a forecast may be vague but
|
||||
* it must never rule out what actually happens, or a player who rigs for the top
|
||||
* of the stated range gets ambushed.
|
||||
*
|
||||
* Numbers are MEASURED (stormStats), not estimated: the card's old inline
|
||||
* `baseCurve peak + powBase + powRamp` read 30 m/s for storm_02, which really
|
||||
* gusts to 32.3, because gust power is drawn per gust and rides a ramp.
|
||||
*
|
||||
* @param {object} def parsed storm JSON
|
||||
* @param {number} [lead] 0..1
|
||||
* @returns {{name, night, wind, rain, confidence, hail, truth}}
|
||||
*/
|
||||
export function forecastLines(def, lead = 0) {
|
||||
const f = forecastFor(def, lead);
|
||||
const i = (v) => v.toFixed(0);
|
||||
|
||||
const sustained = band(f.sustained, i);
|
||||
const gusts = band({ lo: kmh(f.gustPeak.lo), hi: kmh(f.gustPeak.hi) }, i);
|
||||
const sustainedKmh = band({ lo: kmh(f.sustained.lo), hi: kmh(f.sustained.hi) }, i);
|
||||
|
||||
// rain reads as a word, and a vague forecast hedges across two
|
||||
const word = (v) => (v >= 0.8 ? 'heavy' : v >= 0.4 ? 'steady' : 'light');
|
||||
const rainWord = word(f.rain.lo) === word(f.rain.hi)
|
||||
? word(f.rain.hi) : `${word(f.rain.lo)}–${word(f.rain.hi)}`;
|
||||
|
||||
const change = f.changeAt
|
||||
? `· southerly change ${lead > 0 ? band(f.changeAt, i) + 's' : `at ${i(f.changeAt.lo)}s`}`
|
||||
: '· no change forecast';
|
||||
const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;
|
||||
|
||||
return {
|
||||
name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
|
||||
night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
|
||||
wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
|
||||
rain: `rain ${rainWord} ${change} ${hail}`.trim(),
|
||||
// Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise.
|
||||
confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
|
||||
hail: f.hail,
|
||||
truth: f.truth,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolved against this module, not the server root: server.py serves the repo
|
||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||
// import.meta.url is right under both, and under whatever Lane A does next.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user