Add vertical gusts, freeze debris.pieces, fix fog restore
SPRINT2 decisions 3 and 5, plus Lane A's fog nit. Decision 3 — gusts now descend. Cloth pressure goes with dot(wind, normal); a flat panel's normal points at the sky, so in a perfectly horizontal wind the dot is ~0 and "lie it flat and ignore the storm" was the cheapest winning rig. A gust front is descending air, not just faster air. Per-gust downdraft fraction in storm JSON (storm_02 0.3, storm_01 0.18, default 0.25, validated 0..1), each gust varying 0.6-1.4x. Peak downdraft in storm_02 is 4.4 m/s, 17% of the horizontal. Lane B: the cloth-side assert is yours. The vertical draws from its OWN rng stream, and there's an assert pinning that: pulling it from the main stream would shift every subsequent (t0, pow) and silently re-time storms Lane A has already hand-verified. Their carabiner still blows at t=45.4 and cascades at t=56. speedAt() stays horizontal — an anemometer doesn't read falling air, and a wind meter that spikes because a gust is descending reads as a bug. Decision 5 — debris.pieces frozen and documented in contracts.js as the seam Lane B reads in sail.step(), with the sphere/SI/mutated-in-place semantics spelled out and an assert tying the live shape to the contract table. Fog: dispose() captured scene.fog by reference and step() mutates that object in place, so restoring it restored nothing (Lane A caught it). Now captured by value, and fog we created ourselves is removed rather than left behind. Selftest 130/0/0 (was 121); Lane C 28 asserts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
de86aa1662
commit
135511fb05
@ -13,7 +13,8 @@
|
|||||||
"maxGap": 14,
|
"maxGap": 14,
|
||||||
"powBase": 2,
|
"powBase": 2,
|
||||||
"powRand": 3,
|
"powRand": 3,
|
||||||
"powRamp": 2
|
"powRamp": 2,
|
||||||
|
"downdraft": 0.18
|
||||||
},
|
},
|
||||||
|
|
||||||
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
|
"dirCurve": [[0, 0.9], [45, 1.0], [90, 1.15]],
|
||||||
|
|||||||
@ -11,13 +11,16 @@
|
|||||||
|
|
||||||
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
|
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
|
||||||
|
|
||||||
|
"_gusts_comment": "downdraft = fraction of gust power that blows DOWN, per gust (each gust varies 0.6-1.4x this). A gust front is descending air, not just faster air; without it a flat horizontal sail sheds everything and ignoring the storm is the winning move. 0.3 here because a wild night should punish a flat rig hard.",
|
||||||
|
|
||||||
"gusts": {
|
"gusts": {
|
||||||
"firstAt": 3,
|
"firstAt": 3,
|
||||||
"minGap": 5.5,
|
"minGap": 5.5,
|
||||||
"maxGap": 11,
|
"maxGap": 11,
|
||||||
"powBase": 3,
|
"powBase": 3,
|
||||||
"powRand": 5,
|
"powRand": 5,
|
||||||
"powRamp": 7
|
"powRamp": 7,
|
||||||
|
"downdraft": 0.3
|
||||||
},
|
},
|
||||||
|
|
||||||
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
"dirCurve": [[0, 0.85], [50, 0.95], [55, 0.6], [59, -1.25], [70, -1.45], [90, -1.35]],
|
||||||
|
|||||||
@ -190,6 +190,48 @@ export class Emitter {
|
|||||||
* @property {boolean} broken
|
* @property {boolean} broken
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEBRIS — Lane C implements. Lane B consumes `pieces` inside sail.step().
|
||||||
|
*
|
||||||
|
* SPRINT2 decision 5: the sail reads the pieces and applies its own impulses,
|
||||||
|
* rather than debris.js reaching into the cloth. Momentum bookkeeping stays in
|
||||||
|
* the one integrator that owns the nodes. That makes `pieces` a real contract
|
||||||
|
* surface, so it is **frozen** here: fields below are what Lane B may rely on.
|
||||||
|
*
|
||||||
|
* @typedef {object} Debris
|
||||||
|
* @property {DebrisPiece[]} pieces
|
||||||
|
* Live pieces, newest last. The ARRAY IS MUTATED IN PLACE each step — pieces
|
||||||
|
* are spliced out when they leave the yard, so don't hold a reference to it
|
||||||
|
* across frames, and don't hold a piece past the step it despawned in. Read it
|
||||||
|
* fresh inside step(). Order is not stable.
|
||||||
|
* @property {(dt:number, t:number, world?:object) => void} step Fixed dt. Deterministic.
|
||||||
|
* @property {(ev:object, t:number) => DebrisPiece} spawn
|
||||||
|
* @property {(map:Object<string,THREE.Object3D>) => Debris} setModels
|
||||||
|
* @property {() => void} clear
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One airborne object. Frozen shape — Lane C will not remove or repurpose these.
|
||||||
|
*
|
||||||
|
* The collision volume is a SPHERE of radius `r` centred on (x,y,z): a crate is
|
||||||
|
* boxy, but a sphere is what you can afford to test against every cloth node,
|
||||||
|
* every frame. Everything is SI — metres, m/s, kg — so `mass * v` is a real
|
||||||
|
* momentum you can subtract from.
|
||||||
|
*
|
||||||
|
* @typedef {object} DebrisPiece
|
||||||
|
* @property {number} x
|
||||||
|
* @property {number} y Centre, not base. Rests at heightAt(x,z) + r.
|
||||||
|
* @property {number} z
|
||||||
|
* @property {number} vx
|
||||||
|
* @property {number} vy
|
||||||
|
* @property {number} vz
|
||||||
|
* @property {number} r Collision sphere radius, m.
|
||||||
|
* @property {number} mass kg. Crate 9, tub 5, bin 14.
|
||||||
|
* @property {string} model Key into models/debris/, e.g. 'BlueCrate_v2'.
|
||||||
|
* @property {boolean} hitPlayer Already knocked the player down once.
|
||||||
|
* @property {THREE.Object3D|null} mesh Render instance. Lane C drives it; don't move it.
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PLAYER — Lane D implements.
|
* PLAYER — Lane D implements.
|
||||||
*
|
*
|
||||||
@ -258,6 +300,20 @@ export const CONTRACT = {
|
|||||||
interact: { register: 'function' },
|
interact: { register: 'function' },
|
||||||
camera: { object: 'object', yaw: 'number', update: 'function' },
|
camera: { object: 'object', yaw: 'number', update: 'function' },
|
||||||
game: { phase: 'string', on: 'function' },
|
game: { phase: 'string', on: 'function' },
|
||||||
|
debris: { pieces: 'object', step: 'function', spawn: 'function', setModels: 'function', clear: 'function' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frozen DebrisPiece fields (SPRINT2 decision 5). Lane B's sail.step() reads
|
||||||
|
* these off `debris.pieces` and applies impulses from them, so renaming one is a
|
||||||
|
* breaking change to someone else's integrator, not a local tidy-up. Asserted
|
||||||
|
* against live pieces in c.test.js — if this table and debris.js disagree, the
|
||||||
|
* selftest says so before Lane B's cloth does.
|
||||||
|
*/
|
||||||
|
export const DEBRIS_PIECE_FIELDS = {
|
||||||
|
x: 'number', y: 'number', z: 'number',
|
||||||
|
vx: 'number', vy: 'number', vz: 'number',
|
||||||
|
r: 'number', mass: 'number', model: 'string',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -337,10 +337,19 @@ export function createSkyFx(o = {}) {
|
|||||||
dome.renderOrder = -1;
|
dome.renderOrder = -1;
|
||||||
if (scene) scene.add(dome);
|
if (scene) scene.add(dome);
|
||||||
|
|
||||||
// remember what world.js handed us, so dispose() puts it back exactly
|
// Remember what world.js handed us, so dispose() puts it back exactly.
|
||||||
|
// Fog is captured BY VALUE, not by reference: step() mutates that very object
|
||||||
|
// in place, so `scene.fog = original.fog` restores the object we just spent a
|
||||||
|
// storm wrecking. Lane A caught it — sun and hemi came back exactly and the fog
|
||||||
|
// stayed where the storm left it. Harmless today only because the next skyfx
|
||||||
|
// immediately re-drives it, which is exactly the kind of bug that waits.
|
||||||
|
const ownsFog = !!scene && !scene.fog;
|
||||||
const original = {
|
const original = {
|
||||||
background: scene ? scene.background : null,
|
background: scene ? scene.background : null,
|
||||||
fog: scene ? scene.fog : null,
|
fog: scene ? scene.fog : null,
|
||||||
|
fogColor: scene && scene.fog ? scene.fog.color.clone() : null,
|
||||||
|
fogNear: scene && scene.fog ? scene.fog.near : 0,
|
||||||
|
fogFar: scene && scene.fog ? scene.fog.far : 0,
|
||||||
sun: sun ? sun.intensity : 0,
|
sun: sun ? sun.intensity : 0,
|
||||||
hemi: hemi ? hemi.intensity : 0,
|
hemi: hemi ? hemi.intensity : 0,
|
||||||
};
|
};
|
||||||
@ -454,7 +463,14 @@ export function createSkyFx(o = {}) {
|
|||||||
scene.remove(rain.mesh);
|
scene.remove(rain.mesh);
|
||||||
scene.remove(dome);
|
scene.remove(dome);
|
||||||
scene.background = original.background;
|
scene.background = original.background;
|
||||||
scene.fog = original.fog;
|
if (ownsFog) {
|
||||||
|
scene.fog = null; // we brought it; we take it
|
||||||
|
} else if (original.fog) {
|
||||||
|
scene.fog = original.fog;
|
||||||
|
original.fog.color.copy(original.fogColor);
|
||||||
|
original.fog.near = original.fogNear;
|
||||||
|
original.fog.far = original.fogFar;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (sun) sun.intensity = original.sun;
|
if (sun) sun.intensity = original.sun;
|
||||||
if (hemi) hemi.intensity = original.hemi;
|
if (hemi) hemi.intensity = original.hemi;
|
||||||
|
|||||||
@ -15,8 +15,10 @@
|
|||||||
|
|
||||||
import * as THREE from '../../vendor/three.module.js';
|
import * as THREE from '../../vendor/three.module.js';
|
||||||
import { assert, fixedLoop } from '../testkit.js';
|
import { assert, fixedLoop } from '../testkit.js';
|
||||||
import { FIXED_DT, checkContract } from '../contracts.js';
|
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
|
||||||
import { loadStorm, createWind } from '../weather.js';
|
import { loadStorm, createWind } from '../weather.js';
|
||||||
|
import { createDebris } from '../debris.js';
|
||||||
|
import { createSkyFx } from '../skyfx.js';
|
||||||
import { weatherCases } from './weather.selftest.js';
|
import { weatherCases } from './weather.selftest.js';
|
||||||
|
|
||||||
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
|
const STORMS = ['storm_01_gentle', 'storm_02_wildnight'];
|
||||||
@ -43,12 +45,34 @@ export default async function run(t) {
|
|||||||
const pos = new THREE.Vector3(3, 0, -2);
|
const pos = new THREE.Vector3(3, 0, -2);
|
||||||
const a = wind.sample(pos, 12.5);
|
const a = wind.sample(pos, 12.5);
|
||||||
assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3');
|
assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3');
|
||||||
assert(a.y === 0, `wind should be horizontal, got y=${a.y}`);
|
assert(Number.isFinite(a.x) && Number.isFinite(a.y) && Number.isFinite(a.z),
|
||||||
|
`sample returned a non-finite vector: ${a.x},${a.y},${a.z}`);
|
||||||
// out param must not change the answer, only where it lands
|
// out param must not change the answer, only where it lands
|
||||||
const out = new THREE.Vector3();
|
const out = new THREE.Vector3();
|
||||||
const b = wind.sample(pos, 12.5, out);
|
const b = wind.sample(pos, 12.5, out);
|
||||||
assert(b === out, 'out param was ignored');
|
assert(b === out, 'out param was ignored');
|
||||||
assert(a.x === b.x && a.z === b.z, 'out param changed the result');
|
assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result');
|
||||||
|
});
|
||||||
|
|
||||||
|
// This assert used to read `a.y === 0` — "wind should be horizontal". SPRINT2
|
||||||
|
// decision 3 made that false on purpose: gusts now descend, which is what makes
|
||||||
|
// a flat sail pay. Keeping the useful half — y is downward-or-zero, never up,
|
||||||
|
// and never garbage — so player shove and rain angle can still trust the sign.
|
||||||
|
t.test('vertical wind is downward-only, and only during gusts', () => {
|
||||||
|
const wind = createWind(storms.storm_02_wildnight);
|
||||||
|
const pos = new THREE.Vector3(0, 1.7, 0);
|
||||||
|
const v = new THREE.Vector3();
|
||||||
|
let sawDown = false;
|
||||||
|
fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
|
||||||
|
wind.sample(pos, time, v);
|
||||||
|
assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
|
||||||
|
if (v.y < -1) sawDown = true;
|
||||||
|
});
|
||||||
|
assert(sawDown, 'never saw a downdraft worth the name in a whole wild night');
|
||||||
|
// and the wind meter must stay horizontal — a falling gust shouldn't spike the HUD
|
||||||
|
const calm = wind.speedAt(pos, 0.5);
|
||||||
|
assert(Math.abs(calm - Math.hypot(wind.sample(pos, 0.5).x, wind.sample(pos, 0.5).z)) < 1e-9,
|
||||||
|
'speedAt() is not the horizontal magnitude of sample()');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lifted from a.test.js onto the real wind (Lane A's note in this file's
|
// Lifted from a.test.js onto the real wind (Lane A's note in this file's
|
||||||
@ -70,6 +94,66 @@ export default async function run(t) {
|
|||||||
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
|
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- SPRINT2 decision 5: debris.pieces is Lane B's to read, so it's frozen ---
|
||||||
|
t.test('debris conforms and its pieces match the frozen shape', () => {
|
||||||
|
const wind = createWind(storms.storm_02_wildnight);
|
||||||
|
const debris = createDebris({ wind });
|
||||||
|
assert(checkContract('debris', debris).length === 0, checkContract('debris', debris).join('; '));
|
||||||
|
|
||||||
|
const p = debris.spawn({ model: 'BlueCrate_v2', lateral: 0 }, 40);
|
||||||
|
for (const [field, want] of Object.entries(DEBRIS_PIECE_FIELDS)) {
|
||||||
|
const got = typeof p[field];
|
||||||
|
assert(got === want, `piece.${field} is ${got}, contract says ${want}`);
|
||||||
|
if (want === 'number') assert(Number.isFinite(p[field]), `piece.${field} is not finite`);
|
||||||
|
}
|
||||||
|
assert(debris.pieces.includes(p), 'spawn() returned a piece that is not in pieces');
|
||||||
|
assert(p.r > 0 && p.mass > 0, 'a piece with no radius or no mass cannot be collided with');
|
||||||
|
|
||||||
|
// The array is mutated in place and pieces are spliced on despawn — that's
|
||||||
|
// documented, and B reads it fresh inside step(). Prove clear() empties it
|
||||||
|
// rather than swapping in a new array behind their reference.
|
||||||
|
const ref = debris.pieces;
|
||||||
|
debris.clear();
|
||||||
|
assert(ref === debris.pieces && debris.pieces.length === 0,
|
||||||
|
'clear() replaced the pieces array instead of emptying it — B holds a reference');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// a test that passes forever and checks nothing.
|
||||||
|
t.test('skyfx.dispose() hands the scene back exactly as it found it', () => {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x9fc4e8);
|
||||||
|
scene.fog = new THREE.Fog(0x9fc4e8, 30, 140);
|
||||||
|
const camera = new THREE.PerspectiveCamera();
|
||||||
|
const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
|
||||||
|
const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.8);
|
||||||
|
const before = {
|
||||||
|
bg: scene.background, fogColor: scene.fog.color.getHex(),
|
||||||
|
fogNear: scene.fog.near, fogFar: scene.fog.far,
|
||||||
|
sun: sun.intensity, hemi: hemi.intensity, children: scene.children.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wind = createWind(storms.storm_02_wildnight);
|
||||||
|
const sky = createSkyFx({ scene, camera, wind, sun, hemi });
|
||||||
|
fixedLoop(40, FIXED_DT, (dt, time) => sky.step(dt, time, {}));
|
||||||
|
|
||||||
|
assert(sun.intensity < before.sun * 0.9, 'the storm never dimmed the sun — this test proves nothing');
|
||||||
|
assert(scene.fog.near !== before.fogNear, 'the storm never touched the fog — this test proves nothing');
|
||||||
|
|
||||||
|
sky.dispose();
|
||||||
|
assert(sun.intensity === before.sun, `sun left at ${sun.intensity}, want ${before.sun}`);
|
||||||
|
assert(hemi.intensity === before.hemi, `hemi left at ${hemi.intensity}, want ${before.hemi}`);
|
||||||
|
assert(scene.background === before.bg, 'scene.background not restored');
|
||||||
|
assert(scene.fog.color.getHex() === before.fogColor,
|
||||||
|
`fog colour left at #${scene.fog.color.getHex().toString(16)}, want #${before.fogColor.toString(16)}`);
|
||||||
|
assert(scene.fog.near === before.fogNear && scene.fog.far === before.fogFar,
|
||||||
|
`fog left at near=${scene.fog.near} far=${scene.fog.far}, want ${before.fogNear}/${before.fogFar}`);
|
||||||
|
assert(scene.children.length === before.children,
|
||||||
|
`skyfx left ${scene.children.length - before.children} object(s) in the scene`);
|
||||||
|
});
|
||||||
|
|
||||||
t.test('every storm in data/storms/ loads and validates', () => {
|
t.test('every storm in data/storms/ loads and validates', () => {
|
||||||
// loadStorm throws on invalid, so reaching here with all of them is the pass
|
// 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');
|
assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
|
||||||
|
|||||||
@ -280,6 +280,97 @@ export function weatherCases(storms) {
|
|||||||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 9. vertical gust structure (SPRINT2 decision 3) ----
|
||||||
|
// Cloth pressure goes with dot(wind, normal). A flat horizontal panel's normal
|
||||||
|
// points at the sky, so in a perfectly horizontal wind that dot is ~0 and the
|
||||||
|
// cheapest winning rig is "lie it flat and ignore the storm" — the opposite of
|
||||||
|
// the game. Gust fronts are descending air, and descending air hits a flat
|
||||||
|
// panel square on. Lane B owns the cloth-side assert; these are the wind side.
|
||||||
|
test('gusts carry a downdraft, and still air does not', () => {
|
||||||
|
const f = createWindField(storms.storm_02_wildnight);
|
||||||
|
let peakDown = 0, betweenMax = 0;
|
||||||
|
for (let t = 0; t <= f.duration; t += DT) {
|
||||||
|
const v = f.gustVertical(t);
|
||||||
|
assert(v <= 1e-12, `vertical wind went UP (${v.toFixed(2)}) at t=${t.toFixed(2)} — downdraft only`);
|
||||||
|
const live = f.gusts.some((g) => t > g.t0 && t < g.endAt);
|
||||||
|
if (live) peakDown = Math.min(peakDown, v);
|
||||||
|
else betweenMax = Math.max(betweenMax, Math.abs(v));
|
||||||
|
}
|
||||||
|
metrics['storm_02.peakDowndraft'] = +peakDown.toFixed(2);
|
||||||
|
assert(betweenMax === 0, `air is falling between gusts (${betweenMax}) — downdraft must be a gust feature`);
|
||||||
|
assert(peakDown < -2, `peak downdraft only ${peakDown.toFixed(2)} m/s — a flat sail would still shrug it off`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downdraft tracks its own gust and its JSON fraction', () => {
|
||||||
|
const def = storms.storm_02_wildnight;
|
||||||
|
const f = createWindField(def);
|
||||||
|
const frac = def.gusts.downdraft;
|
||||||
|
for (const g of f.gusts) {
|
||||||
|
assert(g.down >= frac * 0.6 - 1e-9 && g.down <= frac * 1.4 + 1e-9,
|
||||||
|
`gust at t=${g.t0.toFixed(1)} has down=${g.down.toFixed(3)}, outside 0.6–1.4× of ${frac}`);
|
||||||
|
// minGap >= GUST.TOTAL means gusts never overlap, so at hold it's exactly this gust
|
||||||
|
const atHold = f.gustVertical(g.t0 + 3);
|
||||||
|
assert(Math.abs(atHold - -(g.pow * g.down)) < 1e-9,
|
||||||
|
`at gust hold vertical is ${atHold.toFixed(3)}, want ${(-g.pow * g.down).toFixed(3)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downdraft 0 gives a perfectly horizontal wind', () => {
|
||||||
|
const def = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||||
|
def.gusts.downdraft = 0;
|
||||||
|
const f = createWindField(def);
|
||||||
|
const out = { x: 0, y: 0, z: 0 };
|
||||||
|
for (let t = 0; t <= f.duration; t += 0.05) {
|
||||||
|
f.vecAt(2, -1, t, out);
|
||||||
|
assert(out.y === 0, `y=${out.y} at t=${t.toFixed(2)} with downdraft 0 — the opt-out leaks`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downdraft does not re-time the storm', () => {
|
||||||
|
// The vertical draws from its own RNG stream precisely so that adding or
|
||||||
|
// tuning it can't shift gust times or powers. Lane A hand-drove storm_02 and
|
||||||
|
// watched the carabiner blow at t=45.4 and p2 cascade at t=56; a downdraft
|
||||||
|
// tweak silently moving those would be a nasty way to lose an afternoon.
|
||||||
|
const base = storms.storm_02_wildnight;
|
||||||
|
const a = createWindField(base);
|
||||||
|
for (const dd of [0, 0.1, 0.25, 0.5, 1]) {
|
||||||
|
const d = JSON.parse(JSON.stringify(base));
|
||||||
|
d.gusts.downdraft = dd;
|
||||||
|
const b = createWindField(d);
|
||||||
|
assert(a.gusts.length === b.gusts.length, `downdraft ${dd} changed the gust count`);
|
||||||
|
a.gusts.forEach((g, i) => {
|
||||||
|
assert(g.t0 === b.gusts[i].t0,
|
||||||
|
`downdraft ${dd} moved gust ${i} from t=${g.t0.toFixed(3)} to ${b.gusts[i].t0.toFixed(3)}`);
|
||||||
|
assert(g.pow === b.gusts[i].pow, `downdraft ${dd} changed gust ${i}'s power`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at a gust peak the downdraft is a real fraction of the horizontal', () => {
|
||||||
|
const f = createWindField(storms.storm_02_wildnight);
|
||||||
|
const out = { x: 0, y: 0, z: 0 };
|
||||||
|
let bestRatio = 0, atT = 0;
|
||||||
|
for (let t = 0; t <= f.duration; t += DT) {
|
||||||
|
f.vecAt(0, 0, t, out);
|
||||||
|
const horiz = Math.hypot(out.x, out.z);
|
||||||
|
if (horiz < 1) continue;
|
||||||
|
const r = Math.abs(out.y) / horiz;
|
||||||
|
if (r > bestRatio) { bestRatio = r; atT = t; }
|
||||||
|
}
|
||||||
|
metrics['storm_02.peakVerticalRatio'] = +bestRatio.toFixed(3);
|
||||||
|
assert(bestRatio > 0.12,
|
||||||
|
`strongest downdraft is only ${(bestRatio * 100).toFixed(0)}% of the horizontal wind (t=${atT.toFixed(1)}) — a flat sail still shrugs`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validator rejects a bad downdraft', () => {
|
||||||
|
for (const dd of [-0.1, 1.5, NaN, 'lots']) {
|
||||||
|
const d = JSON.parse(JSON.stringify(storms.storm_02_wildnight));
|
||||||
|
d.gusts.downdraft = dd;
|
||||||
|
const { ok } = validateStorm(d, 'broken');
|
||||||
|
assert(!ok, `validator ACCEPTED downdraft = ${dd}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { cases, metrics };
|
return { cases, metrics };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -130,17 +130,29 @@ function sampleAngleCurve(curve, t) {
|
|||||||
|
|
||||||
// ---------- gust timeline ----------
|
// ---------- gust timeline ----------
|
||||||
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
// Prototype: pow = 12 + rand*16 + 10*p, next = t + 5 + rand*7. Same shape, from JSON.
|
||||||
|
export const DEFAULT_DOWNDRAFT = 0.25;
|
||||||
|
|
||||||
export function buildGustTimeline(def, seed) {
|
export function buildGustTimeline(def, seed) {
|
||||||
const g = def.gusts || {};
|
const g = def.gusts || {};
|
||||||
const rng = mulberry32(seed >>> 0);
|
const rng = mulberry32(seed >>> 0);
|
||||||
|
// Vertical draws from its OWN stream, deliberately. Pulling it from `rng`
|
||||||
|
// would shift every subsequent (t0, pow) and silently re-time storms that are
|
||||||
|
// already tuned and hand-verified — A drove storm_02 and watched the carabiner
|
||||||
|
// blow at t=45.4 and cascade at t=56, one second after the change. Adding a
|
||||||
|
// downdraft shouldn't move that.
|
||||||
|
const rngV = mulberry32((seed ^ 0x0d0117) >>> 0);
|
||||||
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
|
const minGap = g.minGap ?? 5, maxGap = g.maxGap ?? 12;
|
||||||
|
const downFrac = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||||
const out = [];
|
const out = [];
|
||||||
let t = g.firstAt ?? 3;
|
let t = g.firstAt ?? 3;
|
||||||
// hard cap: a malformed gap can't spin us forever
|
// hard cap: a malformed gap can't spin us forever
|
||||||
while (t < def.duration && out.length < 512) {
|
while (t < def.duration && out.length < 512) {
|
||||||
const p = def.duration > 0 ? t / def.duration : 0;
|
const p = def.duration > 0 ? t / def.duration : 0;
|
||||||
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
|
const pow = (g.powBase ?? 12) + rng() * (g.powRand ?? 16) + (g.powRamp ?? 10) * p;
|
||||||
out.push({ t0: t, pow, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
// Not every gust slams down the same: some roll through nearly flat, some
|
||||||
|
// are a proper little downburst. 0.6–1.4× the storm's fraction.
|
||||||
|
const down = downFrac * (0.6 + rngV() * 0.8);
|
||||||
|
out.push({ t0: t, pow, down, rampAt: t + GUST.TELEGRAPH, endAt: t + GUST.TOTAL });
|
||||||
t += minGap + rng() * Math.max(0, maxGap - minGap);
|
t += minGap + rng() * Math.max(0, maxGap - minGap);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@ -190,6 +202,26 @@ export function createWindField(def, opts = {}) {
|
|||||||
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
|
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical wind, m/s. NEGATIVE = downward. Zero between gusts.
|
||||||
|
*
|
||||||
|
* A gust front is descending air, not just faster air. Without this the field
|
||||||
|
* is perfectly horizontal, and a horizontal sail is a free lunch: cloth
|
||||||
|
* pressure goes with dot(wind, normal), a flat panel's normal points at the
|
||||||
|
* sky, and the dot product is ~0 no matter how hard it blows. So the cheapest
|
||||||
|
* winning rig was "lie it flat and ignore the storm", which is the opposite of
|
||||||
|
* the game (SPRINT2 decision 3). A downdraft hits a flat panel square on.
|
||||||
|
*/
|
||||||
|
function gustVertical(t) {
|
||||||
|
let v = 0;
|
||||||
|
for (let i = 0; i < gusts.length; i++) {
|
||||||
|
const g = gusts[i];
|
||||||
|
if (t <= g.t0) break; // sorted — nothing later is live
|
||||||
|
if (t < g.endAt) v -= gustEnvelope(t - g.t0, g.pow) * g.down;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- noise drift ----
|
// ---- noise drift ----
|
||||||
// The noise pattern rides downwind with the mean flow (Taylor's frozen
|
// The noise pattern rides downwind with the mean flow (Taylor's frozen
|
||||||
// turbulence), so a gust visibly travels ACROSS the yard instead of blinking on
|
// turbulence), so a gust visibly travels ACROSS the yard instead of blinking on
|
||||||
@ -278,7 +310,13 @@ export function createWindField(def, opts = {}) {
|
|||||||
},
|
},
|
||||||
get shelters() { return shelters; },
|
get shelters() { return shelters; },
|
||||||
|
|
||||||
/** Scalar wind speed (m/s) at a point. The cheap path — no allocation. */
|
/**
|
||||||
|
* Scalar wind speed (m/s) at a point — HORIZONTAL only, which is what an
|
||||||
|
* anemometer reads and what the HUD, rain and grass want. The gust downdraft
|
||||||
|
* is deliberately not in here: a wind meter jumping because air is falling
|
||||||
|
* past it would read as a bug. Use vecAt/sample for the full 3D vector.
|
||||||
|
* The cheap path — no allocation.
|
||||||
|
*/
|
||||||
speedAt(x, z, t) {
|
speedAt(x, z, t) {
|
||||||
const uni = uniformSpeed(t);
|
const uni = uniformSpeed(t);
|
||||||
const d = dirAt(t);
|
const d = dirAt(t);
|
||||||
@ -289,16 +327,18 @@ export function createWindField(def, opts = {}) {
|
|||||||
dirAt,
|
dirAt,
|
||||||
uniformSpeed,
|
uniformSpeed,
|
||||||
gustOnly,
|
gustOnly,
|
||||||
|
gustVertical,
|
||||||
|
|
||||||
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
|
/** Writes wind velocity (m/s) into out {x,y,z}. Ground plane is XZ, +Y up. */
|
||||||
vecAt(x, z, t, out) {
|
vecAt(x, z, t, out) {
|
||||||
const uni = uniformSpeed(t);
|
const uni = uniformSpeed(t);
|
||||||
const d = dirAt(t);
|
const d = dirAt(t);
|
||||||
const dirX = Math.cos(d), dirZ = Math.sin(d);
|
const dirX = Math.cos(d), dirZ = Math.sin(d);
|
||||||
let s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
|
const m = spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ);
|
||||||
|
let s = uni * m;
|
||||||
if (s < 0) s = 0;
|
if (s < 0) s = 0;
|
||||||
out.x = dirX * s;
|
out.x = dirX * s;
|
||||||
out.y = 0; // wind is horizontal; lift is the sail's job (Lane B)
|
out.y = gustVertical(t) * m; // gust fronts descend — see gustVertical()
|
||||||
out.z = dirZ * s;
|
out.z = dirZ * s;
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
@ -375,6 +415,10 @@ export function validateStorm(def, name = 'storm') {
|
|||||||
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
|
// Overlapping gusts stack, and a stacked telegraph is unreadable to the player.
|
||||||
if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
|
if (minGap < GUST.TOTAL) bad(`gusts.minGap (${minGap}) < gust length ${GUST.TOTAL}s — gusts would overlap`);
|
||||||
if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
|
if ((g.powBase ?? 12) < 0) bad('gusts.powBase must be >= 0');
|
||||||
|
const dd = g.downdraft ?? DEFAULT_DOWNDRAFT;
|
||||||
|
if (!Number.isFinite(dd) || dd < 0 || dd > 1) {
|
||||||
|
bad(`gusts.downdraft must be 0..1 — the fraction of gust power that blows DOWN — got ${dd}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const e of def.events || []) {
|
for (const e of def.events || []) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user