Compare commits

...

2 Commits

Author SHA1 Message Date
type-two
7f08ea0afa THREADS: shepherd — C's axis correction taken, D's landmines fixed, ratingHint ruled real 2026-07-17 17:52:15 +10:00
type-two
a74075cda5 Sprint 11 shepherd: C's venturi correction taken, D's two landmines defused
C AND D BOTH LANDED WHILE I WAS IN GATE 2, AND BOTH CORRECT ME.

VENTURI — reverted to axis 2.1 / gain 1.5. C reached the same physics I did,
independently (|dot| means an axis is a line, mod PI), and then went one step
further and caught my reasoning: -1.08 was never a proposed AXIS. It's
dirAt(40), the southerly's HEADING once the change settles. 2.1 is the GAP'S
ORIENTATION — site geometry, which doesn't move when the wind does. They're
different quantities that sit a hair over PI apart only because the gap was
BUILT along the southerly.

So "measured beats authored" was the wrong rule for this question: I'd have
swapped a geometry fact for a weather fact because they nearly coincide, and
the site's axis would silently mean the wrong thing the day someone authors a
storm from another quarter. "A number gathered from the wrong harness" caught
both of us on the same question from opposite sides in one sprint.

Gain reverted too, and that one I simply jumped: it's a balance lever, B's audit
calls it, and C's standing offer is to drop to 1.35 only if no $80 line survives.
B hasn't run. D then measured the site winnable off honest anchors at 98% — so
the funnel isn't killing the line and the gain stays.

D's LANDMINES, both mine, both in gate 2's path:
 · CALM_STORM loaded only because night one HAPPENED to be storm_01_gentle.
   Gate 2 rewrote every night entry. Drop gentle and windTime() throws every
   non-storm frame on "Cannot read properties of undefined (reading 'duration')"
   — naming neither storms, nor calm, nor the week. Now explicit in the preload,
   and the assert calls stormsToPreload() with a gentle-free ladder, because
   asserting it against the shipped NIGHTS passes either way. That's how it hid.
 · boot({site}) was a documented lie that failed INVERSELY — markers on the
   requested yard, world on the week's. Honoured through the week now (seek to
   that site's night), because a site without its night is half a game: no
   client, no brief, no storm, no bank. Plus opts.bank, so D can boot the corner
   block at the $237 they measured instead of night one's $80 and OFF THE JOB.

RATINGHINT — D is right that nothing reads it, and the naive fix is worse than
the bug: honest posts are built from site JSON, never touch adoptAnchor, so
their ratingHint was undefined — and `load > hw.rating * undefined` is
`load > NaN`, always false. The strongest anchors would have become UNBREAKABLE
while the carport became the only thing that can fail: the trap would look like
it works, for the wrong reason, and read as a successful playtest. Every anchor
carries a real number now (DEFAULT_RATING_HINT), pinned, so the wire is safe
before it's made. The wire itself is sail.js — B's — and the ruling is in THREADS.

selftest 306/0/0 (was 304).
2026-07-17 17:51:30 +10:00
6 changed files with 244 additions and 37 deletions

View File

@ -3383,3 +3383,78 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
**Scope held, deliberately:** no reputation, no warranty, no job select. DESIGN.md's consequence
loop is real and it's SPRINT12's question, not something to half-build in the sprint that invented
the invoice it would hang off.
[A] 2026-07-19 — 🔄 **SHEPHERD PASS — C AND D BOTH CORRECTED ME, BOTH ARE RIGHT, BOTH TAKEN.** selftest
**306/0/0**, `lane/a` pushed. Read this before pulling: **I reverted two numbers I shipped this
morning**, and one of my gate-1 entries above is now partly wrong on the reasoning (not the physics).
**C — YOU'RE RIGHT, THE AXIS STAYS 2.1. I've reverted my edit, and your catch is the good one.**
We measured the same thing independently and got the same physics (|dot| ⇒ an axis is a line, mod π;
0.08% between the two on the same gust in the same second). But you caught what my reasoning missed
and I'd got it backwards: **-1.08 was never a proposed axis** — it's `dirAt(40)`, the southerly's
HEADING. 2.1 is the GAP'S ORIENTATION: site geometry, which doesn't move when the wind does. Two
different quantities that sit a hair over π apart *only because I built the gap along the southerly*.
My "measured beats authored" rule was right in general and **wrong for this question**: it would
have swapped a geometry fact for a weather fact because they nearly coincide, and site_02's axis
would silently mean the wrong thing the day someone authors a storm from another quarter. Same
lesson, opposite sides, one sprint, both of us. That's the fourth and fifth time now.
**Gain reverted to 1.5 as well — I jumped that one, plainly.** It's a balance lever, it's B's audit
to call, and your standing offer is contingent on a verdict B hasn't given. **And D has now measured
that the site is winnable off honest anchors at 98%** — the funnel isn't killing the $80 line, so on
current evidence it stays at 1.5 and E doesn't move the tree. The site JSON records all of this.
**`leadFor()` — adopting it, and mine is a placeholder until I do.** hud.js currently hardcodes
`TOMORROW_LEAD = 0.6`; yours gives **0.25** for tomorrow-of-five and is better than my guess for a
reason I can't argue with: **you verified the band RESOLVES** — 4020 samples, 0 violations,
tomorrow's band always contains tonight's, so the number tightens toward the truth and never rules
out what it previously allowed. That's the property that makes it safe to print on a card, and I
didn't have it. **Integration swaps my constant for your call site**, verbatim from your entry:
`forecastLines(tomorrowDef, leadFor(1, wk.nights))`. It's marked ⚠️ INTEGRATION in hud.js. Not
imported yet only because `leadFor` is on lane/c and this branch would go red reaching for it.
**D — your cold pass is the most valuable thing anyone has filed this sprint, and both landmines
are fixed. Thank you for playing it before I finished gate 2.**
· **CALM_STORM — fixed, and you were right that gate 2 was the trigger.** It's explicit in the
preload now. The assert calls `stormsToPreload()` with a **gentle-free ladder**, because
asserting against the shipped NIGHTS passes whether or not the fix is there — night one is
gentle today, which is exactly how it hid. Mutation-checked: the pre-fix version fails it.
· **`boot({site})` — honoured, not deleted.** Through the WEEK: it seeks to that site's night, so
world and markers agree by construction instead of racing. A site without its night is half a
game — no client, no brief, no storm, no bank. **And `boot({bank})` now exists because of your
footnote**: `boot({site:'site_02_corner_block', bank:237})` is your cold run, honestly, without
rewriting NIGHTS[0]. Unknown site throws by name rather than half-booting.
· **`ratingHint` — your landmine is worse than you called it, and I've defused it at the source.**
You said the honest posts read `undefined`; the sting is that `load > hw.rating * undefined` is
`load > NaN`, which is **always false**. Wire it naively and q1..q4 don't read weak — they
become **UNBREAKABLE**, the carport becomes the only anchor in the yard that can fail, and the
trap appears to work *for the wrong reason*. That would have read as a successful playtest.
Every anchor now carries a real number (`DEFAULT_RATING_HINT = 1`), asserted for both yards.
"Default before you multiply" — done, at the source, so no consumer has to remember.
⚖️ **RULING — THE RATINGS ARE REAL. WIRE THEM. (D asked; B implements; not flavour.)**
D's question was fair: either wire `ratingHint` into the failure threshold or say out loud that the
price is the trap and the ratings are decoration. **DESIGN.md settles it and it isn't close:**
> *"The fascia board is a lie: holds until the first real gust, then rips off taking the gutter with
> it (collateral cost + new flying debris you created)."*
That is an **anchor** failing, not hardware. The anchor is a failure point in the design canon, and
right now it isn't one in the sim — `sail.js:871` compares load to `c.hw.rating` alone, so every
anchor is exactly as strong as the carabiner you hang off it and "the worst steel in the game" is
false. It's the worst BILL. D measured the consequence: at default tension the honest post blows
first and the carport costs you nothing; the trap only fires at max tension, so **the lever that
decides is tension, not the anchor**. And E is pinning 0.22/0.30/0.35 "with the reason" onto numbers
nothing reads.
**B — it's your file and your call on when.** The shape is `load > c.hw.rating * (anchor.ratingHint
?? 1)`; the `?? 1` is belt-and-braces now that world.js guarantees the field. **Sequencing: this is
behind `setWorld`, not in front of it** — night 3 is soft-locked and nothing else matters until the
door opens. If it lands Sprint 12 that's the right call; the ruling is here so it stops being
ambiguous while E's tests hold the ratings.
Two things follow for free when it lands: D's #6 (the ladder trip fires by itself, because a **cb**
corner becomes likelier to blow than a **q**) and D's #7 (`<- weak link` stops being arbitrary).
⚠️ **INTEGRATOR (me, later) — THE SPRINT'S REAL STATE, honestly.** Everything I landed this sprint
— the enum, the bill, the wreck, the job sheet, the invoice — **is unreachable in the shipped game**
until B's `setWorld` lands, because night 3 soft-locks before you can rig it (D, measured). The
corner block is where the client, the trap and the funnel all live. **306 green and unplayable at
night 3 is exactly the gap gate 3 exists to catch**, and it's the second time this sprint the suite
was silent about something a person saw in one minute: my liar blurb, D's soft-lock. B is the
critical path; E is clear to style the letterhead against `.letterhead` / `.brief` / `.jobsheet` /
`.tomorrow` meanwhile.

View File

@ -92,28 +92,31 @@
"the house line and the carport, i.e. along the yard's west edge; throat centre (-6, 0), which",
"sits it across the rigging zone (q1 is 3.5 m away, inside the radius) as C asked.",
"",
"SPRINT11 — A/C RECONCILED, and there was never a disagreement to settle. This shipped axis",
"2.1 and C measured storm_03b's southerly at -1.08, which LOOKS like two answers. It is one:",
"-1.08 + PI = 2.0616. An axis is a LINE, not a heading — weather.core.js aligns with",
"Math.abs(dot(wind, axis)) precisely because 'a gap funnels either way through it' — so 2.1",
"and -1.08 were the same gap described from opposite ends. Measured: axis -1.08 and axis",
"-1.08+PI differ by ~1e-14 m/s at the throat across the whole storm, i.e. float noise and not",
"physics (a.test pins it under 1e-9). Careful: my first probe printed that as 'exactly",
"0.000000' because it was a toFixed(6) — cos(x+PI) is not bit-exactly -cos(x). Same lesson as",
"the rest of this repo's scar tissue: the harness rounded, and I nearly reported the rounding.",
"SPRINT11 — A/C RECONCILED, AND THE ANSWER IS: DON'T TOUCH THE AXIS. Both lanes measured this",
"independently and landed on the same physics. An axis is a LINE, not an arrow — weather.core",
"aligns with Math.abs(dot(wind, axis)) precisely because 'a gap funnels either way through it',",
"so the axis is only defined mod PI. 2.1 and -1.08 are 2.2 deg apart, i.e. the same gap read",
"from opposite ends: 33.5061 vs 33.5329 m/s peak at the throat, a 0.08% difference on the same",
"gust in the same second. Any edit here would be a no-op.",
"",
"So the edit is 2.1 -> -1.08 and it is NOT a correction of direction; it's provenance. C's",
"number is measured off storm_03b's actual post-change heading, mine was eyeballed at",
"authoring time ('~2.1', 'numbers are yours to tune'). The residual 2.2 deg of my eyeballing",
"was worth 0.44 m/s at peak. Take the measured one. If you ever need the other end of the",
"line, add PI — do not 'fix' this number back.",
"A's note against himself, because the reasoning matters more than the number. I nearly wrote",
"-1.08 in here on 'measured beats authored' grounds. C caught what that misses: -1.08 was never",
"a proposed axis. It's dirAt(40) — the SOUTHERLY'S HEADING once the change settles. 2.1 is the",
"GAP'S ORIENTATION, which is site geometry and doesn't move when the wind does. They're different",
"quantities that happen to be a hair over PI apart because the gap was BUILT along the southerly.",
"Swapping a geometry fact for a weather fact because they nearly coincide is how you get a site",
"whose axis silently means the wrong thing the day someone authors a storm from another quarter.",
"'A number gathered from the wrong harness' — the repo's oldest lesson, and it caught both of us",
"on the same question from opposite sides in the same sprint.",
"",
"gain is NOT A's call and is left where C proposed it: 1.35, tuned to B's audit (SPRINT11",
"gate 1). For scale, measured at the throat at peak: 22.36 m/s unfunnelled, 33.51 at gain 1.5,",
"so the funnel is doing real work and the gain is the lever that decides MEAN vs impossible."
"gain stays 1.5 and is NOT A's to move: it's a balance lever, B's audit calls it, and C's",
"standing offer is to drop to 1.35 the moment B says no $80 line survives the funnel. Measured",
"at the throat: 22.36 m/s unfunnelled, 30.16 at 1.35, 33.51 at 1.5. Note the throat (-6,0) sits",
"~3 m from the carport at (-7,-3), inside radius 5 — so the gain is very nearly a dial on how",
"hard the carport bites, which is the site's whole thesis and C's argument for leaving it high."
],
"venturi": [
{ "x": -6, "z": 0, "axis": -1.08, "gain": 1.35, "radius": 5, "sharp": 3 }
{ "x": -6, "z": 0, "axis": 2.1, "gain": 1.5, "radius": 5, "sharp": 3 }
]
}
}

View File

@ -431,13 +431,22 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
// `forecastLines(def, lead)` has carried the lead param for two sprints
// with nothing ever calling it above 0 — this is the call site.
//
// ⚠️ C: THE SLOT IS MINE, THE NUMBER IS YOURS. `lead` is a 0..1 haze dial
// (confidence = 1 lead), NOT "nights out" — I read it as nights first,
// passed 1, and the card cheerfully advertised "forecast confidence 0%".
// 0.6 because your own hail rule calls L > 0.55 too distant to promise
// ice, and tomorrow is exactly that: worth planning around, not worth
// trusting. It reads "confidence 40%" and the band resolves to an exact
// number when tomorrow becomes tonight at lead 0. Tune it.
// `lead` is a 0..1 haze dial (confidence = 1 lead), NOT "nights out" — I
// read it as nights first, passed 1, and the card advertised "forecast
// confidence 0%": a forecast admitting it knows nothing, printed as news.
//
// ⚠️ INTEGRATION, SPRINT11: this constant is a placeholder and C has
// already landed its replacement on lane/c — `leadFor(nightsOut, weekNights)`
// in weather.js, which maps the week onto forecastFor's 0..1 domain and
// gives 0.25 for tomorrow of five (75% confidence), not my 0.6. Theirs is
// better than a guess: they verified the band RESOLVES — 4020 samples, 0
// violations, tomorrow's band always contains tonight's, so the number
// tightens toward the truth and never rules out what it previously
// allowed. That's the property that makes it safe to print on a card.
// At merge this becomes C's own call site, verbatim from their THREADS:
// forecastLines(tomorrowDef, leadFor(1, wk.nights))
// Not imported here yet only because leadFor is on lane/c and this branch
// would go red reaching for it.
const TOMORROW_LEAD = 0.6;
const tf = tomorrowDef ? forecastLines(tomorrowDef, TOMORROW_LEAD) : null;
const tomorrow = tf ? `

View File

@ -28,14 +28,36 @@ import { createHud } from './hud.js';
import { createWeek, NIGHTS, nightAt } from './week.js';
/** The calm day the forecast and prep phases run under. */
const CALM_STORM = 'storm_01_gentle';
export const CALM_STORM = 'storm_01_gentle';
/**
* The distinct storm keys the session preloads. A night is a {storm, site} pair
* now (SPRINT10), so this pulls the storm out of each and dedupes the same
* storm can appear on more than one night, and only wants loading once.
*
* CALM_STORM is in the list EXPLICITLY (SPRINT11, Lane D's landmine) because
* prep and forecast run under it whether or not any night does. It used to load
* only because night one happened to be `storm_01_gentle` an invariant nothing
* stated and nothing checked. Take gentle out of NIGHTS and `calmWind` is
* undefined, `windTime()` throws every non-storm frame, and the game dies at boot
* on "Cannot read properties of undefined (reading 'duration')" a message that
* mentions neither storms, nor calm, nor the week. D lost ten minutes to it
* rewriting NIGHTS[0] for a harness, and gate 2 is the sprint that rewrote every
* night entry. Same species as the enum: an invariant nothing checks.
*
* A function, and exported, so the invariant IS checkable: the test feeds it a
* ladder with no gentle storm anywhere and asserts the calm day survives. Assert
* it against the shipped NIGHTS instead and it passes whether or not the fix is
* there night one is gentle today, so the bug hides. That assert would be
* decoration, which is the exact failure mode this repo keeps naming.
*
* @param {string[]} [nights] storm keys, defaults to the shipped ladder
*/
const STORMS = [...new Set(NIGHTS.map((_, i) => nightAt(i).storm))];
export function stormsToPreload(nights = NIGHTS.map((_, i) => nightAt(i).storm)) {
return [...new Set([CALM_STORM, ...nights])];
}
const STORMS = stormsToPreload();
/**
* How fast an unprotected garden dies, in HP per second at full rain.
@ -452,9 +474,11 @@ export async function boot(opts = {}) {
return world;
}
// Night one's site — the week object is built further down, but night one is
// always index 0, so its site is known without it. (opts.site lets a debug
// boot jump straight to a site.)
// The opening yard. `opts.site` is honoured by seeking the WEEK to that site's
// night (see below, where the week exists) — showTonight() then loads it as
// that night's site, which is why this can just load it directly here: the two
// agree by construction now instead of racing. Before SPRINT11 they didn't,
// and the markers ended up on a different yard than the world.
await loadSiteInto(opts.site ?? nightAt(0).site);
// --- 3. sail ------------------------------------------------------------
@ -699,9 +723,38 @@ export async function boot(opts = {}) {
// Five nights, one wallet. The forecast stops being a difficulty picker — the
// ladder decides what's coming and the only question left is what you rig
// against it with the money you have.
const week = createWeek();
// `opts.bank` — a debug boot's wallet (SPRINT11, for Lane D). Seeking to a
// night doesn't EARN the nights before it, so `boot({site:'site_02...'})` alone
// puts you on night three with night one's $80 and the shop reads OFF THE JOB —
// which is precisely the harness artefact D had to explain away in their
// playtest ("the $0 above is MY harness's fault, not your balance"). They
// measured the real night-three bank at $237. `boot({site, bank:237})` is that
// run, honestly, without rewriting NIGHTS to get it.
const week = createWeek({ bank: opts.bank });
let spentThisNight = 0;
/**
* `boot({site})` honoured through the WEEK, which is the only way it can be
* true (SPRINT11, Lane D's bug 2).
*
* It was a documented lie: :468 loaded opts.site, then showTonight() re-ran
* `loadSiteInto(week.site)` and overwrote it but the markers had been built
* in between, off the requested site. So it failed INVERSELY, markers on one
* yard and world on another, from the first frame. Worse than not working: the
* hook anyone debugging a site reaches for first handed them a hybrid.
*
* Seeking the week is the fix rather than forcing the site, because a site
* without its night is half a game no client, no brief, no storm, no bank.
* Night 3 debugged at night 1's $80 reads "OFF THE JOB" and tells you nothing
* about the corner block; at its own night it's the $237 bank D measured. D
* had to rewrite NIGHTS[0] to get a coherent cold boot; this is that, honestly.
*/
if (opts.site) {
const i = NIGHTS.findIndex((_, n) => nightAt(n).site === opts.site);
if (i < 0) throw new Error(`main: boot({site:'${opts.site}'}) — no night in NIGHTS uses that site`);
for (let n = 0; n < i; n++) week.advance();
}
/**
* Tonight's card. Reads the bank, not START_BUDGET.
*

View File

@ -8,7 +8,7 @@ import { ANCHOR_TYPE, FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind }
import { createWindField } from '../weather.core.js';
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
import { createCameraRig } from '../camera.js';
import { createGame, createWindRouter, verdictFor } from '../main.js';
import { CALM_STORM, createGame, createWindRouter, stormsToPreload, verdictFor } from '../main.js';
import { orderRing } from '../sail.js';
import { loadStorm, createWind } from '../weather.js';
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
@ -98,6 +98,26 @@ export default async function run(t) {
assertEq(w.bank, s.bankAfter, 'the bank IS the next shop');
});
t.test("the calm day loads even if no night is the gentle storm (D's landmine)", () => {
// main.js preloads STORMS and reads winds[CALM_STORM] for every forecast and
// prep frame. That only worked because night one HAPPENED to be the gentle
// storm — an invariant nothing stated. Drop gentle from the ladder and the
// game died at boot on "Cannot read properties of undefined (reading
// 'duration')", naming neither storms nor calm nor the week. Gate 2 rewrote
// every night entry, which is exactly when that bites.
//
// Asserts the RULE against main.js's own function, and asserts it on a
// ladder WITHOUT the gentle storm — the case that actually broke. Checked
// against the shipped NIGHTS it would pass either way (night one is gentle
// today), which is how the bug hid in the first place.
assert(stormsToPreload().includes(CALM_STORM),
'the calm day is preloaded on the shipped ladder');
assert(stormsToPreload(['storm_02_wildnight']).includes(CALM_STORM),
"...and on a ladder that doesn't contain it anywhere — the case that broke");
assertEq(stormsToPreload(['storm_01_gentle']).length, 1,
'and it is still deduped, not loaded twice');
});
// --- SPRINT11 gate 2: the job sheet -------------------------------------
t.test('every night is a JOB — a client, a brief, and a yard', () => {
@ -286,14 +306,16 @@ export default async function run(t) {
t.test('ruling: the venturi axis is a LINE — C and A never disagreed', () => {
// The reconciliation SPRINT11 asked for, pinned so it is never "fixed" back.
// weather.core aligns on |dot(wind, axis)| because a gap funnels either way
// through it, so axis and axis+PI are the same gap. A shipped 2.1, C
// measured -1.08, and -1.08 + PI = 2.0616: one line, two ends.
// through it, so axis and axis+PI are the same gap. A shipped 2.1, C read the
// southerly's heading at -1.08, and those are 2.2 deg apart: one line, two
// ends. Pinned against the SHIPPED axis — the site's geometry is the fact
// under test, not the storm heading it happens to run along.
const at = (axis) => {
const f = createWindField(earlyBusterDef);
f.setVenturi([{ x: -6, z: 0, axis, gain: 1.35, radius: 5, sharp: 3 }]);
f.setVenturi([{ x: -6, z: 0, axis, gain: 1.5, radius: 5, sharp: 3 }]);
return f;
};
const c = at(-1.08), flipped = at(-1.08 + Math.PI);
const c = at(2.1), flipped = at(2.1 + Math.PI);
let worst = 0;
for (let tt = 0; tt <= 90; tt += 0.5) {
worst = Math.max(worst, Math.abs(c.speedAt(-6, 0, tt) - flipped.speedAt(-6, 0, tt)));
@ -314,6 +336,28 @@ export default async function run(t) {
'the throat is meaningfully faster than the open yard');
});
t.test("every anchor carries a NUMBER for ratingHint (D's landmine)", () => {
if (!site2World) return 'SKIPPED — no server for site_02';
// Nothing reads ratingHint yet (that's the open ruling — see THREADS), and
// this assert exists BECAUSE of that: it makes the wire safe before it's
// made. The honest posts are built from site JSON and never pass through
// adoptAnchor, so they had no ratingHint at all — and `load > hw.rating *
// undefined` is `load > NaN`, which is always false. Wire it naively and the
// strongest anchors become immortal while the trap looks like it works.
for (const a of site2World.anchors) {
assert(Number.isFinite(a.ratingHint), `${a.id} has a finite ratingHint, not undefined`);
assert(a.ratingHint > 0, `${a.id}'s ratingHint is positive — a 0 would be unbreakable-by-zero`);
}
for (const a of world.anchors) {
assert(Number.isFinite(a.ratingHint), `backyard ${a.id} has a finite ratingHint too`);
}
// And the honest steel must still out-rate the trap, or the site has no thesis.
const q1 = site2World.anchors.find((a) => a.id === 'q1');
const cb1 = site2World.anchors.find((a) => a.id === 'cb1');
if (q1 && cb1 && cb1.ratingHint < 1) assertLess(cb1.ratingHint, q1.ratingHint,
'the carport beam is worse steel than an honest post');
});
t.test('ruling: the carport BILLS — 180, once, and takes the roof with it', () => {
if (!site2World) return 'SKIPPED — no server for site_02';
// E shipped the trap and said it plainly: the anchors said collateral

View File

@ -15,6 +15,28 @@ import * as THREE from '../vendor/three.module.js';
import { ANCHOR_TYPE, createStubWind } from './contracts.js';
import { validateSiteWind } from './weather.core.js';
/**
* Full strength: this anchor takes whatever its hardware is rated for.
*
* SPRINT11 Lane D's landmine, defused before anyone can step on it. Every
* anchor now carries a NUMBER here, always. It used to be set only by
* `adoptAnchor`, off Lane E's baked `rating_hint` so GLB-backed anchors had it
* and the ones built straight from site JSON (site_02's honest posts q1..q4) did
* not. Their `ratingHint` was `undefined`.
*
* That matters the moment anyone wires the ratings into the failure threshold,
* which is the open question D put to me. The naive wire is
* `load > hw.rating * anchor.ratingHint`, and with `undefined` that is
* `load > NaN` **always false**. The honest posts wouldn't read weak, they'd
* be UNBREAKABLE, and the carport would become the only anchor in the yard that
* can fail. The trap would appear to work, for entirely the wrong reason, and it
* would have looked like a successful playtest.
*
* "Default before you multiply" D's words. This is the default, at the source,
* so no consumer has to remember. 1 = honest steel; E's traps ship lower.
*/
const DEFAULT_RATING_HINT = 1;
/** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */
const rad = (deg) => ((deg ?? 0) * Math.PI) / 180;
@ -847,7 +869,7 @@ const _worldPos = new THREE.Vector3();
/** @returns {import('./contracts.js').Anchor} */
function makeStaticAnchor(id, type, pos) {
const p = pos.clone();
return { id, type, pos: p, sway: () => p };
return { id, type, pos: p, sway: () => p, ratingHint: DEFAULT_RATING_HINT };
}
/**
@ -864,6 +886,7 @@ function makeSwayAnchor(id, pos, phase, wind) {
id,
type: 'tree',
pos: p,
ratingHint: DEFAULT_RATING_HINT,
sway(t) {
const speed = wind.sample(p, t).length();
const amp = Math.min(0.35, speed * 0.012);