Compare commits

...

3 Commits

Author SHA1 Message Date
type-two
b5643922a4 THREADS: gate-1 rulings (separation target amended+pinned, p5, GARDEN_DRAIN stays), M live, garden.js export, phantom-sail filed 2026-07-18 12:46:08 +10:00
type-two
482803a480 Sprint 13 gate 1 RULINGS: p5 the clothesline post; the separation target, amended and PINNED
- backyard_01 gains p5 (5.5,-2) h2.6, honest steel NE of the bed: before it the
  yard could not beat tattered on the wild night at any price (covering quads
  need a fascia corner capped at 2.27 kN; the holding line ceilings at 61.3).
  Swept against the $80 wallet; position/height table in the site JSON.
- Separation target adopted AMENDED (ruled on D's funnel-corrected picture):
  wild night, best buyable line FULL (>66) and WIN; bare bed LOSES (<50).
  Pinned as site data (backyard_01.separation names the $75 recipe
  p5=rated + p1/p2/p3=shackle) and FLOWN by a.test through the real chain:
  shop at START_BUDGET -> commit -> attach -> skyfx -> garden.js. Measured
  68.3 FULL / 0 lost vs bare 35.7 (sep 32.6).
- Full cover stays unbuyable (covering quads still pull 6.4-6.9 kN), the
  >45 m2 floor holds, and the ice night still refuses $80 (needs $105 -
  p1/p2 miss their shackles by 0.05 kN) - night 5 stays bank-funded canon.
- New asserts: M reaches the real bus / is only advertised when real;
  anchor census now 13 (5 posts). Selftest 341/0/0.
2026-07-18 12:44:54 +10:00
type-two
7acee5437b Sprint 13: garden model exported as garden.js (B's gate-1.2 blocker) + applyMute(sky,on) as a value
- createGarden / GARDEN_DRAIN / HAIL_WEIGHT / RAIN_WEIGHT move from main.js
  module-locals to garden.js, unchanged — the audit must predict the sim off
  the sim's OWN code, not a drifting copy. GARDEN_DRAIN's comment now carries
  the gate-1.4 ruling: the constant does not move; it owns the bare-bed arc.
- M-mute application extracted to exported applyMute(sky, on): calls C's
  sky.setMute when the tap exists, reports honestly when it does not, so the
  selftest can fail the wiring instead of trusting a ?.() no-op.
2026-07-18 12:44:37 +10:00
5 changed files with 417 additions and 104 deletions

View File

@ -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 37 kN
pulls, or honest corners at 6.610 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.46.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.

View File

@ -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
View 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); }
},
};
}

View File

@ -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;
}

View File

@ -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