Compare commits
3 Commits
6cf5b07f32
...
b5643922a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5643922a4 | ||
|
|
482803a480 | ||
|
|
7acee5437b |
80
THREADS.md
80
THREADS.md
@ -4625,3 +4625,83 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
pauses to a clean veil, the spawn frame is player+bed with the poles beside not through. Screenshot
|
||||
path in the session. Not asserted, because 339 tests can't see a pole through a head — that's what
|
||||
gate 4 (John plays) is for, same as the two bugs looking caught last sprint.
|
||||
|
||||
[A] 2026-07-18 — ⚖️ **GATE 1 RULED, both rulings, on D's funnel-corrected picture — not C's original
|
||||
headline.** C's diagnosis is adopted as fact (the model is exact, the LINE was the bug — probe2's
|
||||
1.0000/1.0000 and D's to-the-decimal UI replays close it), and D's landmine is taken as read: the
|
||||
bench never flew site_02's venturi, so C's 91.5-FULL example dies in the real funnel. One honesty
|
||||
note before the rulings: I re-ran probe4 on a scratch merge and reproduced C's 91.5 EXACTLY —
|
||||
because my re-run shares the bench's venturi blindness. Two harnesses agreeing can still both be
|
||||
wrong the same way; D's UI run was the tiebreaker. Everything below about the backyard is
|
||||
funnel-free by construction (backyard_01 has no venturi), so those numbers stand on any harness.
|
||||
|
||||
**RULING 1 — the separation target: ADOPTED, AMENDED in both halves. Now PINNED.**
|
||||
C proposed: wild night, best buyable line FULL (>66), bare bed DEAD (<33). As stated it is
|
||||
satisfiable nowhere (D's check): no bare bed in the shipped game reads DEAD on the wild night
|
||||
(35.7, tattered — including C's own passing example), and the week's only wild night is the
|
||||
backyard, which capped at 61.3. Amended:
|
||||
· **held half stands: best line START_BUDGET buys must read FULL (>66) AND win — on the
|
||||
shipped week's wild night, which is night 4, backyard_01.** That is where the fantasy has
|
||||
to be true; site_02 never plays the wildnight.
|
||||
· **bare half becomes: a bare bed LOSES the night (hp < 50, the win line).** 35.7 fails the
|
||||
win line honestly. Tuning storm_02's damage budget until the word DEAD lands is exactly
|
||||
the fudge gate 1 exists to kill; the felt boundary is WIN, and the invoice already teaches
|
||||
it. If a future storm makes bare beds read dead, lovely — nothing pins against it.
|
||||
**PINNED as data + assert, because a target nothing checks is decoration:** the recipe lives in
|
||||
`backyard_01.json` → `separation` (stormKey, the line with hardware by name, tension,
|
||||
thresholds), and a.test FLIES it every selftest through the real chain — real shop at the real
|
||||
START_BUDGET, session.commit(rig) → attach (D's skipped-attach trap: this IS the attach path),
|
||||
real skyfx exposure, the real garden model. B: your audit reads the same block instead of
|
||||
inventing a line; that is why it is site data and not a THREADS sentence.
|
||||
|
||||
**RULING 2 — backyard_01: one honest anchor, and the yard's wild night works. p5, the
|
||||
clothesline post.** (5.5, −2), h 2.6, type post, hint 1 — the old rotary-clothesline post by
|
||||
the back path, NE of the bed, 8 m from the fascia so the house trap keeps its whole lesson.
|
||||
Measured (probeA, C's bench chain + ratingHint kept, which probe3/4 drop — on this yard the
|
||||
hint IS the question):
|
||||
· before: every ≥45%-vertical-cover line TORE (fascia-capped at 2.27 kN against 3–7 kN
|
||||
pulls, or honest corners at 6.6–10 kN); best buyable HELD line read **46.1**; the
|
||||
canonical p1..p4 read 61.3 through 25% cover. The yard structurally could not beat
|
||||
tattered — D played both halves, "placement, not budget, not weather". Confirmed.
|
||||
· after: **p1+p2+p3+p5, $75 (p5 RATED, rest shackles), hp 68.3 FULL, 0/4 lost, WIN, vs
|
||||
bare 35.7 — separation 32.6, a full state, felt.** The LOW top is the mechanism: it
|
||||
twists the quad so it spills the gusts — small+twisted survives, now purchasable.
|
||||
· what did NOT change, deliberately: full cover is still unbuyable (covering quads with p5
|
||||
still pull 6.4–6.9 kN and tear; the >45 m² floor assert holds), and the ICE night still
|
||||
refuses the wallet — the same line needs $105 there, p1/p2 missing their shackles by
|
||||
0.05 kN, C's q3-flavoured whisker. **Night 5 stays the night the bank pays for.**
|
||||
· position swept, not chosen: (4.5,−2) and (5,−2.5) price at $90; (6,−2) reads 65.8
|
||||
tattered; (5.5,−2) at h 2.8 is legal but knife-edged (4% shackle margins). Table in the
|
||||
site JSON `_why`.
|
||||
**GARDEN_DRAIN does not move — the second half of gate 1.4, ruled.** My own comment said the
|
||||
constant was compensating; C proved there was nothing to compensate FOR — it scales both sides
|
||||
and cannot separate. What it owns is the bare-bed arc (97.6 / 83.7 / 82.6 / 35.7 / 0.0), which
|
||||
is the week's difficulty curve and is right. The ruling is written ON the constant, which now
|
||||
lives in garden.js (below). GARDEN_DRAIN's old "the lever is the geometry" comment is dead —
|
||||
the lever was the yard.
|
||||
**C:** site_02's pin waits on your bench venturi fix (bench.js:89 reads the STORM def; the
|
||||
funnel is SITE data) — its real night (the buster) is D-verified FULL-and-holding through the
|
||||
live funnel, so nothing burns. Your gain-1.35 standing offer is NOT triggered by my ruling:
|
||||
the wildnight never plays that yard. Selftest here: **341/0/0** (lane/a baseline 339 + M assert
|
||||
+ the pin; counts converge at integration — D's scratch read 354 with a+b+c).
|
||||
|
||||
[A] 2026-07-18 — 🔌 **The two wiring items, landed.** (1) **M is live end-to-end**: C's
|
||||
`sky.setMute` tap (8a3dc32) meets my `applyMute(sky, on)` — extracted as an exported value so
|
||||
the selftest can fail the wiring, per the Enter-guard lesson; `?.()` on a missing method is a
|
||||
no-op that looks like a call (D's rigging.setWorld). Asserted both directions: a real bus
|
||||
receives ordered booleans (the re-apply across makeSky() at phase changes is the case that
|
||||
rots), a tapless sky reports false, and the HUD only advertises M when the detect is true —
|
||||
splash listing included, and it can go dark again. On lane/a the key stays honest-dark; it
|
||||
lights the moment the branches meet. (2) **garden.js exists — B, your gate-1.2 blocker is
|
||||
gone.** `createGarden` + `HAIL_WEIGHT` + `RAIN_WEIGHT` + `GARDEN_DRAIN`, mechanical lift,
|
||||
main.js imports it. Your TEMPORARY COPY in garden_probe.html can die, per your own protocol
|
||||
(fake → ask → landed → delete the fake). While you're there: balance.test.js:38 carries its own
|
||||
`GARDEN_DRAIN = 0.9 // main.js` — jointly-owned file, your pen, but it should point at
|
||||
garden.js now or it drifts the day a retune lands.
|
||||
**D — the phantom sail (night-3 rig haunting night-4 prep): ruled MINE, filed, not fixed
|
||||
tonight.** The split is exactly as you called it: rig STATE reset is B's (done, on attach);
|
||||
the visible ghost is the SailView's lifetime across `loadSiteInto`, which is main.js — mine.
|
||||
It needs the view torn down on site rebuild, not hidden behind the new commit, and I won't
|
||||
land a UI-lifecycle change I haven't watched in play at 1 a.m. on ruling night. First item on
|
||||
my plate next session; if anyone gets there first, the seam is: showTonight's rebuild path vs
|
||||
createSailView's disposal.
|
||||
|
||||
@ -97,8 +97,68 @@
|
||||
"than the 23-38 m2 rigs that survive unaided. a.test.js asserts the >45 m2 floor, so",
|
||||
"moving this inward goes red — that assert is the tension's only guard."
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "p5", "x": 5.5, "z": -2, "h": 2.6, "type": "post", "work": "cloth",
|
||||
"_why": [
|
||||
"SPRINT13 gate 1 — A's backyard ruling, the anchor C's diagnosis routed here. The old",
|
||||
"clothesline post: galvanised steel sunk in concrete by the back path, NE of the bed,",
|
||||
"shorter than the sail posts because it was never a sail post. Honest steel (ratingHint 1),",
|
||||
"8 m from the fascia, so the house trap keeps its whole lesson.",
|
||||
"",
|
||||
"WHY IT EXISTS, measured (probeA on C's garden_bench chain, wildnight, funnel-free yard):",
|
||||
"before it, the backyard could not beat TATTERED on the wild night at any price - every",
|
||||
"vertical-covering quad needs a house corner (fascia caps the best steel at 2.27 kN while",
|
||||
"those quads pull 3-7 kN -> tear in the settle), and the best HOLDING line p1..p4 ceilings",
|
||||
"at hp 61.3 through 25% cover. D played both halves through the real UI and confirmed to",
|
||||
"the decimal. Placement, not budget, not weather.",
|
||||
"",
|
||||
"WHY (5.5, -2) at h 2.6 and not somewhere kinder: the LOW top is the point - it twists the",
|
||||
"p1+p2+p3+p5 quad so it spills the gusts (small+twisted survives, DESIGN.md), and the",
|
||||
"position is swept against the $80 wallet:",
|
||||
" (4.5,-2) h2.6 -> p1 3.35 kN, needs RATED, line costs $90 DO NOT",
|
||||
" (5,-2.5) h2.8 -> p5 6.20, p1 3.75 -> $90 DO NOT",
|
||||
" (5.5,-2) h2.8 -> p2 3.08, p1 3.06 (4% margin on shackle) - legal but knife-edged",
|
||||
" (5.5,-2) h2.6 -> p5 4.88, p2 3.14, p1 2.91, p3 1.82 -> $75, hp 68.3 FULL <- here",
|
||||
" (6,-2) h2.6 -> shadow drops, hp 65.8 tattered DO NOT",
|
||||
"The separation block below pins the recipe; a.test flies it. Full cover STAYS unbuyable:",
|
||||
"the covering quads (p1+p2+p4+p5 and up) still pull 6.4-6.9 kN and still tear - the",
|
||||
"smallest full-cover quad stays >45 m2 (a.test's floor holds), so the tradeoff decision 2",
|
||||
"protects is intact. And the ICE night still refuses the $80 wallet: same line needs $105",
|
||||
"there (p1 3.25, p2 3.22 vs a shackle's 3.20 - the same few-hundredths miss as C's q3),",
|
||||
"so night 5 stays the night the bank pays for, exactly as ruled in SPRINT9."
|
||||
]
|
||||
} ],
|
||||
|
||||
"separation": {
|
||||
"_why": [
|
||||
"SPRINT13 gate 1.4 — A's ruling on C's proposed target, AMENDED to the numbers the real game",
|
||||
"measures (ruled on D's funnel-corrected picture, not C's original headline). C proposed:",
|
||||
"'on a wild night the best buyable line reads FULL (>66) and a bare bed reads DEAD (<33)'.",
|
||||
"Adopted in spirit, amended in both halves:",
|
||||
" - The bare half becomes 'a bare bed LOSES the night (hp < 50, the win line)'. Nowhere in",
|
||||
" the shipped game does a bare bed read DEAD on the wild night (35.7, tattered) — D's",
|
||||
" check — and tuning storm_02's damage budget until a slogan lands is exactly the fudge",
|
||||
" gate 1 exists to kill. The felt boundary is the WIN line, and 35.7 fails it honestly.",
|
||||
" - The FULL half binds THIS yard, because the shipped week's only wild night is here",
|
||||
" (week.js night 4). C's passing example was site_02, whose wild night the corner block",
|
||||
" never plays — and with its venturi live the example dies anyway (D's landmine).",
|
||||
"This block is the pin: the recipe below is the measured best $80 line, a.test flies it",
|
||||
"through the real chain (session -> commit -> attach -> skyfx) every run, and B's audit can",
|
||||
"read the same recipe instead of inventing one. A target nothing checks is decoration —",
|
||||
"the repo's oldest lesson, and the reason this is data and not a THREADS sentence."
|
||||
],
|
||||
"stormKey": "storm_02_wildnight",
|
||||
"tension": 1.0,
|
||||
"line": [
|
||||
{ "anchor": "p5", "hw": "rated shackle" },
|
||||
{ "anchor": "p1", "hw": "shackle" },
|
||||
{ "anchor": "p2", "hw": "shackle" },
|
||||
{ "anchor": "p3", "hw": "shackle" }
|
||||
],
|
||||
"heldMustExceed": 66,
|
||||
"bareMustLoseBelow": 50
|
||||
},
|
||||
|
||||
"shed": { "model": "shed_01_v1", "x": 11.8, "z": 6.2, "rotYDeg": -90 },
|
||||
"shedTable": { "model": "shed_table_v1", "x": 9, "z": 6, "rotYDeg": -90, "pickupNode": "pickup_anchor" },
|
||||
|
||||
103
web/world/js/garden.js
Normal file
103
web/world/js/garden.js
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* garden.js — the garden model, one shared copy. [Lane A]
|
||||
*
|
||||
* SPRINT13 gate 1.2 — B's export ask, granted. These were module-locals in
|
||||
* main.js, which made the audit's garden prediction impossible without copying
|
||||
* three constants "C is about to change this very sprint" (B's words) — the
|
||||
* exact drift sweep.js exists to prevent on the winnability side. Now main.js
|
||||
* and tools/site_audit read the SAME model, so "same number, two harnesses"
|
||||
* is structural rather than aspirational. B: the TEMPORARY COPY in
|
||||
* garden_probe.html can die now.
|
||||
*
|
||||
* Nothing here changed in the move — this is a mechanical lift. The one thing
|
||||
* that DID change this sprint is the comment on GARDEN_DRAIN, because the
|
||||
* gate-1 rulings resolved what it is for.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How fast an unprotected garden dies, in HP per second at full exposure.
|
||||
*
|
||||
* SPRINT13 gate 1.4 RULING: **this constant does not move.** My old comment
|
||||
* here said "the lever that actually needs moving is the shadow geometry, not
|
||||
* this" — C measured the geometry and it was never wrong (probe2: raycast and
|
||||
* rasteriser agree at 1.0000). What was wrong was the LINE the yard offered:
|
||||
* the canonical p1..p4 covers 25% of what hail sees, and no drain value can
|
||||
* separate a 25% line from a bare bed because the drain scales BOTH sides.
|
||||
* The separation now comes from anchor placement (backyard_01's p5, its
|
||||
* `separation` block, and the a.test assert that flies it), which is where it
|
||||
* always had to come from.
|
||||
*
|
||||
* What 0.9 actually owns is the week's bare-bed arc — the difficulty curve of
|
||||
* neglect, measured: gentle 97.6 · southerly 83.7 · buster 82.6 · wildnight
|
||||
* 35.7 · icenight 0.0. Move this and all five nights retune at once; the
|
||||
* wildnight bare bed must stay under the win line (< 50) for the pinned
|
||||
* separation target to mean anything, and the early nights must stay
|
||||
* survivable-by-default or night 1 stops being a tutorial. Retunes go through
|
||||
* THREADS with that table, not through this number quietly.
|
||||
*/
|
||||
export const GARDEN_DRAIN = 0.9;
|
||||
|
||||
/**
|
||||
* Decision 13's weights. Hail is what actually kills a garden and cloth honestly
|
||||
* stops it (stones fall ≤20° off vertical; rain leans 73° in a gale and walks
|
||||
* straight under the sail — Lane C's numbers). Rain is demoted to a drizzle of
|
||||
* damage so the night still costs you something when it isn't hailing.
|
||||
*
|
||||
* SPRINT6 gate 1 lists these first among the balance levers.
|
||||
*/
|
||||
export const HAIL_WEIGHT = 5.0;
|
||||
export const RAIN_WEIGHT = 0.25;
|
||||
|
||||
/**
|
||||
* The garden: the thing you are actually protecting, and the only score that
|
||||
* matters. Deliberately not inside hud.js — the HUD reads, it doesn't decide.
|
||||
*
|
||||
* It keeps its damage split by CAUSE, and that isn't bookkeeping for its own
|
||||
* sake: the aftermath verdict is the game's entire feedback channel, and a
|
||||
* verdict that guesses teaches the wrong lesson. A garden that only knows it
|
||||
* went from 100 to 39 cannot tell a player whether their hardware let go or
|
||||
* their perfectly-held sail simply wasn't over the bed — and those are opposite
|
||||
* mistakes with opposite fixes.
|
||||
*
|
||||
* @param {{ setPlants(state: string): void }} world
|
||||
*/
|
||||
export function createGarden(world) {
|
||||
let hp = 100;
|
||||
let state = 'full';
|
||||
let byHail = 0;
|
||||
let byRain = 0;
|
||||
|
||||
return {
|
||||
get hp() { return hp; },
|
||||
get state() { return state; },
|
||||
/** HP lost to each cause this run. The verdict reads these. */
|
||||
get damage() { return { hail: byHail, rain: byRain }; },
|
||||
|
||||
reset() { hp = 100; state = 'full'; byHail = 0; byRain = 0; world.setPlants('full'); },
|
||||
|
||||
/**
|
||||
* Decision 13, and the two terms stay SEPARATE on the way in rather than
|
||||
* being pre-summed by the caller — that sum is exactly the information the
|
||||
* verdict needs and can never recover afterwards.
|
||||
*
|
||||
* @param {number} dt
|
||||
* @param {number} hail 0..1 sky.gardenHailExposure(bed, t)
|
||||
* @param {number} rain 0..1 sky.gardenExposure(bed, t)
|
||||
*/
|
||||
step(dt, hail, rain) {
|
||||
const dHail = HAIL_WEIGHT * hail * GARDEN_DRAIN * dt;
|
||||
const dRain = RAIN_WEIGHT * rain * GARDEN_DRAIN * dt;
|
||||
const total = Math.min(hp, dHail + dRain);
|
||||
if (total > 0) {
|
||||
// Attribute proportionally, so the split still adds up on the last tick
|
||||
// when the garden bottoms out at 0 mid-step.
|
||||
const scale = total / (dHail + dRain);
|
||||
byHail += dHail * scale;
|
||||
byRain += dRain * scale;
|
||||
hp -= total;
|
||||
}
|
||||
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
|
||||
if (next !== state) { state = next; world.setPlants(next); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -26,6 +26,7 @@ import { createSkyFx } from './skyfx.js';
|
||||
import { createRiggingUI } from './rigging.js';
|
||||
import { createHud } from './hud.js';
|
||||
import { createWeek, NIGHTS, nightAt } from './week.js';
|
||||
import { createGarden } from './garden.js';
|
||||
|
||||
/** The calm day the forecast and prep phases run under. */
|
||||
export const CALM_STORM = 'storm_01_gentle';
|
||||
@ -148,90 +149,33 @@ export function canPlayHere(mm = typeof matchMedia === 'function' ? matchMedia :
|
||||
}
|
||||
|
||||
/**
|
||||
* How fast an unprotected garden dies, in HP per second at full rain.
|
||||
* Push the mute state at C's audio bus, honestly. Returns whether a bus took
|
||||
* it — false means the flag is UI-only on this tree and the HUD must not
|
||||
* advertise M (hud.setAudioMuteAvailable reads the same feature-detect).
|
||||
*
|
||||
* Decision 7: drain is rain × (1 − rain shadow), NOT sun coverage. At night the
|
||||
* sun shadow is a number about nothing, and storm_02 is a wildnight.
|
||||
* A value rather than a closure so the selftest can fail it (the Enter-guard
|
||||
* lesson): the wiring bug this pins is calling `sky.setMute?.()` and never
|
||||
* noticing it no-ops, or muting the flag and forgetting the bus after makeSky()
|
||||
* hands back a fresh skyfx at a phase change.
|
||||
*
|
||||
* ⚠️ This number is doing less work than it looks like it is, and the reason is
|
||||
* worth reading before retuning it. Measured over storm_02 with a rig that holds
|
||||
* 4/4 corners all night: sun coverage over the bed stays ~50%, but the RAIN
|
||||
* shadow decays 0.38 → 0.04 as the wind builds, averaging 0.23. Driving rain
|
||||
* blows under the sail and the shadow walks off the bed — which is Lane C's
|
||||
* model being right about weather, not a bug.
|
||||
*
|
||||
* The consequence is that a perfectly rigged bed only ever sits ~23% drier than
|
||||
* a bare one, so NO value here separates good rigging from none; at 1.6 both
|
||||
* ended dead, and the spread stays ~20 points at any setting. 0.9 is chosen to
|
||||
* put a good rig around 60% (a win) and a bare bed just under 50% (a loss), so
|
||||
* the loop is playable and scores something — but the lever that actually needs
|
||||
* moving is the shadow geometry, not this. Flagged for Lane C in THREADS.
|
||||
* @param {{ setMute?: (on: boolean) => void } | null} sky
|
||||
* @param {boolean} on
|
||||
* @returns {boolean} true iff a real bus received the state
|
||||
*/
|
||||
const GARDEN_DRAIN = 0.9;
|
||||
|
||||
/**
|
||||
* Decision 13's weights. Hail is what actually kills a garden and cloth honestly
|
||||
* stops it (stones fall ≤20° off vertical; rain leans 73° in a gale and walks
|
||||
* straight under the sail — Lane C's numbers). Rain is demoted to a drizzle of
|
||||
* damage so the night still costs you something when it isn't hailing.
|
||||
*
|
||||
* SPRINT6 gate 1 lists these first among the balance levers.
|
||||
*/
|
||||
const HAIL_WEIGHT = 5.0;
|
||||
const RAIN_WEIGHT = 0.25;
|
||||
|
||||
/**
|
||||
* The garden: the thing you are actually protecting, and the only score that
|
||||
* matters. Deliberately not inside hud.js — the HUD reads, it doesn't decide.
|
||||
*
|
||||
* It keeps its damage split by CAUSE, and that isn't bookkeeping for its own
|
||||
* sake: the aftermath verdict is the game's entire feedback channel, and a
|
||||
* verdict that guesses teaches the wrong lesson. A garden that only knows it
|
||||
* went from 100 to 39 cannot tell a player whether their hardware let go or
|
||||
* their perfectly-held sail simply wasn't over the bed — and those are opposite
|
||||
* mistakes with opposite fixes.
|
||||
*/
|
||||
function createGarden(world) {
|
||||
let hp = 100;
|
||||
let state = 'full';
|
||||
let byHail = 0;
|
||||
let byRain = 0;
|
||||
|
||||
return {
|
||||
get hp() { return hp; },
|
||||
get state() { return state; },
|
||||
/** HP lost to each cause this run. The verdict reads these. */
|
||||
get damage() { return { hail: byHail, rain: byRain }; },
|
||||
|
||||
reset() { hp = 100; state = 'full'; byHail = 0; byRain = 0; world.setPlants('full'); },
|
||||
|
||||
/**
|
||||
* Decision 13, and the two terms stay SEPARATE on the way in rather than
|
||||
* being pre-summed by the caller — that sum is exactly the information the
|
||||
* verdict needs and can never recover afterwards.
|
||||
*
|
||||
* @param {number} dt
|
||||
* @param {number} hail 0..1 sky.gardenHailExposure(bed, t)
|
||||
* @param {number} rain 0..1 sky.gardenExposure(bed, t)
|
||||
*/
|
||||
step(dt, hail, rain) {
|
||||
const dHail = HAIL_WEIGHT * hail * GARDEN_DRAIN * dt;
|
||||
const dRain = RAIN_WEIGHT * rain * GARDEN_DRAIN * dt;
|
||||
const total = Math.min(hp, dHail + dRain);
|
||||
if (total > 0) {
|
||||
// Attribute proportionally, so the split still adds up on the last tick
|
||||
// when the garden bottoms out at 0 mid-step.
|
||||
const scale = total / (dHail + dRain);
|
||||
byHail += dHail * scale;
|
||||
byRain += dRain * scale;
|
||||
hp -= total;
|
||||
}
|
||||
const next = hp > 66 ? 'full' : hp > 33 ? 'tattered' : 'dead';
|
||||
if (next !== state) { state = next; world.setPlants(next); }
|
||||
},
|
||||
};
|
||||
export function applyMute(sky, on) {
|
||||
const bus = typeof sky?.setMute === 'function';
|
||||
if (bus) sky.setMute(!!on);
|
||||
return bus;
|
||||
}
|
||||
|
||||
/*
|
||||
* The garden model — GARDEN_DRAIN, HAIL_WEIGHT, RAIN_WEIGHT, createGarden —
|
||||
* moved to garden.js (SPRINT13 gate 1.2, B's export ask): the audit must
|
||||
* predict the sim's garden off the sim's OWN code, and module-locals here made
|
||||
* that impossible without a drifting copy. The gate-1.4 ruling on GARDEN_DRAIN
|
||||
* (it does not move, and why) lives on the constant itself, over there.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Why the night went the way it did — read from what ACTUALLY happened.
|
||||
*
|
||||
@ -1052,27 +996,18 @@ export async function boot(opts = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* M — mute.
|
||||
*
|
||||
* ⚠️ The bus is Lane C's and it does not have a tap yet. `createAudio()` holds
|
||||
* a `master` gain inside skyfx.js's closure and the only thing it exposes is
|
||||
* `unlockAudio()`, so there is nothing here for main.js to turn down. Asked in
|
||||
* THREADS: `setMute(on)` on the skyfx API, one line against their master gain.
|
||||
*
|
||||
* Until it lands this returns FALSE, and the HUD asks before it advertises M
|
||||
* (see hud.setAudioMuteAvailable). That is deliberate and it is D's lesson from
|
||||
* the night-3 soft-lock: `rigging.setWorld?.(world)` sat in this same file for
|
||||
* a sprint doing NOTHING, silently, because `?.()` on a missing method is a
|
||||
* no-op that looks like a call. Shipping `sky.setMute?.(on)` behind a key the
|
||||
* splash promises would be the same bug with a keycap on it — on a public URL,
|
||||
* where the person pressing M is a stranger who just wants the noise to stop.
|
||||
* So the key is wired, the state is real, and the UI tells the truth about
|
||||
* whether it does anything. It lights up by itself the day C lands the tap.
|
||||
* M — mute. The tap is C's `sky.setMute(on)` (landed lane/c 8a3dc32, with the
|
||||
* pre-unlock case: M pressed on the splash before the first gesture builds
|
||||
* the audio graph is remembered and honoured by unlock()). The application is
|
||||
* `applyMute` — a value, so a.test can fail it — and the feature-detect stays
|
||||
* because on any tree where the tap is absent, `?.()` on a missing method is
|
||||
* a no-op that looks like a call (D's rigging.setWorld lesson). The HUD asks
|
||||
* before it advertises M (hud.setAudioMuteAvailable): the key lights up on
|
||||
* exactly the trees where it does something, and nowhere else.
|
||||
*/
|
||||
function setMuted(on) {
|
||||
muted = !!on;
|
||||
const bus = typeof sky?.setMute === 'function';
|
||||
if (bus) sky.setMute(muted);
|
||||
const bus = applyMute(sky, muted);
|
||||
hud.setMuted(muted);
|
||||
return bus;
|
||||
}
|
||||
|
||||
@ -4,15 +4,21 @@
|
||||
*/
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { ANCHOR_TYPE, FIXED_DT, PHASES, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
|
||||
import {
|
||||
ANCHOR_TYPE, FIXED_DT, HARDWARE, PHASES, START_BUDGET, STORM_LEN, YARD,
|
||||
checkContract, createStubWind,
|
||||
} from '../contracts.js';
|
||||
import { createWindField } from '../weather.core.js';
|
||||
import { createWorld, heightAt, loadSite, validateSite } from '../world.js';
|
||||
import { createCameraRig, spawnYawFor } from '../camera.js';
|
||||
import {
|
||||
CALM_STORM, accumulate, canPlayHere, createGame, createWindRouter, enterCommits,
|
||||
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
|
||||
stormsToPreload, verdictFor,
|
||||
} from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { createGarden } from '../garden.js';
|
||||
import { RiggingSession } from '../rigging.js';
|
||||
import { createSkyFx } from '../skyfx.js';
|
||||
import { SailRig, orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
|
||||
import { createHud } from '../hud.js';
|
||||
@ -131,6 +137,36 @@ export default async function run(t) {
|
||||
assertEq(canPlayHere(() => ({})), true, 'a browser that answers nothing: let them in');
|
||||
});
|
||||
|
||||
t.test('M reaches the real bus when there is one, and is not advertised when there is not', () => {
|
||||
// SPRINT13 gate 3.3, closed by C's sky.setMute (lane/c 8a3dc32). The wiring
|
||||
// bug this pins is the one D named on rigging.setWorld: `sky.setMute?.(on)`
|
||||
// on a tree without the tap is a no-op that looks like a call, so a key the
|
||||
// splash advertises can silently do nothing on a public URL. applyMute is
|
||||
// the application as a value; the HUD half asserts the advertisement tracks
|
||||
// the same feature-detect in BOTH directions.
|
||||
const calls = [];
|
||||
const bus = { setMute: (v) => calls.push(v) };
|
||||
assertEq(applyMute(bus, true), true, 'a real bus takes the state');
|
||||
assertEq(applyMute(bus, false), true, 'both directions');
|
||||
assertEq(calls.join(','), 'true,false', 'and receives booleans, in order — this is what M re-applies across makeSky()');
|
||||
assertEq(applyMute({}, true), false, 'no tap: says so instead of pretending');
|
||||
assertEq(applyMute(null, true), false, 'no sky at all (pre-boot): still honest');
|
||||
|
||||
if (typeof document === 'undefined') return;
|
||||
const hud = createHud({ scene: new THREE.Scene() });
|
||||
try {
|
||||
assertEq(hud.comfortKeysHint(), 'P pause', 'no bus: the help line does not promise M');
|
||||
hud.setAudioMuteAvailable(true);
|
||||
assertEq(hud.comfortKeysHint(), 'P pause · M mute', 'bus landed: M appears by itself');
|
||||
hud.showSplash(() => {});
|
||||
assert((document.querySelector('#hud-card')?.textContent ?? '').includes('P · M'),
|
||||
'and the splash card lists it');
|
||||
hud.hideCard();
|
||||
hud.setAudioMuteAvailable(false);
|
||||
assertEq(hud.comfortKeysHint(), 'P pause', 'and it can go dark again — the detect is live, not a latch');
|
||||
} finally { hud.dispose(); }
|
||||
});
|
||||
|
||||
t.test('the spawn frame points at the garden with no pole through the player', () => {
|
||||
// The QA pass: "the boot camera puts a pole dead-centre through the player on
|
||||
// every single first impression." Measured, not eyeballed — these are the
|
||||
@ -845,11 +881,12 @@ export default async function run(t) {
|
||||
|
||||
// --- anchors -------------------------------------------------------------
|
||||
|
||||
t.test('yard offers 12 anchors: 3 house, 5 tree, 4 post', () => {
|
||||
t.test('yard offers 13 anchors: 3 house, 5 tree, 5 post', () => {
|
||||
const by = (type) => world.anchors.filter((a) => a.type === type).length;
|
||||
assertEq(by('house'), 3, 'house anchors');
|
||||
assertEq(by('tree'), dressed ? 5 : 2, 'tree anchors (branch_anchor_* arrive with dress())');
|
||||
assertEq(by('post'), 4, 'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1)');
|
||||
assertEq(by('post'), 5,
|
||||
'post anchors — p3 (SPRINT3 dec 2), p4 (SPRINT6 gate 1), p5 the clothesline post (SPRINT13 gate 1)');
|
||||
const ids = world.anchors.map((a) => a.id);
|
||||
assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`);
|
||||
});
|
||||
@ -977,6 +1014,104 @@ export default async function run(t) {
|
||||
`supposed to cost you a sail the storm can take`);
|
||||
});
|
||||
|
||||
// --- SPRINT13 gate 1.4: THE PINNED SEPARATION TARGET ----------------------
|
||||
//
|
||||
// The ruling (THREADS [A], on D's funnel-corrected picture): on the shipped
|
||||
// week's wild night — night 4, THIS yard — the best line START_BUDGET can buy
|
||||
// must read FULL (>66) and WIN, and a bare bed must LOSE (<50). The recipe is
|
||||
// DATA (site.separation) so B's audit reads the same one, and this flight is
|
||||
// the pin: real shop at the real budget, real commit()->attach() (D's
|
||||
// skipped-attach landmine — session.commit(rig) IS the attach path, verified
|
||||
// by C's probe2 agreement), real skyfx exposure, the real garden model out of
|
||||
// garden.js. Flown OUTSIDE t.test() because Suite.test cannot await — the
|
||||
// two async-test ghosts above are the precedent.
|
||||
//
|
||||
// Sway is frozen (balance.test's reasoning): the recipe is all posts, whose
|
||||
// real sway is constant anyway, so frozen here means "identical to the game",
|
||||
// not "conveniently calm".
|
||||
const sep = site.separation;
|
||||
let sepRun = null;
|
||||
if (sep && typeof document !== 'undefined') {
|
||||
try {
|
||||
const frozen = 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, ratingHint: a.ratingHint };
|
||||
});
|
||||
const trees = frozen.filter((a) => a.type === 'tree');
|
||||
const stormDef = await loadStorm(sep.stormKey);
|
||||
const calmDef = await loadStorm(CALM_STORM);
|
||||
|
||||
const session = new RiggingSession({ anchors: frozen }); // the REAL budget
|
||||
const shopLog = [];
|
||||
for (const { anchor } of sep.line) {
|
||||
const r = session.rig(anchor);
|
||||
if (!r.ok) shopLog.push(`rig ${anchor}: ${r.reason}`);
|
||||
}
|
||||
for (const { anchor, hw } of sep.line) {
|
||||
const want = HARDWARE.find((h) => h.name === hw);
|
||||
if (!want) { shopLog.push(`no hardware named "${hw}"`); continue; }
|
||||
const r = session.setHardware(anchor, want);
|
||||
if (!r.ok) shopLog.push(`setHardware ${anchor} ${hw}: ${r.reason}`);
|
||||
}
|
||||
session.setTension(sep.tension);
|
||||
const spent = START_BUDGET - session.budget;
|
||||
const rig = session.commit(new SailRig({ anchors: frozen, gridN: 10 }));
|
||||
|
||||
const calmWind = createWind(calmDef);
|
||||
calmWind.setSheltersFromTrees(trees);
|
||||
let prepT = 0;
|
||||
for (let i = 0; i < Math.round(12 / FIXED_DT); i++) {
|
||||
rig.step(FIXED_DT, calmWind, prepT % 3); prepT += FIXED_DT;
|
||||
}
|
||||
|
||||
const flyNight = (sail) => {
|
||||
const wind = createWind(stormDef);
|
||||
wind.setSheltersFromTrees(trees);
|
||||
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 garden = createGarden({ setPlants() {} }); // the model main.js flies
|
||||
const bed = world.gardenBed;
|
||||
const steps = Math.round(stormDef.duration / FIXED_DT);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t2 = i * FIXED_DT;
|
||||
if (sail) sail.step(FIXED_DT, wind, t2);
|
||||
sky.step(FIXED_DT, t2, { sail });
|
||||
garden.step(FIXED_DT, sky.gardenHailExposure(bed, t2), sky.gardenExposure(bed, t2));
|
||||
}
|
||||
sky.dispose?.();
|
||||
return { hp: garden.hp, state: garden.state };
|
||||
};
|
||||
|
||||
const held = flyNight(rig);
|
||||
const lost = rig.corners.filter((c) => c.broken).length;
|
||||
const bare = flyNight(null);
|
||||
sepRun = { shopLog, spent, held, lost, bare };
|
||||
} catch (err) {
|
||||
sepRun = { err: String(err && err.stack || err) };
|
||||
}
|
||||
}
|
||||
|
||||
t.test('PINNED: the wild night separates — best buyable line FULL and winning, bare bed losing', () => {
|
||||
if (!sep) throw new Error('backyard_01 lost its separation block — the target is decoration again');
|
||||
if (typeof document === 'undefined') return t.skip('needs DOM (skyfx)');
|
||||
if (sepRun.err) throw new Error(`separation flight died: ${sepRun.err}`);
|
||||
assertEq(sepRun.shopLog.join('; '), '', 'the shop sold the whole recipe at the real budget');
|
||||
assert(sepRun.spent <= START_BUDGET, `recipe costs $${sepRun.spent} — must be buyable night 1`);
|
||||
assertEq(sepRun.lost, 0, 'the pinned line HOLDS the wild night — 0/4 lost');
|
||||
assert(sepRun.held.hp > sep.heldMustExceed,
|
||||
`held bed reads ${sepRun.held.hp.toFixed(1)} — the target is FULL (>${sep.heldMustExceed}). ` +
|
||||
`Measured 68.3 at landing; a red here means a retune moved the wild night and the ` +
|
||||
`separation ruling needs re-litigating in THREADS, not a quiet threshold bump.`);
|
||||
assertEq(sepRun.held.state, 'full', 'and the plants SAY full — the state the player sees');
|
||||
assertLess(sepRun.bare.hp, sep.bareMustLoseBelow,
|
||||
`bare bed must LOSE the night (<${sep.bareMustLoseBelow}) — measured 35.7 at landing`);
|
||||
// The felt spread, stated so a red prints the whole story:
|
||||
assert(sepRun.held.hp - sepRun.bare.hp > 25,
|
||||
`separation ${(sepRun.held.hp - sepRun.bare.hp).toFixed(1)} — rigging must matter by a full state, ` +
|
||||
`not a garden-bonus margin (measured 32.6 at landing)`);
|
||||
});
|
||||
|
||||
t.test('sway() returns an absolute position, not an offset', () => {
|
||||
// If sway ever regresses to returning an offset, the returned point lands
|
||||
// near the origin instead of near the anchor, and Lane B's cloth corners
|
||||
|
||||
Loading…
Reference in New Issue
Block a user