Merge remote-tracking branch 'origin/main'
# Conflicts: # THREADS.md # tools/blender/contact_sheet.png
This commit is contained in:
commit
6bab107ea4
@ -1110,3 +1110,96 @@ then A/B/C/E; D returns at the end for yard 4 + the cold play.
|
||||
> and judge everything as the playtester — does choosing feel like running a
|
||||
> business? Does a constraint read as a client or as a rule? Numbers and
|
||||
> feel, your standard. Push, THREADS, verdicts per gate.
|
||||
|
||||
# SPRINT 18 prompts (the book of clients, part 2: the night you didn't rig)
|
||||
|
||||
Same rules: own clone, own branch, `git fetch origin && git reset --hard
|
||||
origin/lane/<x>` on a clean tree (or merge origin/main — the integrator will
|
||||
say). Remote is monster/hardyards.git. Read THREADS' last two [I] entries
|
||||
(the S17 part-1 and part-2 integrations), then SPRINT18.md, then ROADMAP arc 2
|
||||
and DESIGN.md lines 29 / 165 / 179 (the emergency callout, release-cut-or-ride,
|
||||
the glasshouse). Ports: A 8824, B 8825, C 8826, D 8827, E 8828. Baseline
|
||||
**515/0/0**. FIRING ORDER: A's gate 0 ruling + gate 1 phase land first (the
|
||||
sprint's critical path); E runs independently from the start; B and C build
|
||||
against A's published shapes; D returns last for the knife and the cold play.
|
||||
|
||||
⚠️ Every lane: the one-variable law binds hard this sprint — the emergency
|
||||
callout and the glasshouse may NOT debut on the same night, however well
|
||||
DESIGN.md's own sentence pairs them.
|
||||
|
||||
## Lane A — Sprint 18
|
||||
> You are Lane A on HARD YARDS, Sprint 18. Rebase onto main, read THREADS' last
|
||||
> two [I] entries, SPRINT18.md, ROADMAP arc 2. Two jobs: (1) gate 0, FIRST and
|
||||
> FAST — ⚖️ John's ruling: DECLINING A BOOKED NIGHT COSTS YOU. You rule the
|
||||
> number and the memory (the stood-up client remembers), the cost prints on the
|
||||
> OFFER CARD before the choice and on the invoice after it, and the
|
||||
> two-callouts-no-booked-job board state reads as free. (2) Gate 1's spine: THE
|
||||
> EMERGENCY CALLOUT — a night that starts in storm, no prep, no quote, a
|
||||
> DISPATCH instead of a job sheet, emergency rates (yours to rule), the whole
|
||||
> existing ledger running unchanged. Publish the dispatch/emergency-night shapes
|
||||
> in THREADS EARLY — B, C and D all build against them. Enforce the
|
||||
> one-variable law in the ordering so a new job type and a new yard cannot debut
|
||||
> together by accident. Selftest green; mutation-check; look at every card you
|
||||
> touch. Shepherd as always.
|
||||
|
||||
## Lane B — Sprint 18
|
||||
> You are Lane B on HARD YARDS, Sprint 18. Rebase onto main, read THREADS' last
|
||||
> two [I] entries, SPRINT18.md. Three jobs, in this order: (1) ⚠️ GATE 2, which
|
||||
> outranks the features — D's cold play lost p2/p3 on the wildnight flying a
|
||||
> recipe gardenfly holds 0-lost. Two harnesses disagree: FIND THE VARIABLE
|
||||
> (D's candidate: hail impact load reaching the rig only in-game). Name it, pin
|
||||
> it, and rule which harness was lying. (2) Gate 3 — the $5-tier clean boundary:
|
||||
> D flew your audit's own $40 "clean" line and tr1b broke at 1.17/1.2 kN.
|
||||
> Re-rule the clean/marginal boundary at the cheap tier by measurement. (3) Gate
|
||||
> 1's half — THE RIG YOU DIDN'T BUILD: someone else's work as data (hardware
|
||||
> below the night, at least one corner already gone, rating hints telling the
|
||||
> truth), the economy of triage (paid on the callout plus what you SAVE — you
|
||||
> rule whether a garden bonus applies at all and write why), and the CUT the
|
||||
> knife needs: expose cut-away on the rig, D owns the verb. Build against A's
|
||||
> published shapes (fetch origin/lane/a mid-flight). Selftest green;
|
||||
> mutation-check; tables in THREADS.
|
||||
|
||||
## Lane C — Sprint 18
|
||||
> You are Lane C on HARD YARDS, Sprint 18. Rebase onto main, read THREADS' last
|
||||
> two [I] entries, SPRINT18.md. Two jobs: (1) gate 1's dispatch — THE CALLOUT
|
||||
> HAS NO FORECAST. You arrive mid-storm inside weather nobody briefed you on:
|
||||
> rule what an honest read looks like when no paper is coming (what the sky, the
|
||||
> grass and the gust front tell a tradie who just got out of the ute), and give
|
||||
> the dispatch its surface. Your honesty gate covers emergency storms like any
|
||||
> other — a storm that cannot be described honestly cannot be dispatched either.
|
||||
> (2) The mid-storm arrival is a NEW seam for your storm clock: an entry at
|
||||
> t≈30 must produce the same storm a t=0 run would have at t=30 (determinism at
|
||||
> a non-zero start is exactly the class of bug this repo hunts) — pin it. Build
|
||||
> against A's shapes; fetch origin/lane/a mid-flight. Optional if room: your own
|
||||
> polish, filed with reasons. Selftest green; disclose any assert that can't
|
||||
> fail.
|
||||
|
||||
## Lane E — Sprint 18 (independent — start immediately)
|
||||
> You are Lane E on HARD YARDS, Sprint 18. Rebase onto main, read THREADS' last
|
||||
> two [I] entries, SPRINT18.md gates 4 and 5. Two jobs, your standard: (1) THE
|
||||
> GLASSHOUSE TIER — DESIGN.md's maximum-fragility asset (line 179: highest pay
|
||||
> on the board, highest collateral, "your own failure is the worst debris of
|
||||
> all"). Fragility readable at a glance, a collateral price that makes the gnome
|
||||
> look like pocket change, a wreck state, manifest entry unambiguous or
|
||||
> refuse-to-guess with the why, negative controls red-then-green,
|
||||
> byte-identical factory runs, LOOK at the render. File the editor palette hunk
|
||||
> verbatim in THREADS (the _v1 suffix is load-bearing) and flag A the moment
|
||||
> it's placeable. (2) Gate 5 with A — the second-rust-post landmine D filed:
|
||||
> two corroded posts in one yard share the baked collateral key, so first-match
|
||||
> resolution plus envelope dedupe would bill or wreck the wrong post. Disarm it
|
||||
> before a yard author trips it.
|
||||
|
||||
## Lane D — Sprint 18 (last: the knife, then the cold play)
|
||||
> You are Lane D on HARD YARDS, Sprint 18, and you are last on purpose. Rebase
|
||||
> onto the integrated tree (the integrator will tell you, or main). Read THREADS
|
||||
> from the last two [I] entries, SPRINT18.md, DESIGN.md line 165. Two jobs:
|
||||
> (1) THE KNIFE — DESIGN.md's signature decision under load is "release, cut,
|
||||
> or ride it out", and the cut is yours: a deliberate cut-away that spends the
|
||||
> sail to save the steel and the collateral. Input, HUD read, and its line on
|
||||
> the paperwork, all in this sprint (principle 2). B exposes the cut on the rig;
|
||||
> you own the verb. (2) COLD-PLAY the week through the new board: at least one
|
||||
> emergency callout, at least one booked night DECLINED (feel the ruling's
|
||||
> price), and judge it as the playtester — does the knife feel like a decision
|
||||
> or a button? Does declining cost enough to hurt and little enough to be worth
|
||||
> it sometimes? Numbers and feel, your standard. Push, THREADS, verdicts per
|
||||
> gate.
|
||||
|
||||
124
SPRINT18.md
Normal file
124
SPRINT18.md
Normal file
@ -0,0 +1,124 @@
|
||||
# SPRINT 18 — THE BOOK OF CLIENTS, PART 2: THE NIGHT YOU DIDN'T RIG
|
||||
|
||||
*Part 1 shipped whole: the board offers two jobs a morning, client terms refuse
|
||||
in the client's own voice, the pool yard is in the game only by being chosen —
|
||||
515/0/0. D's cold play asked one question the board couldn't answer and **John
|
||||
has RULED it (2026-07-21): declining a booked night COSTS YOU. "It adds
|
||||
pressure."** This sprint lands that ruling and opens the arc's second half: job
|
||||
types that are not "rig for tonight". The headline is DESIGN.md's own line 29 —
|
||||
**the emergency callout: mid-storm, right now, double rates, someone's sail
|
||||
flogging itself to death over their glasshouse.***
|
||||
|
||||
Read THREADS from the last two `[I]` entries. Baseline **515/0/0**; keep it
|
||||
green; mutation-check anything you'd call coverage. Every arc ends live
|
||||
(ROADMAP principle 1) — main stays shippable.
|
||||
|
||||
⚠️ **THE ONE-VARIABLE LAW BINDS THIS SPRINT HARDER THAN ANY BEFORE IT.** We are
|
||||
shipping a new JOB TYPE (the emergency callout) and a new YARD (the glasshouse)
|
||||
in the same sprint, and DESIGN.md pairs them in one sentence. They may **not**
|
||||
debut on the same night. The emergency callout debuts over a yard the player has
|
||||
already rigged; the glasshouse debuts as an ordinary rig-for-tonight job. The
|
||||
sentence they were written for is the payoff, not the introduction — it belongs
|
||||
to a later week, and A's ordering must make that impossible to get wrong by
|
||||
accident.
|
||||
|
||||
## Gate 0 — ⚖️ THE RULING LANDS: DECLINING COSTS YOU (A owns; FIRST, fast)
|
||||
|
||||
John's ruling on D's filing. The board currently lets a fee-maximizer dodge the
|
||||
wildnight and the designed-loss icenight — the week's two hardest lessons — at
|
||||
zero cost. That is the hole. Close it:
|
||||
|
||||
1. **A booked night is a promise.** The "TONIGHT, AS BOOKED" offer has a client
|
||||
expecting you; the alternative callout does not. Taking the alternative
|
||||
stands the booked client up, and standing someone up costs **standing** —
|
||||
the currency the whole of arc 1 built for exactly this. A rules the number
|
||||
(the mirror of `REP.CLEAN` is the obvious candidate; it is yours to rule and
|
||||
to write down as measured-or-unmeasured per house rule).
|
||||
2. **The cost is on the CARD, before the choice.** No hidden tax — the offer
|
||||
card names what declining the booked job costs, in the client's terms
|
||||
("the Hendersons are expecting you"). A choice whose price is invisible
|
||||
until the invoice is a trap, and this repo doesn't ship traps without tells.
|
||||
3. **The stood-up client remembers.** Their next appearance on the board, or
|
||||
their next brief, carries it. Cheap to build (the job log already exists),
|
||||
and it is the whole point of "your work follows you".
|
||||
4. **The invoice states which job you took and what it cost you to take it** —
|
||||
both papers, same as gate 2 constraints in part 1.
|
||||
5. **Two callouts, no booked job** is a legal board state: then declining is
|
||||
free, and it must READ as free. Pin the asymmetry.
|
||||
|
||||
## Gate 1 — THE EMERGENCY CALLOUT (the arc's new job type; A spine · B rig · C dispatch · D verbs)
|
||||
|
||||
ROADMAP: *"you arrive MID-STORM at double rates to someone else's failing rig:
|
||||
triage, repair, cut-away. The storm phase already does everything this needs
|
||||
except spawn you at t=30 with a rig you didn't build."* Ship it end to end:
|
||||
|
||||
1. **A — the phase.** A night that starts in `storm`, not `prep`: no rigging
|
||||
session, no quote, a **dispatch** instead of a job sheet (there was no time
|
||||
to quote — that's the drama). Emergency rates (A rules the multiplier), and
|
||||
the ledger runs unchanged: invoice, rep, warranty, all of it.
|
||||
2. **B — the rig you didn't build.** Someone else's work, authored as data:
|
||||
hardware below what the night needs, at least one corner already gone, the
|
||||
rating hints telling the truth about why. The economy of **triage**: paid on
|
||||
the callout plus what you SAVE, not on a garden you never had a prep phase
|
||||
to protect. B rules whether the garden bonus applies at all and writes why.
|
||||
3. **D — the verbs, and DESIGN.md's signature decision: release, cut, or ride
|
||||
it out.** The knife is canon (line 165: *cut a sail loose — lose the sail,
|
||||
save the anchors*). Ship the knife: a deliberate cut-away that spends the
|
||||
sail to save the steel and the collateral. It needs its input, its HUD read,
|
||||
and its line on the paperwork in this sprint (principle 2, no exceptions).
|
||||
Seam with B: B exposes the cut on the rig, D owns the verb and the input.
|
||||
4. **C — the dispatch has no forecast.** You arrive inside weather nobody
|
||||
briefed you on. C rules what an honest mid-storm read looks like (what the
|
||||
sky and the grass tell you when no paper does), and the honesty gate covers
|
||||
emergency storms like any other.
|
||||
5. **Publish the shapes EARLY in THREADS** (the seam pattern — it is now the
|
||||
thing this repo does best: five lanes, one code conflict in two sprints).
|
||||
|
||||
## Gate 2 — ⚠️ THE TWO HARNESSES DISAGREE (B owns; the repo's highest-priority class)
|
||||
|
||||
D's cold play lost p2 and p3 on the wildnight flying a recipe `gardenfly` holds
|
||||
0-lost. **"When two harnesses disagree, find the variable"** is a README rule
|
||||
and it has never once been wrong. D's candidate: hail impact load reaching the
|
||||
rig only in-game. Find it, name it, pin it. If the played path is right, the
|
||||
audit has been selling recipes it can't back — say so in the card. If the audit
|
||||
is right, D's play found a sim bug. Either way this outranks new features.
|
||||
|
||||
## Gate 3 — THE $5-TIER CLEAN BOUNDARY (B owns)
|
||||
|
||||
D flew the audit's own $40 "clean" line on N3 and `tr1b`'s carabiner broke at
|
||||
1.17 / 1.2 kN. A card that calls a line clean and then watches it break is the
|
||||
card lying — the same defect class as part 1's charter silence, which B fixed
|
||||
well. Re-rule the clean/marginal boundary at the cheap tier with measurement,
|
||||
not margin-of-taste.
|
||||
|
||||
## Gate 4 — THE GLASSHOUSE TIER (E owns; DESIGN.md's maximum-fragility asset)
|
||||
|
||||
*"Highest pay on the board, highest collateral."* Supply glass as a first-class
|
||||
fact your family of honesty rules covers: a glasshouse structure whose
|
||||
fragility is **readable at a glance** (a tell for "this is the most expensive
|
||||
thing you can break in this game"), a collateral price that makes the gnome
|
||||
look like pocket change, and a wreck state — your own failure becoming the
|
||||
worst debris of all is the whole point. Manifest entry unambiguous or
|
||||
refuse-to-guess with the why, as always. It is the emergency callout's eventual
|
||||
stage, but per the one-variable law it debuts as an ordinary night — flag A the
|
||||
moment it is placeable.
|
||||
|
||||
## Gate 5 — THE SECOND-RUST-POST LANDMINE (E + A; D's filing, small)
|
||||
|
||||
Two corroded posts in one yard share the baked `collateral: 'corroded_post'`
|
||||
key; first-match resolution plus envelope dedupe would bill or wreck the wrong
|
||||
post. The pool yard ships exactly ONE and pins it, so this is armed, not
|
||||
firing. Disarm it before a yard author trips it.
|
||||
|
||||
## Gate 6 — JOHN PLAYS (live URL, the standing gate)
|
||||
|
||||
partly.party/hardyards after integration. The week where saying no costs you,
|
||||
and one night you arrive at somebody else's disaster with a knife. Three
|
||||
sentences in THREADS — and this sprint they steer arc 2's close: the cowboy
|
||||
audit and warranty-as-a-night are the two job types still unbuilt, and yards
|
||||
5–6 (the granny-flat alley, the glasshouse) are waiting on an author.
|
||||
|
||||
**Deploy note carried from part 1:** main is one sprint ahead of live —
|
||||
`tools/deploy_hardyards.sh` needs John's hand or his green light (the
|
||||
integrator's session was permission-gated). The rails say the gap never exceeds
|
||||
one sprint; part 2 must not ship before part 1 is up.
|
||||
1910
THREADS.md
1910
THREADS.md
File diff suppressed because it is too large
Load Diff
@ -817,6 +817,73 @@
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "hail_net_frame_01",
|
||||
"dims": [
|
||||
3.42,
|
||||
3.02,
|
||||
2.766
|
||||
],
|
||||
"tris": 328,
|
||||
"nodes": [
|
||||
"footings",
|
||||
"hail_net_frame_01",
|
||||
"legs",
|
||||
"net_anchor_01",
|
||||
"net_anchor_02",
|
||||
"net_anchor_03",
|
||||
"net_anchor_04",
|
||||
"net_roll",
|
||||
"top_frame"
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"node": "net_anchor_01",
|
||||
"anchor_type": "hail_net_frame",
|
||||
"rating_hint": 0.8,
|
||||
"collateral": "glasshouse"
|
||||
},
|
||||
{
|
||||
"node": "net_anchor_02",
|
||||
"anchor_type": "hail_net_frame",
|
||||
"rating_hint": 0.8,
|
||||
"collateral": "glasshouse"
|
||||
},
|
||||
{
|
||||
"node": "net_anchor_03",
|
||||
"anchor_type": "hail_net_frame",
|
||||
"rating_hint": 0.8,
|
||||
"collateral": "glasshouse"
|
||||
},
|
||||
{
|
||||
"node": "net_anchor_04",
|
||||
"anchor_type": "hail_net_frame",
|
||||
"rating_hint": 0.8,
|
||||
"collateral": "glasshouse"
|
||||
}
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "hail_net_frame_01_wrecked",
|
||||
"dims": [
|
||||
4.3007,
|
||||
3.02,
|
||||
2.7832
|
||||
],
|
||||
"tris": 480,
|
||||
"nodes": [
|
||||
"footings",
|
||||
"hail_net_frame_01_wrecked",
|
||||
"legs",
|
||||
"net_down",
|
||||
"top_frame"
|
||||
],
|
||||
"anchors": [],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "pool_kit_01",
|
||||
"dims": [
|
||||
|
||||
@ -20,6 +20,25 @@ Outputs (resolved from this file, so no absolute home paths):
|
||||
tools/blender/contact_sheet.png verification render vs the 1.7 m capsule
|
||||
tools/blender/asset_report.json measured dims / tris / node names
|
||||
|
||||
⚠️ BLENDER VERSION IS PART OF THE OUTPUT (SPRINT18, E — measured, not guessed).
|
||||
The shipped GLBs were exported by Blender **5.1.2** (glTF I/O v5.1.20), which
|
||||
`asset_report.json` records in its `blender` field — check it before you
|
||||
build. On Blender 5.0.1 (I/O v5.0.21) this script is still perfectly
|
||||
deterministic — three consecutive full runs gave 56/56 byte-identical
|
||||
artifacts — but its output DIFFERS from the shipped files in two ways, and
|
||||
both are cosmetic:
|
||||
· GLB accessor min/max drift in the 7th decimal (~6e-8 m, i.e. 60 nm).
|
||||
Node graph, mesh topology, materials and every custom prop are
|
||||
bit-identical; only float formatting of the bounds moves.
|
||||
· The generated PNGs lose their sRGB/gAMA/cHRM chunks — 73 bytes per file.
|
||||
IDAT pixel data decompresses bit-identically; the newer exporter simply
|
||||
writes colour-space chunks that 5.0.x omits.
|
||||
So a full run on the wrong Blender rewrites all 45 binaries with no change
|
||||
anybody asked for, and quietly DROPS the colour-space chunks. If your `git
|
||||
status` shows every GLB and texture modified after adding one asset, that is
|
||||
this and not your work: `git checkout --` the ones you did not author, and
|
||||
commit only the new files. Better: build on 5.1.2 and the churn is zero.
|
||||
|
||||
Idiom follows ~/Documents/Destroyulater/3D-STORE/racks_to_glb.py:
|
||||
reset_to_empty() per asset -> build under a root empty AT THE ORIGIN ->
|
||||
join by group -> stamp custom props -> export_scene.gltf(export_yup=True,
|
||||
@ -150,6 +169,23 @@ PAL = {
|
||||
# a pool is the one water in the game that is
|
||||
# supposed to look inviting
|
||||
"deck_board": "#A8845C", # decking oil, a shade lighter than fence timber
|
||||
"net_frame_white": "#E6E7E1", # SPRINT18 gate 4 — the hail-net frame's
|
||||
# powder-coat, and the BRIGHTEST entry in this
|
||||
# palette on purpose. The reason is metalness,
|
||||
# not lightness — S17's measured lesson applied
|
||||
# deliberately this time. Galvanised ships at
|
||||
# metallic 0.90, and with no environment map a
|
||||
# near-mirror has nothing to mirror, so every
|
||||
# steel you are ALLOWED to trust renders
|
||||
# near-black at yard distance (measured: rgb
|
||||
# 49,51,49 at 20 m). Powder-coat is paint —
|
||||
# metallic 0.05, diffuse — so this frame reads
|
||||
# as a white cage standing over the glass from
|
||||
# the far fence, and can never be misread as
|
||||
# the galvanised steel of an honest sail post.
|
||||
"net_mesh": "#3A4340", # knitted HDPE hail mesh: dark, matte, and the
|
||||
# only large dark mass any yard carries up at
|
||||
# 2.7 m. Rolled it is a bar; down it is a heap.
|
||||
}
|
||||
|
||||
# Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it
|
||||
@ -1953,6 +1989,283 @@ def build_glasshouse_01_wrecked(name):
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE HAIL-NET FRAME (SPRINT18 gate 4 — the glasshouse tier's LIVE half)
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPRINT16 shipped the glasshouse and priced it at 320. SPRINT18 read the gate
|
||||
# and found the thing the price was waiting on, written in e.test's own words:
|
||||
# "nothing in the sim wrecks a glasshouse yet — the value is inert data". The
|
||||
# apex could not be reached. main.js bills collateral off a BROKEN RIG CORNER
|
||||
# (`world.anchor(c.anchorId).collateral`), and a glasshouse deliberately offers
|
||||
# no anchors — correctly, it is the one rule pinned from every direction — so
|
||||
# the most expensive object in the game could not cost anybody a cent. That is
|
||||
# the free-failure bug inverted: not a hazard billing nothing by accident, but
|
||||
# a hazard that cannot bill at all by construction.
|
||||
#
|
||||
# DESIGN.md line 179 already named the missing asset: "Jobs: hail nets and mesh
|
||||
# sails rigged OVER glass, where your own failure is the worst debris of all."
|
||||
# You do not tie to the glasshouse. You tie to the frame that STRADDLES it —
|
||||
# and every corner of that frame is a corner whose failure drops steel, cloth
|
||||
# and hardware through fourteen panes.
|
||||
#
|
||||
# So this is the tier, and its teaching is new. Every other trap in this game
|
||||
# punishes you with a bad number: the carport beam at 0.22, the fascia at 0.35,
|
||||
# the corroded post at 0.55 whose real capacity you cannot read. This one is
|
||||
# GOOD hardware — purpose-built, plumb, on its own footings, factory eyes —
|
||||
# and it is the most dangerous anchor in the game because of WHERE IT STANDS.
|
||||
# The rating is not the hazard. The position is.
|
||||
NET_FRAME = dict(
|
||||
CLEAR_X=0.30, # portal stands this far clear of each gable end
|
||||
CLEAR_Y=0.45, # legs stand this far outside the long walls, on soil
|
||||
HEAD_H=2.75, # head-beam height. MUST clear the ridge (2.28) — a frame
|
||||
# that fouled the glass it protects is a joke, and
|
||||
# e.test measures the clearance off the two shipped GLBs.
|
||||
TUBE=0.032, # 32 mm net-frame section
|
||||
PAD=0.22, # footing pad
|
||||
)
|
||||
|
||||
# The rung. PROPOSAL — Lane A rules the number, same as every rung before it.
|
||||
#
|
||||
# Ladder as shipped: 0.22 carport beam < 0.30 carport post < 0.35 house fascia
|
||||
# < 0.45 swing frame < 0.55 corroded post < 0.65 pergola < 1.00 sail post.
|
||||
# This goes at 0.80, and the argument has two halves:
|
||||
#
|
||||
# · WHY IT OUT-RATES THE PERGOLA (0.65) BY A DISTANCE. The pergola is a deck
|
||||
# structure screwed to a house and it flexes; the corroded post's capacity
|
||||
# is unknowable; the swing frame stands on grass. This is an engineered
|
||||
# portal frame on four real footings with welded eyes, and it is the only
|
||||
# structure in the palette that was BUILT to be tied to. Rating it near the
|
||||
# bad steel would be the lie the corroded post exists to punish, inverted:
|
||||
# honest steel de-rated for looking dangerous.
|
||||
# · WHY IT IS NOT THE SAIL POST (1.00). The section is 32 mm sized for hail
|
||||
# mesh — a couple of hundred grams per square metre, and near-zero drag
|
||||
# because mesh SPILLS wind rather than catching it. A 25 m² impermeable
|
||||
# shade sail in a southerly is a load one order of magnitude larger. 0.80
|
||||
# is a 20% de-rate for "engineered for a load you are about to exceed".
|
||||
#
|
||||
# And 0.80 is chosen so that IT USUALLY HOLDS. That is the point of the tier,
|
||||
# not a softness in it: a corner that fails one night in five costs you nothing
|
||||
# on a sail post and 320 dollars here. The player who learns "0.80 is fine" on
|
||||
# every other yard is exactly the player this yard is for. The lesson is not
|
||||
# "don't trust this steel" — the steel is fine. It is "the same failure has a
|
||||
# different price depending on what is underneath it", which is the sentence
|
||||
# DESIGN.md line 179 has been waiting for an asset to say.
|
||||
NET_FRAME_RATING = 0.80
|
||||
|
||||
|
||||
def _net_frame(name, root, steel, conc, mesh_m, *, wrecked=False):
|
||||
"""The portal frame, shared by intact and wreck so the two cannot drift.
|
||||
|
||||
The wreck fails at the -x portal ONLY: its feet kick out of their pads and
|
||||
its head beam comes down onto the glasshouse ridge. +x stands, holding the
|
||||
eave rails up, which is why the wreck keeps the intact height — a hail-net
|
||||
frame does not fall over, it folds at one end, and the end it folds is the
|
||||
end that empties the glass."""
|
||||
G, N = GLASSHOUSE, NET_FRAME
|
||||
hx = G["W"] / 2 + N["CLEAR_X"] # 1.60 — clear of the gable ends
|
||||
hy = G["D"] / 2 + N["CLEAR_Y"] # 1.40 — clear of the long walls
|
||||
H, r, pad_h = N["HEAD_H"], N["TUBE"] / 2, 0.06
|
||||
|
||||
def foot(sx, sy):
|
||||
"""Where the leg meets the ground. Wrecked -x feet are OUT of the pad —
|
||||
a foot beside its own footing is the most legible steel failure there
|
||||
is, and it costs one offset to say."""
|
||||
if wrecked and sx < 0:
|
||||
return (sx * hx - 0.45, sy * hy, pad_h + (0.14 if sy > 0 else 0.0))
|
||||
return (sx * hx, sy * hy, pad_h)
|
||||
|
||||
def head(sx, sy):
|
||||
"""Where the leg meets the top frame. The wrecked -x portal has come
|
||||
down and is RESTING ON THE RIDGE (2.28) rather than passing through it:
|
||||
the glasshouse's own wreck keeps its aluminium frame standing at full
|
||||
height, so a beam that ended up below the ridge line would be inside
|
||||
it. Lying ACROSS the ridge is both true and non-interpenetrating.
|
||||
|
||||
The head comes IN (+x, dragged by the sail) while the feet kick OUT
|
||||
(-x) — the portal RACKS into a parallelogram, the carport's failure
|
||||
mode on a lighter section. The offsets are deliberately large (0.45
|
||||
each way, ~22° of lean over 2.3 m) because I looked at 0.30/0.16 in
|
||||
the render and the top frame still read as a clean level rectangle:
|
||||
a 0.43 m drop on a 2.77 m frame of 32 mm tube is invisible in
|
||||
perspective, and a wreck you cannot see is not a wreck."""
|
||||
if wrecked and sx < 0:
|
||||
return (sx * hx + 0.45, sy * hy * 0.90, G["RIDGE_H"] + 0.05)
|
||||
return (sx * hx, sy * hy, H)
|
||||
|
||||
def mid(sx):
|
||||
a, b = head(sx, -1), head(sx, 1)
|
||||
return ((a[0] + b[0]) / 2, 0.0, (a[2] + b[2]) / 2)
|
||||
|
||||
pads, legs = [], []
|
||||
for sx in (-1, 1):
|
||||
for sy in (-1, 1):
|
||||
pads.append(add_box(f"{name}_pad_{sx}_{sy}", (N["PAD"], N["PAD"], pad_h),
|
||||
(sx * hx, sy * hy, pad_h / 2), conc))
|
||||
legs.append(add_tube_between(f"{name}_leg_{sx}_{sy}",
|
||||
foot(sx, sy), head(sx, sy), r, steel, verts=8))
|
||||
join_group(pads, "footings", root)
|
||||
join_group(legs, "legs", root)
|
||||
|
||||
top = []
|
||||
for sx in (-1, 1): # portal head beams, spanning y
|
||||
top.append(add_tube_between(f"{name}_head_{sx}", head(sx, -1), head(sx, 1),
|
||||
r, steel, verts=8))
|
||||
for sy in (-1, 1): # eave rails, spanning x
|
||||
top.append(add_tube_between(f"{name}_eave_{sy}", head(-1, sy), head(1, sy),
|
||||
r, steel, verts=8))
|
||||
# The tensioning wire down the middle, deliberately thinner than the frame:
|
||||
# at 20 m the gauge difference is the "made for a net" read, and it is the
|
||||
# honest reason the whole thing rates 0.80 instead of 1.00.
|
||||
top.append(add_tube_between(f"{name}_ridge_line", mid(-1), mid(1),
|
||||
r * 0.35, steel, verts=6))
|
||||
join_group(top, "top_frame", root)
|
||||
|
||||
if not wrecked:
|
||||
# Rolled along the +y eave, because the JOB is to rig it. A deployed
|
||||
# net would be a finished yard, and this asset exists to be worked on.
|
||||
join_group([add_tube_between(f"{name}_roll", (-hx, hy, H - 0.11),
|
||||
(hx, hy, H - 0.11), 0.085, mesh_m, verts=10)],
|
||||
"net_roll", root)
|
||||
else:
|
||||
# YOUR debris, not the storm's. The net has come off its lashings at the
|
||||
# collapsed end and is draped down over the glass and heaped out past
|
||||
# the footing — deterministic scatter, so the same wreck twice.
|
||||
rng = rng_for(name)
|
||||
drape = []
|
||||
pts = [(hx * 0.85, hy, H - 0.10),
|
||||
(hx * 0.10, hy * 0.92, H - 0.78),
|
||||
(-hx * 0.55, hy * 0.80, G["RIDGE_H"] * 0.55),
|
||||
(-hx - 0.28, hy * 0.55, 0.24),
|
||||
(-hx - 0.80, hy * 0.20, 0.06)]
|
||||
for i in range(len(pts) - 1):
|
||||
drape.append(add_tube_between(f"{name}_net_sag_{i}", pts[i], pts[i + 1],
|
||||
0.055, mesh_m, verts=6))
|
||||
for i in range(9):
|
||||
a = rng.uniform(0, math.tau)
|
||||
rr = rng.uniform(0.05, 0.45)
|
||||
drape.append(add_box(
|
||||
f"{name}_net_heap_{i}",
|
||||
(rng.uniform(0.22, 0.50), rng.uniform(0.16, 0.40), rng.uniform(0.03, 0.09)),
|
||||
(-hx - 0.58 + math.cos(a) * rr, hy * 0.20 + math.sin(a) * rr * 0.8,
|
||||
rng.uniform(0.02, 0.07)),
|
||||
mesh_m, rot=(0, 0, rng.uniform(0, math.pi))))
|
||||
join_group(drape, "net_down", root)
|
||||
|
||||
return hx, hy
|
||||
|
||||
|
||||
def build_hail_net_frame_01(name):
|
||||
"""The hail-net frame — four good anchors in the worst place in the game.
|
||||
|
||||
The glasshouse tier's live half (SPRINT18 gate 4). This is what DESIGN.md
|
||||
line 179 means by "hail nets and mesh sails rigged OVER glass": a portal
|
||||
frame that straddles the glasshouse, clear of it on all four sides, with a
|
||||
head height that clears the ridge and a rolled mesh net waiting to go up.
|
||||
|
||||
THE TELL, and it is a position rather than a defect. Every reading of every
|
||||
other trap in this game is a bad number on the object. This object's numbers
|
||||
are GOOD — 0.80, plumb, four footings, welded eyes — and what the player has
|
||||
to read instead is the plan view: the frame BRACKETS the glass. e.test pins
|
||||
that geometrically off the two shipped GLBs (frame footprint strictly
|
||||
contains the glasshouse footprint; head height strictly clears the ridge),
|
||||
because "it stands over the glass" has to be a measurement, not a vibe.
|
||||
The 20 m read is the powder-coat: white, matte, metallic 0.05, against a
|
||||
palette where every trustworthy steel is metallic 0.90 and therefore renders
|
||||
near-black. A white cage over a glass box, visible from the far fence.
|
||||
|
||||
WHAT THE ANCHORS SAY. All four bake collateral="glasshouse". They are the
|
||||
only nodes in the palette whose collateral names a DIFFERENT structure than
|
||||
the one they sit on (the house's fascia→"gutter" is the precedent, and it is
|
||||
the same shape: the anchor names what its failure TAKES, not what it is).
|
||||
Break a corner here and the sail, the shackle and the turnbuckle go through
|
||||
fourteen panes: your own failure is the worst debris of all, wired as data.
|
||||
|
||||
AND WHAT THIS ROOT DELIBERATELY DOES NOT CARRY: collateral_key. See below —
|
||||
it is the whole of gate 5 in one omission.
|
||||
"""
|
||||
root = add_empty(name)
|
||||
# Powder-coat is PAINT, so metallic stays near zero. This is not a style
|
||||
# choice, it is the S17 measurement: high metalness with no environment map
|
||||
# renders near-black, and this asset's entire long-range tell is that it is
|
||||
# the brightest thing in the yard.
|
||||
steel = get_material("Mat_NetFrame", PAL["net_frame_white"], 0.50, metallic=0.05)
|
||||
mesh_m = get_material("Mat_HailNet", PAL["net_mesh"], 0.85, metallic=0.0)
|
||||
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
hx, hy = _net_frame(name, root, steel, conc, mesh_m)
|
||||
|
||||
# The four eyes, at the four corners of the top frame — where a net corner
|
||||
# ties, and therefore where a sail corner will be tied instead.
|
||||
for i, (sx, sy) in enumerate(((-1, -1), (1, -1), (1, 1), (-1, 1))):
|
||||
e = add_empty(f"net_anchor_{i + 1:02d}",
|
||||
(sx * hx, sy * hy, NET_FRAME["HEAD_H"] - 0.06), root, size=0.18)
|
||||
e["anchor_type"] = "hail_net_frame"
|
||||
e["rating_hint"] = NET_FRAME_RATING
|
||||
e["collateral"] = "glasshouse"
|
||||
e["why"] = ("sound steel, and the most expensive corner on the board: it is "
|
||||
"rated for hail mesh, and everything it drops lands on glass")
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["site_hint"] = "glasshouse"
|
||||
root["is_anchor_trap"] = True
|
||||
root["rated_for"] = ("knitted hail mesh, ~0.2 kg/m2, near-zero drag — mesh spills "
|
||||
"wind. A 25 m2 impermeable sail is an order of magnitude more.")
|
||||
# ⚠️ NO collateral_key ON THIS ROOT, AND THAT IS THE POINT (gate 5).
|
||||
#
|
||||
# world.js resolves a collateral key to a structure with structFor(), which
|
||||
# returns the FIRST structure in site order whose key matches. If this frame
|
||||
# claimed collateral_key="glasshouse" — which looks like the helpful thing
|
||||
# to do, since its anchors bill that key — then in a yard where the frame is
|
||||
# authored before the glasshouse, every failure would bill "the glasshouse"
|
||||
# for 320 dollars and then WRECK THE FRAME: the player watches their own
|
||||
# frame fold while the invoice charges them for panes that are still in
|
||||
# their rebates. The bill and the wreck would agree with each other and
|
||||
# both be wrong, which is the only kind of wrong this repo cannot see.
|
||||
#
|
||||
# So exactly ONE structure owns a key, and it is the one that owns the
|
||||
# price. This frame POINTS at the key and never claims it. The pointer
|
||||
# lives in a field nothing resolves, so it can document intent without
|
||||
# arming anything.
|
||||
root["collateral_target"] = "glasshouse"
|
||||
root["rigs_over"] = "glasshouse_01"
|
||||
return root
|
||||
|
||||
|
||||
def build_hail_net_frame_01_wrecked(name):
|
||||
"""The frame after the corner you trusted let go.
|
||||
|
||||
The -x portal has folded: both its feet are out of their pads, and its head
|
||||
beam is lying across the glasshouse ridge with the net draped down over the
|
||||
glass and heaped out past the footing. The +x portal stands, holding the
|
||||
eave rails at full height — so this wreck is the same height as the intact
|
||||
frame, which is the honest read: a portal frame folds at one end, it does
|
||||
not lie down.
|
||||
|
||||
It carries NO GLASS. The glasshouse has its own wreck variant and swaps
|
||||
independently; a net-frame wreck that shipped shards would draw them twice
|
||||
the moment both swapped on the same night, and would also quietly claim
|
||||
the glass's failure as its own. e.test pins the absence.
|
||||
|
||||
UNPRICED, BY RULING — the bike/jacaranda pattern. A folded portal frame is
|
||||
obviously a repair bill, but the player is being taught about the glass, and
|
||||
one broken corner can only name one collateral key. Inventing a second bill
|
||||
with no mechanism to split it between them is exactly the guess the invoice
|
||||
exists to refuse. Price it when a break can bill two things."""
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_NetFrame", PAL["net_frame_white"], 0.50, metallic=0.05)
|
||||
mesh_m = get_material("Mat_HailNet", PAL["net_mesh"], 0.85, metallic=0.0)
|
||||
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
_net_frame(name, root, steel, conc, mesh_m, wrecked=True)
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "hail_net_frame_01"
|
||||
# No anchor survives the wreck — the fascia rule, sixth application. An
|
||||
# anchor that outlives its structure is the free-failure bug in a costume.
|
||||
root["collateral_target"] = "glasshouse"
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE POOL KIT (SPRINT16 gate 5 — the compliant ring whose steel all says no)
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -3968,6 +4281,25 @@ ASSETS = [
|
||||
dict(name="glasshouse_01_wrecked", fn=build_glasshouse_01_wrecked,
|
||||
dims=((3.4, 5.4), (2.6, 3.8), (2.15, 2.40)),
|
||||
nodes=["frame", "door", "panes_left", "shards"]),
|
||||
# SPRINT18 gate 4 — the glasshouse tier's live half. Every band here is a
|
||||
# design claim about the glass underneath it, which is why they are tight:
|
||||
# x and y floors must both EXCEED the glasshouse's ceilings (2.8 / 2.1), or
|
||||
# the frame has stopped straddling the thing it bills; z must clear the
|
||||
# ridge (2.40 ceiling) or a frame built to protect glass is resting on it.
|
||||
dict(name="hail_net_frame_01", fn=build_hail_net_frame_01,
|
||||
dims=((3.30, 3.55), (2.90, 3.15), (2.70, 2.85)),
|
||||
nodes=["footings", "legs", "top_frame", "net_roll",
|
||||
"net_anchor_01", "net_anchor_02", "net_anchor_03", "net_anchor_04"]),
|
||||
# Folded at one end, not felled: the SAME z band as the intact frame,
|
||||
# because the +x portal stands and holds the eave rails up. If z ever
|
||||
# collapses, someone has laid a portal frame down flat and it never does
|
||||
# that. x runs WIDE on the -x side — the net heaped out past the footing,
|
||||
# and the wreck apron a yard author has to keep clear.
|
||||
# 4.10 floor, not 4.30: the measured span is 4.30 and it is set by the NET
|
||||
# HEAP, not by the racked feet, so it must not sit on the band boundary.
|
||||
dict(name="hail_net_frame_01_wrecked", fn=build_hail_net_frame_01_wrecked,
|
||||
dims=((4.10, 5.30), (2.90, 3.15), (2.70, 2.85)),
|
||||
nodes=["footings", "legs", "top_frame", "net_down"]),
|
||||
# The compliance ring. z is pinned tight around 1.2 m because the height
|
||||
# IS the flavour (AS 1926.1); y reaches past the ring for the ajar gate.
|
||||
dict(name="pool_kit_01", fn=build_pool_kit_01,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 5.1 MiB |
@ -19,7 +19,11 @@
|
||||
* are winners. The garden side of the audit lives in gardenfly.js (browser).
|
||||
*/
|
||||
|
||||
import { SailRig, orderRing } from '../../web/world/js/sail.js';
|
||||
// OVERLOAD_SECS / OVERLOAD_RECOVER are the failure law itself, imported rather
|
||||
// than re-typed: gate 3 replaces a percentage of taste with sail.js's own rule,
|
||||
// and a second copy of the rule would be the same drift the shared sweep exists
|
||||
// to prevent.
|
||||
import { SailRig, orderRing, OVERLOAD_SECS, OVERLOAD_RECOVER } from '../../web/world/js/sail.js';
|
||||
import { windForSite } from '../../web/world/js/weather.js';
|
||||
import { HARDWARE, START_BUDGET, FIXED_DT } from '../../web/world/js/contracts.js';
|
||||
// gate 2 (SPRINT17): one validator for the constraint shapes, shared with the
|
||||
@ -32,12 +36,23 @@ export const AUDIT = {
|
||||
MIN_COVER: 0.25, // A's "shades the bed" bar
|
||||
SPARE_COST: 15, // a $15 spare is the difference between a repair and a prayer
|
||||
/**
|
||||
* C's margin rule (SPRINT13 correction): even a corrected bench under-reads
|
||||
* the real UI's peaks by ~5–15% on site_02 (cause unfound, in the pool).
|
||||
* Until it's found, a corner priced within this fraction of its effective
|
||||
* rating "breaks in the game" — the bench held q4 at 1.24-vs-1.2 and read
|
||||
* 91.9 FULL; the real game pushed it over and shipped D's 39.8 TATTERED.
|
||||
* A line with a marginal corner is flagged, never sold as a clean PASS.
|
||||
* ⚠️ C's margin rule — ITS CAUSE IS FOUND, AND IT NO LONGER DECIDES ANYTHING
|
||||
* (SPRINT18 gate 3).
|
||||
*
|
||||
* It was invented because "even a corrected bench under-reads the real UI's
|
||||
* peaks by ~5–15% on site_02, cause unfound". The cause was `porosity`: the
|
||||
* shipped game never called `rig.setFabric`, so the sim flew the waterproof
|
||||
* membrane (full wind load) every night while this sweep — correctly — flew the
|
||||
* shade cloth every card promised. Gate 2's measurement, on two sites and two
|
||||
* storms: with the fabric held equal the bench and the FULL game chain agree to
|
||||
* three decimals on all four corners. There is no residual left to pad.
|
||||
*
|
||||
* So the 15% is not a physical fact and never was; it was a fudge factor
|
||||
* cancelling a missing function call, and keeping it would now make the audit
|
||||
* lie in the other direction (selling $15 of steel nobody needs). The
|
||||
* clean/marginal boundary is ruled by sail.js's OWN failure law instead — see
|
||||
* `priceCandidate`. This constant survives only as the DISPLAY band the
|
||||
* front-ends have always printed, so their prose keeps a number to name.
|
||||
*/
|
||||
MARGIN: 0.15,
|
||||
/**
|
||||
@ -231,7 +246,34 @@ export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BU
|
||||
// windy night — the sweep charter, named on the card since SPRINT17 gate 0.3
|
||||
const rig = new SailRig({ anchors, gridN: 10, porosity: AUDIT.POROSITY })
|
||||
.attach(cnd.ids, Array(4).fill({ name: 'audit', cost: 0, rating: Infinity }), 1.0);
|
||||
for (let i = 0; i < stormDef.duration * 60; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
// SPRINT18 gate 3 — REPLAY THE FAILURE LAW, don't pad the peak.
|
||||
//
|
||||
// sail.js breaks a corner on DWELL, not on peak: `overload` fills at +dt above
|
||||
// the effective rating, bleeds at −OVERLOAD_RECOVER·dt below it, and the corner
|
||||
// lets go past OVERLOAD_SECS. A peak-only price cannot express that, which is
|
||||
// why the audit needed a 15% pad to stand in for it. So run the real timer, for
|
||||
// every corner against every tier in the shop, as the flight happens: 12
|
||||
// accumulators, no trace buffers, and no second copy of the law (the constants
|
||||
// are imported from sail.js).
|
||||
//
|
||||
// `worst` is the closest that corner/tier pair ever came to letting go, in
|
||||
// seconds of the 0.4 s it takes. worst === 0 means the load never once reached
|
||||
// the rating; 0 < worst < OVERLOAD_SECS means it went over and survived on
|
||||
// TIMING; worst >= OVERLOAD_SECS means the game breaks it.
|
||||
const dwell = rig.corners.map(() => HARDWARE.map(() => ({ o: 0, worst: 0 })));
|
||||
for (let i = 0; i < stormDef.duration * 60; i++) {
|
||||
rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
for (let k = 0; k < rig.corners.length; k++) {
|
||||
const c = rig.corners[k], hint = c.anchor.ratingHint ?? 1, load = c.load;
|
||||
for (let h = 0; h < HARDWARE.length; h++) {
|
||||
const d = dwell[k][h];
|
||||
if (load > HARDWARE[h].rating * hint) d.o += FIXED_DT;
|
||||
else d.o = Math.max(0, d.o - FIXED_DT * OVERLOAD_RECOVER);
|
||||
if (d.o > d.worst) d.worst = d.o;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Price each corner against its anchor's EFFECTIVE strength. c.anchor is the
|
||||
// resolved anchor handed in above; `?? 1` mirrors sail.js for bare fixtures.
|
||||
@ -245,15 +287,32 @@ export function priceCandidate(cnd, { anchors, stormDef, wind, budget = START_BU
|
||||
// A corner whose `tier` sits inside the margin band is `marginal`: it
|
||||
// holds on this bench and "breaks in the game" (the residual's working
|
||||
// rule). The row's clean price is what closing that gap costs.
|
||||
const tiers = rig.corners.map((c) => {
|
||||
const tiers = rig.corners.map((c, k) => {
|
||||
const hint = c.anchor.ratingHint ?? 1;
|
||||
const tier = tierFor(c.peakLoad, hint);
|
||||
return { id: c.anchorId, peak: c.peakLoad, hint, tier,
|
||||
cleanTier: tierFor(c.peakLoad / (1 - AUDIT.MARGIN), hint),
|
||||
const d = dwell[k];
|
||||
// The cheapest tier the LAW lets live — it may go over its rating, so long as
|
||||
// no excursion lasts the 0.4 s that breaks it. This is the gamble price.
|
||||
const tier = HARDWARE.find((h, h_i) => d[h_i].worst < OVERLOAD_SECS) ?? null;
|
||||
// The cheapest tier that never reaches its rating at all. THIS is "clean",
|
||||
// measured: the steel is simply stronger than the night, with no reliance on
|
||||
// a gust being brief. It replaces `tierFor(peak / (1 - MARGIN))`.
|
||||
const cleanTier = HARDWARE.find((h, h_i) => d[h_i].worst === 0) ?? null;
|
||||
return { id: c.anchorId, peak: c.peakLoad, hint, tier, cleanTier,
|
||||
// seconds of the 0.4 s fuse the CHOSEN tier burned — 0 is untouched steel
|
||||
dwell: tier ? +d[HARDWARE.indexOf(tier)].worst.toFixed(3) : null,
|
||||
// effective rating in newtons, so a reader never has to do hint arithmetic:
|
||||
// a $5 carabiner is 1.2 kN on the price list and 1.056 on a 0.88 branch, and
|
||||
// that haircut is the whole of the cheap tier's danger (gate 3).
|
||||
effN: tier ? Math.round(tier.rating * hint) : null,
|
||||
cleanEffN: cleanTier ? Math.round(cleanTier.rating * hint) : null,
|
||||
headroom: tier ? +(1 - c.peakLoad / (tier.rating * hint)).toFixed(3) : null };
|
||||
});
|
||||
const unholdable = tiers.filter((c) => !c.tier);
|
||||
const marginal = tiers.filter((c) => c.tier && c.headroom < AUDIT.MARGIN);
|
||||
// MARGINAL, re-ruled by measurement: the tier survives the night only because
|
||||
// every excursion above its rating was shorter than the fuse. That is a real
|
||||
// knife edge with a real mechanism — "it held on timing, not on strength" — and
|
||||
// it is the thing the 15% pad was groping for.
|
||||
const marginal = tiers.filter((c) => c.tier && c.dwell > 0);
|
||||
const hw = tiers.reduce((s, c) => s + (c.tier ? c.tier.cost : 0), 0);
|
||||
const cleanHw = tiers.every((c) => c.cleanTier)
|
||||
? tiers.reduce((s, c) => s + c.cleanTier.cost, 0) : null;
|
||||
|
||||
@ -119,21 +119,46 @@ test('pricing reads the ANCHOR, not just the steel — ratingHint reaches tierFo
|
||||
'an over-ceiling corner must land in unholdable, or the verdict lies');
|
||||
});
|
||||
|
||||
test('the margin rule: a corner inside 15% of its effective rating is not a clean win', () => {
|
||||
// SPRINT13, C's residual: even a corrected bench under-reads the real UI's
|
||||
// peaks by ~5-15%, so "any corner within ~15% of rating breaks in the game".
|
||||
// The sweep must therefore refuse to call a knife-edge line a winner — the
|
||||
// wild-night 91.9-FULL-with-q4-at-1.24-vs-1.2 headline died of exactly this
|
||||
// (D's UI: 39.8 TATTERED). Craft a hint that leaves the best steel ~8%
|
||||
// headroom on a1's measured peak: still priced, still "holds" on paper,
|
||||
// and the verdict must refuse to bless it. Delete the marginal logic from
|
||||
// sweep.js and this goes red on the verdict code.
|
||||
test('SPRINT18 gate 3: the boundary is DWELL against the fuse, not a percentage of peak', () => {
|
||||
// WHAT THIS REPLACES. SPRINT13 priced every corner twice — `tier` (peak <=
|
||||
// rating) and `cleanTier` (peak <= rating × 0.85) — because C had measured the
|
||||
// bench under-reading the real UI by 5–15% with "cause unfound", so 15% of
|
||||
// taste stood in for it. Gate 2 found the cause (the shipped game never called
|
||||
// `rig.setFabric`, so it flew the membrane while every card said shade cloth);
|
||||
// with the fabric held equal the bench and the full game chain agree to three
|
||||
// decimals. There is no residual left, so the pad is not covering anything.
|
||||
//
|
||||
// But the real reason a percentage had to go is that NO percentage can work.
|
||||
// Measured on the wildnight pin, one flight, two corners on identical $15
|
||||
// shackles (THREADS [B] SPRINT18 gate 3):
|
||||
//
|
||||
// p1 peak 3.528 kN / 3.20 = 10.3% OVER its rating -> HELD (dwell 0.150 s)
|
||||
// p2 peak 3.493 kN / 3.20 = 9.2% OVER its rating -> BROKE (dwell 0.400 s)
|
||||
//
|
||||
// p1 goes HIGHER than p2 and survives. Ordering by peak is INVERTED against
|
||||
// the outcome, so a rule of the form "peak within X% of rating" is wrong at
|
||||
// every X: 15% condemns p1's perfectly good steel, 9% blesses p2's break.
|
||||
// sail.js has never used peak — it fills an `overload` timer while a corner is
|
||||
// over its rating, bleeds it at OVERLOAD_RECOVER, and lets go past
|
||||
// OVERLOAD_SECS. `priceCandidate` replays exactly that, so the audit and the
|
||||
// sim now answer "will it hold" with the same arithmetic.
|
||||
//
|
||||
// What this fixture can prove: a corner with 8% headroom is now CLEAN (its load
|
||||
// never reaches the rating at all), where the old rule called it marginal —
|
||||
// that IS the re-ruling. Take the dwell replay out of sweep.js and `cleanTier`
|
||||
// falls back to a pad, `cleanHw` stops equalling `hw`, and this goes red.
|
||||
const sweep = (anchors) => auditSweep({ anchors, bed: BED, stormDef: STORM, venturi: [] });
|
||||
const honest = sweep(ANCHORS);
|
||||
const a1h = honest.rows[0].tiers.find((c) => c.id === 'a1');
|
||||
assert(a1h.tier, 'fixture drift: a1 must be holdable at hint 1');
|
||||
assert(honest.verdict.ok && honest.verdict.code === 'pass',
|
||||
'fixture drift: the honest yard must be a clean pass or this test asserts nothing');
|
||||
assert(honest.rows[0].tiers.every((c) => c.dwell === 0),
|
||||
`the honest yard burned fuse on ${honest.rows[0].tiers.filter((c) => c.dwell > 0).map((c) => c.id)} — ` +
|
||||
'a yard nothing is over-loaded on must replay a dwell of exactly 0');
|
||||
assert(honest.rows[0].cleanHw === honest.rows[0].hw,
|
||||
`cleanHw $${honest.rows[0].cleanHw} != hw $${honest.rows[0].hw} on a yard where no corner ever reaches ` +
|
||||
'its rating. That gap is the retired 15% pad — the audit is charging for steel the night cannot need');
|
||||
|
||||
const RATED = HARDWARE.at(-1);
|
||||
const hint = a1h.peak / (RATED.rating * 0.92); // → best steel holds a1 with 8% headroom
|
||||
@ -142,11 +167,26 @@ test('the margin rule: a corner inside 15% of its effective rating is not a clea
|
||||
const a1 = row.tiers.find((c) => c.id === 'a1');
|
||||
assert(a1.tier === RATED, `a1 at the crafted hint must price to the rated shackle, got ${a1.tier?.name}`);
|
||||
assert(a1.headroom > 0 && a1.headroom < AUDIT.MARGIN,
|
||||
`fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN})`);
|
||||
assert(row.marginal.some((c) => c.id === 'a1'), 'a1 must land in row.marginal');
|
||||
assert(row.affordable && !row.clean, 'the line is affordable but must NOT be clean');
|
||||
assert(!lied.verdict.ok && lied.verdict.code === 'marginal-only',
|
||||
`the only affordable line is marginal — verdict must say so, got ${lied.verdict.code}/${lied.verdict.ok}`);
|
||||
`fixture: a1's headroom ${a1.headroom} must sit inside (0, ${AUDIT.MARGIN}) — the band the OLD rule condemned`);
|
||||
assert(a1.dwell === 0,
|
||||
`a1 burned ${a1.dwell}s of fuse at 8% headroom. Headroom means the load never reached the rating, so the ` +
|
||||
'fuse must never start — if this fires, the replay is reading the wrong quantity');
|
||||
assert(a1.cleanTier === RATED && row.clean,
|
||||
`8% headroom must now read CLEAN (it did not, got clean=${row.clean}): the steel is simply stronger than ` +
|
||||
'the night, and calling it a knife edge was the pad talking');
|
||||
assert(lied.verdict.ok && lied.verdict.code === 'pass',
|
||||
`the verdict must bless a line no corner is ever over, got ${lied.verdict.code}/${lied.verdict.ok}`);
|
||||
|
||||
// and the other side of the boundary: drop the ceiling UNDER the peak and the
|
||||
// tier is refused outright — this fixture's gusts are seconds long, so any
|
||||
// excursion outlasts the 0.4 s fuse and there is no timing to survive on.
|
||||
const tooWeak = sweep(ANCHORS.map((a) => (a.id === 'a1'
|
||||
? { ...a, ratingHint: a1h.peak / (RATED.rating * 1.05), sway: a.sway } : a)));
|
||||
const weak = tooWeak.rows[0].tiers.find((c) => c.id === 'a1');
|
||||
assert(!weak.tier && tooWeak.rows[0].unholdable.some((c) => c.id === 'a1'),
|
||||
`a1 priced to ${weak.tier?.name} with its ceiling 5% UNDER the peak — on a gust that lasts seconds the ` +
|
||||
'fuse burns through and the corner is simply unholdable');
|
||||
return 'clean = the fuse never starts (8% headroom passes); over the ceiling on a long gust = unholdable';
|
||||
});
|
||||
|
||||
export const SWEEP_TESTS = TESTS;
|
||||
|
||||
@ -95,7 +95,13 @@ windProxy.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
|
||||
const rig = new SailRig({ anchors: world.anchors });
|
||||
let sailView = null;
|
||||
|
||||
async function rigSail(anchorIds, hwChoices, tension) {
|
||||
async function rigSail(anchorIds, hwChoices, tension, fabric) {
|
||||
// SPRINT18 gate 2: the fabric BEFORE the attach. Without this line the sail
|
||||
// flies `new SailRig`'s default porosity (0 — the waterproof membrane) no
|
||||
// matter what the F key says, which is exactly the bug the shipped game had:
|
||||
// the panel sold shade cloth and the sim flew full wind load. This is the
|
||||
// shape of the one-line pickup filed for main.js's own rigSail.
|
||||
if (fabric) rig.setFabric(fabric);
|
||||
rig.attach(anchorIds, hwChoices, tension);
|
||||
if (sailView) {
|
||||
scene.remove(sailView);
|
||||
@ -109,7 +115,7 @@ let msg = '';
|
||||
let msgT = 0;
|
||||
const ui = await createRiggingUI({
|
||||
scene, camera: cameraRig.object, domElement: canvas, world,
|
||||
onCommit: (ids, hw, tension) => { rigSail(ids, hw, tension); wind = wildWind; t = 0; phase = 'storm'; },
|
||||
onCommit: (ids, hw, tension, fabric) => { rigSail(ids, hw, tension, fabric); wind = wildWind; t = 0; phase = 'storm'; },
|
||||
onMessage: (m) => { msg = m; msgT = 2; },
|
||||
});
|
||||
|
||||
|
||||
@ -80,6 +80,16 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({
|
||||
"sail_post_corroded/top_anchor"
|
||||
]
|
||||
},
|
||||
"hail_net_frame": {
|
||||
"collateral": "glasshouse",
|
||||
"rating_hint": 0.8,
|
||||
"sources": [
|
||||
"hail_net_frame_01/net_anchor_01",
|
||||
"hail_net_frame_01/net_anchor_02",
|
||||
"hail_net_frame_01/net_anchor_03",
|
||||
"hail_net_frame_01/net_anchor_04"
|
||||
]
|
||||
},
|
||||
"house": {
|
||||
"collateral": "gutter",
|
||||
"rating_hint": 0.35,
|
||||
|
||||
@ -265,6 +265,373 @@ export const POOL = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* ⚖️ **SPRINT18 gate 0 — DECLINING A BOOKED NIGHT COSTS YOU. JOHN'S RULING,
|
||||
* 2026-07-25: "it adds pressure."**
|
||||
*
|
||||
* D's cold play found the hole and named it from the chair: the board was a FREE
|
||||
* OPTION. Twice in one week it offered a fee-maximizer a dodge around the
|
||||
* campaign's own set-pieces — the wildnight for a Tuesday southerly at similar
|
||||
* money, and the designed-loss icenight against a winnable constrained job at
|
||||
* the SAME $68 — and saying no cost nothing. A player could skip the week's two
|
||||
* hardest lessons and never know they existed.
|
||||
*
|
||||
* ── THE NUMBER, AND WHY IT IS THIS NUMBER ─────────────────────────────────
|
||||
*
|
||||
* Standing is the currency, because standing is the currency arc 1 built for
|
||||
* exactly this: `week.js` §REP already prices your name, moves it at dawn with
|
||||
* its reasons printed, and pays you less when it's low. A new "reliability"
|
||||
* number beside it would be two scores for one idea, and the second one would
|
||||
* be the one nobody read.
|
||||
*
|
||||
* The cost is **the mirror of `REP.CLEAN`** — a stood-up client costs you
|
||||
* exactly what a perfect night earns. That is not an aesthetic choice, it is
|
||||
* the sentence the week already says in numbers: *one night of flawless work,
|
||||
* cancelled by one phone call you didn't make.* Any smaller and the dodge is
|
||||
* still free with extra steps; any larger and it outweighs `REP.COLLATERAL`
|
||||
* (−1.0), which would rank standing a client up above WRECKING THEIR PROPERTY,
|
||||
* and the invoice would be arguing with itself.
|
||||
*
|
||||
* The number itself lives in `week.js` (`REP.STOOD_UP`) with the rest of the
|
||||
* reputation ruling — this file must not import week.js (week imports board;
|
||||
* the cycle is the reason board.js is pure data + pure functions). What lives
|
||||
* HERE is the structural half: **who a take stands up**, which is a fact about
|
||||
* the board and nothing else. `a.test` pins the mirror (`STOOD_UP === -CLEAN`)
|
||||
* so the two halves cannot drift apart in silence.
|
||||
*
|
||||
* ⚠️ UNMEASURED — the house rule, same status as `CONSTRAINT_PREMIUM` above and
|
||||
* `REP` itself. Nobody has played a week where declining costs. The lever is one
|
||||
* constant in week.js; if John's next play says the price is wrong, move THAT.
|
||||
* Do not fund it by touching `REP.CLEAN`, which the whole rep ladder is measured
|
||||
* against.
|
||||
*
|
||||
* ── THE ASYMMETRY, AND IT IS THE POINT ────────────────────────────────────
|
||||
*
|
||||
* You owe standing to a client who is EXPECTING you, and nobody else. The
|
||||
* fiction is a tradie's two sources of work: **the spine slot is your diary, the
|
||||
* pool is your phone.**
|
||||
*
|
||||
* · **the booked night** (`kind:'scripted'`) — the diary. A client has you down
|
||||
* for tonight; the ruling's own words are the "TONIGHT, AS BOOKED" offer.
|
||||
* Taking anything else stands them up.
|
||||
* · **a callout** (`kind:'callout'`) — the phone. Nobody has you down, so
|
||||
* declining it costs a fee and not a name. ⚠️ THE FIRST CUT OF THIS GOT IT
|
||||
* BACKWARDS and the probe caught it in one call: I derived `booked` from the
|
||||
* night alone, and the pool yard came back booked — so taking the scripted
|
||||
* job printed "the Karalis place is expecting you" over a client who had
|
||||
* never been promised anything. A callout you turn down is a callout somebody
|
||||
* else takes. Recorded because the wrong version READ plausible.
|
||||
* · **an EMERGENCY callout in the diary slot (gate 1)** — the storm is already
|
||||
* running and the phone rang at ten at night. It is not a booking even though
|
||||
* it arrives in the booking's chair: nobody had you down, so nobody is stood
|
||||
* up. A morning whose board is two callouts and no booked job is a LEGAL
|
||||
* board state, declining is genuinely FREE there, and the card must SAY so —
|
||||
* "no cost shown" and "no cost" have to be the same sentence on a document.
|
||||
*
|
||||
* So a booking is a DIARY ENTRY THAT IS NOT AN EMERGENCY, both halves checked,
|
||||
* and the free case falls out of the filter returning nothing rather than out of
|
||||
* an `if` somebody has to remember to write.
|
||||
*
|
||||
* @param {string} kind the offer's provenance — 'scripted' (the diary) or 'callout'
|
||||
* @param {object} night the night entry
|
||||
*/
|
||||
export function isBooked(kind, night) {
|
||||
return kind === 'scripted' && !night?.emergency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who taking `taking` stands up: every OTHER offer on this board that a client
|
||||
* is expecting you at.
|
||||
*
|
||||
* Returns a LIST, not a boolean, for the reason board.js returns lists
|
||||
* everywhere: today a morning has one booked job and at most one client can be
|
||||
* stood up, and a `standsUpSomeone` boolean would have to be rewritten the first
|
||||
* morning that offers three. The cost is `list.length × REP.STOOD_UP`, so the
|
||||
* arithmetic scales with the board on its own.
|
||||
*/
|
||||
export function standUpsIfTaken(offers, taking) {
|
||||
return (offers ?? []).filter((o) => o !== taking && o.booked);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚨 **SPRINT18 gate 1 — THE EMERGENCY CALLOUT. DESIGN.md line 29, finally in
|
||||
* the game:** *"Emergency callout — mid-storm, right now, double rates. The
|
||||
* chaos levels. Someone's sail is flogging itself to death over their
|
||||
* glasshouse."* ROADMAP arc 2 sizes it exactly: *"the storm phase already does
|
||||
* everything this needs except spawn you at t=30 with a rig you didn't build."*
|
||||
*
|
||||
* ── WHY THIS IS ITS OWN TABLE AND NOT A POOL ENTRY ────────────────────────
|
||||
*
|
||||
* Both reasons are load-bearing and neither is tidiness:
|
||||
*
|
||||
* 1. **THE DRAW.** `alternativeFor` indexes the ELIGIBLE POOL by seed. A seventh
|
||||
* pool entry therefore re-rolls every night's alternative — it would change
|
||||
* the board on six nights it isn't even on, invalidating the shipped board
|
||||
* pins and D's seven-night cold-play record for nothing. Scheduled per night,
|
||||
* every other morning's draw is byte-identical and `offers().length === 2`
|
||||
* still holds.
|
||||
* 2. **THE ONE-VARIABLE LAW.** An emergency is a SET PIECE, and the law needs an
|
||||
* AUTHOR to place it: *"has the player already rigged this yard by night N"*
|
||||
* is only a decidable question if N is authored. You cannot ask an RNG to
|
||||
* respect a law about ordering. `oneVariableOffences` below is that law in
|
||||
* code rather than in a comment, which SPRINT18's own spec demands of me
|
||||
* ("A's ordering must make that impossible to get wrong by accident").
|
||||
*
|
||||
* `kind` stays `'callout'` — its provenance is the phone, and an emergency is
|
||||
* the most callout-y callout there is. The costume comes off `night.emergency`,
|
||||
* so there is one source of truth and every existing pin stays green.
|
||||
*
|
||||
* ── THE SLOT: NIGHT 5's MORNING, OPPOSITE THE WILDNIGHT. ⚖️ RULED, with every
|
||||
* other slot rejected for a stated reason ───────────────────────────────────
|
||||
*
|
||||
* · **site_02 × storm_03_southerly** — the yard night 3 rigs, the storm night 2
|
||||
* survives. **The only new variable is the JOB TYPE**, which is the law
|
||||
* satisfied literally rather than argued around.
|
||||
* · **not night 4** — that morning's booked job is the swing lawn's DEBUT. Two
|
||||
* new things on one board, and taking the emergency would kill the swing
|
||||
* lawn's only slot in the whole campaign.
|
||||
* · **not nights 6 or 7** — the MEMORY needs somewhere to land. The Hendersons
|
||||
* are on the board nights 1, 2, 5 and 6, so standing them up on night 5 puts
|
||||
* the grudge on the icenight's card the very next morning. From 6 or 7,
|
||||
* gate 0 clause 3 is unreachable in the shipped week — a mechanic nobody
|
||||
* would ever see.
|
||||
* · and it answers D's cold-play finding #1 in its own words: *"the wildnight
|
||||
* was declinable for a Tuesday southerly at similar money."* It still is —
|
||||
* at double rates now, and −½★, both printed before you choose.
|
||||
*/
|
||||
export const EMERGENCIES = [
|
||||
{
|
||||
/** 1-based, the MORNING it arrives. Read by emergencyFor(index). */
|
||||
night: 5,
|
||||
id: 'corner_block_emergency',
|
||||
storm: 'storm_03_southerly', site: 'site_02_corner_block',
|
||||
client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block',
|
||||
brief: 'Nobody quoted this one. You are being asked how fast you can be there.',
|
||||
emergency: {
|
||||
/**
|
||||
* Seconds into the storm you turn up — ROADMAP's own number. STORM_LEN is
|
||||
* 90, so an emergency night is 60 seconds long and every one of them is
|
||||
* inside weather that is already working.
|
||||
*/
|
||||
arriveAt: 30,
|
||||
/**
|
||||
* THE PHONE CALL. This is the dispatch's whole brief, and it is deliberately
|
||||
* not a forecast and not a quote: *there was no time* is the drama, and a
|
||||
* client who reads you a wind speed at nine at night is a client who is not
|
||||
* panicking. What the WEATHER is doing when you arrive is C's `midStormRead`,
|
||||
* because nobody briefed you — you have to look at the sky yourself.
|
||||
*/
|
||||
dispatch: 'Ten past nine and the phone goes. The corner block — they got a second sail put '
|
||||
+ 'up over the carport side by a mob off a card in the letterbox, and it is coming '
|
||||
+ 'apart in the dark. She is not asking what it costs. She is asking how fast you '
|
||||
+ 'can be there.',
|
||||
/**
|
||||
* ⇦ **B OWNS THIS: THE RIG YOU DIDN'T BUILD.** Per-corner
|
||||
* `{anchorId, hw, broken?}` — see THREADS [A] 2026-07-25 §2 for the shape
|
||||
* and the seam. This is a MINIMAL FIRST CUT so the phase is verifiable end
|
||||
* to end in my tree; it is flagged for B and revert-and-tell-me applies.
|
||||
*
|
||||
* What it says as authored, and why these four anchors: **the cowboy took
|
||||
* the bait the player was warned about.** `cb1` is the carport beam — the
|
||||
* worst steel in the game (ratingHint under 0.35, a.test pins it below the
|
||||
* fascia), $180 of collateral under it, and the client has been selling it
|
||||
* as "plenty to tie off to" since night 3. A $5 carabiner on a 0.22 beam is
|
||||
* ~264 N effective against a southerly, so **this corner is failing while
|
||||
* you walk up the drive** — emergently, through the real overload path,
|
||||
* with the real event and the real invMass release, not through a poked
|
||||
* flag. That is the night: **release, cut, or ride it out**, with $180 on
|
||||
* the line and a knife in the ute. DESIGN.md line 165's signature decision,
|
||||
* over the one piece of collateral in this game that a player has already
|
||||
* been taught to fear.
|
||||
*/
|
||||
/**
|
||||
* ⚖️ **B's AUTHORED SET, ADOPTED VERBATIM — landed by D, and it should have landed at
|
||||
* integration.** B published it in THREADS with the measurements ([B] gate 1 §5) and A
|
||||
* pre-authorised the swap in the note above ("revert-and-tell-me applies… yours is canon the
|
||||
* moment it lands"). It never landed, because it is DATA IN A's FILE while B rules the
|
||||
* semantics. **I found it by playing the night:** the dispatch card read *"CB1 carabiner — ON
|
||||
* THE GROUND"*, which is a different night from the one this sprint is about.
|
||||
*
|
||||
* Why the difference IS the gate and not a detail: in the placeholder the CARPORT CORNER
|
||||
* ARRIVES ALREADY DOWN, so the $180 carport is `theirs` — unbillable, unwreckable — and there
|
||||
* is nothing left for the knife to save. DESIGN.md line 165's signature decision had no
|
||||
* object. In B's set `cb1` is a SHACKLE on the 0.22 beam (704 N effective, holding their roof)
|
||||
* which the southerly puts over its ceiling from t=35 to t=42, and `tr1b` — the 0.88 branch on
|
||||
* the cheapest steel — is the corner that arrived gone. That is B's measured triage window:
|
||||
* ride it out → cb1 blows @35.9 s and takes q1 → the carport WRECKED, $180
|
||||
* cut at t=33 → nothing breaks, the carport saved
|
||||
* 5.9 seconds. Measured by B on the rig; re-flown by D through the shipped game.
|
||||
*/
|
||||
rig: [
|
||||
{ anchorId: 'q3', hw: 'shackle' }, // sound post, still holding
|
||||
{ anchorId: 'q1', hw: 'carabiner' }, // under-specced
|
||||
{ anchorId: 'tr1b', hw: 'carabiner', broken: true }, // 0.88 branch + cheapest steel: already gone
|
||||
{ anchorId: 'cb1', hw: 'shackle' }, // ⚠️ the CARPORT BEAM, hint 0.22
|
||||
],
|
||||
},
|
||||
/**
|
||||
* ⚖️ **B's RULING ON THE ECONOMY OF TRIAGE, landed by D — same story as the rig above: a ruling
|
||||
* B made and could not apply, because it is per-job DATA in A's file.** ([B] gate 1 §2.)
|
||||
*
|
||||
* **The garden BONUS does not apply on a callout.** It is a per-HP payment for a bed you chose
|
||||
* hardware to protect; you arrive at t=30 with thirty seconds of somebody else's storm already
|
||||
* in that bed, under steel you did not buy. Paying per-HP prices the PREVIOUS contractor's
|
||||
* geometry and docks you for their mistakes. Data, not a branch — so A's acceptance criterion
|
||||
* (§10: `settle()` runs unchanged on an emergency) holds exactly.
|
||||
*
|
||||
* **The garden's WIN still applies, and that is the knife's price.** `win = hp >= 50` gates the
|
||||
* FEE (`PAY.lostNightShare`), so cutting spends garden HP — and can spend 65% of the callout —
|
||||
* to buy certainty on their property. Ride it out for the full double rate and gamble their
|
||||
* carport; cut and take the smaller cheque. The garden is the STAKE, not the wage.
|
||||
*/
|
||||
pay: { garden: 0 },
|
||||
_why: 'THE PAIRING IS THE POOL\'S OWN AUDITED corner_block_southerly (A, S17 gate 1, '
|
||||
+ 'audit.html): 19 holding lines / 7 marginal / 38 unholdable, cheapest hold $40 '
|
||||
+ '(tr1,tr1b,q1,q3 → garden 87.6 FULL). ⚠️ THAT AUDIT ANSWERS A DIFFERENT QUESTION and '
|
||||
+ 'the difference is flagged, not smoothed over: it measures whether a rig-for-tonight '
|
||||
+ 'job can win here. Whether the INHERITED rig is TRIAGEABLE — what a knife, a spare and '
|
||||
+ 'sixty seconds can do to somebody else\'s carabiners — is B\'s to measure, and the '
|
||||
+ 'admission price for a board entry (this file\'s own rule) is not paid until they have. '
|
||||
+ 'One-variable: the yard is night 3\'s, the storm is night 2\'s, the JOB TYPE is new.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Tonight's scheduled emergency, or null. 0-based index in, 1-based `night` out —
|
||||
* the table is authored in the numbers a human counts in, because a table of
|
||||
* set-pieces is read by people far more often than by code.
|
||||
*/
|
||||
export function emergencyFor(nightIndex, table = EMERGENCIES) {
|
||||
return (table ?? []).find((e) => e.night === (nightIndex | 0) + 1) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails loud on a malformed emergency — `validateConstraint`'s precedent, and the
|
||||
* same reasoning: a content bug must be a crash at the board, not a runtime
|
||||
* surprise sixty seconds into a storm the player cannot leave.
|
||||
*/
|
||||
export function validateEmergency(night, where = '?') {
|
||||
const e = night?.emergency;
|
||||
if (!e) return night;
|
||||
const bad = [];
|
||||
if (!Number.isFinite(e.arriveAt) || e.arriveAt <= 0) {
|
||||
bad.push('needs a positive numeric `arriveAt` — the storm clock you turn up on');
|
||||
}
|
||||
if (!e.dispatch || String(e.dispatch).length < 20) {
|
||||
bad.push('needs `dispatch` — the phone call. There is no forecast and no quote; this is the whole brief');
|
||||
}
|
||||
if (!Array.isArray(e.rig) || e.rig.length !== 4) {
|
||||
bad.push(`needs a 4-corner \`rig\` (the rig you didn't build), got ${e.rig?.length ?? 'none'}`);
|
||||
}
|
||||
for (const c of e.rig ?? []) {
|
||||
if (!c?.anchorId) bad.push('a rig corner with no `anchorId`');
|
||||
if (!c?.hw) bad.push(`rig corner ${c?.anchorId ?? '?'} has no \`hw\` — someone tied it to something`);
|
||||
}
|
||||
if (bad.length) throw new Error(`board: emergency on ${where} is invalid:\n ${bad.join('\n ')}`);
|
||||
return night;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **THE ONE-VARIABLE LAW, IN CODE.** ROADMAP principle 5, and SPRINT18 is the
|
||||
* first sprint that could break it by accident: a new JOB TYPE (the emergency
|
||||
* callout) and a new YARD (E's glasshouse) ship in the same sprint, and DESIGN.md
|
||||
* pairs them in one sentence. **They may not debut on the same night.** That
|
||||
* sentence is the payoff, not the introduction.
|
||||
*
|
||||
* Constructed as C's `pairingRefusals` is, and for their stated reason: it
|
||||
* **ACCUSES, it does not silently filter.** Dropping a bad entry would leave a
|
||||
* morning with one offer and no explanation — a runtime surprise wearing a fix's
|
||||
* clothes. Every offence carries its measurement in the message.
|
||||
*
|
||||
* The three rules, and what each one actually catches:
|
||||
* 1. **a NIGHTS emergency at index i needs its site in NIGHTS at some j < i** —
|
||||
* the emergency debuts over a yard the player has already rigged.
|
||||
* 2. **an EMERGENCIES entry for night N needs its site rigged by an earlier
|
||||
* night** — same rule, the other table. This is the one the shipped data
|
||||
* lives under.
|
||||
* 3. **a POOL emergency needs its site in NIGHTS at all** — a yard whose ONLY
|
||||
* route into the game is under the new job type is precisely the accident.
|
||||
* This is the rule that protects E: the glasshouse is not in NIGHTS, so it
|
||||
* CANNOT be an emergency's stage this sprint, no matter who wires what.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {object[]} o.nights the ladder (week.js NIGHTS)
|
||||
* @param {object[]} [o.pool]
|
||||
* @param {object[]} [o.emergencies]
|
||||
*/
|
||||
export function oneVariableOffences({ nights, pool = POOL, emergencies = EMERGENCIES }) {
|
||||
const offences = [];
|
||||
const siteOf = (n) => (typeof n === 'string' ? 'backyard_01' : n?.site ?? 'backyard_01');
|
||||
const ladder = (nights ?? []).map(siteOf);
|
||||
/** The first night index that rigs this yard, or -1. */
|
||||
const firstRig = (site) => ladder.indexOf(site);
|
||||
|
||||
(nights ?? []).forEach((n, i) => {
|
||||
if (typeof n === 'string' || !n?.emergency) return;
|
||||
const j = firstRig(siteOf(n));
|
||||
if (!(j >= 0 && j < i)) {
|
||||
offences.push(`NIGHTS[${i}] (night ${i + 1}) is an EMERGENCY over ${siteOf(n)}, a yard `
|
||||
+ `${j < 0 ? 'no night rigs' : `first rigged on night ${j + 1}`} — the new JOB TYPE and `
|
||||
+ 'the yard would debut together. The emergency callout debuts over a yard the player has already rigged.');
|
||||
}
|
||||
});
|
||||
|
||||
for (const e of emergencies ?? []) {
|
||||
const i = (e.night | 0) - 1;
|
||||
const j = firstRig(siteOf(e));
|
||||
if (!(j >= 0 && j < i)) {
|
||||
offences.push(`EMERGENCIES '${e.id ?? '?'}' arrives on night ${e.night} over ${siteOf(e)}, a yard `
|
||||
+ `${j < 0 ? 'no scripted night rigs' : `first rigged on night ${j + 1}`} — a player taking it `
|
||||
+ 'would meet a new JOB TYPE and a new YARD on the same night.');
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of pool ?? []) {
|
||||
if (!p?.emergency) continue;
|
||||
if (firstRig(siteOf(p)) < 0) {
|
||||
offences.push(`POOL '${p.id ?? '?'}' is an EMERGENCY over ${siteOf(p)}, which is in no NIGHTS `
|
||||
+ 'slot at all — that yard\'s only route into the game would be under the new job type.');
|
||||
}
|
||||
}
|
||||
return offences;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **SPRINT18 gate 4 — E ASKED FOR A SECOND LAW HERE (a new TIER must not debut
|
||||
* with the callout). I WROTE IT, MEASURED IT, AND DELETED IT, because RULE 2 ABOVE
|
||||
* ALREADY IMPLIES IT — and a law that cannot fire is decoration this file has
|
||||
* refused before.**
|
||||
*
|
||||
* E's worry is exact and worth having had: `hail_net_frame` is not a yard, it is an
|
||||
* ANCHOR TYPE with $320 under it, placeable into an EXISTING yard — so it looked
|
||||
* like it could land in site_02_corner_block, which is the emergency's own stage,
|
||||
* and debut alongside the new job type having passed every check I had written.
|
||||
*
|
||||
* It cannot, and the proof is one line of the ladder:
|
||||
*
|
||||
* 1 backyard_01 · 2 backyard_01 · 3 site_02 · 4 site_03 · 5 backyard_01 …
|
||||
* ↑ the emergency's yard, rigged on NIGHT 3
|
||||
*
|
||||
* Rule 2 requires an emergency's yard to be rigged by a STRICTLY EARLIER scripted
|
||||
* night. So every tier resident in that yard necessarily debuts on that earlier
|
||||
* ordinary rig-for-tonight night — night 3 here, two nights before the callout. The
|
||||
* tier question reduces to the yard question, and the yard question is already
|
||||
* pinned with a three-way vacuity guard.
|
||||
*
|
||||
* I ran the version that checked types anyway: **it returned zero offences on E's
|
||||
* own poisoned scenario** (a hail-net frame dropped into site_02), because
|
||||
* `earlier` legitimately contains site_02's own types. Green, permanently, for a
|
||||
* structural reason — the exact shape of A's own S17 `stormsToPreload` filing
|
||||
* ("every pool storm also lives in NIGHTS, so the assert passes with the source
|
||||
* deleted"). Shipping it would have added a row to the suite and no teeth to the
|
||||
* repo.
|
||||
*
|
||||
* **The case that is NOT implied, and where the real risk lives:** a tier whose
|
||||
* ONLY route into the game is a POOL or EMERGENCIES entry — a glasshouse yard
|
||||
* authored straight into the pool. That is rule 3, which already refuses it by
|
||||
* name, and it is the rule E's asset is actually protected by.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A deterministic draw. `rng` is contracts.js's mulberry32 — the same PRNG the
|
||||
* storm system reproduces against, so there is exactly one seeded-randomness
|
||||
@ -298,7 +665,19 @@ export function eligibleAlternatives(scripted, pool = POOL) {
|
||||
* @param {number} weekSeed
|
||||
* @param {number} nightIndex
|
||||
*/
|
||||
export function alternativeFor(scripted, weekSeed, nightIndex, pool = POOL) {
|
||||
export function alternativeFor(scripted, weekSeed, nightIndex, pool = POOL, emergencies = EMERGENCIES) {
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1 — A SCHEDULED EMERGENCY DISPLACES THE DRAW, and it is
|
||||
* checked BEFORE the rng is even constructed. Deliberate: an emergency is an
|
||||
* authored set-piece, so the night it lands on is not the seed's business, and
|
||||
* a board that rolled dice about whether the phone rings would make the
|
||||
* one-variable law unenforceable (you cannot ask an RNG to respect an ordering
|
||||
* rule). Every OTHER night's draw is byte-identical to before this existed —
|
||||
* `rng` is constructed fresh from seedFor(week, night) with no cross-night
|
||||
* state, so skipping the call here cannot shift tomorrow's.
|
||||
*/
|
||||
const emergency = emergencyFor(nightIndex, emergencies);
|
||||
if (emergency) return emergency;
|
||||
const eligible = eligibleAlternatives(scripted, pool);
|
||||
if (!eligible.length) return null;
|
||||
const r = rng(seedFor(weekSeed, nightIndex));
|
||||
@ -344,6 +723,69 @@ export function exposureOf(site) {
|
||||
return { total, items };
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **SPRINT18 gate 5 (A's half) — THE SECOND-RUST-POST LANDMINE, DISARMED AS A
|
||||
* TRIPWIRE.** D's filing, S17 gate 3.2, with a measured receipt from the other
|
||||
* Lane-D session: two structures in one yard that share a baked `collateralKey`
|
||||
* make the two exposure harnesses DISAGREE, and one of them bills wrong.
|
||||
*
|
||||
* THE MEASUREMENT, D's, on a real two-corroded-post yard:
|
||||
* · `exposureOf()` (this file, the OFFER CARD) read **$115** — per STRUCTURE.
|
||||
* · `collateralExposure` (B/C, the FAILURE route) read **$45** — per KEY, deduped,
|
||||
* because `collateralFor`/`wreckStructure` match the FIRST structure carrying a
|
||||
* key and C's failure envelope dedupes keys.
|
||||
* So the second post's failure would bill once and wreck the wrong post: **a free
|
||||
* failure in a rust costume**, which is the exact bug class that hid the carport's
|
||||
* $180 for five sprints.
|
||||
*
|
||||
* ⚖️ **WHY THIS REPORTS AND DOES NOT THROW, and why it does not "fix" `exposureOf`.**
|
||||
* Two deliberate refusals:
|
||||
* 1. **No throw.** D and E are authoring yards in parallel right now. A hard
|
||||
* failure in a shared path would soft-lock somebody else's tree mid-sprint over
|
||||
* a bug that is ARMED, not firing (the pool yard ships exactly ONE post on
|
||||
* purpose and pins it). It accuses instead — C's `pairingRefusals`
|
||||
* construction, and their reasoning applies unchanged.
|
||||
* 2. **I did not make `exposureOf` dedupe by key.** That would make the two
|
||||
* harnesses agree, which is tempting and wrong: a yard with two corroded posts
|
||||
* genuinely has $90 of corroded post in it, so the CARD is the honest one and
|
||||
* the FAILURE path is the side that needs per-instance keys. Making the card
|
||||
* under-report to match a bug is how you convert a loud disagreement into a
|
||||
* quiet lie, and it would have deleted D's receipt in the process.
|
||||
*
|
||||
* **THE REAL FIX, for the joint E+A pass:** per-instance collateral keys — the
|
||||
* anchor naming the STRUCTURE it will wreck, not the KIND of thing it is. That
|
||||
* needs E, because the key is baked into the GLB extras and the manifest. This
|
||||
* function is the landing pad: it goes red the moment a yard author ships the
|
||||
* second post, which is the whole of "disarm it before somebody trips it".
|
||||
*
|
||||
* @param {object} site a site JSON
|
||||
* @returns {string[]} one accusation per clashing key, with its measurement
|
||||
*/
|
||||
export function collateralKeyClashes(site) {
|
||||
/** @type {Map<string, object[]>} */
|
||||
const byKey = new Map();
|
||||
for (const s of site?.structures ?? []) {
|
||||
// No key ⇒ world.js's collateralFor resolves it by `id`, which is unique by
|
||||
// construction (the editor generates s1, s2, …) — so an unkeyed structure
|
||||
// cannot clash and must not be reported as if it could.
|
||||
if (!s.collateralKey) continue;
|
||||
if (!byKey.has(s.collateralKey)) byKey.set(s.collateralKey, []);
|
||||
byKey.get(s.collateralKey).push(s);
|
||||
}
|
||||
const out = [];
|
||||
for (const [key, group] of byKey) {
|
||||
if (group.length < 2) continue;
|
||||
const perStructure = group.reduce((n, s) => n + (s.collateralValue ?? 0), 0);
|
||||
const perKey = group[0].collateralValue ?? 0;
|
||||
out.push(`${site?.name ?? site?.id ?? 'this yard'}: ${group.length} structures share the `
|
||||
+ `collateral key '${key}' (${group.map((s) => s.id).join(', ')}). The offer card would read `
|
||||
+ `$${perStructure} (per structure) and the failure route $${perKey} (per key, deduped) — `
|
||||
+ `so ${group.length - 1} of them would fail FREE and wreck '${group[0].id}' instead. `
|
||||
+ 'Per-instance collateral keys are needed first (SPRINT18 gate 5, E+A).');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* BUILD THE MORNING'S BOARD — two offers, tonight's scripted night first.
|
||||
*
|
||||
@ -363,18 +805,58 @@ export function exposureOf(site) {
|
||||
* offer. Absent ⇒ the board still builds (client, site, brief, constraints),
|
||||
* which is what keeps this testable with no server.
|
||||
*/
|
||||
export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced = null }) {
|
||||
export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced = null, nights = null }) {
|
||||
const alt = alternativeFor(scripted, weekSeed, nightIndex, pool);
|
||||
/**
|
||||
* ⚠️ SPRINT18 gate 1 — THE ONE-VARIABLE LAW, ENFORCED AT THE DOOR. `nights` is
|
||||
* threaded in by week.js's `offers()` (which owns NIGHTS; this file must not
|
||||
* import it), so the law runs in the REAL GAME every morning and not only in
|
||||
* the suite. Omit it and the board still builds — that is what keeps board.js
|
||||
* testable with no ladder — but the shipped path always supplies it, and
|
||||
* a.test pins that it does. THROW, not filter: a board that quietly dropped an
|
||||
* offer would leave a morning with one door and no explanation.
|
||||
*/
|
||||
if (nights) {
|
||||
const offences = oneVariableOffences({ nights, pool, emergencies: EMERGENCIES });
|
||||
if (offences.length) {
|
||||
throw new Error(`board: THE ONE-VARIABLE LAW IS BROKEN (ROADMAP principle 5) — a new job `
|
||||
+ `type and a new yard may not debut together:\n ${offences.join('\n ')}`);
|
||||
}
|
||||
}
|
||||
const mk = (night, kind, id) => {
|
||||
for (const c of night.constraints ?? []) validateConstraint(c, id);
|
||||
validateEmergency(night, id);
|
||||
const offer = {
|
||||
id, kind, night,
|
||||
constraints: night.constraints ?? [],
|
||||
premium: premiumFor(night.constraints),
|
||||
/**
|
||||
* SPRINT18 gate 0 — is a client EXPECTING you tonight? See isBooked: the
|
||||
* diary slot, minus emergencies — so gate 1's emergency reads as unbooked
|
||||
* without this function learning what an emergency is.
|
||||
*/
|
||||
booked: isBooked(kind, night),
|
||||
};
|
||||
return priced ? Object.assign(offer, priced(night)) : offer;
|
||||
};
|
||||
const offers = [mk(scripted, 'scripted', 'scripted')];
|
||||
if (alt) offers.push(mk(alt, 'callout', `alt:${alt.id}`));
|
||||
/**
|
||||
* SPRINT18 gate 0 — WHAT EACH TAKE COSTS, ON THE OFFER ITSELF.
|
||||
*
|
||||
* Annotated here rather than computed by the card, for the reason the fee is:
|
||||
* a price the card works out for itself is a price the invoice can disagree
|
||||
* with. week.take() charges off this same field, so the number the player read
|
||||
* before choosing IS the number that moves their standing — by construction,
|
||||
* not by two surfaces agreeing.
|
||||
*
|
||||
* Summaries, not offer references: an offer that pointed at its sibling would
|
||||
* be a cycle the moment anything tried to serialise a board.
|
||||
*/
|
||||
for (const o of offers) {
|
||||
o.standsUp = standUpsIfTaken(offers, o).map((x) => ({
|
||||
id: x.id, client: x.night.client ?? null, addr: x.night.addr ?? null,
|
||||
}));
|
||||
}
|
||||
return offers;
|
||||
}
|
||||
|
||||
@ -100,10 +100,26 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
|
||||
* can't inspect the inside of). One rating for the whole type, per the
|
||||
* manifest rule. The GLB wears the tell (rust, a sagging pad eye); the type
|
||||
* string is the player's pre-rig read of the same fact.
|
||||
*
|
||||
* `hail_net_frame` (SPRINT18 gate 4, E): the frame that straddles a glasshouse
|
||||
* so a hail net can be rigged OVER the glass (DESIGN.md line 179). It is the
|
||||
* first anchor word in this enum whose danger is not in its own number.
|
||||
* rating_hint 0.80 — the highest rung below the honest post, and it EARNS it:
|
||||
* an engineered portal frame on four real footings with welded eyes, the only
|
||||
* structure in the palette that was built to be tied to. The 20% de-rate is
|
||||
* for section, not condition (32 mm sized for hail mesh, which spills wind;
|
||||
* a 25 m² impermeable sail is an order of magnitude more load).
|
||||
* What makes it the most dangerous anchor in the game is that its baked
|
||||
* `collateral` names the GLASSHOUSE, not itself — the `house`→"gutter"
|
||||
* precedent, and the same shape: an anchor names what its failure TAKES, not
|
||||
* what it is. Break a corner here and the sail, the shackle and the turnbuckle
|
||||
* go through fourteen panes at $320. The type string is the player's pre-rig
|
||||
* read of a hazard that is a POSITION, and the reason it must not be typed
|
||||
* `post`: "post" promises a corner whose failure costs a shackle.
|
||||
*/
|
||||
export const ANCHOR_TYPE = Object.freeze([
|
||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame', 'pergola',
|
||||
'corroded_post',
|
||||
'corroded_post', 'hail_net_frame',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -46,7 +46,39 @@ export function createDebris(o = {}) {
|
||||
// contracts.js documents world.heightAt as the thing Lane C bounces debris off.
|
||||
// Flat fallback so this still runs against a graybox yard.
|
||||
const groundAt = o.heightAt || (() => o.groundY ?? 0);
|
||||
const rand = rng(((wind && wind.seed) || 1) ^ 0x5eed1e);
|
||||
|
||||
/**
|
||||
* A crate's jitter is keyed on THE EVENT, not on a shared stream. [SPRINT18]
|
||||
*
|
||||
* This was `const rand = rng(seed ^ 0x5eed1e)` — one stream, drawn from inside
|
||||
* spawn(). A stream's POSITION encodes how many pieces have already spawned,
|
||||
* so history leaked into every later crate, and it cost us two determinism
|
||||
* breaks measured on the wildnight (crate heights, m):
|
||||
*
|
||||
* the same storm twice through one module night 1 y 1.582 night 2 y 1.165
|
||||
* (main.js builds ONE debris module at boot and clear()s it per phase
|
||||
* change; clear() rewound leafDist/leafEma and NOT this stream, so night 2
|
||||
* of the seven-night week flew different crates than night 1 — live on
|
||||
* main today, no emergency callout required.)
|
||||
* an entry at t=45, past the t=38 event t=0 run y 0.937 sx 0.394 ph 4.413
|
||||
* t=45 y 1.623 sx 0.581 ph 0.839
|
||||
* (every crate after a skipped event wore the SKIPPED crate's clothes: the
|
||||
* stream sat 5 draws behind. Skipping history corrupted the future, not
|
||||
* just the present — the thing that makes a mid-storm arrival unpinnable.)
|
||||
*
|
||||
* Keyed on `ev.t` (stable authored data, unique per event in every shipped
|
||||
* storm) so the crate at t=38 is the same crate whether you watched the first
|
||||
* 38 seconds or arrived at 30. Manual spawns — A's debug keys, tuning — have no
|
||||
* `ev.t` and fall back to the spawn time, which differs per keypress.
|
||||
*
|
||||
* This is the same move weather.core.js's header argues for: determinism
|
||||
* STRUCTURAL, not a promise. A crate is now a pure function of its event.
|
||||
*/
|
||||
const seedBase = (((wind && wind.seed) || 1) ^ 0x5eed1e) | 0;
|
||||
const eventRng = (ev, t) => {
|
||||
const key = Math.round((Number.isFinite(ev.t) ? ev.t : t) * 1000);
|
||||
return rng((seedBase ^ Math.imul(key, 374761393)) | 0);
|
||||
};
|
||||
|
||||
const pieces = [];
|
||||
const w = new THREE.Vector3();
|
||||
@ -122,6 +154,7 @@ export function createDebris(o = {}) {
|
||||
function spawn(ev, t) {
|
||||
const spec = { ...(MODEL_SPEC[ev.model] || DEFAULT_SPEC) };
|
||||
if (Number.isFinite(ev.mass)) spec.mass = ev.mass;
|
||||
const rand = eventRng(ev, t); // this crate's own stream — see eventRng above
|
||||
|
||||
// upwind of the yard, offset sideways, so it crosses the whole thing
|
||||
const d = wind.dirAt(t);
|
||||
@ -282,6 +315,57 @@ export function createDebris(o = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* ARRIVE MID-STORM at t0. [SPRINT18 — the emergency callout's entry]
|
||||
*
|
||||
* The storm ran; you just weren't there. Three classes of state, and only one
|
||||
* of them can be teleported:
|
||||
*
|
||||
* 1. closed-form in t — the wind field, rain rate, hail rate. Measured
|
||||
* order-independent (sample t=30 cold, or after walking 0→30, or after
|
||||
* scrambling 90/0/45/12/88/30: byte-identical). Needs nothing.
|
||||
* 2. a pure function of its own event — crate jitter, as of eventRng above.
|
||||
* Needs nothing, now.
|
||||
* 3. a genuine path integral — `leafDist` (metres travelled downwind) and
|
||||
* `leafEma` (2.5 s smoothed speed). These CANNOT be teleported, and
|
||||
* pretending otherwise is what made the arrival read wrong: measured on
|
||||
* the wildnight, a t=0 run has 6 leaves crossing the grass at t=30 and a
|
||||
* cold entry has 0 — and stays becalmed for ~7 s while the EMA climbs
|
||||
* off zero. DESIGN.md line 149 makes the moving grass the gust front's
|
||||
* tell, and gate 1's whole premise is that the sky and the grass are the
|
||||
* only briefing a callout gets. Losing the tell for the first seven
|
||||
* seconds robs exactly the read the dispatch is asking the player to make.
|
||||
*
|
||||
* So class 3 is RUN, not skipped: this is `fixedLoop(t0, dt, step)` and
|
||||
* nothing more — the same ladder, the same code path, no shortcut. It is
|
||||
* cheap because the ambient layer is two scalars and a wind sample (crates
|
||||
* spawn, fly and despawn honestly on the way past). Do NOT "optimise" this
|
||||
* into a teleport; the pin in c.test.js exists to catch that, and the
|
||||
* negative control beside it records what the teleport actually costs.
|
||||
*
|
||||
* ⚠️ The ladder ACCUMULATES — `t += dt`, not `i * dt`. I wrote it as `i * dt`
|
||||
* first (exact multiplication, no drift) and the pin went red: 6 leaves in
|
||||
* both yards, in different places. `i * dt` is the MORE ACCURATE ladder and
|
||||
* that is precisely why it is wrong — main.js's `phaseT += dt`, testkit's
|
||||
* `fixedLoop` and sail.js's `this.t += SIM_DT` all accumulate, so a warm-up
|
||||
* that multiplies reproduces a storm the game never flies. Measured: over
|
||||
* 1800 steps the two ladders drift 4.2e-13 s apart in total, they pass a
|
||||
* different `t` on 1770 of those 1800 steps, and they first disagree at step
|
||||
* SIX (0.09999999999999999 against 0.1). A number gathered from a harness
|
||||
* that is a hair better than the game's is still a number from the wrong
|
||||
* harness — the class of bug this repo hunts, in its smallest possible form.
|
||||
*
|
||||
* @param {number} t0 storm time to arrive at, seconds
|
||||
* @param {number} dt fixed step — pass contracts.FIXED_DT, the same ladder
|
||||
* the caller will keep stepping on
|
||||
*/
|
||||
enterAt(t0, dt) {
|
||||
const steps = Math.round(t0 / dt);
|
||||
let t = 0;
|
||||
for (let i = 0; i < steps; i++) { debris.step(dt, t, {}); t += dt; }
|
||||
return t;
|
||||
},
|
||||
|
||||
/** Drop everything (phase change, restart). */
|
||||
clear() {
|
||||
for (const p of pieces) despawn(p);
|
||||
|
||||
@ -377,6 +377,56 @@ export const PALETTE = [
|
||||
anchors: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
// SPRINT18 gate 4, E — APPLIED VERBATIM (leadFor precedent, eighth
|
||||
// application; E could not use the PLACE panel at all until this landed, so
|
||||
// they verified in tools/assetcheck/look.html instead).
|
||||
//
|
||||
// The glasshouse tier's LIVE half — the frame you rig a hail net FROM,
|
||||
// straddling the glass you must never rig TO. DESIGN.md line 179: "hail nets
|
||||
// and mesh sails rigged over glass, where your own failure is the worst
|
||||
// debris of all." Place it at the same x/z as a glasshouse and it straddles
|
||||
// correctly; the eyes land at y=2.690, clear of the 2.28 ridge.
|
||||
//
|
||||
// ⚖️ **0.80, RULED (A) — E's proposal, ADOPTED AS PROPOSED, reasoning
|
||||
// endorsed and not re-derived.** It earns the highest rung below the sail
|
||||
// post because it is the only structure in the palette that was BUILT to be
|
||||
// tied to (engineered portal, four footings, welded eyes), and it is short of
|
||||
// 1.00 for SECTION rather than condition — 32 mm sized for ~0.2 kg/m² of
|
||||
// knitted mesh that spills wind, against a 25 m² impermeable sail in a
|
||||
// southerly. De-rating honest steel for looking dangerous would be the
|
||||
// corroded post's lie inverted, and this ladder already has one of those.
|
||||
//
|
||||
// AND IT USUALLY HOLDS. That is the tier, not a softness in it — the whole
|
||||
// sentence line 179 has been waiting for an asset to say is **"the same
|
||||
// failure has a different price depending on what is underneath it."** The
|
||||
// player who learned "0.80 is fine" everywhere else is exactly the player
|
||||
// this yard is for.
|
||||
//
|
||||
// ⚠️ `_v1` is LOAD-BEARING and its absence is SILENT: adoptAnchor does
|
||||
// `rating_hint ?? 1`, so a typo does not fail — it rates these four eyes
|
||||
// 1.00, the best steel in the game, on the highest-collateral anchor there
|
||||
// is. (S14, D's cold pass.)
|
||||
// ⚠️ THIS ITEM WRITES NO collateralKey, ON PURPOSE. The anchors bill
|
||||
// 'glasshouse'; the GLASSHOUSE owns that key. structFor() returns the FIRST
|
||||
// structure matching a key, so a frame that claimed it would bill the panes
|
||||
// and then wreck the FRAME — both papers agreeing and both wrong.
|
||||
// ⚠️ PLACE IT OVER A GLASSHOUSE. With no glasshouse in the site,
|
||||
// collateralFor('glasshouse') returns null and these four corners are a FREE
|
||||
// failure — the gutter bug. e.test pins that per-yard and names the anchor.
|
||||
kind: 'structure', label: 'Hail-net frame (0.80 — over the glass, $320 under it)',
|
||||
model: 'hail_net_frame_01_v1',
|
||||
requires: ['hail_net_frame'],
|
||||
hint: 'four good eyes at 2.7 m, rated 0.80 — and everything they drop lands on the glasshouse',
|
||||
make: (id, x, z) => ({
|
||||
id, model: 'hail_net_frame_01_v1',
|
||||
wreckedModel: 'hail_net_frame_01_wrecked_v1',
|
||||
x, z, rotYDeg: 0, solid: true,
|
||||
anchors: [1, 2, 3, 4].map((i) => ({
|
||||
id: `${id}_a${i}`, node: `net_anchor_0${i}`, type: 'hail_net_frame', work: 'cloth',
|
||||
})),
|
||||
}),
|
||||
},
|
||||
{
|
||||
// The compliance ring. Fourteen posts, every one tie_off:false in the GLB;
|
||||
// unpriced BY RULING (the bike rule — price it when debris can bend it).
|
||||
|
||||
@ -19,7 +19,7 @@ import { forecastLines, leadFor } from './weather.js';
|
||||
// SPRINT16 — the end card compares the final rep against neutral. A constant,
|
||||
// not state: the HUD still only READS. week.js has no DOM and no THREE, so
|
||||
// this import costs nothing it didn't already cost main.js.
|
||||
import { NIGHTS, REP } from './week.js';
|
||||
import { NIGHTS, PAY, REP } from './week.js';
|
||||
|
||||
// SPRINT16 gate 4 (Lane D, flagged for A): the week is data — NIGHTS.length —
|
||||
// and these surfaces used to hardcode "five". A six-night week whose splash,
|
||||
@ -66,6 +66,42 @@ const docketNo = (night) => 1040 + night;
|
||||
* the business is the constant, and this is the one number the business has.
|
||||
*/
|
||||
const repFmt = (r) => `★ ${Number(r).toFixed(1)}`;
|
||||
/**
|
||||
* SPRINT18 gate 0 — a MOVEMENT in standing, in the letterhead's own units.
|
||||
*
|
||||
* Half-stars written as halves, because that is how the number is ruled
|
||||
* (week.js §REP: half-steps, so every printed value stays exact). "−0.5★" is a
|
||||
* float on a business document; "−½★" is a rating. The sign is always printed —
|
||||
* a cost with no sign on it is a number the player has to guess the direction of.
|
||||
*/
|
||||
const starDelta = (d) => {
|
||||
const a = Math.abs(Number(d) || 0);
|
||||
const whole = Math.floor(a);
|
||||
const half = a - whole >= 0.5;
|
||||
return `${d < 0 ? '−' : '+'}${whole || !half ? whole : ''}${half ? '½' : ''}★`;
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚠️ **SPRINT18 gate 1.3 [Lane D] — WHAT STANDING A CLIENT UP WILL ACTUALLY COST, WHICH IS NOT
|
||||
* ALWAYS `REP.STOOD_UP`.** Found by playing the week to ★0.0 and taking the emergency anyway.
|
||||
*
|
||||
* `settle()` clamps rep at `REP.MIN`, so at ★0.0 a −½★ debit moves NOTHING — and both papers went on
|
||||
* promising it. On the offer card that is a price the game will not charge; on the invoice it is
|
||||
* worse, because the same card carries the row *"stood up the Hendersons · −½★ standing"* four lines
|
||||
* above *"reputation ★ 0.0 → ★ 0.0"*. **The invoice arguing with itself**, which is the defect class
|
||||
* this repo has spent five sprints hunting, on John's own ruling.
|
||||
*
|
||||
* This function is the PAPER half only, and the split is deliberate: whether the ruling should have
|
||||
* teeth at the floor is a DESIGN question and it is John's (filed in THREADS with the measurement —
|
||||
* at ★0.0 a fee-maximiser can dodge every booked night for free again, which is precisely the player
|
||||
* the ruling was written for). Until he rules, the documents tell the truth about the number.
|
||||
*
|
||||
* @param {number} n how many clients would be stood up
|
||||
* @param {number|null} rep the standing the charge lands against (`wk.rep` / `w.repBefore`)
|
||||
*/
|
||||
const standUpCost = (n, rep) => (rep == null
|
||||
? REP.STOOD_UP * n
|
||||
: Math.max(REP.MIN, rep + REP.STOOD_UP * n) - rep);
|
||||
|
||||
// Load bar colours: green → amber → red. The amber band is deliberately wide;
|
||||
// a corner at 60% in a lull is one gust from 100% and the player should feel
|
||||
@ -342,6 +378,59 @@ const CSS = `
|
||||
#hud-card .offer .fee b { color:#ffd9a3; }
|
||||
#hud-card .offer .risk b { color:#ff8f86; }
|
||||
#hud-card .offer.constrained { border-left:3px solid #ffc24a; }
|
||||
/* ⚖️ SPRINT18 gate 0 — THE PRICE OF SAYING NO, on the card, before the choice.
|
||||
Deliberately styled like the risk row and NOT like the constraint mark: a
|
||||
constraint is something the client asks of you, this is something taking the
|
||||
job costs you. Red-side, because it is a debit — and it sits directly under
|
||||
the fee, so the two numbers a dodge is decided on are adjacent. The whole
|
||||
point of the ruling is that this is never a surprise on the invoice. */
|
||||
#hud-card .offer .cost b { color:#ff8f86; }
|
||||
#hud-card .offer .cost span { color:#c99a94; }
|
||||
/* The memory. Grey, italic, quiet — a client who remembers is not a warning
|
||||
label, it is a fact about the room you are walking into. */
|
||||
#hud-card .offer .remembers, #hud-card .remembers {
|
||||
margin-top:7px; font-size:11px; font-style:italic; color:#8a7f74;
|
||||
}
|
||||
#hud-card .board-note.free { color:#8ba0ad; }
|
||||
/* 🚨 SPRINT18 gate 1 — an emergency on the board. Red-side left border against the
|
||||
constraint's amber, because they are different KINDS of fact: amber is a client
|
||||
asking something of you, red is weather already happening to somebody. */
|
||||
#hud-card .offer.emergency { border-left:3px solid #ff8f86; }
|
||||
#hud-card .offer .line.siren span { color:#ff8f86; letter-spacing:.04em; }
|
||||
|
||||
/* --- 🚨 SPRINT18 gate 1: THE DISPATCH ------------------------------------
|
||||
The paper chain's fourth document, and the design brief for it is one line:
|
||||
IT IS NOT A JOB SHEET. A job sheet is a quote — a letterhead, a schedule,
|
||||
three amounts each carrying its condition. A dispatch is a phone call written
|
||||
down at ten at night. So it wears the kit's letterhead (one outfit, four
|
||||
papers) and then deliberately does NOT have the sched block: no garden bonus,
|
||||
no clean bonus, no total, because nobody costed this job. The one number on it
|
||||
is the RATE, because double rates are the reason you get in the ute.
|
||||
|
||||
Red, not amber. The board's constraint mark is amber (a client asking
|
||||
something of you); this is the only card in the game that opens while weather
|
||||
is already happening to somebody. */
|
||||
#hud-card .dispatch .docket .kind { color:#ff8f86; }
|
||||
#hud-card .dispatch h1 { color:#ffd9a3; }
|
||||
#hud-card .dispatch .siren { margin:2px 0 12px; font-size:11px; letter-spacing:.18em;
|
||||
text-transform:uppercase; color:#ff8f86; }
|
||||
/* The call itself, in the client's voice, and given the room a brief gets —
|
||||
it IS the brief. There is no other one. */
|
||||
#hud-card .dispatch .call { margin:10px 0 4px; padding:11px 13px; border-left:3px solid #ff8f86;
|
||||
background:#1a1417; font-size:13px; line-height:1.6; color:#e6d8d5; }
|
||||
/* C's mid-storm read: what the sky and the grass tell you, since no paper does.
|
||||
Same rhythm as the offer card's weather band, one shade colder — nobody
|
||||
briefed this, you are reading it off the yard. */
|
||||
#hud-card .dispatch .read { display:flex; justify-content:space-between; gap:14px;
|
||||
font-size:12px; color:#8ba0ad; padding:2px 0; }
|
||||
#hud-card .dispatch .read.hail span { color:#cbd8e0; }
|
||||
#hud-card .dispatch .noread { font-size:11px; font-style:italic; color:#6d818e; margin:8px 0 2px; }
|
||||
/* The inherited rig, listed. A corner that arrives on the ground is named
|
||||
before you go, not discovered — the tell rule (DESIGN.md: traps have tells). */
|
||||
#hud-card .dispatch .theirs { font-size:12px; color:#8ba0ad; padding:2px 0;
|
||||
display:flex; justify-content:space-between; gap:14px; }
|
||||
#hud-card .dispatch .theirs b { color:#dde5ea; font-weight:700; }
|
||||
#hud-card .dispatch .theirs.down b { color:#ff8f86; }
|
||||
#hud-card .offer .says { margin-top:8px; padding-left:9px; border-left:2px solid #3f5561;
|
||||
font-size:11px; font-style:italic; color:#c8b78a; line-height:1.5; }
|
||||
#hud-card .offer .cons { font-size:10px; letter-spacing:.12em; text-transform:uppercase;
|
||||
@ -827,19 +916,71 @@ export function createHud(d) {
|
||||
// The .hail line is lit brighter because it is the one a player must
|
||||
// not skim past — the soaker's whole trap is a gentle-reading wind.
|
||||
return `
|
||||
<button class="offer${cons.length ? ' constrained' : ''}" data-offer="${i}">
|
||||
<span class="tag">${o.kind === 'scripted' ? 'tonight, as booked' : 'callout'}</span>
|
||||
<button class="offer${cons.length ? ' constrained' : ''}${n.emergency ? ' emergency' : ''}" data-offer="${i}">
|
||||
${/* 🚨 SPRINT18 gate 1 — AN EMERGENCY MUST NOT LOOK LIKE AN ORDINARY
|
||||
CALLOUT, and my first cut let it. On the live board it wore the same
|
||||
grey "callout" tag as the Tuesday alternative beside it: double
|
||||
rates, no prep, no quote, somebody else's rig — the biggest new
|
||||
thing in the game — and the card said nothing. A player would find
|
||||
out one click later, which is the tell rule broken on the exact
|
||||
surface the tell rule exists for.
|
||||
So: its own tag, its own left border, and one line naming what is
|
||||
different about it, above the money. */''}
|
||||
<span class="tag">${
|
||||
n.emergency ? 'right now · emergency' : o.kind === 'scripted' ? 'tonight, as booked' : 'callout'
|
||||
}</span>
|
||||
<div class="who">${n.client ?? 'no client'}</div>
|
||||
<div class="addr">${n.addr ?? ''}</div>
|
||||
${n.emergency ? `<div class="line siren"><span>storm already running · no prep · no quote · you arrive ${
|
||||
n.emergency.arriveAt}s in</span></div>` : ''}
|
||||
${(o.band ?? []).map((l) => `<div class="line wx ${l.key}"><span>${l.text}</span></div>`).join('')}
|
||||
${o.fee != null ? `<div class="line fee"><span>the job pays${
|
||||
o.premium ? ` · +${Math.round(o.premium * 100)}% on their terms` : ''
|
||||
}${/* the doubled number gets its reason printed beside it — money with no
|
||||
stated cause is the thing every ledger comment in this repo is about */''}${
|
||||
n.emergency ? ` · ${PAY.emergencyRate}× emergency rate` : ''
|
||||
}</span><b>$${o.fee}</b></div>` : ''}
|
||||
${o.exposure != null ? `<div class="line risk"><span>their property at risk${
|
||||
o.exposureItems?.length ? ` — ${o.exposureItems.map((x) => x.what).join(', ')}` : ''
|
||||
}</span><b>$${o.exposure}</b></div>` : ''}
|
||||
${/* ⚖️ SPRINT18 gate 0, clause 2 — WHAT TAKING THIS COSTS, IN THE
|
||||
CLIENT'S TERMS, BEFORE THE CHOICE. John's ruling is explicit that
|
||||
this may not wait for the invoice: "a choice whose price is
|
||||
invisible until the invoice is a trap, and this repo doesn't ship
|
||||
traps without tells." board.js works out WHO is stood up and
|
||||
week.js rules the number; the card's whole job is to say it out
|
||||
loud, next to the fee that is tempting you.
|
||||
|
||||
When nothing on the board is booked (gate 1's two-callout
|
||||
morning) `standsUp` is empty and this renders NOTHING — the free
|
||||
case reads as free because there is no line, not because a line
|
||||
says "$0". */''}
|
||||
${/* ⚠️ "ARE", ALWAYS, and it is not a typo I am defending. My first cut
|
||||
agreed the verb with the NUMBER OF CLIENTS STOOD UP and rendered
|
||||
"the Hendersons IS expecting you" on the live board — bad English
|
||||
on the one line John's ruling says must be readable before the
|
||||
choice. The count is the wrong thing to agree with: every client in
|
||||
this game is a collective noun (a household, a place), whose number
|
||||
no code here can infer, and the repo's own voice already treats them
|
||||
as plural — night 7's brief says "The Vasilaros place again — THEY
|
||||
asked for you by name". The spec's own example wording is "the
|
||||
Hendersons are expecting you". So: no agreement logic at all. */''}
|
||||
${/* SPRINT18 gate 1.3 [D] — the cost AS IT WILL BE CHARGED (standUpCost): at ★0.0 the
|
||||
clamp eats it, and a card promising a price the invoice will not take is the same lie
|
||||
as a card hiding one. The words change with it, because "−0★" would be gibberish. */''}
|
||||
${o.standsUp?.length ? `<div class="line cost"><span>${
|
||||
o.standsUp.map((s) => s.client ?? 'a client').join(' and ')
|
||||
} are expecting you</span><b>${
|
||||
standUpCost(o.standsUp.length, wk.rep) === 0
|
||||
? 'nothing left to dock'
|
||||
: `${starDelta(standUpCost(o.standsUp.length, wk.rep))} standing`}</b></div>` : ''}
|
||||
${cons.length ? `<div class="cons">⚖ ${cons.map((c) => c.label).join(' · ')}</div>` : ''}
|
||||
${cons.map((c) => `<div class="says">“${c.says}”</div>`).join('')}
|
||||
${/* gate 0, clause 3 — and if you have done this to THEM before, it is
|
||||
on their card. main.js injects week.remembers(client). */''}
|
||||
${o.remembers?.length ? `<div class="remembers">They remember night ${
|
||||
o.remembers.map((s) => s.night).join(' and night ')
|
||||
} — you took another job.</div>` : ''}
|
||||
</button>`;
|
||||
};
|
||||
|
||||
@ -860,7 +1001,20 @@ export function createHud(d) {
|
||||
<h2>Take one. The other goes to somebody else.</h2>
|
||||
${offers.map(offerHtml).join('')}
|
||||
<div class="row" style="margin-top:12px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||||
<div class="board-note">Both are tonight. You cannot work two yards in one storm.</div>
|
||||
${/* ⚖️ SPRINT18 gate 0, clause 5 — THE ASYMMETRY, SAID IN WORDS.
|
||||
A morning with a booked job on it: turning it down costs your name,
|
||||
and the offer that costs says so on its own card. A morning with
|
||||
NOTHING booked (two callouts — legal, and what gate 1's emergency
|
||||
makes reachable): declining is genuinely free, and the note SAYS
|
||||
free rather than leaving the player to infer it from an absence.
|
||||
"No cost shown" and "no cost" have to be the same sentence on a
|
||||
document, or the quiet morning teaches the same lie the loud one
|
||||
would. */''}
|
||||
<div class="board-note${offers.some((o) => o.booked) ? '' : ' free'}">${
|
||||
offers.some((o) => o.booked)
|
||||
? 'Both are tonight. You cannot work two yards in one storm.'
|
||||
: 'Nobody has you booked tonight. Neither of these costs you anything to turn down.'
|
||||
}</div>
|
||||
<div class="terms">Fees quoted on the forecast at time of callout. Weather is not a variation.</div>
|
||||
</div>`;
|
||||
card.classList.add('on');
|
||||
@ -873,6 +1027,92 @@ export function createHud(d) {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1 — **THE DISPATCH.** The paper an emergency callout gets
|
||||
* INSTEAD of a job sheet, and the "instead" is the whole design: SPRINT18's
|
||||
* spec says *"a dispatch instead of a job sheet (there was no time to quote —
|
||||
* that's the drama)"*, so what makes this card right is mostly what it refuses
|
||||
* to print.
|
||||
*
|
||||
* WHAT IT SHOWS, and why exactly this:
|
||||
* · **THE CALL** — `emergency.dispatch`, in the client's voice. It is the
|
||||
* brief, because there is no other one.
|
||||
* · **WHAT THE SKY IS DOING** — C's `midStormRead(def, arriveAt)`, MAPPED as
|
||||
* an ordered list exactly as the offer card maps `offerBand`, so a fact C
|
||||
* adds cannot be dropped by this card picking two fields. Absent ⇒ one
|
||||
* honest italic line saying nobody briefed you, which is the truth and not
|
||||
* a placeholder.
|
||||
* · **THE RIG YOU DIDN'T BUILD** — every corner named with its hardware, and
|
||||
* the ones already on the ground marked. DESIGN.md's tell rule: the trap is
|
||||
* allowed to be brutal, it is not allowed to be a surprise. A player who
|
||||
* reads "carabiner — ON THE GROUND" against the carport has been told.
|
||||
* · **THE RATE** — the fee and the multiplier it came from. The one number.
|
||||
*
|
||||
* WHAT IT REFUSES TO SHOW: the garden bonus, the clean bonus, and a total.
|
||||
* `quote.emergency` exists so this card can be a VIEW of the same money object
|
||||
* the invoice settles against (see week.js) while printing none of the rows
|
||||
* nobody quoted.
|
||||
*
|
||||
* @param {object} d {job, quote, def, site, read, rig}
|
||||
* @param {object} wk {night, nights, bank, rep}
|
||||
* @param {() => void} onRoll
|
||||
*/
|
||||
showDispatch({ job, quote, read, site }, wk, onRoll) {
|
||||
const em = job?.emergency ?? {};
|
||||
const q = quote ?? {};
|
||||
const rigRows = (em.rig ?? []).map((c) => `
|
||||
<div class="theirs${c.broken ? ' down' : ''}"><span>${c.anchorId?.toUpperCase()}</span><b>${
|
||||
c.hw ?? '?'}${c.broken ? ' — ON THE GROUND' : ''}</b></div>`).join('');
|
||||
|
||||
card.innerHTML = `<div class="card jobcard dispatch">
|
||||
<div class="letterhead">
|
||||
<div>
|
||||
<div class="mark">${BUSINESS.mark}</div>
|
||||
<div class="trade">${BUSINESS.trade}</div>
|
||||
</div>
|
||||
<div class="docket">
|
||||
<div class="kind">DISPATCH</div>
|
||||
<div class="no">CALL ${docketNo(wk.night)} · ${ABN}</div>
|
||||
${wk.rep != null ? `<div class="rep">standing ${repFmt(wk.rep)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="billto">
|
||||
<div class="who">${job?.client ?? 'no client'}</div>
|
||||
${job?.addr ? `<div class="addr">${job.addr}</div>` : ''}
|
||||
</div>
|
||||
<h1>RIGHT NOW</h1>
|
||||
<div class="siren">no quote · no prep · you arrive ${em.arriveAt ?? 0}s into it</div>
|
||||
${site ? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? ` — ${site.blurb}` : ''}</div>` : ''}
|
||||
${em.dispatch ? `<div class="call">${em.dispatch}</div>` : ''}
|
||||
${/* C's seam. An ordered list, mapped — never two hand-picked fields. */''}
|
||||
<div class="sect">WHAT YOU CAN SEE FROM THE DRIVE</div>
|
||||
${read?.length
|
||||
? read.map((l) => `<div class="read ${l.key}"><span>${l.text}</span></div>`).join('')
|
||||
: '<div class="noread">Nobody briefed you. You will find out what this storm is doing by standing in it.</div>'}
|
||||
${rigRows ? `<div class="sect">THE RIG THAT IS ALREADY UP</div>${rigRows}` : ''}
|
||||
<div class="sect">WHAT IT PAYS</div>
|
||||
<div class="sched">
|
||||
<div class="row"><span>emergency callout${
|
||||
q.emergency?.rate ? ` — ${q.emergency.rate}× the going rate` : ''
|
||||
}${q.feeMult && q.feeMult !== 1 ? ` · standing ×${q.feeMult.toFixed(2)}` : ''}</span><b>$${q.base ?? 0}</b></div>
|
||||
<div class="cond">Nobody costed this job. What the garden is worth, and what you owe for
|
||||
anything that breaks, is on the invoice in the morning.</div>
|
||||
</div>
|
||||
${(q.warranty ?? []).length ? `
|
||||
<div class="sect">STILL OWED FROM LAST NIGHT</div>
|
||||
<div class="sched">
|
||||
${q.warranty.map((it) => `
|
||||
<div class="row"><span>warranty — ${it.anchorId.toUpperCase()}, ${it.hw}, no charge to the client</span><b>−$${it.cost}</b></div>`).join('')}
|
||||
</div>` : ''}
|
||||
<div class="row" style="margin-top:12px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||||
<button class="go">ROLL</button>
|
||||
<div class="terms">Emergency attendance. Rates as advised by telephone. No pre-attendance inspection was possible.</div>
|
||||
</div>`;
|
||||
card.classList.add('on');
|
||||
hud.setVisible(false);
|
||||
card.querySelector('.go').addEventListener('click', () => { hud.hideCard(); onRoll(); });
|
||||
},
|
||||
|
||||
/**
|
||||
* The forecast. Its job is to sell the dread and, incidentally, to be the
|
||||
* difficulty select for free — three authored storms already bracket the
|
||||
@ -936,6 +1176,15 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
const brief = job.brief
|
||||
? `<div class="brief">${job.brief}</div>` : '';
|
||||
|
||||
// ⚖️ SPRINT18 gate 0, clause 3 — you have stood THIS client up before, and
|
||||
// the sheet says so under their brief. The board's card is where the memory
|
||||
// changes a decision; this is where it changes the room you walk into.
|
||||
// Off the same week ledger, so it cannot disagree with the card.
|
||||
const remembers = (wk.remembers ?? []).length ? `
|
||||
<div class="remembers">They have not forgotten night ${
|
||||
wk.remembers.map((s) => s.night).join(' and night ')
|
||||
}. You are here on their second-choice terms.</div>` : '';
|
||||
|
||||
// SPRINT17 gate 2 — THE CLIENT'S TERMS, on the sheet, in their words.
|
||||
// Under the brief and above the weather on purpose: the constraint is
|
||||
// part of what they're asking for, not a footnote to it, and the player
|
||||
@ -1028,6 +1277,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
card.innerHTML = `<div class="card jobcard">
|
||||
${letterhead}
|
||||
${brief}
|
||||
${remembers}
|
||||
${constraintBlock}
|
||||
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
||||
<div class="pips">${pips}</div>
|
||||
@ -1253,6 +1503,72 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
every decision on it. Off the settlement (week.js carries
|
||||
s.constraints), so it stays true when you re-read night 3's
|
||||
invoice on night 5's morning. */''}
|
||||
${/* ⚖️ SPRINT18 gate 0, clause 4 — THE INVOICE STATES WHICH JOB YOU TOOK
|
||||
AND WHAT IT COST YOU TO TAKE IT. Both papers, same as gate 2's
|
||||
constraints: the offer card priced the decision, this is the record
|
||||
of having made it. Off the settlement (week.js carries `taken` and
|
||||
`stoodUp`), so night 5's invoice still names night 5's broken promise
|
||||
when you re-read it on night 7's morning.
|
||||
|
||||
The standing row prints even at zero cost — an off-the-board job on a
|
||||
morning where nothing was booked SAYS so ("nobody was expecting you"),
|
||||
because gate 0's asymmetry has to be legible on the paper as well as
|
||||
on the card. This is the one place the free case is worth a line
|
||||
rather than a silence: the invoice is the document that explains, and
|
||||
a player who paid the price on Tuesday should be able to see why
|
||||
Thursday was free. */''}
|
||||
${w?.taken ? `
|
||||
<div class="sect">OFF THE BOARD</div>
|
||||
<div class="sched">
|
||||
<div class="row"><span>you took this callout</span><b>${w.client ?? 'no client'}</b></div>
|
||||
${w.stoodUp?.length
|
||||
? w.stoodUp.map((s) => `
|
||||
<div class="row ${standUpCost(1, w.repBefore) === 0 ? '' : 'bad'}"><span>stood up ${
|
||||
s.client ?? 'a client'} — they were expecting you</span><b>${
|
||||
standUpCost(1, w.repBefore) === 0
|
||||
? 'nothing left to dock'
|
||||
: `${starDelta(standUpCost(1, w.repBefore))} standing`}</b></div>
|
||||
<div class="cond">${standUpCost(1, w.repBefore) === 0
|
||||
? 'Your standing is already on the floor, so this one was free. They will remember the phone call anyway.'
|
||||
: 'No invoice for that one. They will remember the phone call.'}</div>`).join('')
|
||||
: '<div class="row"><span>nobody was expecting you — nothing to stand up</span><b>—</b></div>'}
|
||||
</div>` : ''}
|
||||
${/* 🚨 SPRINT18 gate 1 — THE EMERGENCY'S PAPERWORK. Three things the invoice
|
||||
for a night you didn't rig has to say, and every one of them exists
|
||||
because the alternative is the paper lying:
|
||||
|
||||
· IT WAS A CALLOUT, at what rate. The fee row already carries the
|
||||
money; this says why it was double.
|
||||
· WHOSE CORNERS WERE ALREADY DOWN — named, and explicitly NOT BILLED.
|
||||
This is the split-the-blame line, the same job the icenight's
|
||||
invoice does ("the shackle at P2 is the part that's on you"). Without
|
||||
it a player reads a $180 carport against a corner they never chose.
|
||||
· THE KNIFE (D's verb, principle 2 — a verb with no line on the
|
||||
paperwork is a verb the game does not admit to). Silent until D's
|
||||
input lands, which is the degradation rule, not a stub. */''}
|
||||
${w?.emergency ? `
|
||||
<div class="sect">EMERGENCY ATTENDANCE</div>
|
||||
<div class="sched">
|
||||
<div class="row"><span>called out mid-storm — arrived ${w.emergency.arriveAt}s in, no prep, no quote</span><b>${w.emergency.rate}× rate</b></div>
|
||||
${(w.inherited ?? []).map((c) => `
|
||||
<div class="row"><span>${c.anchorId.toUpperCase()} — ${c.hw}, already on the ground when you arrived</span><b>not billed</b></div>
|
||||
<div class="cond">That corner is what the callout was for. It is not on you.</div>`).join('')}
|
||||
</div>` : ''}
|
||||
${/* 🚨 SPRINT18 gate 1.3 [Lane D] — **THE KNIFE'S LINE, ON ANY NIGHT.** It shipped inside the
|
||||
EMERGENCY ATTENDANCE block, so a sail cut loose on an ordinary Tuesday printed NOTHING —
|
||||
the verb is available every night the sail is up, and principle 2 does not have a "only
|
||||
on callouts" clause. Its own section for the same reason the emergency has one: this is
|
||||
not a charge and not a bonus, it is a decision, and the ledger below has no row shape for
|
||||
that. `reason` is printed because B put it in the payload for the paperwork and D fills
|
||||
it with what the cut was FOR — the sentence a player would say out loud. */''}
|
||||
${w?.cutAways?.length ? `
|
||||
<div class="sect">THE KNIFE</div>
|
||||
<div class="sched">
|
||||
${w.cutAways.map((c) => `
|
||||
<div class="row"><span>sail cut loose${c.corners?.length ? ` at ${c.corners.map((x) => String(x).toUpperCase()).join(', ')}` : ''} — ${
|
||||
Math.round(c.t)}s in, deliberate${c.reason ? ` · for ${c.reason}` : ''}</span><b>your call</b></div>
|
||||
<div class="cond">You spent the sail to keep the steel. That is the trade the knife is for.</div>`).join('')}
|
||||
</div>` : ''}
|
||||
${w?.constraints?.length ? `
|
||||
<div class="sect">PAID UNDER THESE TERMS</div>
|
||||
<div class="sched">
|
||||
|
||||
336
web/world/js/knife.js
Normal file
336
web/world/js/knife.js
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* knife.js — ⚠️ THE KNIFE. (Lane D, SPRINT18 gate 1.3)
|
||||
*
|
||||
* DESIGN.md line 165: *"knife (cut a sail loose — lose the sail, save the anchors)"*, and the
|
||||
* signature decision under load: **release, cut, or ride it out.** B built the physics half
|
||||
* (`rig.cutAway`); this is the verb — the input, the reach rule, the read the decision is made off,
|
||||
* and the panel that finally puts this lane's verbs on the glass (`createVerbPanel`, at the bottom).
|
||||
*
|
||||
* The panel lives HERE rather than in player.js for one reason and it is the suite: player.js
|
||||
* statically imports GLTFLoader, which is why d.test.js has never been able to import it (it tests
|
||||
* player.sim.js instead — the same rule ladder.js and broom.js follow by loading their GLBs lazily).
|
||||
* A prompt surface that only a browser can construct is a prompt surface with no asserts, which is
|
||||
* precisely how it went five sprints unnoticed that nothing drew it.
|
||||
*
|
||||
* ── WHY IT IS ONE HOLD AND NOT FOUR ────────────────────────────────────────────────────────────
|
||||
* The knife cuts the WHOLE SAIL, which is what the verb means in DESIGN.md and what B's
|
||||
* `cutAway()` does with no arguments. The per-corner cut B also exposes is NOT this verb: DESIGN.md
|
||||
* lists the **quick-release corner** ("installed in prep: deliberately depower a sail mid-storm")
|
||||
* as its own tool, bought in the shop, and depowering one corner is that tool's job. One sprint,
|
||||
* one verb; the shipped emergency is decided by the whole-sail cut and the corner version can
|
||||
* arrive with the thing that sells it.
|
||||
*
|
||||
* ── WHERE YOU CAN CUT, AND WHY IT IS THE GROUND ────────────────────────────────────────────────
|
||||
* At the cleat, not at the corner. ladder.js's own header states the yard's rule: *"a sail post is
|
||||
* tensioned from a cleat at its base, a tree anchor is a strop you throw"* — while a fascia or
|
||||
* carport bracket is worked at height. So the knife reaches a post or a tree from the grass, and it
|
||||
* CANNOT reach a bracket corner.
|
||||
*
|
||||
* That is not a limitation, it is the mechanic, and the shipped emergency is the proof: the corner
|
||||
* about to take the $180 carport is CB1, a bracket at y=2.36 that needs a ladder you have no time
|
||||
* to fetch — B measured the window at 5.9 s. **You cannot get to the corner that is failing, so you
|
||||
* cut the sail at the one you can reach and the whole thing goes limp.** The ladder is for repairs
|
||||
* (slow, two trips, both hands); the knife is for the seconds you actually have. They are
|
||||
* complements, and neither is redundant.
|
||||
*
|
||||
* ── THE HOLD, AND WHAT IT DOES *NOT* TOUCH ─────────────────────────────────────────────────────
|
||||
* X, held for `cutSecs`, standing at a cleat. It is a hold and not a keypress on purpose: this is
|
||||
* the most irreversible input in the game, and one tap of a key that spends the sail is a button,
|
||||
* not a decision. Interruptible by walking out of reach, by releasing X, and by the storm putting
|
||||
* you down mid-cut — the same three ways a hold-E dies.
|
||||
*
|
||||
* It deliberately does NOT write `player.state`. interact.js's header states the invariant: *"this
|
||||
* module both ENTERS and LEAVES the player's `busy` state. Nothing else writes it, so a dropped
|
||||
* release cannot strand the player."* A second writer of `busy` is how a player gets stranded in a
|
||||
* locked state with nothing to release them, so the knife instead REFUSES to start while the player
|
||||
* is busy (you cannot cut mid-crank or mid-poke) and cancels itself if a locked state arrives while
|
||||
* it is running. The cost is that there is no cut animation; filed rather than faked.
|
||||
*
|
||||
* Hands are NOT a gate, and that is a ruling. A knife is on your belt and cutting one line is a
|
||||
* one-handed job — but the real reason is that the alternative is worse: gating the game's
|
||||
* signature decision behind dropping the spare would put an undiscoverable inventory step inside a
|
||||
* 5.9-second window, and the player would read it as the game refusing rather than as a choice.
|
||||
*/
|
||||
import { needsLadder } from './ladder.js';
|
||||
|
||||
export const KNIFE_TUNE = {
|
||||
/**
|
||||
* How long the cut takes. Sits between the broom's poke (1.5 s) and the turnbuckle trim (1.2 s):
|
||||
* a loaded sail cuts FAST — that is why it is dangerous — so this is not a long hold. It is long
|
||||
* enough to be a decision you can change your mind about and short enough to fit the window B
|
||||
* measured (cut at t=33 saves the carport; the corner blows at 35.9).
|
||||
*/
|
||||
cutSecs: 1.2,
|
||||
/** How near the cleat/strop you must stand, m. The broom's `standRadius` is 2.0 for the same job. */
|
||||
reach: 2.0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Can a knife reach this corner from the grass?
|
||||
*
|
||||
* A live corner (there is nothing to cut on one that is already down) on an anchor whose work
|
||||
* happens at the cloth rather than at a bracket — `needsLadder` is the repo's one rule for that
|
||||
* distinction and it reads the site's own `work` field, so a new yard follows with no code change.
|
||||
*/
|
||||
export const cuttable = (corner, anchor) =>
|
||||
!!corner && !corner.broken && !needsLadder(anchor);
|
||||
|
||||
/**
|
||||
* The cleat: the base of the post, the foot of the tree. Ground level at the anchor's x/z, which is
|
||||
* where the line comes down to and where a person stands to work it.
|
||||
*/
|
||||
export const cleatOf = (anchor) => (anchor?.pos ? { x: anchor.pos.x, z: anchor.pos.z } : null);
|
||||
|
||||
/**
|
||||
* What the cut is FOR, in one phrase, for the paperwork's `reason` field (B's payload carries it;
|
||||
* their own example is 'the glasshouse'). The most expensive thing the live corners are still
|
||||
* holding up — because that is the sentence a player would say out loud.
|
||||
*/
|
||||
export function reasonFor(saves) {
|
||||
if (!saves || !saves.length) return 'the steel';
|
||||
const worst = saves.reduce((a, b) => (b.cost > a.cost ? b : a));
|
||||
// The article, because this phrase is read INSIDE a sentence on the invoice and the collateral
|
||||
// labels do not agree with each other about it: site JSON ships "the carport", "the gutter" and
|
||||
// "garden gnome". Played it and the invoice said "cut loose at Q3 — 9s in, deliberate · for garden
|
||||
// gnome", which reads like a typo rather than a reason.
|
||||
return /^the /.test(worst.what) ? worst.what : `the ${worst.what}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* getRig() -> SailRig|null (live: rigSail() REPLACES the object)
|
||||
* player -> PlayerSim
|
||||
* getWorld() -> contracts World|null (anchors + collateral prices)
|
||||
* getPhase() -> 'storm'|… (optional; absent = "any time there is a sail")
|
||||
* getGarden() -> {hp} (optional; absent = no cost line)
|
||||
* collateralAtRisk(corners, world) -> {items,total} (optional; main.js injects ITS OWN walk, so
|
||||
* the read and the invoice cannot disagree about what a cut saves. Absent = no saves line.)
|
||||
* secsLeft() -> number (optional; how long the bed would be bare)
|
||||
*/
|
||||
export function createKnife(deps = {}) {
|
||||
const tune = { ...KNIFE_TUNE, ...(deps.tune || {}) };
|
||||
const player = deps.player;
|
||||
const state = { progress: 0, cut: null };
|
||||
|
||||
const rigOf = () => (deps.getRig ? deps.getRig() : null);
|
||||
const worldOf = () => (deps.getWorld ? deps.getWorld() : null);
|
||||
const anchorOf = (c) => {
|
||||
const w = worldOf();
|
||||
return c?.anchor || (w && w.anchor ? w.anchor(c.anchorId) : null) || null;
|
||||
};
|
||||
|
||||
/** Every corner still flying — what a cut would release, and what is still at risk if you don't. */
|
||||
function liveCorners() {
|
||||
const rig = rigOf();
|
||||
if (!rig || !rig.rigged || !Array.isArray(rig.corners)) return [];
|
||||
return rig.corners.filter((c) => !c.broken);
|
||||
}
|
||||
|
||||
/** The nearest cleat within reach, or null. Ground only — you cannot cut from up a ladder. */
|
||||
function target() {
|
||||
if (!player || (player.climbY ?? 0) > 0.02) return null;
|
||||
let best = null, bestD = Infinity;
|
||||
for (const c of liveCorners()) {
|
||||
const a = anchorOf(c);
|
||||
if (!cuttable(c, a)) continue;
|
||||
const cleat = cleatOf(a);
|
||||
if (!cleat) continue;
|
||||
const d = Math.hypot(cleat.x - player.pos.x, cleat.z - player.pos.z);
|
||||
if (d <= tune.reach && d < bestD) { best = c; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** True when there is a sail to cut in a phase where cutting means something. */
|
||||
function live() {
|
||||
if (deps.getPhase && deps.getPhase() !== 'storm') return false;
|
||||
return liveCorners().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the decision is made off — and it is the whole of the HUD read, because "cut or ride it
|
||||
* out" is unanswerable without both halves of the trade printed at once:
|
||||
* saves — the client's property the live corners are still holding up, priced through main.js's
|
||||
* OWN collateral walk (injected), so this can never advertise a saving the invoice
|
||||
* won't honour.
|
||||
* costs — the bed, bare for whatever is left of the storm. The knife's price, per B's ruling:
|
||||
* the garden is the STAKE, not the wage.
|
||||
*/
|
||||
function read() {
|
||||
if (!live()) return { state: 'none' };
|
||||
const corners = liveCorners();
|
||||
const w = worldOf();
|
||||
const risk = (deps.collateralAtRisk && w) ? deps.collateralAtRisk(corners, w) : null;
|
||||
const hp = deps.getGarden ? deps.getGarden()?.hp : null;
|
||||
const secs = deps.secsLeft ? Math.max(0, Math.round(deps.secsLeft())) : null;
|
||||
const at = target();
|
||||
return {
|
||||
state: state.cut ? 'cutting' : at ? 'ready' : 'unavailable',
|
||||
at: at ? at.anchorId : null,
|
||||
corners: corners.map((c) => c.anchorId),
|
||||
progress: state.progress,
|
||||
saves: risk ? risk.items : [],
|
||||
savesTotal: risk ? risk.total : 0,
|
||||
hp: hp == null ? null : Math.round(hp),
|
||||
secsLeft: secs,
|
||||
/**
|
||||
* The refusal, and it has to name the REAL reason — which is two different reasons.
|
||||
*
|
||||
* Played it and caught this in my own read: the line was one constant that always blamed the
|
||||
* bracket, so on the swing lawn (a yard with no bracket corner in the quad) it told the player
|
||||
* to fetch a ladder for a corner that does not exist. That is `needsLadder`'s own disease from
|
||||
* SPRINT10 — a rule keyed on the wrong thing, failing plausibly — on the surface the rule is
|
||||
* supposed to explain. So: is there a cleat to walk to, or is every live corner up a wall?
|
||||
*/
|
||||
whyNot: at ? null
|
||||
: corners.some((c) => cuttable(c, anchorOf(c)))
|
||||
? 'get to a post cleat or a tree strop'
|
||||
: 'every corner left is bracket work — the knife cannot reach it',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} dt @param {number} t
|
||||
* @param {boolean} holding is the knife key down this frame
|
||||
* @returns {object} read() — so the caller can draw the panel off one call
|
||||
*/
|
||||
function step(dt, t, holding) {
|
||||
const at = target();
|
||||
const locked = !!player && player.busy;
|
||||
|
||||
if (state.cut) {
|
||||
// the three ways a cut dies, and they are the hold-E rules: let go, walk off, get put down
|
||||
if (!holding || !at || at !== state.cut || locked) {
|
||||
state.cut = null;
|
||||
state.progress = 0;
|
||||
}
|
||||
} else if (holding && at && !locked && !state.latched) {
|
||||
state.cut = at;
|
||||
state.progress = 0;
|
||||
}
|
||||
if (!holding) state.latched = false;
|
||||
|
||||
if (state.cut) {
|
||||
state.progress += dt / Math.max(1e-6, tune.cutSecs);
|
||||
if (state.progress >= 1) {
|
||||
const rig = rigOf();
|
||||
const risk = read();
|
||||
state.cut = null;
|
||||
state.progress = 0;
|
||||
// One press, one cut. Without the latch a held key re-arms the instant the sail is down and
|
||||
// the next frame's `cutAway` refuses forever — a ticker full of "the sail is already down".
|
||||
state.latched = true;
|
||||
const res = rig && rig.cutAway ? rig.cutAway(null, reasonFor(risk.saves)) : { ok: false };
|
||||
state.last = { t, ...res };
|
||||
}
|
||||
}
|
||||
return read();
|
||||
}
|
||||
|
||||
return {
|
||||
tune,
|
||||
read,
|
||||
step,
|
||||
target,
|
||||
liveCorners,
|
||||
get progress() { return state.progress; },
|
||||
get cutting() { return !!state.cut; },
|
||||
/** The last cutAway result, for a ticker or a test. */
|
||||
get last() { return state.last ?? null; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ THE VERBS ON THE GLASS. (Lane D, SPRINT18 gate 1.3)
|
||||
*
|
||||
* **What I found building the knife, and it is five sprints old:** `interact.step()` returns
|
||||
* `{target, progress, label, holding, usable}` "for hud.js to draw the prompt + radial" — its own
|
||||
* words — and in the SHIPPED GAME nobody has ever drawn it. main.js calls
|
||||
* `interact.step(dt, t, sim, keyboard.holding)` and throws the return value away; hud.js has no
|
||||
* prompt element; the only renderer of a prompt in this repo is `dev_player.html`, a bench. So every
|
||||
* sentence this lane has written for the player's hands — *"out of reach — needs the ladder"*,
|
||||
* *"push the water off (169 kg)"*, *"hands full"*, and the whole greyed-prompt surface I built for
|
||||
* Lane A in SPRINT13 — has never reached a player. The hold-E verbs have shipped with no prompt and
|
||||
* no radial: you walk to a broken corner, hold a key you were told about on a help line, and the
|
||||
* game says nothing until it is over. Same disease as `setFabric` (B, this sprint) and the F key
|
||||
* before it — the code was right, the wire was missing, and no assert could see the gap because the
|
||||
* gap was on the glass.
|
||||
*
|
||||
* So this panel exists, and the knife is not the reason it exists — the knife is just the verb that
|
||||
* finally made it unignorable, because a cut with no radial is a keypress that eats your sail.
|
||||
*
|
||||
* It is D's own overlay rather than an edit to hud.js for two reasons: hud.js is Lane A's file and
|
||||
* says so in line 2, and its own header already assigns *"the repair prompt, which Lane D already
|
||||
* owns"* to this lane. z-index 9 — UNDER #hud (10) and the cards (30), so a card always wins.
|
||||
*/
|
||||
export function createVerbPanel(opts = {}) {
|
||||
if (typeof document === 'undefined') return { update() {}, dispose() {} }; // headless: no-op
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#verbs { position:fixed; left:50%; bottom:40px; transform:translateX(-50%); z-index:9;
|
||||
font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:#dde5ea; text-align:center;
|
||||
text-shadow:0 1px 3px #000a; pointer-events:none; user-select:none; display:none; }
|
||||
#verbs.on { display:block; }
|
||||
#verbs .verb { background:#0d1418d9; border:1px solid #2c3a44; border-radius:6px;
|
||||
padding:6px 12px; margin-top:5px; display:inline-block; min-width:200px; }
|
||||
#verbs .key { font-weight:700; letter-spacing:.06em; }
|
||||
#verbs .off { color:#8ba0ad; border-color:#243038; }
|
||||
#verbs .bar { height:5px; background:#00000066; border-radius:3px; overflow:hidden; margin-top:5px; }
|
||||
#verbs .bar i { display:block; height:100%; width:0; background:#ffd27a; }
|
||||
/* The knife is the one irreversible input in the game. It gets the invoice's own red, and its two
|
||||
consequence lines are coloured for what they mean: what you keep, what you spend. */
|
||||
#verbs .knife { border-color:#7a2a22; background:#1a0f0dd9; }
|
||||
#verbs .knife .key { color:#ff8f86; }
|
||||
#verbs .knife .bar i { background:#ff5b4a; }
|
||||
#verbs .saves { color:#6ee06e; }
|
||||
#verbs .costs { color:#ffc24a; }`;
|
||||
document.head.appendChild(style);
|
||||
const root = document.createElement('div');
|
||||
root.id = 'verbs';
|
||||
document.body.appendChild(root);
|
||||
let last = '';
|
||||
|
||||
const bar = (p) => `<div class="bar"><i style="width:${Math.round(Math.min(1, p) * 100)}%"></i></div>`;
|
||||
|
||||
/**
|
||||
* @param {object|null} prompt interact.step()'s return
|
||||
* @param {object|null} knife knife.read()
|
||||
*/
|
||||
function update(prompt, knife) {
|
||||
const showable = !opts.getPhase || opts.getPhase() === 'storm' || opts.getPhase() === 'prep';
|
||||
let html = '';
|
||||
if (showable && prompt && prompt.target) {
|
||||
html += `<div class="verb ${prompt.usable ? '' : 'off'}">`
|
||||
+ `<span class="key">${prompt.usable ? '[E]' : '···'}</span> ${prompt.label || ''}`
|
||||
+ `${prompt.holding ? bar(prompt.progress) : ''}</div>`;
|
||||
}
|
||||
if (showable && knife && knife.state !== 'none') {
|
||||
const ready = knife.state === 'ready' || knife.state === 'cutting';
|
||||
// The trade, both halves, always — "cut or ride it out" is unanswerable off one of them.
|
||||
// "$N at risk" is the BOARD's own vocabulary for collateral, deliberately: the offer card
|
||||
// priced this night at "$205 at risk" and the knife is the one move that takes that number off
|
||||
// the table. Same words, same meaning, two cards apart. The items are named because a bare
|
||||
// total is what B's gate 3 caught the SCORE IT card doing.
|
||||
const saves = knife.saves?.length
|
||||
? `<div class="saves">saves $${knife.savesTotal} at risk · ${
|
||||
knife.saves.map((s) => s.what).join(', ')}</div>`
|
||||
: '';
|
||||
const costs = knife.hp == null ? ''
|
||||
: `<div class="costs">costs the garden — ${knife.hp}% now, bare${
|
||||
knife.secsLeft != null ? ` for ${knife.secsLeft}s` : ''}</div>`;
|
||||
html += `<div class="verb knife ${ready ? '' : 'off'}">`
|
||||
+ `<span class="key">${ready ? '[X]' : '···'}</span> ${
|
||||
ready ? 'CUT THE SAIL LOOSE — hold' : `the knife: ${knife.whyNot}`}`
|
||||
+ `${ready ? saves + costs : ''}`
|
||||
+ `${knife.state === 'cutting' ? bar(knife.progress) : ''}</div>`;
|
||||
}
|
||||
if (html !== last) { last = html; root.innerHTML = html; }
|
||||
root.classList.toggle('on', !!html);
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
get el() { return root; },
|
||||
dispose() { root.remove(); style.remove(); },
|
||||
};
|
||||
}
|
||||
@ -18,6 +18,13 @@ import { FIXED_DT, PHASES, STORM_LEN, HARDWARE, SPARE_COST, Emitter } from './co
|
||||
import { createWorld, loadSite } from './world.js';
|
||||
import { createCameraRig, spawnYawFor } from './camera.js';
|
||||
import { loadStorm, createWind, forecastLines, offerBand } from './weather.js';
|
||||
// SPRINT18 gate 1 — a NAMESPACE import beside the named ones, for exactly one
|
||||
// reason: C's `midStormRead` (the dispatch's mid-storm read) lands at
|
||||
// integration, and a named import of a function that does not exist yet is a
|
||||
// hard module error rather than a graceful no-op. `weather.midStormRead?.()` is
|
||||
// the `setConstraints?.()` seam pattern, adapted to ES modules. Delete this
|
||||
// import the day the named one can carry it.
|
||||
import * as weather from './weather.js';
|
||||
import { SailRig, createSailView } from './sail.js';
|
||||
import { createPlayer } from './player.js';
|
||||
import { Interact, wireYardActions } from './interact.js';
|
||||
@ -216,7 +223,7 @@ export function applyMute(sky, on) {
|
||||
*
|
||||
* @returns {{verdict: string, mode: string}}
|
||||
*/
|
||||
export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSaving = false }) {
|
||||
export function verdictFor({ hp, lost, cut = [], win, dmg, pondPeak, pondDumped, beyondSaving = false }) {
|
||||
// SPRINT12 — B's flag, actioned: "weakest link that went first" reduces on
|
||||
// the EFFECTIVE rating (rating × ratingHint), matching sail.js's failure
|
||||
// line and B's summary.weakest tie-break fix. On the bare rating a rated
|
||||
@ -230,6 +237,33 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
|
||||
const named = worst ? `${worst.hw.name} at ${worst.anchorId.toUpperCase()}` : '';
|
||||
const hailKilled = dmg.hail > dmg.rain;
|
||||
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1.3 [Lane D] — **THE KNIFE'S OWN VERDICT, and without it this function calls a
|
||||
* deliberate cut "the hail fell where your sail wasn't".**
|
||||
*
|
||||
* That is mode `uncovered`, and it is the single worst thing the card could say to a player who
|
||||
* just made the game's signature decision: it blames placement on a night where the sail came down
|
||||
* because they cut it down. The verdict's whole charter is that it reads what ACTUALLY happened.
|
||||
*
|
||||
* It fires only when nothing of YOURS failed, and that composition rule is deliberate rather than
|
||||
* tidy: if a corner blew as well, that failure is the louder fact and the modes below say it
|
||||
* truthfully — the cut is still named on the invoice's own line. So the knife speaks when the knife
|
||||
* is the story, and shuts up when it isn't.
|
||||
*
|
||||
* "Nothing of theirs" is true BY CONSTRUCTION here, not by hope: `collateralWalk` prices `yours`,
|
||||
* and with `yours` empty the gnome's two-corner rule and every structure key are empty too.
|
||||
*/
|
||||
if (cut.length && !lost.length) {
|
||||
const where = cut.map((c) => c.anchorId.toUpperCase()).join(', ');
|
||||
return win
|
||||
? { mode: 'cut-held',
|
||||
verdict: `YOU CUT IT LOOSE at ${where} — and the bed came through anyway. `
|
||||
+ 'Nothing of theirs is on the ground, and nothing of yours broke.' }
|
||||
: { mode: 'cut',
|
||||
verdict: `YOU CUT IT LOOSE at ${where}. The bed went with it — and their property didn't. `
|
||||
+ 'That was the trade.' };
|
||||
}
|
||||
|
||||
// SPRINT9 decision 1. This case had to come FIRST because it did not used to
|
||||
// exist: `lost >= 2` was unconditionally a cascade-and-a-loss, and under the
|
||||
// new rule it can be the best night the game has. A sail that tore itself
|
||||
@ -322,10 +356,18 @@ export function verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped, beyondSav
|
||||
* partition semantics are exactly the kind of rule that would otherwise only
|
||||
* ever be "tested" by a green suite that never ran it.
|
||||
*
|
||||
* 🚨 SPRINT18 gate 1 — `theirs` marks a corner that ARRIVED BROKEN on an emergency
|
||||
* callout, and it is not a cosmetic flag. Without it the ledger books TOMORROW'S
|
||||
* WARRANTY on a cowboy's carabiner: you would drive back, unpaid, to redo work
|
||||
* you never did, and your standing would drop for a corner that was on the ground
|
||||
* before you got out of the ute. week.js's `warrantyItems()` and the rep block
|
||||
* both skip it. See the ruling in scoreRun.
|
||||
*
|
||||
* @param {Array} corners rig.corners at dawn
|
||||
* @param {Map<string,{broke:number,repaired:number}>} [tally] the night's break/repair events per anchor
|
||||
* @param {Set<string>} [theirs] anchor ids that arrived broken (emergency callouts)
|
||||
*/
|
||||
export function rigRecordFor(corners, tally = new Map()) {
|
||||
export function rigRecordFor(corners, tally = new Map(), theirs = new Set()) {
|
||||
return corners.map((c) => {
|
||||
const tal = tally.get(c.anchorId);
|
||||
const broke = (tal?.broke ?? 0) > 0 || !!c.broken;
|
||||
@ -337,10 +379,178 @@ export function rigRecordFor(corners, tally = new Map()) {
|
||||
broke,
|
||||
repaired: broke && !c.broken,
|
||||
brokenAtDawn: !!c.broken,
|
||||
theirs: theirs.has(c.anchorId),
|
||||
/**
|
||||
* SPRINT18 gate 1.3 [D] — you let this one go on purpose. week.js reads it exactly where it
|
||||
* already reads `theirs`, and for the same reason: warranty and standing are about work that
|
||||
* FAILED. A corner still down at dawn because you cut it booked a free callout to go and
|
||||
* re-hang your own decision, and docked half a star for taking it.
|
||||
*
|
||||
* NOTE it does not suppress `broke`/`brokenAtDawn`. Those two are the RECORD — the sail really
|
||||
* is on the ground and the client can see it — and a record that edits itself to be flattering
|
||||
* is the one thing this ledger has never done.
|
||||
*/
|
||||
cut: !!c.cut,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT AN OFFER CARD PRINTS — pure, exported, and it did not use to be either.
|
||||
*
|
||||
* ⚠️ **EXTRACTED BECAUSE IT SHIPPED A LIVE BUG THE SUITE COULD NOT SEE.** This was
|
||||
* an inline closure inside `boot()`, unreachable by any assert, and on night 5's
|
||||
* real board it advertised the emergency callout at **$57 while the dispatch one
|
||||
* click later quoted $114** — it had simply never been given the emergency term.
|
||||
* That is exactly the quote-vs-settle lie this file's own comment says the board
|
||||
* must not be able to tell ("the board can never be more confident than the
|
||||
* sheet"), and it was invisible to 535 asserts because the money lived behind a
|
||||
* WebGL context. Eyes on the card found it; being a function is what stops the
|
||||
* next one.
|
||||
*
|
||||
* The rule it now keeps by construction: **every number on an offer card comes
|
||||
* from the same function the job sheet and the invoice use.** `calloutFee` for the
|
||||
* money, C's `forecastLines`/`offerBand` for the weather, `exposureOf` for the
|
||||
* risk — nothing retyped here.
|
||||
*
|
||||
* @param {object} night a normalised night (week.js normaliseNight)
|
||||
* @param {object} ctx
|
||||
* @param {object} [ctx.def] the storm def; absent ⇒ the weather lines degrade away
|
||||
* @param {object} [ctx.site] the site JSON, for the exposure walk
|
||||
* @param {number} ctx.rep standing as of this morning — the fee is priced on it
|
||||
* @param {object[]} [ctx.remembers] this client's grudges (week.remembers)
|
||||
*/
|
||||
export function priceOffer(night, { def = null, site = null, rep = 3, remembers = [] } = {}) {
|
||||
const exp = exposureOf(site);
|
||||
return {
|
||||
// C's honest lines at lead 0 — tonight, exact. The same call the job sheet
|
||||
// makes, so the board can never be more confident than the sheet.
|
||||
forecast: def ? forecastLines(def, 0) : null,
|
||||
// SPRINT17 [C], applied by the integrator: the ORDERED band the offer card
|
||||
// maps, so a night's hail can never be dropped by the card picking two
|
||||
// fields. Night 7 read softer than the buster on wind+stones alone (55 vs 76
|
||||
// km/h) while carrying 6x the hail — the board recommending its own trap.
|
||||
band: def ? offerBand(def, 0) : null,
|
||||
// 🚨 SPRINT18 gate 1 — the fourth argument IS the bug above, fixed: an
|
||||
// emergency pays double ON THE CARD, because the card is where the player
|
||||
// decides whether double is worth it.
|
||||
fee: def ? calloutFee(def, rep, night.constraints, !!night.emergency) : null,
|
||||
exposure: exp.total,
|
||||
exposureItems: exp.items,
|
||||
remembers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚨 **SPRINT18 gate 1 — ⚖️ WHO IS ANSWERABLE FOR WHICH CORNER.** The ruling that
|
||||
* makes an emergency invoice honest, as a pure function so that the RULING is
|
||||
* pinnable and not merely its consequences.
|
||||
*
|
||||
* Exported for the `rigRecordFor` reason, stated in that function's own note:
|
||||
* scoreRun lives inside `boot()` behind a WebGL context where no assert can reach
|
||||
* it, and this is exactly the kind of rule that would otherwise only ever be
|
||||
* "tested" by a green suite that never ran it. It was worth extracting twice over,
|
||||
* because rendering the first real emergency invoice found TWO bugs downstream of
|
||||
* this split — the verdict blaming an inherited corner, and the gnome billable off
|
||||
* corners the player never chose.
|
||||
*
|
||||
* **A corner that was already down when you arrived is the JOB, not your failure.**
|
||||
* It bills nothing, wrecks nothing, books no warranty and costs no standing — it is
|
||||
* the REASON there is a callout. Without the split, an emergency night charges you
|
||||
* $180 for the carport the cowboy was already losing, bills his $5 carabiner, docks
|
||||
* your standing for his corner, and books you a free warranty callout tomorrow to
|
||||
* go and fix his work: four surfaces, all agreeing, all wrong.
|
||||
*
|
||||
* Two lists out, deliberately, because they answer two different questions:
|
||||
* · `lost` — every corner down. REPORTING (the HUD's corners-intact count). It
|
||||
* must stay honest: reading 4/4 over a sail with three corners on the ground
|
||||
* would be its own lie.
|
||||
* · `yours` — the ones you are ANSWERABLE for. Billing, collateral, the subtitle
|
||||
* and the verdict read this.
|
||||
*
|
||||
* @param {Array} corners rig.corners at dawn
|
||||
* @param {object|null} emergency the night's `emergency` block, if it is one
|
||||
*/
|
||||
export function answerableFor(corners, emergency = null) {
|
||||
const theirs = new Set((emergency?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId));
|
||||
const lost = (corners ?? []).filter((c) => c.broken);
|
||||
/**
|
||||
* 🚨 **SPRINT18 gate 1.3 [Lane D] — A CORNER YOU CUT IS NOT A CORNER THAT FAILED, AND WITHOUT
|
||||
* THIS LINE THE KNIFE IS A TRAP.**
|
||||
*
|
||||
* B's `cutAway` deliberately emits no `break` ("warranty must never chase a corner the player
|
||||
* chose to let go") — but every ledger surface downstream reads `c.broken`, and `_releaseCorner`
|
||||
* sets it for a cut exactly as it does for an overload. So on the tree as merged, taking DESIGN.md
|
||||
* line 165's signature decision billed you for it on five surfaces at once: the cut corner's
|
||||
* hardware went on `bill`, the collateral walk BILLED AND WRECKED the $180 carport you cut the
|
||||
* sail to save, a four-corner cut tripped the gnome's two-corner rule, `clean` was voided by the
|
||||
* collateral it caused, and week.js booked warranty and docked standing for every corner you let
|
||||
* go. The invoice would have printed *"You spent the sail to keep the steel. That is the trade the
|
||||
* knife is for"* directly above a $180 charge for the thing you kept.
|
||||
*
|
||||
* Found by playing it, not by a test — the same way every card bug in this repo has been found.
|
||||
* A is the author of both halves and neither half is wrong; nobody joined them, because the join
|
||||
* is a flag on an object and there was no verb setting the flag until now.
|
||||
*
|
||||
* The ruling, and it is B's extended to every surface that reads a corner: **a cut is a DECISION.
|
||||
* It bills nothing, wrecks nothing, books no warranty and costs no standing. Its price is the
|
||||
* garden — and through the garden, the night's fee — and that is enough.** `lost` still counts it
|
||||
* (the HUD's corners-intact read must stay honest: the sail IS on the ground), and `cut` is named
|
||||
* so the paper can say who put it there.
|
||||
*/
|
||||
const cut = lost.filter((c) => c.cut);
|
||||
return {
|
||||
theirs,
|
||||
lost,
|
||||
cut,
|
||||
yours: lost.filter((c) => !theirs.has(c.anchorId) && !c.cut),
|
||||
inherited: lost.filter((c) => theirs.has(c.anchorId))
|
||||
.map((c) => ({ anchorId: c.anchorId, hw: c.hw?.name ?? '?' })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚨 **SPRINT18 gate 1.3 [Lane D] — THE COLLATERAL WALK, EXTRACTED.** What a set of failed corners
|
||||
* costs you in the client's property. Lifted OUT of scoreRun unchanged, for the two reasons
|
||||
* `answerableFor` and `rigRecordFor` were extracted before it, and one new one:
|
||||
*
|
||||
* · scoreRun lives inside `boot()` behind a WebGL context where no assert can reach it.
|
||||
* · **the knife's HUD read has to promise exactly what the invoice will honour.** The panel says
|
||||
* "saves the carport $180" before the cut and the invoice charges $0 after it; if those two ever
|
||||
* came off different arithmetic, the game would be advertising a saving. One walk, two readers —
|
||||
* the same discipline that made `calloutFee` one function for the board, the dispatch and the
|
||||
* invoice.
|
||||
*
|
||||
* PURE, and that is load-bearing: `wreckStructure()` stays at the call site. A HUD asking what a cut
|
||||
* would save must not swap a structure for its wreck as a side effect of the question.
|
||||
*
|
||||
* @param {Array} corners the corners to price — `yours` at dawn, or the LIVE ones for the knife
|
||||
* @param {object} world contracts World (anchor(), collateralFor(), gnome)
|
||||
* @returns {{items: {what:string,cost:number}[], keys: string[], total: number}}
|
||||
*/
|
||||
export function collateralWalk(corners, world) {
|
||||
const items = [];
|
||||
const keys = [];
|
||||
// The gnome is collateral if the sail came down over it (DESIGN.md: the worst debris in any storm
|
||||
// is your own failed work). Two corners, per the original rule.
|
||||
if ((corners ?? []).length >= 2 && world?.gnome) {
|
||||
items.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
|
||||
}
|
||||
/**
|
||||
* Priced per STRUCTURE, not per corner: two beams letting go is one carport gone, not $360.
|
||||
*/
|
||||
const taken = new Set();
|
||||
for (const c of corners ?? []) {
|
||||
const key = world?.anchor ? world.anchor(c.anchorId)?.collateral : null;
|
||||
const priced = key && world.collateralFor ? world.collateralFor(key) : null;
|
||||
if (!priced || taken.has(key)) continue;
|
||||
taken.add(key);
|
||||
keys.push(key);
|
||||
items.push({ what: priced.label, cost: priced.cost });
|
||||
}
|
||||
return { items, keys, total: items.reduce((s, i) => s + i.cost, 0) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase machine
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -361,13 +571,33 @@ export function createGame() {
|
||||
|
||||
on(type, fn) { return emitter.on(type, fn); },
|
||||
|
||||
/** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
|
||||
setPhase(next) {
|
||||
/**
|
||||
* @param {'forecast'|'prep'|'storm'|'aftermath'} next
|
||||
* @param {{at?:number}} [opts] 🚨 SPRINT18 gate 1 — THE EMERGENCY CALLOUT'S
|
||||
* ONE LINE OF ENGINE WORK. `at` opens the phase clock at that many seconds
|
||||
* instead of 0, which is the entire mechanism behind *"a night that starts
|
||||
* in storm"*: ROADMAP sized this gate as *"the storm phase already does
|
||||
* everything this needs except spawn you at t=30 with a rig you didn't
|
||||
* build"*, and this is the t=30 half.
|
||||
*
|
||||
* Everything downstream is untouched BY CONSTRUCTION rather than by care,
|
||||
* which is why it is one argument here and not a new phase:
|
||||
* · `windTime()` already returns `phaseT` during a storm, so the wind, the
|
||||
* sky, the hail and the storm's own authored events are all mid-flight
|
||||
* the moment you get out of the ute — you do not arrive at a calm yard
|
||||
* that then starts blowing.
|
||||
* · `tick()` already ends the storm at `phaseT >= STORM_LEN`, so an
|
||||
* emergency night is `STORM_LEN − at` long and no clock needed telling.
|
||||
* · `phaseChange` still fires with the same payload, so every listener
|
||||
* (sky rebuild, rigging deactivation, pond/tally resets) runs as usual.
|
||||
*/
|
||||
setPhase(next, { at = 0 } = {}) {
|
||||
if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
|
||||
if (next === phase) return;
|
||||
if (!(at >= 0) || !Number.isFinite(at)) throw new Error(`setPhase('${next}', {at}) — at must be a finite number ≥ 0, got ${at}`);
|
||||
const from = phase;
|
||||
phase = next;
|
||||
phaseT = 0;
|
||||
phaseT = at;
|
||||
emitter.emit('phaseChange', { from, to: next });
|
||||
},
|
||||
|
||||
@ -599,7 +829,18 @@ export async function boot(opts = {}) {
|
||||
// world into its ground clamp, solid collider, ladder and broom, none of
|
||||
// which have a setter (Lane D's modules). A site change only happens at a
|
||||
// forecast, never mid-storm, so a clean rebuild is honest and cheap.
|
||||
player = await createPlayer(scene, world, cameraRig, { wind, interact });
|
||||
// SPRINT18 gate 1.3 — D's knife rides in on the opts it already had. Every one of these is a
|
||||
// GETTER because `game` and `garden` are declared below the first call to this function (the
|
||||
// TDZ trap documented twice above); the knife reads them at frame time, when they exist.
|
||||
player = await createPlayer(scene, world, cameraRig, {
|
||||
wind,
|
||||
interact,
|
||||
getPhase: () => game.phase,
|
||||
getGarden: () => garden,
|
||||
/** One walk, two readers: what the panel promises is what the invoice honours. */
|
||||
collateralAtRisk: collateralWalk,
|
||||
secsLeft: () => STORM_LEN - game.phaseT,
|
||||
});
|
||||
|
||||
// SPRINT13 gate 2.5 — the opening frame, per YARD rather than per game.
|
||||
// Done here because the spawn is here: the player and the yard are rebuilt
|
||||
@ -713,8 +954,60 @@ export async function boot(opts = {}) {
|
||||
sailView = null;
|
||||
}
|
||||
|
||||
async function rigSail(anchorIds, hwChoices, tension = 1.0) {
|
||||
rig.attach(anchorIds, hwChoices, tension);
|
||||
/**
|
||||
* ⚠️ **SPRINT18 gate 2 — THE FABRIC REACHES THE SIM. B FOUND IT; THIS IS THE
|
||||
* PICKUP, AND IT IS THE WHOLE FIX.**
|
||||
*
|
||||
* `rig.setFabric()` had exactly ONE call site in the repo — `RiggingSession
|
||||
* .commit(rig)` — and **the shipped commit path never reached it.** The rigging
|
||||
* UI's `commit()` calls `onCommit(...)`, and this function went straight to
|
||||
* `rig.attach()`. So `session.commit(rig)` ran in TESTS AND BENCHES ONLY, and
|
||||
* the sim's rig — built once at boot as `new SailRig({anchors})`, the
|
||||
* constructor's `porosity = 0` — **flew the WATERPROOF MEMBRANE at full wind
|
||||
* load every night of every playtest**, while the prep panel, the SCORE IT
|
||||
* card, the HUD row, the invoice and every audit number said "shade cloth".
|
||||
* **The F key has been moving the paperwork and never the physics**, and it was
|
||||
* honest exactly half the time by coincidence: pick membrane and the sim agrees
|
||||
* with you by accident; pick cloth — the DEFAULT — and it does not.
|
||||
*
|
||||
* B's table on the wildnight's pinned recipe, one variable: cloth 0.30 loses
|
||||
* **0/4**; membrane 0 loses **p2 at 3.48 then p3 at 3.90** — which is D's cold
|
||||
* play to the decimal, and the site's own `_playedReference`. So **the harness
|
||||
* that was lying was the GAME**: the bench flew the fabric the cards promised,
|
||||
* every audit number stands, and D's play found a real bug rather than an
|
||||
* artefact. It also retires C's `AUDIT.MARGIN` residual — with the fabric held
|
||||
* equal, bench and full game chain agree to three decimals on all four corners.
|
||||
*
|
||||
* **BOTH ROUTES, deliberately, and neither is redundant:**
|
||||
* · the ARGUMENT is B's filed hunk and the better contract — `dev_rigging.html`
|
||||
* has no session to reach into and had the identical bug.
|
||||
* · the SESSION FALLBACK is what makes this LIVE ON lane/a TODAY. B's
|
||||
* `commit()` passes the 4th argument on lane/b; on this branch `commit()`
|
||||
* still calls `onCommit` with three, so the argument alone would be an inert
|
||||
* no-op until integration — and B's own words are "until this lands the sim
|
||||
* still flies the membrane". A fix that waits for a merge to start working is
|
||||
* not a fix yet. `session.fabric` is the expression this file already reads
|
||||
* for the paperwork, so the physics and the invoice now read ONE source.
|
||||
* **AT INTEGRATION (S18, integrator): B's rigging.js is merged, the argument
|
||||
* now arrives on both wired routes — and the fallback STAYS.** A's filed
|
||||
* instruction was to delete it here; it is deliberately not deleted, and A can
|
||||
* revert this on sight. The reason is a third caller neither lane enumerated:
|
||||
* `rigSail` is exposed on the `SHADES` API, where a bench or a cold play can
|
||||
* still call it with three arguments. Deleting the fallback would make that
|
||||
* path fly the membrane while every card said cloth — the exact five-sprint bug
|
||||
* this fix exists to kill, reintroduced through the one door the suite cannot
|
||||
* see (this wire lives inside `boot()` behind a WebGL context). The fallback is
|
||||
* one `??` and it agrees with the argument on every wired path.
|
||||
*/
|
||||
/**
|
||||
* @param {object} [opts] `{at, wind}` — B's `attach` door (sail.js). Absent/`at:0` is
|
||||
* byte-for-byte the old behaviour, so every ordinary night and every bench is untouched.
|
||||
* See the emergency's note on `onCommit` for the one caller that passes it.
|
||||
*/
|
||||
async function rigSail(anchorIds, hwChoices, tension = 1.0, fabric, opts) {
|
||||
const cloth = fabric ?? rigging?.session?.fabric;
|
||||
if (cloth) rig.setFabric(cloth);
|
||||
rig.attach(anchorIds, hwChoices, tension, opts);
|
||||
disposeSailView();
|
||||
sailView = await createSailView(rig);
|
||||
scene.add(sailView);
|
||||
@ -821,7 +1114,31 @@ export async function boot(opts = {}) {
|
||||
camera: cameraRig.object,
|
||||
domElement: canvas,
|
||||
world,
|
||||
onCommit: (ids, hw, tension) => { void rigSail(ids, hw, tension); },
|
||||
// SPRINT18 gate 2 — the fabric rides through with the steel. B's filed hunk,
|
||||
// verbatim; `fabric` is undefined on bare lane/a (their commit() passes it,
|
||||
// and that lands at integration) and rigSail falls back to session.fabric so
|
||||
// the sim flies the right cloth on both branches. See rigSail's note.
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1.3 [D] — **`{at, wind}`: THE CLOTH FINALLY ARRIVES IN THE STORM IT IS
|
||||
* STANDING IN.** C measured it and B fixed it and nobody wired it, so on the merged tree the one
|
||||
* night that starts mid-storm attached its sail at t=0: measured live by D on the shipped
|
||||
* emergency, **`rig.clockSkew` read 30.02** while the sky, garden, debris and player were all
|
||||
* thirty seconds in. B's own number for what that hides: the same rig entering the same storm
|
||||
* pulls **90 N at t=0 and 655 N at t=40**. The triage that this whole gate is about was being
|
||||
* judged against a sail feeling a seventh of the load, and the knife's own invoice line said
|
||||
* *"cut loose 9s in"* on a night you arrived at 30 — because the log stamps the SAIL's clock.
|
||||
*
|
||||
* The night is asked, not a flag: `week.job.emergency` is a fact the night already owns, so this
|
||||
* needs no new state and no ordering change (`adoptEmergencyRig` runs before `setPhase`, so
|
||||
* reading `game.phaseT` here would read the forecast's clock — which is how it would go wrong).
|
||||
* With a `wind` B's `attach` genuinely PRE-FLIES the thirty seconds rather than teleporting the
|
||||
* cloth into a gale, so the drape and the belly's water are real history.
|
||||
*/
|
||||
onCommit: (ids, hw, tension, fabric) => {
|
||||
const em = week.job?.emergency;
|
||||
void rigSail(ids, hw, tension, fabric,
|
||||
em ? { at: em.arriveAt, wind: winds[week.stormKey] } : undefined);
|
||||
},
|
||||
onMessage: pushEvent,
|
||||
panel: true,
|
||||
});
|
||||
@ -849,12 +1166,31 @@ export async function boot(opts = {}) {
|
||||
|
||||
/** What the storm cost, assembled at the moment it ends. */
|
||||
function scoreRun() {
|
||||
const lost = rig.corners.filter((c) => c.broken);
|
||||
const bill = lost.reduce((s, c) => s + c.hw.cost, 0);
|
||||
const collateral = [];
|
||||
// The gnome is collateral if the sail came down over it. DESIGN.md: the
|
||||
// worst debris in any storm is your own failed work.
|
||||
if (lost.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
|
||||
/**
|
||||
* 🚨 **SPRINT18 gate 1 — ⚖️ THE RULING THAT MAKES AN EMERGENCY INVOICE HONEST:
|
||||
* A CORNER THAT WAS ALREADY DOWN WHEN YOU ARRIVED IS THE JOB, NOT YOUR
|
||||
* FAILURE.** It bills you nothing, wrecks nothing, books no warranty and costs
|
||||
* no standing — it is the REASON there is a callout.
|
||||
*
|
||||
* Without this, the shape of an emergency night is grotesque and would have
|
||||
* shipped looking correct: you turn up at somebody else's collapsing rig and
|
||||
* the invoice charges you $180 for the carport the cowboy was already losing,
|
||||
* bills you for his $5 carabiner, docks your standing for his corner, and then
|
||||
* books you a free warranty callout tomorrow to fix his work. The paper has to
|
||||
* split the blame, and this repo already knows how to say it — the icenight's
|
||||
* invoice does exactly this ("The shackle at P2 letting go is the part that's
|
||||
* on you").
|
||||
*
|
||||
* Two lists, deliberately, because they answer two different questions:
|
||||
* · `lost` — every corner down at dawn. This is REPORTING (the HUD's
|
||||
* "corners intact" count), and it must stay honest: reading 4/4 over a
|
||||
* sail with three corners on the ground would be its own lie.
|
||||
* · `yours` — the ones you are ANSWERABLE for. Billing, collateral and the
|
||||
* subtitle read this.
|
||||
*/
|
||||
const em = week.job.emergency;
|
||||
const { theirs, lost, yours, cut, inherited } = answerableFor(rig.corners, em);
|
||||
const bill = yours.reduce((s, c) => s + c.hw.cost, 0);
|
||||
|
||||
/**
|
||||
* SPRINT11 — the carport trap finally BILLS (§gate 1).
|
||||
@ -873,16 +1209,13 @@ export async function boot(opts = {}) {
|
||||
*
|
||||
* Priced per STRUCTURE, not per corner: two beams letting go is one carport
|
||||
* gone, not $360. It bills once and the wreck swaps once.
|
||||
*
|
||||
* SPRINT18 gate 1.3 [D] — the ARITHMETIC moved out to `collateralWalk` (pure, exported, above)
|
||||
* so the knife's HUD read prices what it saves through this exact walk. The WRECK stays here:
|
||||
* a HUD asking "what would a cut save" must not swap a structure for its wreck.
|
||||
*/
|
||||
const taken = new Set();
|
||||
for (const c of lost) {
|
||||
const key = world.anchor(c.anchorId)?.collateral;
|
||||
const priced = key ? world.collateralFor(key) : null;
|
||||
if (!priced || taken.has(key)) continue;
|
||||
taken.add(key);
|
||||
collateral.push({ what: priced.label, cost: priced.cost });
|
||||
world.wreckStructure(key); // false offline/graybox — the bill still lands
|
||||
}
|
||||
const { items: collateral, keys: wrecked } = collateralWalk(yours, world);
|
||||
for (const key of wrecked) world.wreckStructure(key); // false offline/graybox — the bill still lands
|
||||
const s = rigging.summary;
|
||||
const hp = garden.hp;
|
||||
/**
|
||||
@ -905,7 +1238,19 @@ export async function boot(opts = {}) {
|
||||
*/
|
||||
const win = hp >= 50;
|
||||
const dmg = garden.damage;
|
||||
const { verdict, mode } = verdictFor({ hp, lost, win, dmg, pondPeak, pondDumped,
|
||||
/**
|
||||
* ⚠️ **`yours`, NOT `lost` — AND THIS WAS A LIVE BUG ON THE FIRST EMERGENCY
|
||||
* INVOICE I RENDERED.** With `lost` the verdict narrated the corner the player
|
||||
* INHERITED as their failure: the ledger said *"CB1 — already on the ground when
|
||||
* you arrived · not billed · That corner is what the callout was for. It is not
|
||||
* on you"* and four lines below it the verdict said *"the carabiner at CB1 went
|
||||
* first and took 1 more with it."* One card, two contradictory accounts of the
|
||||
* same corner — the invoice arguing with itself, which is the defect class this
|
||||
* repo has spent five sprints hunting. The verdict speaks about work you are
|
||||
* answerable for; the corner count above it still reports every corner down,
|
||||
* because those are two different facts and both papers say so honestly.
|
||||
*/
|
||||
const { verdict, mode } = verdictFor({ hp, lost: yours, cut, win, dmg, pondPeak, pondDumped,
|
||||
beyondSaving: week.job.gardenBeyondSaving });
|
||||
|
||||
// SPRINT16 gate 2.2 [Lane B] — the fabric bet on the record. The F key
|
||||
@ -931,9 +1276,19 @@ export async function boot(opts = {}) {
|
||||
collateral,
|
||||
budgetLeft: Math.max(0, s.budget - bill - collateral.reduce((a, c) => a + c.cost, 0)),
|
||||
win,
|
||||
subtitle: lost.length
|
||||
? `${lost.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
|
||||
: 'Every corner held.',
|
||||
subtitle: yours.length
|
||||
? `${yours.map((c) => `${c.hw.name} at ${c.anchorId.toUpperCase()}`).join(', ')} let go.`
|
||||
// SPRINT18 gate 1.3 [D] — a cut sail must not be headlined "Every corner held." The card
|
||||
// would then carry that line, a CUT LOOSE row in the ledger and a cut verdict underneath:
|
||||
// three accounts of one night, one of them false. `cut` before `inherited` because on the
|
||||
// shipped emergency both are true and the cut is the thing the PLAYER did.
|
||||
: cut.length
|
||||
? `You cut it loose at ${cut.map((c) => c.anchorId.toUpperCase()).join(', ')}.`
|
||||
: inherited.length
|
||||
// An emergency you held: nothing of yours went, and the corner that was
|
||||
// already down stays named rather than quietly counted as a hold.
|
||||
? `Nothing of yours let go. ${inherited.map((c) => c.anchorId.toUpperCase()).join(', ')} was on the ground when you got there.`
|
||||
: 'Every corner held.',
|
||||
/** Decision 13's headline: how much of the bed's damage the cloth stopped. */
|
||||
hailBlocked: dmg.hail + dmg.rain > 0
|
||||
? `${Math.round(dmg.hail)} HP to hail, ${Math.round(dmg.rain)} to rain`
|
||||
@ -944,16 +1299,40 @@ export async function boot(opts = {}) {
|
||||
* that rode out a gale isn't new any more. Broken gear is worth nothing,
|
||||
* which is the whole of `bill`.
|
||||
*/
|
||||
intactHardwareValue:
|
||||
rig.corners.filter((c) => !c.broken).reduce((sum, c) => sum + c.hw.cost, 0)
|
||||
+ (rigging.session.spares ?? 0) * SPARE_COST,
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1 — ⚖️ RULED: **ON AN EMERGENCY, THE INTACT HARDWARE IS
|
||||
* NOT YOURS TO UNCLIP.** It is the cowboy's steel in the client's yard, and
|
||||
* you leave it there. Only the spare out of your own ute comes home.
|
||||
*
|
||||
* This is also the check that keeps `emergencyRate: 2.0` from being free
|
||||
* money: without it, a double-rate callout would ALSO hand you half of
|
||||
* somebody else's four rated shackles at dawn, and the biggest earner in the
|
||||
* game would be turning up to other people's disasters and salvaging them.
|
||||
*/
|
||||
/**
|
||||
* SPRINT18 gate 1.3 [D] — **"LOSE THE SAIL, SAVE THE ANCHORS" IS A LEDGER LINE, NOT A
|
||||
* SLOGAN.** `!c.broken` alone excluded a CUT corner from the refund, so the knife also quietly
|
||||
* took your steel: you cut the cloth, and the shackle still bolted to the post — undamaged,
|
||||
* unclippable in thirty seconds at dawn — paid you nothing. DESIGN.md line 165 promises the
|
||||
* opposite in the same breath as the verb, and `bill` already agrees (a cut corner is not
|
||||
* `yours`, so its hardware is not written off). This is the other half of that sentence.
|
||||
* On an EMERGENCY it changes nothing — none of that steel is yours to unclip either way.
|
||||
*/
|
||||
intactHardwareValue: em
|
||||
? (rigging.session.spares ?? 0) * SPARE_COST
|
||||
: rig.corners.filter((c) => !c.broken || c.cut).reduce((sum, c) => sum + c.hw.cost, 0)
|
||||
+ (rigging.session.spares ?? 0) * SPARE_COST,
|
||||
pondPeak,
|
||||
pondDumped,
|
||||
verdict,
|
||||
/** Which failure mode the verdict is speaking from. Asserted in a.test.js. */
|
||||
verdictMode: mode,
|
||||
/** SPRINT16 gate 1 — the work history. See rigRecordFor above. */
|
||||
rigRecord: rigRecordFor(rig.corners, nightTally),
|
||||
rigRecord: rigRecordFor(rig.corners, nightTally, theirs),
|
||||
/** SPRINT18 gate 1 — their corners, still down, named on the invoice and unbilled. */
|
||||
inherited,
|
||||
/** SPRINT18 gate 1 — D's knife. Tallied off rig.events; see the cutAway note. */
|
||||
cutAways: cutAwayLog.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1034,24 +1413,18 @@ export async function boot(opts = {}) {
|
||||
*/
|
||||
async function pricedOffers() {
|
||||
for (const o of week.offers()) await siteJsonFor(o.night.site);
|
||||
return week.offers((night) => {
|
||||
const def = defs[night.storm];
|
||||
const exp = exposureOf(siteJson.get(night.site));
|
||||
return {
|
||||
// C's honest lines at lead 0 — tonight, exact. The same call the job
|
||||
// sheet makes, so the board can never be more confident than the sheet.
|
||||
forecast: def ? forecastLines(def, 0) : null,
|
||||
// SPRINT17 [C], applied by the integrator: the ORDERED band the offer
|
||||
// card maps, so a night's hail can never be dropped by the card picking
|
||||
// two fields. Night 7 read softer than the buster on wind+stones alone
|
||||
// (55 vs 76 km/h) while carrying 6× the hail — the board recommending
|
||||
// its own trap. offerBand carries every fact the storm has.
|
||||
band: def ? offerBand(def, 0) : null,
|
||||
fee: def ? calloutFee(def, week.rep, night.constraints) : null,
|
||||
exposure: exp.total,
|
||||
exposureItems: exp.items,
|
||||
};
|
||||
});
|
||||
return week.offers((night) => priceOffer(night, {
|
||||
def: defs[night.storm],
|
||||
site: siteJson.get(night.site),
|
||||
rep: week.rep,
|
||||
// ⚖️ SPRINT18 gate 0, clause 3 — THE STOOD-UP CLIENT REMEMBERS. Injected
|
||||
// with the rest of the per-offer facts because the memory is week state and
|
||||
// board.js is pure: same seam as the fee and the exposure, and the card
|
||||
// renders nothing when the list is empty (the degradation rule every other
|
||||
// injected field follows). A client you left waiting on Tuesday who is on
|
||||
// the board again on Thursday is the whole of "your work follows you".
|
||||
remembers: week.remembers(night.client),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1074,6 +1447,89 @@ export async function boot(opts = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1 — **THE RIG YOU DIDN'T BUILD.** Someone else's work,
|
||||
* installed from `night.emergency.rig` (B's data) before the storm opens.
|
||||
*
|
||||
* Through the session's own PUBLIC MOVES, exactly as `resetRig()` is, and for
|
||||
* the same reason: the economy stays the single source of truth and nothing
|
||||
* here reaches into B's fields. Two things worth stating out loud:
|
||||
*
|
||||
* **1. THE UTE CARRIES ONE SPARE. ⚖️ RULED (A).** You had no prep phase, so you
|
||||
* bought nothing — but a tradie's ute has a shackle rolling around in it, and
|
||||
* without one, triage is a verb with no object: you would arrive at a broken
|
||||
* corner unable to do the only thing the job is for. It is FREE, and the way it
|
||||
* is made free is visible arithmetic rather than a private door: bank the
|
||||
* session at `week.bank + SPARE_COST`, buy the spare, and the session's budget
|
||||
* lands back on exactly `week.bank`. `spentThisNight` therefore reads $0, which
|
||||
* is the truth — there was no shop. (B: if you would rather expose a
|
||||
* `grantSpare()`, say so in THREADS and I will move to it.)
|
||||
*
|
||||
* **2. THE PRE-BROKEN CORNER IS B'S DOOR TO OPEN.** `broken: true` needs more
|
||||
* than a flag — sail.js's own comment says so: a break must also hand the node
|
||||
* its mass back (`invMass`), dump the pond and emit, or the corner stays welded
|
||||
* in mid-air instead of flying. There is no public `breakCorner`, so this calls
|
||||
* one feature-detected (`setConstraints?.()`'s pattern) and the night does not
|
||||
* depend on it: the authored corner is a $5 carabiner on the carport beam, which
|
||||
* is ~264 N effective under a southerly, so it fails within seconds THROUGH THE
|
||||
* REAL OVERLOAD PATH whether or not the door exists. Emergent beats poked.
|
||||
*/
|
||||
async function adoptEmergencyRig(em) {
|
||||
const s = rigging.session;
|
||||
resetRig();
|
||||
/**
|
||||
* ⚠️ **THE ARITHMETIC, AND THE BUG IT FIXES — CAUGHT LIVE, NOT BY A TEST.** The
|
||||
* first cut banked the session at `week.bank + SPARE_COST` and the real game came
|
||||
* up reading **budget $60 against a bank of $80**: the session had charged me $20
|
||||
* for the cowboy's four carabiners. The LEDGER was fine (`spentThisNight` is
|
||||
* forced to 0 on the ROLL) but the SHOP was lying by $20 — and `run.budgetLeft`,
|
||||
* plus every refusal B prices off `budget`, reads that number.
|
||||
*
|
||||
* You arrive having spent NOTHING: their steel is not yours, you did not buy it,
|
||||
* and the ute's spare is free. So the session is pre-banked by exactly what
|
||||
* adopting the night is about to cost it and the budget lands back on
|
||||
* `week.bank`. Computed FROM THE RIG DATA rather than hardcoded, so B can author
|
||||
* rated shackles into a cowboy's rig without this line silently going wrong.
|
||||
*/
|
||||
const theirCost = (em.rig ?? []).reduce(
|
||||
(sum, c) => sum + (HARDWARE.find((h) => h.name === c.hw)?.cost ?? 0), 0);
|
||||
s.setBudget(week.bank + theirCost + SPARE_COST);
|
||||
for (const c of em.rig ?? []) {
|
||||
s.rig(c.anchorId);
|
||||
const hw = HARDWARE.find((h) => h.name === c.hw);
|
||||
if (hw) s.setHardware(c.anchorId, hw);
|
||||
}
|
||||
s.setSpares(1);
|
||||
if (!rigging.commit()) {
|
||||
// Loud, not silent: an emergency whose rig would not commit is a night with
|
||||
// no sail in it, and the player would be standing in a storm wondering what
|
||||
// they were called out for.
|
||||
throw new Error(`main: the emergency rig would not commit — ${em.rig?.length ?? 0} corners `
|
||||
+ `on ${week.site}. Check every anchorId exists in that yard's site JSON.`);
|
||||
}
|
||||
/**
|
||||
* Corners that ARRIVED gone.
|
||||
*
|
||||
* ⚠️ **SPRINT18 gate 1.3 [D] — THE DOOR IS CALLED `failCorner`, AND FOR THIS WHOLE SPRINT THIS
|
||||
* LINE CALLED A METHOD THAT DOES NOT EXIST.** B opened it ([B] gate 1 §4) under the name
|
||||
* `failCorner`; this asked for `breakCorner?.()`, and an optional call to a renamed method is a
|
||||
* silent no-op that reads exactly like a call — **the `rigging.setWorld` lesson this file quotes
|
||||
* elsewhere, arriving through the door built to prevent it.** Measured live:
|
||||
* `typeof rig.failCorner === 'function'`, `typeof rig.breakCorner === 'undefined'`, and the
|
||||
* corner every card called "ON THE GROUND" was flying.
|
||||
*
|
||||
* A's fallback reasoning ("the authored corner is a $5 carabiner on the carport beam, so it fails
|
||||
* within seconds through the real overload path") was doing the work and hid the break — but it
|
||||
* only holds for A's placeholder rig. Under B's canon set the arrived-broken corner is `tr1b`, a
|
||||
* carabiner on a 0.88 branch that does NOT self-destruct, so the dispatch would have promised a
|
||||
* corner on the ground and the sim would have flown it all night. Still feature-detected, because
|
||||
* the pattern is right; only the name was wrong.
|
||||
*/
|
||||
for (const c of em.rig ?? []) {
|
||||
if (c.broken) rig.failCorner?.(c.anchorId);
|
||||
}
|
||||
}
|
||||
|
||||
async function showTonight() {
|
||||
// SPRINT10: tonight's yard. loadSiteInto no-ops when the site is unchanged
|
||||
// (the four backyard nights) and rebuilds + re-points everything holding the
|
||||
@ -1101,6 +1557,56 @@ export async function boot(opts = {}) {
|
||||
// doesn't, this line is the first suspect.
|
||||
rigging.session.setConstraints?.(week.job?.constraints ?? []);
|
||||
spentThisNight = 0;
|
||||
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1 — **THE FORK: A DISPATCH INSTEAD OF A JOB SHEET.** The one
|
||||
* branch this whole gate needed in the phase flow, and it is deliberately the
|
||||
* last thing in the morning rather than a new phase: everything above this line
|
||||
* (the yard, the bank, the client's terms) is identical for both job types,
|
||||
* because an emergency IS a job — it just isn't a quote.
|
||||
*
|
||||
* Two orderings in here are load-bearing:
|
||||
* · `stormKey` is set BEFORE the phase change, exactly as the ordinary path
|
||||
* does on its GO button, because `phaseChange` points the wind router at
|
||||
* `winds[stormKey]`. Set it after and you would stand in a calm yard while
|
||||
* the card said gale.
|
||||
* · **the rig is adopted on the BUTTON, not before the card.** The forecast
|
||||
* phase's own handler runs `showMorning().then(() => { garden.reset();
|
||||
* resetRig(); … })`, and `resetRig()` would strip an emergency rig installed
|
||||
* any earlier — a night with no sail in it, and the player standing in a
|
||||
* storm wondering what they were called out for. It is also better fiction:
|
||||
* you roll, and THEN you are there.
|
||||
*/
|
||||
const em = week.job.emergency;
|
||||
if (em) {
|
||||
stormKey = week.stormKey;
|
||||
const emDef = defs[week.stormKey];
|
||||
hud.showDispatch(
|
||||
{
|
||||
job: week.job,
|
||||
quote: week.quote(emDef),
|
||||
site: siteMeta[week.site],
|
||||
/**
|
||||
* C's seam (THREADS [A] 2026-07-25 §8): what the sky and the grass tell
|
||||
* you when no paper briefed you. Called through the NAMESPACE import on
|
||||
* purpose — a named import of a function lane/c has not exported yet is a
|
||||
* hard module error, not a graceful no-op, so `weather.midStormRead?.()`
|
||||
* is the `setConstraints?.()` pattern adapted to ES modules. Until it
|
||||
* lands the card prints one honest italic line saying nobody briefed you.
|
||||
*/
|
||||
read: emDef ? (weather.midStormRead?.(emDef, em.arriveAt) ?? null) : null,
|
||||
},
|
||||
{ night: week.night, nights: week.nights, bank: week.bank, rep: week.rep },
|
||||
() => {
|
||||
adoptEmergencyRig(em).then(() => {
|
||||
spentThisNight = 0; // there was no shop; the ute's spare is free.
|
||||
game.setPhase('storm', { at: em.arriveAt });
|
||||
});
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// SPRINT11 — the job sheet reads three new things off the week: tonight's
|
||||
// JOB (client + brief), the QUOTE (what it pays, before you rig it, which is
|
||||
// the half of DESIGN.md's brief the game never showed), and TOMORROW's storm
|
||||
@ -1117,6 +1623,11 @@ export async function boot(opts = {}) {
|
||||
// (quote.rep), but the sheet renders a letterhead even on nights whose
|
||||
// quote degrades away, so it rides the week block explicitly.
|
||||
rep: week.rep,
|
||||
// ⚖️ SPRINT18 gate 0 — and if you have stood THIS client up before, the
|
||||
// sheet says so under their brief. The board's card is where the memory
|
||||
// changes a decision; the sheet is where it changes the room you walk
|
||||
// into. Same week ledger, read by name.
|
||||
remembers: week.remembers(week.job?.client),
|
||||
},
|
||||
() => { stormKey = week.stormKey; game.setPhase('prep'); },
|
||||
);
|
||||
@ -1153,6 +1664,31 @@ export async function boot(opts = {}) {
|
||||
rig.events.on('break', (e) => { tallyFor(e.anchorId).broke++; });
|
||||
rig.events.on('repair', (e) => { tallyFor(e.anchorId).repaired++; });
|
||||
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1 — **D'S KNIFE, THE TALLY AND THE PAPER HALF.** DESIGN.md
|
||||
* line 165: *"knife (cut a sail loose — lose the sail, save the anchors)"*, and
|
||||
* the signature decision under load: **release, cut, or ride it out.**
|
||||
*
|
||||
* The seam agreed in THREADS [A] 2026-07-25 §9: **D owns the verb, the input and
|
||||
* the HUD read; I own the tally, the settlement field and the line on the
|
||||
* paperwork.** D emits `cutAway` on `rig.events` with `{t, corners, reason?}` —
|
||||
* the same Emitter `break`/`repair`/`pondDump` already ride, so this needed no
|
||||
* new wiring on either side. Subscribed once (the Emitter survives a re-rig) and
|
||||
* cleared at storm start, exactly like nightTally above: a cut on the calm day
|
||||
* is not the night's story.
|
||||
*
|
||||
* Principle 2, no exceptions — a verb with no line on the paperwork is a verb the
|
||||
* game does not admit to. Until D's input lands this log stays empty and every
|
||||
* surface renders nothing, which is the degradation rule rather than a stub.
|
||||
* @type {{t:number, corners:string[], reason:string|null}[]}
|
||||
*/
|
||||
const cutAwayLog = [];
|
||||
rig.events.on('cutAway', (e) => {
|
||||
const corners = Array.isArray(e?.corners) ? [...e.corners] : (e?.anchorId ? [e.anchorId] : []);
|
||||
cutAwayLog.push({ t: e?.t ?? game.phaseT, corners, reason: e?.reason ?? null });
|
||||
pushEvent(`sail cut loose${corners.length ? ` at ${corners.map((c) => String(c).toUpperCase()).join(', ')}` : ''}`);
|
||||
});
|
||||
|
||||
// --- phases -------------------------------------------------------------
|
||||
game.on('phaseChange', ({ to }) => {
|
||||
// Prep and forecast happen on the calm day; the storm you picked only
|
||||
@ -1173,7 +1709,7 @@ export async function boot(opts = {}) {
|
||||
if (paused) { paused = false; hud.setPaused(false); }
|
||||
if (muted) setMuted(true);
|
||||
|
||||
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); }
|
||||
if (to === 'storm') { pondPeak = 0; pondDumped = 0; nightTally.clear(); cutAwayLog.length = 0; }
|
||||
|
||||
if (to === 'forecast') {
|
||||
hud.setDawn(false);
|
||||
@ -1207,7 +1743,11 @@ export async function boot(opts = {}) {
|
||||
// that doesn't wait), and M only once C's bus has a tap — hud owns that
|
||||
// question, because a help line that lists a dead key is a help line that
|
||||
// teaches a stranger the game is broken.
|
||||
hud.setHelp(`WASD move · shift run · E repair/pickup · C brace · RMB orbit · ${hud.comfortKeysHint()}`);
|
||||
// SPRINT18 gate 1.3 — X is on the help line because a key nobody advertises is a key nobody
|
||||
// presses, and the knife is the one verb the game has to offer BEFORE the moment it is needed.
|
||||
// "hold X cut sail" rather than "X cut sail": it is a hold, and the difference matters when the
|
||||
// window is six seconds long.
|
||||
hud.setHelp(`WASD move · shift run · E repair/pickup · hold X cut sail · C brace · RMB orbit · ${hud.comfortKeysHint()}`);
|
||||
}
|
||||
if (to === 'aftermath') {
|
||||
// The dawn comes up BEFORE the scoreboard, on E's 2.2 s ease. Their note
|
||||
|
||||
@ -18,8 +18,10 @@ import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
|
||||
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
|
||||
import { createLadder } from './ladder.js';
|
||||
import { createBroom } from './broom.js';
|
||||
import { createKnife, createVerbPanel, KNIFE_TUNE } from './knife.js';
|
||||
|
||||
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom };
|
||||
export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom,
|
||||
createKnife, createVerbPanel, KNIFE_TUNE };
|
||||
|
||||
export const CHAR_URL = './models/player_01.glb';
|
||||
export const ANIM_URL = './models/player_anims.glb';
|
||||
@ -306,6 +308,25 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
const broom = (opts.broom === false || !opts.interact)
|
||||
? null : createBroom(scene, world, opts.interact, sim, () => opts.interact.sailRig);
|
||||
|
||||
/**
|
||||
* THE KNIFE (SPRINT18 gate 1.3). Same self-wiring as the ladder and the broom — the rig is read
|
||||
* LIVE off interact, because `rigSail()` replaces the object on every commit. Everything main.js
|
||||
* has to supply is optional and injected as a GETTER, not a value: `game` and `garden` are
|
||||
* declared in boot() BELOW the first `await loadSiteInto()` that builds the player, so a plain
|
||||
* `{ game, garden }` would be a temporal-dead-zone throw at boot — the trap A has documented twice
|
||||
* in that file. A getter is read at frame time, when both exist.
|
||||
*/
|
||||
const knife = (opts.knife === false || !opts.interact) ? null : createKnife({
|
||||
player: sim,
|
||||
getRig: () => opts.interact.sailRig,
|
||||
getWorld: () => world,
|
||||
getPhase: opts.getPhase,
|
||||
getGarden: opts.getGarden,
|
||||
collateralAtRisk: opts.collateralAtRisk,
|
||||
secsLeft: opts.secsLeft,
|
||||
});
|
||||
const verbs = opts.verbPanel === false ? null : createVerbPanel({ getPhase: opts.getPhase });
|
||||
|
||||
return {
|
||||
get pos() { return sim.pos; },
|
||||
get carrying() { return sim.carrying; },
|
||||
@ -318,7 +339,10 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
sim.step(dt, t, input, opts.wind);
|
||||
if (ladder) ladder.update(dt, t, input);
|
||||
if (broom) broom.update(dt, t, input);
|
||||
if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
|
||||
// The prompt state is KEPT now rather than dropped on the floor — see createVerbPanel.
|
||||
const prompt = opts.interact ? opts.interact.step(dt, t, sim, keyboard.holding) : null;
|
||||
const cut = knife ? knife.step(dt, t, keyboard.cutting) : null;
|
||||
verbs?.update(prompt, cut);
|
||||
view.sync(sim, dt);
|
||||
},
|
||||
|
||||
@ -328,11 +352,16 @@ export async function createPlayer(scene, world, cameraRig, opts = {}) {
|
||||
view,
|
||||
ladder,
|
||||
broom,
|
||||
knife,
|
||||
verbs,
|
||||
keyboard,
|
||||
dispose() {
|
||||
keyboard.dispose(); view.dispose();
|
||||
if (ladder) ladder.dispose();
|
||||
if (broom) broom.dispose();
|
||||
// The panel is rebuilt with the player on every site change (loadSiteInto disposes the old
|
||||
// one), so a leaked overlay would stack a second #verbs on the third night.
|
||||
if (verbs) verbs.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -356,6 +385,18 @@ export class KeyboardInput {
|
||||
|
||||
get holding() { return this.keys.has('KeyE'); }
|
||||
|
||||
/**
|
||||
* SPRINT18 — THE KNIFE'S KEY. X, held.
|
||||
*
|
||||
* Its own key rather than a third hold-E target, because at a post base E already offers two
|
||||
* things (re-rig, tighten) and `interact.nearest()` picks by DISTANCE — a knife registered at the
|
||||
* same position would race the repair for the same key, and the one input the player must never
|
||||
* misfire is the irreversible one. X is free (WASD/arrows, shift, C brace, E hold, P, M, Enter,
|
||||
* and prep's [ ] S F are the whole keyboard), and it is next to C on the row your left hand
|
||||
* already lives on during a storm.
|
||||
*/
|
||||
get cutting() { return this.keys.has('KeyX'); }
|
||||
|
||||
/** @param {number} camYaw radians */
|
||||
read(camYaw = 0) {
|
||||
const k = this.keys;
|
||||
|
||||
@ -297,15 +297,101 @@ export class RiggingSession {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ SPRINT18 GATE 1 — THE RIG YOU DIDN'T BUILD. Adopt somebody else's work.
|
||||
* [B rules the semantics; installs Lane A's `night.emergency.rig` data]
|
||||
*
|
||||
* An emergency callout drops you at t=30 under a sail you never quoted, never
|
||||
* bought and never hung. Lane A's spine wanted to install it through `rig()` +
|
||||
* `setHardware()` — the public-moves discipline, which is right — but those
|
||||
* moves are a SHOP, and putting someone else's rig through the shop charges the
|
||||
* player for it. Two things then go wrong, and the second one is the bad one:
|
||||
*
|
||||
* 1. the wallet. $40 of another contractor's shackles comes off tonight's
|
||||
* bank, for steel that was already in the yard when the phone rang.
|
||||
* 2. THE REFUND INVERTS THE KNIFE. week.js pays back half of every intact
|
||||
* corner's hardware (`intactHardwareValue` × refundShare). If the inherited
|
||||
* steel counts as the player's, then every corner they CUT costs them
|
||||
* money — and DESIGN.md's signature decision becomes a thing the ledger
|
||||
* punishes you for getting right. A rule that pays you to let a stranger's
|
||||
* sail take out a glasshouse is not a hard trade-off, it's a bug.
|
||||
*
|
||||
* So: adopted corners cost $0, are flagged `inherited`, and are excluded from
|
||||
* `intactHardwareValue`. The steel is real to the PHYSICS (its rating is what
|
||||
* fails) and invisible to the WALLET, which is exactly what "someone else's
|
||||
* work" means. `spent`/`budget` never move, so the invoice's spend line reads
|
||||
* $0 on a callout with no branch anywhere in the ledger (A's acceptance §10).
|
||||
*
|
||||
* Client terms are still honoured — a house ban still refuses, because the ban
|
||||
* is about what may be TOUCHED, not about who paid. A refusal here is a
|
||||
* content bug (the author hung a sail off banned steel), so it comes back as a
|
||||
* result for the caller to surface, not a throw: A installs this at the same
|
||||
* boundary `setConstraints` uses and can ticker it.
|
||||
*
|
||||
* @param {{anchorId: string, hw: string, broken?: boolean}[]} spec A's shape:
|
||||
* `hw` is a HARDWARE *name*, since this arrives from authored JSON
|
||||
* @returns {{ok: boolean, reason?: string, broken?: string[]}} `broken` lists
|
||||
* the corners that must arrive DOWN — hand them to `SailRig.failCorner`
|
||||
* after commit (the pond and the freed node are the sail's business, not the
|
||||
* session's)
|
||||
*/
|
||||
inherit(spec) {
|
||||
if (!Array.isArray(spec) || spec.length !== MAX_CORNERS) {
|
||||
return fail(`an inherited rig is ${MAX_CORNERS} corners, got ${Array.isArray(spec) ? spec.length : typeof spec}`);
|
||||
}
|
||||
const before = { budget: this.budget, picks: this.picks };
|
||||
this.picks = [];
|
||||
const broken = [];
|
||||
for (const s of spec) {
|
||||
const a = this.anchors.find((x) => x.id === s.anchorId);
|
||||
if (!a) { this.picks = before.picks; return fail(`inherit: no anchor ${s.anchorId} in this yard`); }
|
||||
const ban = this._banFor(a);
|
||||
if (ban) { this.picks = before.picks; return fail(`inherit: ${s.anchorId} is ${a.type} steel and the client's terms say "${ban.label}"`); }
|
||||
const hw = HARDWARE.find((h) => h.name === s.hw);
|
||||
if (!hw) {
|
||||
this.picks = before.picks;
|
||||
return fail(`inherit: "${s.hw}" is not shop hardware — the yard sells ${HARDWARE.map((h) => h.name).join(', ')}`);
|
||||
}
|
||||
if (this.picks.some((p) => p.anchorId === s.anchorId)) {
|
||||
this.picks = before.picks;
|
||||
return fail(`inherit: ${s.anchorId} named twice`);
|
||||
}
|
||||
this.picks.push({ anchorId: s.anchorId, hw, inherited: true });
|
||||
if (s.broken) broken.push(s.anchorId);
|
||||
}
|
||||
this._reorder();
|
||||
// the money never moved, and that is the whole point
|
||||
this.budget = before.budget;
|
||||
return { ...OK, broken };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the player would get back for hardware still standing at dawn — with
|
||||
* inherited steel excluded, because you cannot sell a shackle you never
|
||||
* bought. main.js reads `rig.corners` for this today; a callout needs the
|
||||
* SESSION's view, which is the only one that knows who paid.
|
||||
*/
|
||||
intactHardwareValue(rig) {
|
||||
const corners = rig?.corners ?? [];
|
||||
return corners.reduce((sum, c) => {
|
||||
if (c.broken) return sum;
|
||||
const p = this.pickOf(c.anchorId);
|
||||
return p?.inherited ? sum : sum + c.hw.cost;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unrig a corner and refund its hardware. Not in the prototype (which had no
|
||||
* way back from a misclick) but it is a pure refund, so it costs the economy
|
||||
* nothing and saves the player a restart.
|
||||
*
|
||||
* SPRINT18: an INHERITED corner refunds nothing — pulling somebody else's
|
||||
* shackle off their post is not a trip to the returns counter.
|
||||
*/
|
||||
unrig(anchorId) {
|
||||
const i = this.picks.findIndex((p) => p.anchorId === anchorId);
|
||||
if (i < 0) return fail('not rigged');
|
||||
this.budget += this.picks[i].hw.cost;
|
||||
if (!this.picks[i].inherited) this.budget += this.picks[i].hw.cost;
|
||||
this.picks.splice(i, 1);
|
||||
return OK;
|
||||
}
|
||||
@ -315,13 +401,29 @@ export class RiggingSession {
|
||||
const p = this.pickOf(anchorId);
|
||||
if (!p) return fail('not rigged');
|
||||
const next = HARDWARE[(HARDWARE.indexOf(p.hw) + 1) % HARDWARE.length];
|
||||
const capNo = this._capRefusal(next.cost - p.hw.cost);
|
||||
const price = this._priceOfChange(p, next);
|
||||
const capNo = this._capRefusal(price);
|
||||
if (capNo) return capNo;
|
||||
if (!this._spend(next.cost - p.hw.cost)) return fail('not enough budget');
|
||||
if (!this._spend(price)) return fail('not enough budget');
|
||||
p.hw = next;
|
||||
p.inherited = false; // you paid for this one; it is yours now
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* What changing a corner's hardware costs. [SPRINT18 gate 1]
|
||||
*
|
||||
* Normally the difference — the shop takes the old piece back, because you
|
||||
* bought it here ten seconds ago. On an INHERITED corner there is nothing to
|
||||
* trade in: the carabiner on that post belongs to whoever rigged it last, and
|
||||
* crediting its $5 against your shackle would have the player selling somebody
|
||||
* else's steel to fund their own. So an inherited corner is priced at FULL
|
||||
* whack, and replacing it is exactly as expensive as triage should feel.
|
||||
*/
|
||||
_priceOfChange(pick, next) {
|
||||
return pick.inherited ? next.cost : next.cost - pick.hw.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put specific hardware on a rigged corner, paying the difference.
|
||||
*
|
||||
@ -350,10 +452,12 @@ export class RiggingSession {
|
||||
}
|
||||
const p = this.pickOf(anchorId);
|
||||
if (!p) return fail('not rigged');
|
||||
const capNo = this._capRefusal(hw.cost - p.hw.cost);
|
||||
const price = this._priceOfChange(p, hw);
|
||||
const capNo = this._capRefusal(price);
|
||||
if (capNo) return capNo;
|
||||
if (!this._spend(hw.cost - p.hw.cost)) return fail('not enough budget');
|
||||
if (!this._spend(price)) return fail('not enough budget');
|
||||
p.hw = hw;
|
||||
p.inherited = false; // paid for, so it counts as yours at dawn
|
||||
return OK;
|
||||
}
|
||||
|
||||
@ -437,8 +541,18 @@ export class RiggingSession {
|
||||
this.picks = ring.map((a) => byId.get(a.id));
|
||||
}
|
||||
|
||||
/** Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach(). */
|
||||
commit(rig) {
|
||||
/**
|
||||
* Hand the finished rig to the sim. Mirrors contracts.js sailRig.attach().
|
||||
*
|
||||
* `opts` is forwarded to `attach` untouched, which exists for exactly one
|
||||
* reason (SPRINT18, Lane C's finding): an emergency callout's storm is already
|
||||
* 30 seconds old, and `attach({at, wind})` is what stops the cloth flying the
|
||||
* wrong sixty seconds of it. Without this the only way to seed the clock would
|
||||
* be a SECOND call after commit — a step a caller can forget, on the one night
|
||||
* where forgetting it silently understates every load by 4x. The session's
|
||||
* public-moves discipline and a correct clock should not be a choice.
|
||||
*/
|
||||
commit(rig, opts = undefined) {
|
||||
if (!this.canStart) throw new Error(`sail needs ${MAX_CORNERS} corners, have ${this.picks.length}`);
|
||||
// gate 2, the promise the sprint doc names ("enforces the budget cap at
|
||||
// commit"): by now the refusals above have made these states unreachable
|
||||
@ -466,7 +580,7 @@ export class RiggingSession {
|
||||
// shade cloth + C's 0.40 downdraft drop p1 to 1.08 kN, under a $5
|
||||
// carabiner's rating, which is the $10 that closes the $90 -> $80 gap.)
|
||||
if (rig.setFabric) rig.setFabric(this.fabric);
|
||||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension);
|
||||
return rig.attach(this.picks.map((p) => p.anchorId), this.picks.map((p) => p.hw), this.tension, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -557,7 +671,14 @@ export class RiggingSession {
|
||||
anchorId: p.anchorId, hw: p.hw.name, rating: p.hw.rating, cost: p.hw.cost,
|
||||
effRating: this._effRating(p),
|
||||
hint: this.anchors.find((x) => x.id === p.anchorId)?.ratingHint ?? 1,
|
||||
// SPRINT18 gate 1: whose steel this is. The dispatch card wants to say
|
||||
// "somebody else's carabiner on a 0.88 branch" and the invoice wants to
|
||||
// leave it out of the refund; both need the flag, not an inference.
|
||||
inherited: !!p.inherited,
|
||||
})),
|
||||
// $0 on an ordinary night; on a callout, what the previous contractor left
|
||||
// hanging. A number the dispatch can print instead of a spend line.
|
||||
inheritedValue: this.picks.reduce((s, p) => s + (p.inherited ? p.hw.cost : 0), 0),
|
||||
// The weak link is the lowest EFFECTIVE rating — hw.rating × the anchor's
|
||||
// ratingHint, the same product sail.js fails a corner on (SPRINT12, A's
|
||||
// ruling). D's #7: this used to reduce on hw.rating alone with strict <,
|
||||
@ -591,7 +712,10 @@ export class RiggingSession {
|
||||
* @param {object} o.camera cameraRig.object — what we raycast from
|
||||
* @param {Element} o.domElement renderer.domElement — where clicks land
|
||||
* @param {object} o.world needs world.anchors
|
||||
* @param {function} o.onCommit (anchorIds, hwChoices, tension) => void — Lane A's rigSail
|
||||
* @param {function} o.onCommit (anchorIds, hwChoices, tension, fabric) => void — Lane A's
|
||||
* rigSail. The FOURTH argument is the FABRIC entry, and it is
|
||||
* not decoration — see commit() below for the sprint-18 gate-2
|
||||
* finding that put it there.
|
||||
* @param {function} [o.onMessage] (text) => void — refusals, for the event ticker
|
||||
* @param {boolean} [o.panel=true] draw the built-in prep panel; false if hud.js takes it over
|
||||
*/
|
||||
@ -879,7 +1003,37 @@ export async function createRiggingUI({
|
||||
}
|
||||
},
|
||||
|
||||
/** Hand the finished rig to Lane A's rigSail. Returns false if it isn't four corners. */
|
||||
/**
|
||||
* Hand the finished rig to Lane A's rigSail. Returns false if it isn't four
|
||||
* corners.
|
||||
*
|
||||
* ⚠️ SPRINT18 GATE 2 — THE FABRIC RIDES ALONG NOW, AND HERE IS WHY.
|
||||
*
|
||||
* `RiggingSession.commit(rig)` sets the fabric on the sail (`rig.setFabric`)
|
||||
* and it is the ONLY place in the repo that does. This function — the
|
||||
* shipped commit path, the one the player's Enter key reaches — does not
|
||||
* call it: it calls `onCommit`, and Lane A's `rigSail` goes straight to
|
||||
* `rig.attach()`. So `session.commit(rig)` runs in TESTS AND BENCHES ONLY,
|
||||
* and the sim's rig (built once at main.js boot as `new SailRig({anchors})`,
|
||||
* porosity defaulting to 0) has flown the WATERPROOF MEMBRANE every night of
|
||||
* every playtest, while the panel, the SCORE IT card, the HUD row, the
|
||||
* invoice and every audit number said "shade cloth".
|
||||
*
|
||||
* Measured, sprint 18 (the wildnight pin p5/p1/p2/p3, backyard_01):
|
||||
* shade cloth 0.30 (what every card promised) 0/4 lost · p2 3.07 · p3 1.78
|
||||
* membrane 0 (what the sim actually flew) 2/4 lost · p2 3.48 · p3 3.90
|
||||
* The second row IS D's cold play and IS the pin's own `_playedReference`
|
||||
* (hp 55.7, p2 then p3) — reproduced in the shipped game to the decimal.
|
||||
* Two harnesses, one variable: `porosity`. The GAME was the liar; the bench
|
||||
* flew what the card sold. This also retires C's "benches under-read the
|
||||
* real UI by 5–15%, cause unfound": with the fabric held equal, the bench and
|
||||
* the full game chain agree to three decimals on both sites.
|
||||
*
|
||||
* So the fabric is now an ARGUMENT, not a thing the caller must remember to
|
||||
* go and fetch. Lane A's one-line pickup is filed in THREADS; until it
|
||||
* lands, `rigSail` dropping a 4th argument is exactly as broken as it was
|
||||
* before and no more, which is why this is safe to ship on its own.
|
||||
*/
|
||||
commit() {
|
||||
if (!session.canStart) {
|
||||
onMessage(`rig ${MAX_CORNERS - session.picks.length} more corner(s) first`);
|
||||
@ -889,6 +1043,7 @@ export async function createRiggingUI({
|
||||
session.picks.map((p) => p.anchorId),
|
||||
session.picks.map((p) => p.hw),
|
||||
session.tension,
|
||||
session.fabric,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
@ -555,6 +555,95 @@ test('gate 3: a corroded corner costs MORE steel than a sound post at the same l
|
||||
return `same carabiner: sound holds ${sound} N, corroded holds ${cor} N — the rust costs a tier`;
|
||||
});
|
||||
|
||||
// --- SPRINT18 gate 1: THE RIG YOU DIDN'T BUILD -----------------------------
|
||||
|
||||
const INHERITED = [
|
||||
{ anchorId: 'h1', hw: 'carabiner' },
|
||||
{ anchorId: 'h3', hw: 'shackle' },
|
||||
{ anchorId: 'p1', hw: 'carabiner', broken: true },
|
||||
{ anchorId: 'p2', hw: 'carabiner' },
|
||||
];
|
||||
|
||||
test('gate 1: an inherited rig costs the player nothing and names its dead corner', () => {
|
||||
const s = session();
|
||||
const r = s.inherit(INHERITED);
|
||||
assert(r.ok, `inherit refused: ${r.reason}`);
|
||||
assert(s.budget === START_BUDGET,
|
||||
`adopting somebody else's rig charged $${START_BUDGET - s.budget}. That steel was in the yard when the ` +
|
||||
'phone rang — the callout pays for the callout, not for the previous contractor\'s shackles');
|
||||
assert(s.spent === 0, `spent reads $${s.spent} on a rig the player did not buy`);
|
||||
assert(s.picks.length === 4 && s.canStart, 'an inherited rig is not startable');
|
||||
assert(s.picks.every((p) => p.inherited), 'some adopted corners are not flagged inherited');
|
||||
assert(r.broken.join(',') === 'p1',
|
||||
`inherit reported broken corners "${r.broken.join(',')}" — Lane A hands these to SailRig.failCorner`);
|
||||
assert(s.summary.inheritedValue === CARABINER.cost * 3 + SHACKLE.cost,
|
||||
`inheritedValue reads $${s.summary.inheritedValue} — the dispatch prints this where a spend line would go`);
|
||||
return `4 corners adopted for $0 (worth $${s.summary.inheritedValue}), p1 arrives down, budget still $${s.budget}`;
|
||||
});
|
||||
|
||||
test('gate 1: ⚠️ the refund cannot pay you for a stranger\'s steel — the knife stays honest', () => {
|
||||
const s = session();
|
||||
assert(s.inherit(INHERITED).ok, 'inherit refused');
|
||||
// Stand in for the sail at dawn: nothing broken, so every corner is "intact".
|
||||
const rig = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: false })) };
|
||||
assert(s.intactHardwareValue(rig) === 0,
|
||||
`an all-inherited rig returned $${s.intactHardwareValue(rig)} of refundable hardware. week.js pays back half ` +
|
||||
'of that, so every corner the player CUT would cost them money — DESIGN.md\'s signature decision, ' +
|
||||
'punished by the ledger for being taken');
|
||||
// Buy one corner yourself and it becomes yours — at FULL price, no trade-in.
|
||||
const before = s.budget;
|
||||
assert(s.setHardware('p2', RATED).ok, 'buying an inherited corner up was refused');
|
||||
assert(before - s.budget === RATED.cost,
|
||||
`upgrading an inherited carabiner cost $${before - s.budget}, not $${RATED.cost}. Crediting the old ` +
|
||||
'piece would have the player selling somebody else\'s steel to fund their own');
|
||||
const rig2 = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: false })) };
|
||||
assert(s.intactHardwareValue(rig2) === RATED.cost,
|
||||
`after paying for p2, refundable value reads $${s.intactHardwareValue(rig2)} — it should be exactly the ${RATED.name} the player bought`);
|
||||
// and a corner that is GONE at dawn refunds nothing either way
|
||||
const rig3 = { corners: s.picks.map((p) => ({ anchorId: p.anchorId, hw: p.hw, broken: p.anchorId === 'p2' })) };
|
||||
assert(s.intactHardwareValue(rig3) === 0, 'a broken corner still came home');
|
||||
return `inherited steel refunds $0; the $${RATED.cost} the player bought refunds, and only that`;
|
||||
});
|
||||
|
||||
test('gate 1: commit carries the mid-storm clock through to the sail', () => {
|
||||
// The emergency flow is inherit -> commit -> put the dead corner down -> roll.
|
||||
// If commit cannot pass `at`, seeding the clock becomes a separate call on the
|
||||
// one night where forgetting it understates every load ~4x (C's finding).
|
||||
const s = session();
|
||||
assert(s.inherit(INHERITED).ok, 'inherit refused');
|
||||
const rig = s.commit(new SailRig({ anchors: ANCHORS, gridN: 10 }), { at: 30 });
|
||||
assert(rig.t === 30,
|
||||
`commit({at:30}) left the cloth at t=${rig.t}. It is now flying the storm's FIRST sixty seconds ` +
|
||||
'under a sky flying its last sixty, and nothing about that is visible from outside');
|
||||
assert(rig.clockSkew === 0, `a freshly seeded commit already reads ${rig.clockSkew}s of skew`);
|
||||
// and the ordinary night is untouched — no opts, clock at 0
|
||||
const plain = session();
|
||||
for (const id of ['h1', 'h3', 'p1', 'p2']) plain.rig(id);
|
||||
assert(plain.commit(new SailRig({ anchors: ANCHORS, gridN: 10 })).t === 0,
|
||||
'an ordinary commit no longer starts its storm at 0');
|
||||
return 'commit(rig, {at:30}) seeds the cloth to 30s; a plain commit still starts at 0';
|
||||
});
|
||||
|
||||
test('gate 1: an inherited rig still obeys the client, and refuses a rig it cannot build', () => {
|
||||
const s = session();
|
||||
s.setConstraints([HOUSE_BAN]);
|
||||
const banned = s.inherit(INHERITED); // h1/h3 are house steel
|
||||
assert(!banned.ok && /h1/.test(banned.reason),
|
||||
`a banned anchor was adopted anyway: ${JSON.stringify(banned)} — the ban is about what may be TOUCHED, not who paid`);
|
||||
assert(s.picks.length === 0, 'a refused inherit left half a rig behind');
|
||||
|
||||
const s2 = session();
|
||||
assert(!s2.inherit(INHERITED.slice(0, 3)).ok, 'a three-corner sail was adopted');
|
||||
assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'p1', hw: 'gaffer tape' }]).ok,
|
||||
'hardware that is not in the shop was adopted — an authored typo would silently fly a carabiner');
|
||||
assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'h1', hw: 'shackle' }]).ok,
|
||||
'the same anchor twice was adopted');
|
||||
assert(!s2.inherit([...INHERITED.slice(0, 3), { anchorId: 'nowhere', hw: 'shackle' }]).ok,
|
||||
'an anchor this yard does not have was adopted');
|
||||
assert(s2.budget === START_BUDGET, `a refused inherit moved the money to $${s2.budget}`);
|
||||
return 'refuses banned steel, wrong corner counts, unknown hardware, duplicates and strangers — money never moves';
|
||||
});
|
||||
|
||||
export const RIGGING_TESTS = TESTS;
|
||||
|
||||
export function runRiggingSelftest() {
|
||||
|
||||
@ -96,8 +96,20 @@ const DEBRIS_SKIN = 0.06; // contact margin, ~cloth thickness
|
||||
// real load; POND_BELLY_MAX below is what stops a sail bellying to 5 m first.
|
||||
const DIVERGENCE_N = 80000; // 80 kN — past here the solver has actually blown up
|
||||
const POND_BELLY_MAX = 4.0; // metres of sag before the sail tears and dumps (DESIGN.md "sudden dump… tear")
|
||||
const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
|
||||
const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
|
||||
/**
|
||||
* THE FAILURE LAW, and it is EXPORTED because a second copy of it is the exact
|
||||
* drift this repo keeps paying for (SPRINT18 gate 3).
|
||||
*
|
||||
* A corner does not fail the instant it passes its rating: `overload` fills at
|
||||
* +dt while over and bleeds at −2dt while under, and the corner lets go when it
|
||||
* passes OVERLOAD_SECS. So "will this hold" is a question about DWELL, not about
|
||||
* peak load — a corner can peak 10% over its rating and survive if the gust is
|
||||
* short, and the audit was answering it with a percentage of taste
|
||||
* (AUDIT.MARGIN) instead of the law itself. tools/site_audit replays these two
|
||||
* numbers against the load trace now; they must never be re-typed there.
|
||||
*/
|
||||
export const OVERLOAD_SECS = 0.4; // prototype: 0.4 s sustained overload before it lets go
|
||||
export const OVERLOAD_RECOVER = 2.0; // prototype: overload timer bleeds off at 2x
|
||||
const LOAD_TAU = 0.11; // load meter smoothing time constant, s
|
||||
|
||||
export const TENSION_MIN = 0.6;
|
||||
@ -172,8 +184,16 @@ export class SailRig {
|
||||
* @param {string[]} anchorIds 4 anchor ids; reordered into a ring internally
|
||||
* @param {object[]} hwChoices hardware per anchor id, same order as anchorIds
|
||||
* @param {number} tension 0.6 (loose, flogs) .. 1.4 (drum tight, shock-loads)
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.at=0] STORM SECONDS ALREADY ELAPSED when this sail
|
||||
* enters the sim. 0 for every ordinary night — see the clock note below, and
|
||||
* ⚠️ SPRINT18 gate 1 / Lane C's finding for the night where it is not 0.
|
||||
* @param {object} [opts.wind=null] if given alongside `at`, the sail is FLOWN
|
||||
* from 0 to `at` through this wind instead of teleported there — the
|
||||
* difference between a sail that has been up all storm and one that appears
|
||||
* mid-gale with no drape, no pond and no history.
|
||||
*/
|
||||
attach(anchorIds, hwChoices, tension = 1.0) {
|
||||
attach(anchorIds, hwChoices, tension = 1.0, { at = 0, wind = null } = {}) {
|
||||
if (anchorIds.length !== 4) throw new Error(`sail needs exactly 4 corners, got ${anchorIds.length}`);
|
||||
|
||||
const picked = anchorIds.map((id) => {
|
||||
@ -215,9 +235,29 @@ export class SailRig {
|
||||
* caller comes through (boot, the picking adapter, balance.test, the audit),
|
||||
* and commit → attach → game.advance() → storm all land in a single keypress,
|
||||
* so t=0 at attach IS t=0 at the first storm frame.
|
||||
*
|
||||
* ⚠️ SPRINT18 — THAT LAST SENTENCE IS A PRECONDITION, AND THE EMERGENCY
|
||||
* CALLOUT IS THE FIRST NIGHT THAT BREAKS IT. [Lane C's finding, B's fix]
|
||||
*
|
||||
* `_substep` samples the wind at `this.t`, NOT at the `t` handed to step() —
|
||||
* deliberately, because the internal fixed-step clock is what makes a 144 Hz
|
||||
* browser and a fast-forwarded bench produce byte-equal traces. That is only
|
||||
* safe while the two clocks START together. An emergency night opens its storm
|
||||
* phase at t=30, so a sail attached the old way would fly the storm's FIRST
|
||||
* 60 seconds while the sky, the hail and the garden fly its LAST 60. C
|
||||
* measured the gap through that door: the sky blowing 9.074 m/s over a cloth
|
||||
* feeling 4.468 (4.12x understated load) and raining 5.000 mm/hr onto a cloth
|
||||
* ponding 0.054 (93x light) — an emergency that would demo fine and merge
|
||||
* fine and simply read as "triage is easy".
|
||||
*
|
||||
* So the clock is SEEDED rather than assumed, and `clockSkew` below makes any
|
||||
* future divergence readable instead of silent. `at` is 0 for every ordinary
|
||||
* night, which is byte-for-byte the behaviour above.
|
||||
*/
|
||||
this.t = 0;
|
||||
this.t = at;
|
||||
this._acc = 0;
|
||||
/** Largest |caller's t − this.t| seen since attach. 0 means the clocks agree. */
|
||||
this._skew = 0;
|
||||
this.corners = ring.map((a) => ({
|
||||
anchorId: a.id,
|
||||
anchor: a,
|
||||
@ -226,15 +266,66 @@ export class SailRig {
|
||||
peakLoad: 0,
|
||||
overload: 0,
|
||||
broken: false,
|
||||
// SPRINT18 gate 1 — HOW a corner came to be down, not just that it is.
|
||||
// Declared here with the rest so a re-rig clears them: a sail cut away on
|
||||
// night 5 that still read `cut` on night 6 would put last night's knife on
|
||||
// tonight's invoice, which is the phantom-sail bug wearing a new hat.
|
||||
cut: false, // the player chose to let this one go (`cutAway`)
|
||||
inherited: false, // it was already gone when you arrived (`failCorner`)
|
||||
trim: 1.0,
|
||||
loadVec: { x: 0, y: 0, z: 0 }, // reaction direction, not just magnitude — see _measureLoads
|
||||
}));
|
||||
|
||||
this._build(ring);
|
||||
this.rigged = true;
|
||||
|
||||
// A sail that has ALREADY been up for `at` seconds. Flown, not teleported:
|
||||
// the drape, the pond and the peak loads of those seconds are the difference
|
||||
// between inheriting somebody's rig and finding a brand-new one hanging in a
|
||||
// gale. Costs `at`/SIM_DT substeps once (30 s ≈ 1800, single-digit ms) and it
|
||||
// is the only way the pre-broken corner and the belly's water mean anything.
|
||||
// Without a wind we can only seed the clock, which gets the WEATHER right and
|
||||
// says nothing about the history — stated rather than hidden.
|
||||
if (at > 0 && wind) {
|
||||
this.t = 0;
|
||||
for (let i = 0, n = Math.round(at / SIM_DT); i < n; i++) this._substep(SIM_DT, wind, this.t, null);
|
||||
this.t = at;
|
||||
this._skew = 0;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the sail's own clock to `t` seconds of storm. [SPRINT18 gate 1]
|
||||
*
|
||||
* The post-attach door, for a caller that only learns where the storm is after
|
||||
* committing (Lane A's emergency flow: adopt the rig → commit → put the dead
|
||||
* corner down → roll). `attach(..., {at})` is the better door when you know the
|
||||
* number in time; this exists so the knowledge arriving late cannot mean the
|
||||
* cloth flies the wrong sixty seconds.
|
||||
*/
|
||||
seedClock(t) {
|
||||
if (!Number.isFinite(t)) return { ok: false, reason: `seedClock needs a finite time, got ${t}` };
|
||||
this.t = t;
|
||||
this._acc = 0;
|
||||
this._skew = 0;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the caller's storm clock has drifted from the cloth's own, in seconds.
|
||||
*
|
||||
* Reads 0 on every honest night. It is a DIAGNOSTIC rather than a throw because
|
||||
* one legitimate harness diverges on purpose — balance.test's `fly()` settles
|
||||
* 12 s on the calm day before the storm loop restarts its own `t` at 0, which is
|
||||
* the phantom settle the pinned separation target is calibrated against. What
|
||||
* this catches is the silent case: a phase that opens mid-storm while the cloth
|
||||
* starts from scratch (Lane C's emergency finding). If it reads non-zero on a
|
||||
* night you did not deliberately skew, the sail is in a different storm from the
|
||||
* sky and every load you are looking at is fiction.
|
||||
*/
|
||||
get clockSkew() { return this._skew; }
|
||||
|
||||
_build(ring) {
|
||||
const N = this.N;
|
||||
const nodeCount = N * N;
|
||||
@ -419,6 +510,15 @@ export class SailRig {
|
||||
*/
|
||||
step(dt, wind, t, debris = null) {
|
||||
if (!this.rigged) return;
|
||||
// `t` is not used to sample the wind (that is `this.t`, on purpose — see
|
||||
// attach) but it IS the caller telling us where it thinks the storm is, and
|
||||
// comparing the two costs nothing. SPRINT18: this is the only reason Lane C's
|
||||
// emergency-clock finding is detectable from inside the sail rather than by
|
||||
// noticing that triage felt suspiciously easy.
|
||||
if (Number.isFinite(t)) {
|
||||
const d = Math.abs(t - this.t);
|
||||
if (d > this._skew) this._skew = d;
|
||||
}
|
||||
const pieces = debris ? (debris.pieces ?? debris) : null;
|
||||
this._acc += dt;
|
||||
let n = 0;
|
||||
@ -925,6 +1025,130 @@ export class SailRig {
|
||||
* anchor (DEFAULT_RATING_HINT, a.test pins it), because `load > rating *
|
||||
* undefined` is `load > NaN`: always false, i.e. an UNBREAKABLE anchor.
|
||||
*/
|
||||
/**
|
||||
* ONE definition of what it means for a corner to stop holding — extracted in
|
||||
* SPRINT18 because gate 1 adds two more ways for it to happen (a corner that
|
||||
* arrives already gone, and the knife) and three copies of this body would be
|
||||
* exactly the drift this repo keeps paying for.
|
||||
*
|
||||
* Every caller gets the same five facts: the corner is broken, its overload
|
||||
* fuse and load are zeroed, THE NODE GETS ITS MASS BACK (this is the line every
|
||||
* good thing about a failure comes from — the freed corner stops being pinned,
|
||||
* so it flies on the wind and the flogging is emergent rather than animated),
|
||||
* the belly dumps whatever it was holding, and something is emitted.
|
||||
*
|
||||
* `reason` also decides WHICH event, and that is the design, not plumbing:
|
||||
* · 'overload' / 'divergence' → `break`. Your hardware failed. Warranty, rep.
|
||||
* · 'cut' / 'inherited' → no `break` at all. A cut is a DECISION and a
|
||||
* corner you found already gone is somebody else's failure; billing either
|
||||
* as your break would put another contractor's shackle on your invoice.
|
||||
* `cutAway` carries the knife; the inherited case is silent by construction.
|
||||
*
|
||||
* The pond dump carries a reason for the same accounting: main.js only counts a
|
||||
* REASONLESS dump as broom work (`if (!e.reason) pondDumped += e.kg`), so a
|
||||
* belly that empties because the sail was cut is not credited to the player's
|
||||
* arms.
|
||||
*
|
||||
* @returns {boolean} false if the corner was already gone (idempotent)
|
||||
*/
|
||||
_releaseCorner(k, reason) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) return false;
|
||||
c.broken = true;
|
||||
c.overload = 0;
|
||||
c.load = 0;
|
||||
c.loadVec.x = c.loadVec.y = c.loadVec.z = 0;
|
||||
if (reason === 'cut') c.cut = true;
|
||||
if (reason === 'inherited') c.inherited = true;
|
||||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||||
this.dumpPond(reason === 'cut' ? 'sail cut away'
|
||||
: reason === 'inherited' ? 'arrived down' : 'corner blew');
|
||||
if (reason === 'overload' || reason === 'divergence') {
|
||||
this.events.emit('break',
|
||||
{ type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Index of a corner by its anchor id, or −1. */
|
||||
_cornerIndex(anchorId) {
|
||||
return this.corners.findIndex((c) => c.anchorId === anchorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* SPRINT18 gate 1 — A CORNER THAT WAS ALREADY GONE WHEN YOU GOT THERE.
|
||||
*
|
||||
* Lane A's emergency callout installs someone else's rig through the session's
|
||||
* public moves and then needs one corner to arrive DOWN — which public moves
|
||||
* cannot express, because nothing in the shop sells a broken shackle. A asked
|
||||
* for the door on this side (their THREADS shapes, §2) and this is it.
|
||||
*
|
||||
* It is deliberately NOT a `break`: you did not break it, and an invoice that
|
||||
* says you did is the same lie as billing the player for the previous
|
||||
* contractor's steel. Silent, so every existing break listener is untouched.
|
||||
*
|
||||
* @param {string} anchorId
|
||||
* @returns {{ok: boolean, reason?: string}} the session's result shape, so a
|
||||
* caller can put a refusal in the ticker instead of catching a throw
|
||||
*/
|
||||
failCorner(anchorId) {
|
||||
if (!this.rigged) return { ok: false, reason: 'there is no rig to fail a corner on' };
|
||||
const k = this._cornerIndex(anchorId);
|
||||
if (k < 0) return { ok: false, reason: `no corner at ${anchorId} — this sail is on ${this.corners.map((c) => c.anchorId).join(', ')}` };
|
||||
if (!this._releaseCorner(k, 'inherited')) return { ok: false, reason: `${anchorId} is already down` };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ THE KNIFE — DESIGN.md line 165: *cut a sail loose: lose the sail, save the
|
||||
* anchors.* The signature decision under load, and the physics half of it.
|
||||
* [SPRINT18 gate 1; B exposes the cut, D owns the verb, the input and the HUD]
|
||||
*
|
||||
* There is no new physics here and that is the point. A cut corner is released
|
||||
* through the same `_releaseCorner` an overload uses, so the cloth goes limp and
|
||||
* flies on exactly the wind it already had — the difference is entirely in what
|
||||
* it COSTS, and the economy falls out of rules the game already has:
|
||||
*
|
||||
* · what the cut SAVES is certain. A released corner carries zero load
|
||||
* forever, so no anchor it hangs off can be torn out and no collateral that
|
||||
* anchor reaches can be wrecked. On a yard whose steel holds a carport beam
|
||||
* or a glasshouse, that is the whole wage of a callout (`clean`, week.js —
|
||||
* "about THEIR property, not your success").
|
||||
* · what the cut COSTS is the garden, and it costs it honestly: the bed is
|
||||
* bare for every second left in the storm, hail included, and `win` is
|
||||
* `hp >= 50`. So the knife spends garden HP — and possibly the night's fee —
|
||||
* to buy certainty on somebody's property. Ride it out for the full money
|
||||
* and gamble their glasshouse, or cut and take the smaller cheque.
|
||||
*
|
||||
* NOT a `break` event, per `_releaseCorner`: cutting is a judgement call, not a
|
||||
* hardware failure, and warranty must never chase a corner the player chose to
|
||||
* let go. `cutAway` is the record — the payload is Lane A's requested shape
|
||||
* (their §9) so their night tally and the invoice line need no new wiring.
|
||||
*
|
||||
* @param {string[]|null} [anchorIds] corners to cut; null/omitted = the whole
|
||||
* sail, which is what the verb means in DESIGN.md and what D's key will send
|
||||
* @param {string} [reason] free text for the paperwork ('the glasshouse')
|
||||
* @returns {{ok: boolean, cut?: string[], reason?: string}}
|
||||
*/
|
||||
cutAway(anchorIds = null, reason = null) {
|
||||
if (!this.rigged) return { ok: false, reason: 'there is no sail to cut' };
|
||||
const ids = anchorIds ?? this.corners.map((c) => c.anchorId);
|
||||
const unknown = ids.filter((id) => this._cornerIndex(id) < 0);
|
||||
if (unknown.length) {
|
||||
return { ok: false, reason: `no corner at ${unknown.join(', ')} — this sail is on ${this.corners.map((c) => c.anchorId).join(', ')}` };
|
||||
}
|
||||
const cut = [];
|
||||
for (const id of ids) if (this._releaseCorner(this._cornerIndex(id), 'cut')) cut.push(id);
|
||||
// Every named corner was already down. Refuse rather than emit an empty cut:
|
||||
// a paperwork line saying you cut nothing loose is worse than no line.
|
||||
if (!cut.length) return { ok: false, reason: 'the sail is already down' };
|
||||
this.events.emit('cutAway', { type: 'cutAway', t: this.t, corners: cut, reason });
|
||||
return { ok: true, cut };
|
||||
}
|
||||
|
||||
/** Corners released by the knife tonight — the paperwork's own read. */
|
||||
get cutCorners() { return this.corners.filter((c) => c.cut).map((c) => c.anchorId); }
|
||||
|
||||
_checkFailure(dt) {
|
||||
let diverged = false;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
@ -937,32 +1161,13 @@ export class SailRig {
|
||||
// path, not just under the test-only watchDivergence tripwire. The event
|
||||
// carries reason:'divergence' so a log can tell it from an honest blow.
|
||||
if (!Number.isFinite(c.load)) {
|
||||
c.broken = true;
|
||||
c.overload = 0;
|
||||
c.load = 0;
|
||||
c.loadVec.x = c.loadVec.y = c.loadVec.z = 0;
|
||||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||||
this.dumpPond('corner blew');
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
|
||||
this._releaseCorner(k, 'divergence');
|
||||
diverged = true;
|
||||
continue;
|
||||
}
|
||||
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
|
||||
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
|
||||
if (c.overload > OVERLOAD_SECS) {
|
||||
c.broken = true;
|
||||
c.overload = 0;
|
||||
c.load = 0;
|
||||
// Hand the node its mass back. Everything good about a failure comes
|
||||
// from this one line: the freed corner stops being pinned, so it flies
|
||||
// on the wind and the flogging is emergent rather than animated.
|
||||
// Without it a "blown" corner stays welded in mid-air.
|
||||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||||
// the belly loses its shape the instant a corner goes, so any pond goes
|
||||
// with it — DESIGN.md's "sudden dump", onto whatever is below.
|
||||
this.dumpPond('corner blew');
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t });
|
||||
}
|
||||
if (c.overload > OVERLOAD_SECS) this._releaseCorner(k, 'overload');
|
||||
}
|
||||
// SPRINT16 gate 2.1 — THE HEAL. The break above made divergence detectable
|
||||
// (the fresh-eyes review's fix); this makes it survivable to LOOK at. The
|
||||
|
||||
@ -1314,6 +1314,160 @@ test('ruling: at MAX tension the carport still goes first — and the hint is do
|
||||
return `max tension: ${breaks[firstCb].id} first at t=${breaks[firstCb].t.toFixed(1)}s; hint-free control held 4/4`;
|
||||
});
|
||||
|
||||
// --- SPRINT18 gate 1: THE KNIFE, and the corner that was already gone -------
|
||||
//
|
||||
// DESIGN.md line 165 — *cut a sail loose: lose the sail, save the anchors.* The
|
||||
// physics half is B's; D owns the verb. These pin the two facts the rest of the
|
||||
// game will be built on: a cut corner carries NO load ever again (that is the
|
||||
// whole "save the anchors"), and a cut is NOT a break (warranty must never chase
|
||||
// a corner the player chose to let go).
|
||||
|
||||
test('gate 1: the knife releases the sail and the anchors stop being loaded', () => {
|
||||
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
r.attach(ALL_IDS, Array(4).fill(HARDWARE[0]), 1.0); // cheapest steel, so it is genuinely at risk
|
||||
const w = makeStubWind({ stormLen: 90 });
|
||||
const broke = [], cuts = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
r.events.on('cutAway', (e) => cuts.push(e));
|
||||
|
||||
runStorm(r, w, 3);
|
||||
const before = r.corners.map((c) => c.load);
|
||||
assert(before.some((l) => l > 1), `nothing was under load before the cut (${before.map((l) => l.toFixed(0))}) — this test would prove nothing`);
|
||||
|
||||
const res = r.cutAway(null, 'the glasshouse');
|
||||
assert(res.ok && res.cut.length === 4, `cutAway refused or cut only ${res.cut?.length}: ${res.reason}`);
|
||||
assert(cuts.length === 1 && cuts[0].corners.length === 4,
|
||||
`cutAway emitted ${cuts.length} events with ${cuts[0]?.corners?.length} corners — Lane A's tally reads this payload`);
|
||||
assert(cuts[0].reason === 'the glasshouse', 'the cut dropped its reason — the invoice line needs it');
|
||||
assert(Number.isFinite(cuts[0].t), 'the cut has no time on it');
|
||||
|
||||
// THE POINT: keep flying and no anchor can ever be torn out again.
|
||||
runStorm(r, w, 20);
|
||||
assert(r.corners.every((c) => c.load === 0),
|
||||
`a cut corner is still carrying load (${r.corners.map((c) => c.load.toFixed(0))}) — "save the anchors" is not true`);
|
||||
assert(broke.length === 0,
|
||||
`the knife emitted ${broke.length} break event(s). A cut is a DECISION, not a hardware failure — ` +
|
||||
'billing it as a break puts warranty and rep damage on the player for doing exactly what the knife is for');
|
||||
assert(r.cutCorners.length === 4, `cutCorners reads ${r.cutCorners.length}, not 4`);
|
||||
return `cut 4 corners on ${cuts[0].t.toFixed(1)}s of storm; 20 s more wind, 0 breaks, every corner at 0 N`;
|
||||
});
|
||||
|
||||
test('gate 1: the knife refuses an empty cut, and a re-rig forgets last night\'s', () => {
|
||||
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
const bare = r.cutAway();
|
||||
assert(!bare.ok && /no sail/.test(bare.reason), `cutting an unrigged sail gave ${JSON.stringify(bare)}`);
|
||||
|
||||
r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0);
|
||||
assert(!r.cutAway(['nope']).ok, 'cutting a corner that is not on this sail was allowed');
|
||||
assert(r.cutAway(['a0']).ok, 'a single-corner cut was refused');
|
||||
const again = r.cutAway(['a0']);
|
||||
assert(!again.ok && /already down/.test(again.reason),
|
||||
`cutting the same corner twice gave ${JSON.stringify(again)} — a second paperwork line for one knife stroke`);
|
||||
|
||||
// the phantom-sail lesson, one field over: state that outlives its night lies.
|
||||
r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0);
|
||||
assert(r.cutCorners.length === 0, `a re-rig kept ${r.cutCorners.join(',')} cut — last night's knife on tonight's invoice`);
|
||||
return 'refuses no-sail / unknown corner / already-cut; re-rig clears the flags';
|
||||
});
|
||||
|
||||
test('gate 1: a corner can arrive ALREADY GONE, and it is not your break', () => {
|
||||
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
r.attach(ALL_IDS, Array(4).fill(HARDWARE[1]), 1.0);
|
||||
const broke = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
|
||||
const res = r.failCorner('a2');
|
||||
assert(res.ok, `failCorner refused: ${res.reason}`);
|
||||
const c = r.corners.find((x) => x.anchorId === 'a2');
|
||||
assert(c.broken && c.inherited, 'the corner is not marked broken-and-inherited');
|
||||
assert(!c.cut, 'a corner that arrived down was flagged as CUT — that is the player taking blame for a stranger');
|
||||
assert(broke.length === 0,
|
||||
`failCorner emitted a break. You did not break it: on an emergency callout that line would bill the ` +
|
||||
'player warranty for the previous contractor\'s shackle');
|
||||
// and the freed node really is free — the sim must fly it, not weld it
|
||||
const w = makeStubWind({ stormLen: 90 });
|
||||
runStorm(r, w, 5);
|
||||
assert(c.load === 0, `a corner that arrived down is carrying ${c.load.toFixed(0)} N`);
|
||||
assert(!r.failCorner('a2').ok, 'failing the same corner twice was allowed');
|
||||
assert(!r.failCorner('ghost').ok, 'failCorner accepted an anchor this sail has never heard of');
|
||||
return 'a2 arrives down: broken, inherited, 0 N, and NOT a break event';
|
||||
});
|
||||
|
||||
// --- SPRINT18: THE CLOCK A MID-STORM ARRIVAL BREAKS ------------------------
|
||||
//
|
||||
// Lane C's finding, B's file. `_substep` samples the wind at `this.t`, not at the
|
||||
// `t` step() is handed — deliberate (it is what makes 144 Hz and a bench agree),
|
||||
// and safe only while the two clocks START together. `attach()`'s own comment
|
||||
// says so: "commit → attach → storm all land in a single keypress, so t=0 at
|
||||
// attach IS t=0 at the first storm frame." An emergency callout opens the storm
|
||||
// phase at t=30 and that precondition is gone — the cloth would fly the storm's
|
||||
// first 60 s under a sky flying its last 60.
|
||||
|
||||
test('the sail can be attached INTO a storm already running, and feels that storm', () => {
|
||||
const w = makeStubWind({ stormLen: 90 });
|
||||
// A wind whose speed climbs with t, so "which second is the cloth in" is
|
||||
// readable straight off the load. If the two clocks agree, these differ.
|
||||
const early = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
early.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
|
||||
runStorm(early, w, 4);
|
||||
const earlyLoad = Math.max(...early.corners.map((c) => c.load));
|
||||
|
||||
const late = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
late.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 40 });
|
||||
assert(late.t === 40, `attach({at:40}) left the clock at ${late.t}`);
|
||||
runStorm(late, w, 4, undefined);
|
||||
const lateLoad = Math.max(...late.corners.map((c) => c.load));
|
||||
|
||||
assert(Math.abs(earlyLoad - lateLoad) > 1e-6,
|
||||
`a sail attached at t=40 pulled exactly what one attached at t=0 pulled (${earlyLoad.toFixed(1)} N). ` +
|
||||
'The seed never reached the wind sampling, so an emergency callout is flying the wrong sixty seconds ' +
|
||||
"of its own storm — C's 4.12x understated load, undetectable from the outside");
|
||||
return `t=0 entry pulls ${earlyLoad.toFixed(0)} N, t=40 entry pulls ${lateLoad.toFixed(0)} N — different storms, as they must be`;
|
||||
});
|
||||
|
||||
test('clockSkew reads 0 when the clocks agree and NAMES the drift when they do not', () => {
|
||||
const w = makeStubWind({ stormLen: 90 });
|
||||
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
|
||||
for (let i = 0; i < 120; i++) r.step(SIM_DT, w, i * SIM_DT);
|
||||
assert(r.clockSkew < SIM_DT * 2,
|
||||
`an honest night reads a skew of ${r.clockSkew.toFixed(3)}s — the diagnostic is crying wolf and will be ignored`);
|
||||
|
||||
// now the emergency shape, done WRONG: phase at 30, cloth from scratch
|
||||
const bad = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
bad.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
|
||||
for (let i = 0; i < 120; i++) bad.step(SIM_DT, w, 30 + i * SIM_DT);
|
||||
assert(bad.clockSkew > 25,
|
||||
`a sail stepped with a caller 30 s ahead of it reported a skew of ${bad.clockSkew.toFixed(3)}s. This is the ` +
|
||||
'one thing that made the emergency-clock bug invisible — it must be readable');
|
||||
|
||||
// and seeding it closes the gap
|
||||
const good = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
good.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30 });
|
||||
for (let i = 0; i < 120; i++) good.step(SIM_DT, w, 30 + i * SIM_DT);
|
||||
assert(good.clockSkew < SIM_DT * 2,
|
||||
`a correctly seeded mid-storm arrival still reads ${good.clockSkew.toFixed(3)}s of skew`);
|
||||
return `honest 0.000s · unseeded mid-storm arrival ${bad.clockSkew.toFixed(1)}s · seeded ${good.clockSkew.toFixed(3)}s`;
|
||||
});
|
||||
|
||||
test('a sail that has ALREADY been up carries the history, not just the clock', () => {
|
||||
const w = makeStubWind({ stormLen: 90 });
|
||||
// seeded only: right weather, no past. flown: the 30 s actually happened.
|
||||
const seeded = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
seeded.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30 });
|
||||
const flown = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
flown.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0, { at: 30, wind: w });
|
||||
|
||||
assert(seeded.t === 30 && flown.t === 30, `clocks are ${seeded.t} / ${flown.t}, both should be 30`);
|
||||
assert(flown.corners.some((c) => c.peakLoad > 0),
|
||||
'a flown pre-history left every peak at 0 — the substeps never ran, so the sail did not live through anything');
|
||||
assert(seeded.corners.every((c) => c.peakLoad === 0),
|
||||
'the seeded-only sail has peaks already, so it is not the clean control this comparison needs');
|
||||
// and it must not have secretly advanced past where it was told to be
|
||||
assert(flown.clockSkew === 0, `the pre-flight left ${flown.clockSkew} s of skew behind it`);
|
||||
return `both at t=30; flown carries peaks up to ${Math.max(...flown.corners.map((c) => c.peakLoad)).toFixed(0)} N, seeded carries none`;
|
||||
});
|
||||
|
||||
export const SAIL_TESTS = TESTS;
|
||||
|
||||
export function runSailSelftest() {
|
||||
|
||||
@ -14,6 +14,9 @@ import { createCameraRig, spawnYawFor } from '../camera.js';
|
||||
import {
|
||||
CALM_STORM, accumulate, applyMute, canPlayHere, createGame, createWindRouter, enterCommits,
|
||||
rigRecordFor, stormsToPreload, verdictFor,
|
||||
// SPRINT18 gate 1 — both extracted BECAUSE rendering a real emergency card found
|
||||
// bugs the suite could not see: the board's pricing and the answerability split.
|
||||
answerableFor, priceOffer,
|
||||
} from '../main.js';
|
||||
import { createGarden } from '../garden.js';
|
||||
import { RiggingSession } from '../rigging.js';
|
||||
@ -22,10 +25,21 @@ import { SailRig, orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import {
|
||||
BROKE_BELOW, PAY, REP, calloutFee, createWeek, gradeFor, nightAt, NIGHTS,
|
||||
// SPRINT18 gate 1 — priceOffer takes a NORMALISED night, the same shape the board
|
||||
// installs, so the pin prices what the game prices rather than a raw table entry.
|
||||
normaliseNight,
|
||||
} from '../week.js';
|
||||
// SPRINT17 gate 1/2 — the board and the constraint data are mine; these are
|
||||
// the seam this suite pins (offer shape, pool, exposure, constraint enum).
|
||||
import { CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint } from '../board.js';
|
||||
import {
|
||||
CONSTRAINT_PREMIUM, POOL, exposureOf, validateConstraint,
|
||||
// SPRINT18 gate 0 — the ruling's structural half: who a take stands up.
|
||||
isBooked, standUpsIfTaken,
|
||||
// SPRINT18 gate 1 — the emergency callout, and the one-variable law in code.
|
||||
EMERGENCIES, oneVariableOffences, validateEmergency,
|
||||
// SPRINT18 gate 5 (A's half) — the second-rust-post tripwire.
|
||||
collateralKeyClashes,
|
||||
} from '../board.js';
|
||||
import { createHud } from '../hud.js';
|
||||
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
|
||||
import { assert, assertClose, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
@ -698,6 +712,529 @@ export default async function run(t) {
|
||||
}
|
||||
});
|
||||
|
||||
// --- ⚖️ SPRINT18 gate 0: DECLINING A BOOKED NIGHT COSTS YOU ---------------
|
||||
// John's ruling, 2026-07-25, on D's cold-play filing: "it adds pressure."
|
||||
// The board was a free option — a fee-maximizer could dodge the wildnight AND
|
||||
// the designed-loss icenight at zero cost and never meet the week's two
|
||||
// hardest lessons. These pin the ruling's five clauses.
|
||||
|
||||
t.test('SPRINT18 gate 0: the DIARY is booked and the PHONE is not — the asymmetry, every night', () => {
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < NIGHTS.length; i++) {
|
||||
const [booked, callout] = w.offers();
|
||||
assertEq(booked.booked, true, `night ${i + 1}: the scripted job is a booking — a client has you down`);
|
||||
assertEq(callout.booked, false, `night ${i + 1}: a pool callout is the phone ringing, not a promise`);
|
||||
// Taking the job you were booked for stands NOBODY up. This is the half
|
||||
// that would silently invert if `booked` were ever derived from the night
|
||||
// alone (it was, in the first cut, and the pool yard came back booked —
|
||||
// taking the spine then printed "the Karalis place is expecting you" over
|
||||
// a client nobody had promised anything).
|
||||
assertEq(booked.standsUp.length, 0, `night ${i + 1}: keeping your word costs nothing`);
|
||||
assertEq(callout.standsUp.length, 1, `night ${i + 1}: taking the callout stands the booked client up`);
|
||||
assertEq(callout.standsUp[0].client, nightAt(i).client,
|
||||
`night ${i + 1}: and it names WHO, in the client's own name, on the card`);
|
||||
w.advance();
|
||||
}
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 0: the number is the MIRROR of a clean night, and cannot outrank a wrecked carport', () => {
|
||||
// ⚖️ A's ruling. The structural half lives in board.js and the number in
|
||||
// week.js §REP (board.js must not import week.js — week imports board). This
|
||||
// is the pin that stops the two halves drifting into a coincidence.
|
||||
assertEq(REP.STOOD_UP, -REP.CLEAN,
|
||||
'a stood-up client must cost exactly what a flawless night earns — one night of perfect '
|
||||
+ 'work, cancelled by one phone call you didn\'t make');
|
||||
assertLess(Math.abs(REP.STOOD_UP), Math.abs(REP.COLLATERAL),
|
||||
'standing a client up must not rank above WRECKING THEIR PROPERTY, or the invoice argues with itself');
|
||||
assert(REP.STOOD_UP < 0, 'and it is a cost, not a reward');
|
||||
// Half-steps, like every other rep constant — the letterhead prints exact
|
||||
// numbers and a third of a star is a float on a business document.
|
||||
assertEq(REP.STOOD_UP * 2, Math.round(REP.STOOD_UP * 2), 'the cost lands on a half-star');
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 0: the cost lands at DAWN, on the invoice, and does NOT move tonight\'s fee', () => {
|
||||
if (!boardDefs.storm_01_gentle) return 'SKIPPED — no server for storm defs';
|
||||
const w = createWeek();
|
||||
const [booked, callout] = w.offers();
|
||||
const def = boardDefs[callout.night.storm];
|
||||
if (!def) return 'SKIPPED — no def for the drawn alternative';
|
||||
// The fee as the OFFER CARD printed it, priced at this morning's standing.
|
||||
const cardFee = calloutFee(def, w.rep, callout.night.constraints);
|
||||
w.take(callout);
|
||||
// ⚠️ THE SEAM THIS PIN EXISTS FOR. Charging the standing inside take() would
|
||||
// drop rep between the board and the job sheet, and the fee is priced off
|
||||
// rep — so the card would advertise one number and the sheet, one click
|
||||
// later, would quote another. That is the quote-vs-settle lie moved one card
|
||||
// earlier, which is the exact bug calloutFee() was extracted to make
|
||||
// impossible. The cost lands at DAWN with every other rep movement.
|
||||
assertEq(w.rep, REP.START, 'standing does not move at the board — the fee is quoted off it');
|
||||
assertEq(w.quote(def).base, cardFee, 'the sheet quotes exactly what the card advertised');
|
||||
|
||||
const s = w.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0);
|
||||
assertEq(s.fullFee, cardFee, 'and the invoice pays it');
|
||||
// Clause 4: the invoice states which job you took and what it cost to take it.
|
||||
assertEq(s.taken, callout.id, 'the invoice says the job came off the board');
|
||||
assertEq(s.stoodUp.length, 1, 'and names the client it stood up');
|
||||
assertEq(s.stoodUp[0].client, booked.night.client);
|
||||
assertEq(s.standUpCost, REP.STOOD_UP, 'with the standing it cost, already multiplied');
|
||||
assert(s.repReasons.some((r) => r.includes('stood up')),
|
||||
`the repline must say WHY standing moved, got ${JSON.stringify(s.repReasons)}`);
|
||||
// ADDITIVE, not exclusive: a clean night at the yard you went to instead
|
||||
// nets to level. You did good work — for somebody else, on a night you were
|
||||
// promised elsewhere.
|
||||
assertEq(s.rep, REP.START + REP.CLEAN + REP.STOOD_UP,
|
||||
'a clean night on a stood-up promise nets to level, and that is the ruling working');
|
||||
|
||||
// ⚠️ A GAP IN MY OWN PIN, FOUND BY PROBING IT (A, S18 mutation M7). Deleting
|
||||
// the `repDelta += REP.STOOD_UP` line and nothing else left the REASON still
|
||||
// printing on the invoice while the NUMBER never moved — "stood up the
|
||||
// Hendersons" over standing that didn't budge, which is the paper lying in
|
||||
// the most persuasive way available to it. The asserts above caught it, but
|
||||
// only through the net-to-level arithmetic. This is the DIFFERENTIAL that
|
||||
// says it directly: the same night, kept, must leave you strictly better off.
|
||||
const kept = createWeek();
|
||||
const keptOffers = kept.offers();
|
||||
kept.take(keptOffers[0]); // took the booking — nobody stood up
|
||||
const sKept = kept.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] },
|
||||
boardDefs[keptOffers[0].night.storm] ?? def, 0);
|
||||
assertEq(sKept.stoodUp.length, 0, 'precondition: keeping your word stands nobody up');
|
||||
assertLess(s.rep, sKept.rep, 'a broken promise must leave you BELOW the same night kept');
|
||||
assertEq(Number((sKept.rep - s.rep).toFixed(2)), Math.abs(REP.STOOD_UP),
|
||||
'and below it by exactly the ruled amount — a reason with no movement behind it is the paper lying');
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 0: the stood-up client REMEMBERS — and one decision is charged once', () => {
|
||||
const w = createWeek();
|
||||
const [booked, callout] = w.offers();
|
||||
assertEq(w.remembers(booked.night.client).length, 0, 'nobody remembers anything on night one');
|
||||
w.take(callout);
|
||||
const mem = w.remembers(booked.night.client);
|
||||
assertEq(mem.length, 1, 'the client you left waiting remembers it');
|
||||
assertEq(mem[0].night, 1, 'and remembers WHICH night — the card and the sheet print it');
|
||||
assertEq(mem[0].took, callout.night.client, 'the ledger records who you went to instead');
|
||||
// Taking twice on one morning is the SAME take (S17's commitment rule), so it
|
||||
// must be ONE broken promise. A double charge for one decision would be the
|
||||
// worst kind of bug: invisible, and in the direction of punishment.
|
||||
w.take(callout);
|
||||
w.take(booked);
|
||||
assertEq(w.standUps.length, 1, 'one decision, one broken promise');
|
||||
// And the memory is READ-ONLY to its readers — hud.js's charter is that the
|
||||
// face never takes a decision, and a getter handing out the live array is a
|
||||
// card one `.push` away from inventing a grudge.
|
||||
w.standUps.push({ night: 99, client: 'a card that pushed' });
|
||||
assertEq(w.standUps.length, 1, 'week.standUps hands out a copy');
|
||||
// A new week is a clean slate — the clients forget when the run does.
|
||||
w.reset();
|
||||
assertEq(w.standUps.length, 0, 'reset clears the ledger; a memory outliving its run is a bug');
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 0: TWO CALLOUTS, NO BOOKED JOB — declining is FREE, and that is not vacuous', () => {
|
||||
// Clause 5. A legal board state the moment gate 1's emergency exists: an
|
||||
// emergency arrives in the diary's chair but nobody booked you, so nobody is
|
||||
// stood up. Built here from the predicate rather than from a shipped night,
|
||||
// because the point is that the FREE case falls out of the filter returning
|
||||
// nothing — not out of an `if` somebody remembered to write.
|
||||
assertEq(isBooked('scripted', { storm: 'storm_01_gentle' }), true,
|
||||
'an ordinary diary night is a booking');
|
||||
assertEq(isBooked('scripted', { storm: 'storm_01_gentle', emergency: { arriveAt: 30 } }), false,
|
||||
'an EMERGENCY in the diary slot is not a booking — the phone rang, nobody had you down');
|
||||
assertEq(isBooked('callout', { storm: 'storm_01_gentle' }), false, 'a callout is never a booking');
|
||||
|
||||
const free = [
|
||||
{ id: 'scripted', booked: false, night: { client: 'the Karalis place' } },
|
||||
{ id: 'alt:x', booked: false, night: { client: 'the Delaneys' } },
|
||||
];
|
||||
assertEq(standUpsIfTaken(free, free[0]).length, 0, 'nothing booked ⇒ taking either costs nothing');
|
||||
assertEq(standUpsIfTaken(free, free[1]).length, 0, 'in both directions');
|
||||
// ⚠️ THE VACUITY GUARD, and it is the whole reason this test is worth its
|
||||
// line: a `standUpsIfTaken` that always returned [] would pass every assert
|
||||
// above. Flip ONE flag and the charge must appear.
|
||||
const booked = [{ ...free[0], booked: true }, free[1]];
|
||||
assertEq(standUpsIfTaken(booked, booked[1]).length, 1,
|
||||
'and the free result is produced by an empty board, not by a function that cannot charge');
|
||||
assertEq(standUpsIfTaken(booked, booked[0]).length, 0, 'while taking the booking itself is still free');
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 0: the price is on the CARD before the choice, and the free morning SAYS free', () => {
|
||||
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
|
||||
// "A choice whose price is invisible until the invoice is a trap, and this
|
||||
// repo doesn't ship traps without tells." The ruling names the card
|
||||
// explicitly, so the card is pinned in DOM — the S12 lesson: both of that
|
||||
// sprint's card bugs were invisible to a suite that never rendered one.
|
||||
const w = createWeek();
|
||||
const offers = w.offers();
|
||||
const hud = createHud({ scene: new THREE.Scene() });
|
||||
try {
|
||||
hud.showBoard(offers, { night: 1, nights: NIGHTS.length, bank: 80, log: [], rep: 3.0 }, () => {});
|
||||
const cards = [...document.querySelectorAll('#hud-card .offer')];
|
||||
assertEq(cards.length, 2, 'two offers rendered');
|
||||
assert(!cards[0].querySelector('.cost'), 'the booked job costs nothing to take — no cost line on it');
|
||||
const cost = cards[1].querySelector('.cost');
|
||||
assert(cost, 'the CALLOUT card must print what taking it costs, before the choice');
|
||||
const text = cost.textContent;
|
||||
assert(text.includes(nightAt(0).client),
|
||||
`in the client's own terms — expected "${nightAt(0).client}", got "${text}"`);
|
||||
assert(text.includes('expecting you'), `and in plain words, got "${text}"`);
|
||||
assert(text.includes('½★'), `as a half-star, not a float, got "${text}"`);
|
||||
assert(text.includes('−'), `and signed as a debit, got "${text}"`);
|
||||
|
||||
// The free morning: nothing booked, and the note SAYS free rather than
|
||||
// leaving a player to infer it from an absence.
|
||||
const note = document.querySelector('#hud-card .board-note')?.textContent ?? '';
|
||||
assert(note.includes('cannot work two yards'), `a booked morning reads as normal, got "${note}"`);
|
||||
hud.hideCard();
|
||||
const freeOffers = offers.map((o) => ({ ...o, booked: false, standsUp: [] }));
|
||||
hud.showBoard(freeOffers, { night: 1, nights: NIGHTS.length, bank: 80, log: [], rep: 3.0 }, () => {});
|
||||
const freeNote = document.querySelector('#hud-card .board-note')?.textContent ?? '';
|
||||
assert(freeNote.includes('costs you anything to turn down'),
|
||||
`a morning with nothing booked must SAY declining is free, got "${freeNote}"`);
|
||||
assert(!document.querySelector('#hud-card .offer .cost'),
|
||||
'and no offer on it carries a cost line');
|
||||
} finally { hud.dispose(); }
|
||||
});
|
||||
|
||||
// --- 🚨 SPRINT18 gate 1: THE EMERGENCY CALLOUT (A's spine) ----------------
|
||||
|
||||
t.test('SPRINT18 gate 1: a night that STARTS in storm — setPhase({at}) is the whole engine change', () => {
|
||||
const g = createGame();
|
||||
// ROADMAP sized this gate as "the storm phase already does everything this
|
||||
// needs except spawn you at t=30 with a rig you didn't build". This is the
|
||||
// t=30 half, and the pin is that NOTHING ELSE had to move.
|
||||
g.setPhase('storm', { at: 30 });
|
||||
assertEq(g.phase, 'storm', 'an emergency night opens in the storm, not in prep');
|
||||
assertEq(g.phaseT, 30, 'and the storm clock opens where the storm already is');
|
||||
// The storm is STORM_LEN long TOTAL, not STORM_LEN long from your arrival —
|
||||
// you get what is left of somebody else's night. 90 − 30 = 60.
|
||||
g.tick(STORM_LEN - 30 - 1);
|
||||
assertEq(g.phase, 'storm', 'still in it a second before the end');
|
||||
g.tick(2);
|
||||
assertEq(g.phase, 'aftermath', `an emergency night is STORM_LEN − at long, not STORM_LEN`);
|
||||
// The ordinary path is untouched — `at` defaults to 0, so every existing
|
||||
// phase change opens on zero exactly as it always did.
|
||||
const g2 = createGame();
|
||||
g2.setPhase('prep'); g2.setPhase('storm');
|
||||
assertEq(g2.phaseT, 0, 'an ordinary storm still starts at t=0');
|
||||
// And a nonsense clock is a loud content bug, not a silent NaN storm.
|
||||
let threw = '';
|
||||
try { createGame().setPhase('storm', { at: NaN }); } catch (err) { threw = err.message; }
|
||||
assert(threw.includes('at must be'), `a NaN arrival must fail loud, got: ${threw || '(no throw)'}`);
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: the emergency is on the board, priced at DOUBLE, and stands nobody up', () => {
|
||||
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
|
||||
const em = EMERGENCIES[0];
|
||||
assert(em, 'the sprint ships one emergency');
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < em.night - 1; i++) w.advance();
|
||||
const offers = w.offers();
|
||||
assertEq(offers.length, 2, 'an emergency morning still offers two jobs');
|
||||
assertEq(offers[1].kind, 'callout', 'its provenance is the phone — the most callout-y callout there is');
|
||||
assertEq(offers[1].night.storm, em.storm, 'and it IS the scheduled emergency, not a seeded draw');
|
||||
assertEq(offers[1].night.site, em.site);
|
||||
assert(offers[1].night.emergency, 'the costume comes off night.emergency — one source of truth');
|
||||
|
||||
// ⚖️ DOUBLE RATES: DESIGN.md line 29 and ROADMAP arc 2, in those words.
|
||||
const def = boardDefs[em.storm];
|
||||
const plain = calloutFee(def, REP.START, []);
|
||||
const emergency = calloutFee(def, REP.START, [], true);
|
||||
assertEq(emergency, Math.round(plain * PAY.emergencyRate),
|
||||
'an emergency pays the ruled multiple of the ordinary fee, through the SAME calloutFee()');
|
||||
assertEq(PAY.emergencyRate, 2.0, 'and the multiple is the canon 2.0 — "double rates", DESIGN.md line 29');
|
||||
|
||||
// gate 0 × gate 1, the join: taking it stands up the booked client (it is a
|
||||
// callout), and the emergency itself is NOT a booking anybody can be stood
|
||||
// up over. Both halves, on one board.
|
||||
assertEq(offers[0].booked, true, 'the wildnight is still a booking');
|
||||
assertEq(offers[1].booked, false, 'nobody booked you for an emergency — the phone rang');
|
||||
assertEq(offers[1].standsUp.length, 1, 'so taking it stands the booked client up');
|
||||
assertEq(offers[1].standsUp[0].client, nightAt(em.night - 1).client);
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: there is NO QUOTE on an emergency, and the ledger runs UNCHANGED anyway', () => {
|
||||
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
|
||||
const em = EMERGENCIES[0];
|
||||
const def = boardDefs[em.storm];
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < em.night - 1; i++) w.advance();
|
||||
w.take(w.offers()[1]);
|
||||
assert(w.job.emergency, 'the taken emergency IS the night — normaliseNight carried it');
|
||||
assertEq(w.job.emergency.arriveAt, em.emergency.arriveAt, 'with its arrival clock intact');
|
||||
|
||||
const q = w.quote(def);
|
||||
assert(q.emergency, 'the quote carries the emergency block the dispatch renders');
|
||||
assertEq(q.emergency.rate, PAY.emergencyRate);
|
||||
assertEq(q.base, calloutFee(def, REP.START, [], true), 'the dispatch prints the doubled fee');
|
||||
|
||||
// ⚠️ THE ACCEPTANCE CRITERION FOR THE WHOLE GATE (THREADS [A] §10): the ledger
|
||||
// runs on an emergency night through the code it already had. If any of these
|
||||
// needed a branch on "was this an emergency", the SHAPE is wrong.
|
||||
const s = w.settle({ hp: 72, win: true, collateral: [], intactHardwareValue: 15,
|
||||
rigRecord: [], inherited: [{ anchorId: 'cb1', hw: 'carabiner' }], cutAways: [] }, def, 0);
|
||||
assertEq(q.base, s.fullFee, 'quote==settle holds on a night nobody quoted');
|
||||
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral - s.warrantyTotal,
|
||||
'the ledger adds up on an emergency exactly as it does on a scripted night');
|
||||
assert(s.rep != null && s.grade != null && s.bankAfter != null, 'rep, grade and the bank all computed');
|
||||
assertEq(s.emergency.rate, PAY.emergencyRate, 'and the invoice records that it was a callout, at what rate');
|
||||
assertEq(s.inherited.length, 1, 'and names the corner that was already down');
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: a corner that ARRIVED broken bills nothing, wrecks nothing, books no warranty, costs no standing', () => {
|
||||
// ⚖️ A's ruling, and the pin exists because the wrong version would have
|
||||
// shipped looking correct: you turn up at somebody else's collapsing rig and
|
||||
// the invoice charges you for the cowboy's carabiner, docks your standing for
|
||||
// his corner, and books you a free warranty callout tomorrow to fix his work.
|
||||
const theirs = new Set(['cb1']);
|
||||
const corner = (id, broken) => ({
|
||||
anchorId: id, hw: { name: 'carabiner', cost: 5, rating: 1200 },
|
||||
anchor: { ratingHint: 0.22 }, broken,
|
||||
});
|
||||
const rec = rigRecordFor([corner('cb1', true), corner('q3', true), corner('tr1', false)],
|
||||
new Map(), theirs);
|
||||
assertEq(rec[0].theirs, true, 'the inherited corner is marked as theirs');
|
||||
assertEq(rec[1].theirs, false, 'and yours is not');
|
||||
assertEq(rec[0].brokenAtDawn, true, 'it is still honestly recorded as down — the record does not lie');
|
||||
|
||||
// The WARRANTY half: last dawn's inherited corner books nothing tonight.
|
||||
const def = { baseCurve: [[0, 10], [10, 20]], gusts: {} };
|
||||
const w = createWeek();
|
||||
w.settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0, rigRecord: rec }, def, 0);
|
||||
const q = w.quote(def);
|
||||
assertEq(q.warranty.length, 1,
|
||||
`only YOUR corner books a warranty — got ${JSON.stringify(q.warranty.map((i) => i.anchorId))}`);
|
||||
assertEq(q.warranty[0].anchorId, 'q3', 'and it is the one you were answerable for');
|
||||
|
||||
// The STANDING half: two corners down at dawn, one of them theirs, so the rep
|
||||
// hit is for ONE. Read off the settlement that produced the record above.
|
||||
const s = w.log[0];
|
||||
assertEq(s.repReasons.some((r) => r.includes('1 corner down at dawn')), true,
|
||||
`standing moves for one corner, not two — got ${JSON.stringify(s.repReasons)}`);
|
||||
assertEq(s.rep, REP.START + REP.WARRANTY, 'exactly one warranty step, not two');
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: ⚠️ THE ONE-VARIABLE LAW IS ENFORCED IN CODE, and it accuses out loud', () => {
|
||||
// SPRINT18's spec: "A's ordering must make that impossible to get wrong by
|
||||
// accident." A comment cannot do that. This walks the SHIPPED data...
|
||||
assertEq(oneVariableOffences({ nights: NIGHTS }).length, 0,
|
||||
`the shipped week breaks the law: ${oneVariableOffences({ nights: NIGHTS }).join(' | ')}`);
|
||||
// ...and the shipped emergency's yard really is one an earlier night rigs,
|
||||
// which is the fact the law is protecting rather than a restatement of it.
|
||||
const em = EMERGENCIES[0];
|
||||
const firstRig = NIGHTS.findIndex((n) => (n.site ?? 'backyard_01') === em.site);
|
||||
assert(firstRig >= 0 && firstRig < em.night - 1,
|
||||
`the emergency arrives night ${em.night} over ${em.site}, first rigged night ${firstRig + 1}`);
|
||||
|
||||
// ⚠️ THE VACUITY GUARD — three ways to break the law, three accusations. A
|
||||
// law that cannot say no is decoration, and this repo has shipped one before.
|
||||
const glass = { storm: 'storm_03_southerly', site: 'site_99_glasshouse', client: 'x', addr: 'y' };
|
||||
const asEmergency = { ...glass, emergency: { arriveAt: 30, dispatch: 'x'.repeat(30), rig: [] } };
|
||||
const o1 = oneVariableOffences({ nights: [...NIGHTS, asEmergency] });
|
||||
assert(o1.some((m) => m.includes('site_99_glasshouse') && m.includes('debut')),
|
||||
`a NIGHTS emergency over an unrigged yard must be an offence, got ${JSON.stringify(o1)}`);
|
||||
const o2 = oneVariableOffences({ nights: NIGHTS, emergencies: [{ ...asEmergency, night: 2, id: 'too_early' }] });
|
||||
assert(o2.some((m) => m.includes('too_early')),
|
||||
`an EMERGENCIES entry over an unrigged yard must be an offence, got ${JSON.stringify(o2)}`);
|
||||
const o3 = oneVariableOffences({ nights: NIGHTS, pool: [{ ...asEmergency, id: 'pool_glass' }] });
|
||||
assert(o3.some((m) => m.includes('pool_glass') && m.includes('no NIGHTS')),
|
||||
`a POOL emergency over a yard in no NIGHTS slot must be an offence — this is the rule that `
|
||||
+ `protects E's glasshouse from becoming the emergency's debut stage. Got ${JSON.stringify(o3)}`);
|
||||
// And an emergency over a yard the ladder DOES rig early is legal, or the law
|
||||
// would be refusing the thing it exists to permit.
|
||||
assertEq(oneVariableOffences({ nights: NIGHTS,
|
||||
emergencies: [{ ...EMERGENCIES[0], night: 7 }] }).length, 0,
|
||||
'the law permits an emergency over an already-rigged yard');
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: a malformed emergency fails LOUD at the board, not 30s into a storm', () => {
|
||||
// validateConstraint's precedent. A content bug must be a crash on the morning
|
||||
// card, never a runtime surprise inside a storm the player cannot leave.
|
||||
const ok = { storm: 'storm_01_gentle', site: 'backyard_01', emergency: EMERGENCIES[0].emergency };
|
||||
validateEmergency(ok, 'fixture'); // the shipped shape passes
|
||||
for (const p of POOL) validateEmergency(p, p.id);
|
||||
for (const e of EMERGENCIES) validateEmergency(e, e.id);
|
||||
const bad = (em, needle) => {
|
||||
let msg = '';
|
||||
try { validateEmergency({ ...ok, emergency: { ...ok.emergency, ...em } }, 'fixture'); }
|
||||
catch (err) { msg = err.message; }
|
||||
assert(msg.includes(needle), `expected "${needle}" in the failure, got: ${msg || '(no throw)'}`);
|
||||
};
|
||||
bad({ arriveAt: 0 }, 'arriveAt'); // arriving at t=0 is a prep night wearing a siren
|
||||
bad({ dispatch: 'too short' }, 'dispatch'); // no forecast, no quote — the call IS the brief
|
||||
bad({ rig: [] }, 'rig'); // an emergency with no rig is not a rig you didn't build
|
||||
bad({ rig: [{ hw: 'carabiner' }, {}, {}, {}] }, 'anchorId');
|
||||
// A night with NO emergency block passes untouched — every scripted night.
|
||||
for (let i = 0; i < NIGHTS.length; i++) validateEmergency(nightAt(i), `night ${i + 1}`);
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: the DISPATCH is not a job sheet — no schedule, no total, and it says why', () => {
|
||||
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
|
||||
// The S12 lesson: both of that sprint's card bugs were invisible to a suite
|
||||
// that never rendered a card. This card's whole design is what it REFUSES to
|
||||
// print, and a refusal is only checkable by rendering it.
|
||||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const em = EMERGENCIES[0];
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < em.night - 1; i++) w.advance();
|
||||
w.take(w.offers()[1]);
|
||||
const hud = createHud({ scene: new THREE.Scene() });
|
||||
try {
|
||||
hud.showDispatch({ job: w.job, quote: w.quote(def), site: null, read: null },
|
||||
{ night: w.night, nights: w.nights, bank: w.bank, rep: w.rep }, () => {});
|
||||
const qs = (sel) => document.querySelector(`#hud-card ${sel}`);
|
||||
assertEq(qs('.letterhead .docket .kind')?.textContent, 'DISPATCH', 'the fourth paper in the chain');
|
||||
assert((qs('.letterhead .docket .no')?.textContent ?? '').includes('CALL '),
|
||||
'a dispatch is a CALL, not a JOB or an INV — one outfit, four papers');
|
||||
assert((qs('.siren')?.textContent ?? '').includes('no quote'),
|
||||
'it says out loud that nobody quoted this');
|
||||
assert(qs('.call'), 'the phone call is the brief, because there is no other one');
|
||||
// The REFUSALS, which are the design.
|
||||
const body = qs('.jobcard')?.textContent ?? '';
|
||||
assert(!/AMOUNT DUE|garden bonus|clean site/i.test(body),
|
||||
'a card for a job nobody costed must not print a schedule or a total');
|
||||
assert(!qs('.row.due'), 'and it must not conclude — you find out on the invoice');
|
||||
// The one number it DOES print, and the rate it came from.
|
||||
assert(body.includes(`${PAY.emergencyRate}\u00d7 the going rate`),
|
||||
`the rate is the reason you get in the ute — not found in the card`);
|
||||
assert(body.includes(`$${w.quote(def).base}`), 'and the fee it applies to is on the card');
|
||||
// DESIGN.md's tell rule: the inherited rig is NAMED before you go, and the
|
||||
// corner already on the ground is marked. Brutal is allowed; surprising is not.
|
||||
const down = [...document.querySelectorAll('#hud-card .theirs.down')];
|
||||
assertEq(down.length, em.emergency.rig.filter((c) => c.broken).length,
|
||||
'every corner that arrives on the ground is marked as such on the dispatch');
|
||||
assert(down[0].textContent.includes('ON THE GROUND'), 'in words, not by colour alone');
|
||||
// No read from C yet ⇒ one honest line, not an empty section.
|
||||
assert((qs('.noread')?.textContent ?? '').includes('Nobody briefed you'),
|
||||
'with no mid-storm read available the card is honest about it rather than blank');
|
||||
} finally { hud.dispose(); }
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: ⚖️ the answerability split — their corner is the JOB, yours is your failure', () => {
|
||||
const c = (id, broken) => ({ anchorId: id, hw: { name: 'carabiner', cost: 5 }, broken });
|
||||
const em = { rig: [{ anchorId: 'cb1', hw: 'carabiner', broken: true },
|
||||
{ anchorId: 'q3', hw: 'carabiner' }, { anchorId: 'tr1', hw: 'carabiner' },
|
||||
{ anchorId: 'tr1b', hw: 'carabiner' }] };
|
||||
const a = answerableFor([c('cb1', true), c('q3', true), c('tr1', false), c('tr1b', false)], em);
|
||||
assertEq(a.lost.length, 2, 'REPORTING stays honest — two corners are on the ground');
|
||||
assertEq(a.yours.length, 1, 'but you are answerable for one of them');
|
||||
assertEq(a.yours[0].anchorId, 'q3', 'the one that went on your watch');
|
||||
assertEq(a.inherited.length, 1, 'and their corner is named, for the invoice');
|
||||
assertEq(a.inherited[0].anchorId, 'cb1');
|
||||
// The two facts the split protects, in the same shape scoreRun consumes them:
|
||||
// the gnome needs TWO corners of YOURS (not two on the ground), and the
|
||||
// collateral walk only visits `yours` — so the $180 carport under an inherited
|
||||
// corner is not billed to a player who never chose it.
|
||||
assert(a.yours.length < 2, 'one corner of yours cannot bill the gnome, even with two corners down');
|
||||
assert(!a.yours.some((x) => x.anchorId === 'cb1'), 'the carport corner is not in the billable set');
|
||||
// An ORDINARY night is untouched: no emergency ⇒ everything down is yours, which
|
||||
// is the property that keeps 522 asserts of scripted week honest.
|
||||
const plain = answerableFor([c('p1', true), c('p2', true)], null);
|
||||
assertEq(plain.yours.length, 2, 'on a night you rigged, every corner down is yours');
|
||||
assertEq(plain.inherited.length, 0, 'and nothing is inherited');
|
||||
assertEq(answerableFor([c('p1', true)], { rig: [] }).yours.length, 1,
|
||||
'an emergency whose rig arrived whole still makes every break yours');
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: the OFFER CARD prices an emergency at double — the live bug, pinned', () => {
|
||||
if (!boardDefs.storm_03_southerly) return 'SKIPPED — no server for storm defs';
|
||||
// ⚠️ THIS PIN EXISTS BECAUSE THE BUG SHIPPED AND EYES FOUND IT, not the suite:
|
||||
// on night 5's real board the emergency advertised $57 while the dispatch one
|
||||
// click later quoted $114. The pricing was an unreachable closure inside boot();
|
||||
// priceOffer is that closure, extracted, so the board's money is assertable.
|
||||
const def = boardDefs.storm_03_southerly;
|
||||
const em = EMERGENCIES[0];
|
||||
const emNight = normaliseNight(em);
|
||||
const plainNight = normaliseNight({ storm: em.storm, site: em.site });
|
||||
const carded = priceOffer(emNight, { def, rep: REP.START });
|
||||
const plain = priceOffer(plainNight, { def, rep: REP.START });
|
||||
assertEq(plain.fee, calloutFee(def, REP.START, []), 'an ordinary night is priced as it always was');
|
||||
assertEq(carded.fee, calloutFee(def, REP.START, [], true), 'and an emergency is priced as an emergency');
|
||||
assertEq(carded.fee, Math.round(plain.fee * PAY.emergencyRate),
|
||||
`the card must print the doubled fee — got $${carded.fee} against $${plain.fee}`);
|
||||
// And the card is the same document as the sheet: what priceOffer prints is what
|
||||
// week.quote() quotes, which is the promise the whole board rests on.
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < em.night - 1; i++) w.advance();
|
||||
w.take(w.offers()[1]);
|
||||
assertEq(w.quote(def).base, carded.fee, 'the dispatch quotes exactly what the card advertised');
|
||||
// The weather and the risk still come from C's and board.js's own functions.
|
||||
assert(Array.isArray(carded.band) && carded.band.length, "C's ordered band survives the extraction");
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 1: an emergency does NOT look like an ordinary callout on the board', () => {
|
||||
if (typeof document === 'undefined') return 'SKIPPED — no DOM';
|
||||
// ⚠️ ALSO FOUND BY EYES, NOT BY THE SUITE: the first cut wore the same grey
|
||||
// "callout" tag as the Tuesday alternative beside it. Double rates, no prep, no
|
||||
// quote, somebody else's rig — the biggest new thing in the game — and the card
|
||||
// said nothing until one click later. That is the tell rule broken on the exact
|
||||
// surface the tell rule is for.
|
||||
const em = EMERGENCIES[0];
|
||||
const w = createWeek();
|
||||
for (let i = 0; i < em.night - 1; i++) w.advance();
|
||||
// PRICED, through the same priceOffer() the real board uses — my first cut built
|
||||
// an UNPRICED board and then asserted the fee line's contents, so the pin failed
|
||||
// on a fixture that could not produce what it was demanding. A card assert has to
|
||||
// be given the card the game renders.
|
||||
const offers = w.offers((night) => priceOffer(night, { def: boardDefs[night.storm], rep: w.rep }));
|
||||
const priced = offers[1].fee != null;
|
||||
const hud = createHud({ scene: new THREE.Scene() });
|
||||
try {
|
||||
hud.showBoard(offers, { night: w.night, nights: w.nights, bank: 80, log: [], rep: 3.0 }, () => {});
|
||||
const cards = [...document.querySelectorAll('#hud-card .offer')];
|
||||
assertEq(cards.length, 2, 'an emergency morning still offers two jobs');
|
||||
assert(!cards[0].classList.contains('emergency'), 'the booked wildnight is not an emergency');
|
||||
assert(cards[1].classList.contains('emergency'),
|
||||
'the emergency offer must be marked as one, not left to read as a Tuesday alternative');
|
||||
const tag = cards[1].querySelector('.tag')?.textContent ?? '';
|
||||
assert(/emergency/i.test(tag), `its tag must say so — got "${tag}"`);
|
||||
const siren = cards[1].querySelector('.siren')?.textContent ?? '';
|
||||
assert(siren.includes('no prep') && siren.includes('no quote'),
|
||||
`and one line must name what is different about it — got "${siren}"`);
|
||||
assert(siren.includes(`${em.emergency.arriveAt}s in`), 'including how late you arrive');
|
||||
// The doubled number carries its reason, on the card, next to the number.
|
||||
// (Guarded on the money being present at all: with no server there are no storm
|
||||
// defs, so the fee line degrades away exactly as the job sheet's forecast does.)
|
||||
if (priced) {
|
||||
const fee = cards[1].querySelector('.fee')?.textContent ?? '';
|
||||
assert(fee.includes(`${PAY.emergencyRate}\u00d7 emergency rate`),
|
||||
`the rate is printed beside the fee — got "${fee}"`);
|
||||
assert(fee.includes(`$${offers[1].fee}`), 'and the doubled number itself');
|
||||
}
|
||||
} finally { hud.dispose(); }
|
||||
});
|
||||
|
||||
t.test('SPRINT18 gate 5: ⚠️ the SECOND-RUST-POST landmine is a tripwire now, not a silent free failure', () => {
|
||||
// D's filing + the other D session's measured receipt. Every shipped yard is
|
||||
// clean TODAY (the pool yard ships exactly one corroded post on purpose), so
|
||||
// this walk is green — which is why the guard below is the part that matters.
|
||||
for (const [name, json] of Object.entries(siteJsonForTest)) {
|
||||
if (!json) continue;
|
||||
assertEq(collateralKeyClashes(json).join(' | '), '',
|
||||
`${name} ships clashing collateral keys — see the accusation for the numbers`);
|
||||
}
|
||||
// ⚠️ THE VACUITY GUARD, and it carries D's measured numbers so the next author
|
||||
// reads the actual failure rather than a policy. A yard with TWO corroded posts:
|
||||
// the offer card would read $90 and the failure route $45, so one post fails
|
||||
// FREE and wrecks the other one.
|
||||
const twoPosts = { name: 'the fixture yard', structures: [
|
||||
{ id: 'c1', collateralKey: 'corroded_post', collateralValue: 45 },
|
||||
{ id: 'c2', collateralKey: 'corroded_post', collateralValue: 45 },
|
||||
] };
|
||||
const clash = collateralKeyClashes(twoPosts);
|
||||
assertEq(clash.length, 1, 'two structures on one key must be accused exactly once');
|
||||
assert(clash[0].includes('$90') && clash[0].includes('$45'),
|
||||
`the accusation must carry BOTH harnesses' numbers — got "${clash[0]}"`);
|
||||
assert(clash[0].includes('c1') && clash[0].includes('c2'), 'and name the structures');
|
||||
assert(/fail FREE/.test(clash[0]), 'and say what actually goes wrong, not just that something does');
|
||||
// An UNKEYED structure resolves by its own id, which the editor generates unique,
|
||||
// so it cannot clash — and reporting it would be a false alarm that teaches an
|
||||
// author to ignore this check.
|
||||
assertEq(collateralKeyClashes({ structures: [
|
||||
{ id: 's1', collateralValue: 180 }, { id: 's2', collateralValue: 180 },
|
||||
] }).length, 0, 'unkeyed structures resolve by unique id and must not be accused');
|
||||
// And one post is fine — the shipped pool yard's own shape.
|
||||
assertEq(collateralKeyClashes({ structures: [twoPosts.structures[0]] }).length, 0,
|
||||
'one corroded post is the shipped shape and must stay legal');
|
||||
});
|
||||
|
||||
// Gate 2 acceptance: both sites load from data, and the corner block is not
|
||||
// the backyard with the furniture moved. Built up front (Suite.test() can't
|
||||
// await — the guard I added last sprint enforces it, and just caught me).
|
||||
|
||||
@ -71,13 +71,17 @@ async function buildYard() {
|
||||
// the real backyard_01 keeps this suite's "read the yard, never copy it" rule
|
||||
// intact — it's the same yard, from its source instead of from code.
|
||||
const { loadSite } = await import('../world.js');
|
||||
const world = createWorld(scene, { wind: calm, site: await loadSite('backyard_01') });
|
||||
// Kept, not just consumed: SPRINT18 gate 2 flies the site's own pinned
|
||||
// `separation` recipe, and reading it from the site the yard was built from is
|
||||
// the same "never copy the yard" rule one level up.
|
||||
const siteDef = await loadSite('backyard_01');
|
||||
const world = createWorld(scene, { wind: calm, site: siteDef });
|
||||
if (world.dress) { try { await world.dress(); } catch { /* graybox anchors are enough */ } }
|
||||
const anchors = 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 };
|
||||
});
|
||||
return { anchors, bed: world.gardenBed, heightAt: world.heightAt };
|
||||
return { anchors, bed: world.gardenBed, heightAt: world.heightAt, siteDef };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -393,6 +397,54 @@ async function gardenHailByPorosity(yard, stormName, burstT) {
|
||||
return { cloth, membrane, lost: rig.corners.filter((c) => c.broken).length };
|
||||
}
|
||||
|
||||
/**
|
||||
* SPRINT18 GATE 2 — fly the site's PINNED separation recipe at one stated
|
||||
* porosity and report only what the steel did. Game-true: no calm settle (the
|
||||
* real game's commit→attach→storm is one keypress), no skyfx, no garden — the
|
||||
* question is which corners let go, and corners are decided by `rig.step` alone.
|
||||
*
|
||||
* Deliberately NOT `fly()`: that helper settles 12 s on the calm day first,
|
||||
* which is the phantom-settle skew gardenfly was rewritten to delete. A peak
|
||||
* quoted off a settled rig is not the number the player's night produces.
|
||||
*
|
||||
* The fabric goes on through `RiggingSession.commit(rig)` — the same door that
|
||||
* was missing from the shipped commit path, so this helper exercises the wire it
|
||||
* is here to protect.
|
||||
*/
|
||||
async function flySeparationSteel(yard, porosity) {
|
||||
const sep = yard.siteDef?.separation;
|
||||
if (!sep) throw new Error('balance: backyard_01 lost its pinned separation block');
|
||||
const def = await loadStorm(sep.stormKey);
|
||||
const wind = createWind(def);
|
||||
wind.setSheltersFromTrees(yard.anchors.filter((a) => a.type === 'tree'));
|
||||
|
||||
const s = new RiggingSession({ anchors: yard.anchors });
|
||||
for (const l of sep.line) if (!s.rig(l.anchor).ok) throw new Error(`balance: cannot rig ${l.anchor}`);
|
||||
for (const l of sep.line) {
|
||||
const hw = HARDWARE.find((h) => h.name === l.hw);
|
||||
if (!s.setHardware(l.anchor, hw).ok) throw new Error(`balance: cannot buy ${l.hw} for ${l.anchor}`);
|
||||
}
|
||||
s.setTension(sep.tension ?? 1.0);
|
||||
s.setFabric(porosity === 0 ? 'membrane' : 'cloth');
|
||||
const rig = s.commit(new SailRig({ anchors: yard.anchors, gridN: 10 }));
|
||||
if (rig.porosity !== porosity) {
|
||||
throw new Error(`balance: asked for porosity ${porosity}, the rig committed at ${rig.porosity} — ` +
|
||||
'the fabric did not reach the sail, which is gate 2 all over again');
|
||||
}
|
||||
|
||||
const order = [];
|
||||
rig.events.on('break', (e) => order.push(e.anchorId));
|
||||
for (let i = 0, n = Math.round(def.duration / FIXED_DT); i < n; i++) rig.step(FIXED_DT, wind, i * FIXED_DT);
|
||||
|
||||
return {
|
||||
porosity: rig.porosity,
|
||||
lost: rig.corners.filter((c) => c.broken).length,
|
||||
order,
|
||||
peak: Object.fromEntries(rig.corners.map((c) => [c.anchorId, c.peakLoad])),
|
||||
eff: Object.fromEntries(rig.corners.map((c) => [c.anchorId, c.hw.rating * (c.anchor.ratingHint ?? 1)])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../testkit.js').Suite} t
|
||||
*
|
||||
@ -465,6 +517,38 @@ export default async function run(t) {
|
||||
}
|
||||
}
|
||||
|
||||
// SPRINT18 GATE 2 — the pinned recipe, flown at each fabric, nothing else
|
||||
// changed. These two rows ARE the two-harness disagreement D reported, and the
|
||||
// membrane row is D's cold play and the site's own `_playedReference`.
|
||||
const sepCloth = await flySeparationSteel(yard, 0.30);
|
||||
const sepMembrane = await flySeparationSteel(yard, 0);
|
||||
|
||||
// GATE 2's SEAM, pinned on the door the shipped game actually walks through.
|
||||
// `RiggingSession.commit(rig)` was never reached in play — the rigging UI's
|
||||
// commit() calls onCommit, so whatever onCommit fails to forward, the sim never
|
||||
// learns. The fabric is the 4th argument now; this is the assert that keeps it
|
||||
// there. Browser-only (the UI needs THREE, a scene and a canvas), which is why
|
||||
// it lives in this suite and not in rigging.selftest.js's node-safe set.
|
||||
let seam;
|
||||
{
|
||||
const { createRiggingUI } = await import('../rigging.js');
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(60, 1.6, 0.1, 400);
|
||||
const canvas = document.createElement('canvas');
|
||||
const seen = [];
|
||||
const ui = await createRiggingUI({
|
||||
scene, camera, domElement: canvas,
|
||||
world: { anchors: yard.anchors },
|
||||
onCommit: (ids, hw, tension, fabric) => seen.push({ ids, hw, tension, fabric }),
|
||||
panel: false,
|
||||
});
|
||||
for (const id of COVER_QUAD) ui.session.rig(id);
|
||||
const flipped = ui.session.setFabric('membrane').ok;
|
||||
ui.commit();
|
||||
ui.dispose();
|
||||
seam = { seen: seen[0], flipped, sessionFabric: ui.session.fabric };
|
||||
}
|
||||
|
||||
// --- then judge -----------------------------------------------------------
|
||||
|
||||
// SPRINT8 gate 0' — CLOSED. The harnesses agree; the disagreement was a miscount.
|
||||
@ -555,6 +639,116 @@ export default async function run(t) {
|
||||
`(hp ${membrane.hp}) — the fabric is the decision`;
|
||||
});
|
||||
|
||||
// ⚠️ SPRINT18 GATE 2 — THE TWO HARNESSES, AND THE VARIABLE BETWEEN THEM.
|
||||
//
|
||||
// D flew backyard_01's pinned wildnight recipe by hand and lost p2 then p3;
|
||||
// `gardenfly` holds the same recipe 0-lost. One variable, measured:
|
||||
//
|
||||
// shade cloth 0.30 0/4 lost p2 3.07 kN p3 1.78 p1 2.97 p5 5.11
|
||||
// membrane 0 2/4 lost p2 3.48 p3 3.90 p1 3.50 p5 5.65
|
||||
//
|
||||
// The second row is D's cold play to the decimal, and it is also the site's own
|
||||
// `_playedReference` (hp 55.7, p2 then p3, 2026-07-18). The bench was NOT
|
||||
// lying: it flew the fabric every card in the game promised. The GAME was
|
||||
// lying — `rig.setFabric` was never called on the sim's rig, so main.js's
|
||||
// `new SailRig({anchors})` flew porosity 0 every night while the panel, the
|
||||
// SCORE IT card, the HUD row and the invoice all said "shade cloth". Fixed by
|
||||
// widening the commit seam (rigging.js) to carry the fabric.
|
||||
//
|
||||
// This assert is the tripwire that keeps the two rows apart. The break ORDER is
|
||||
// pinned too, because p2-then-p3 is the physics: p2 goes at the change and p3
|
||||
// catches its share. If these ever converge, either porosity has stopped
|
||||
// reaching the wind load or the wildnight went soft.
|
||||
t.test('gate 2: the wildnight pin holds on the fabric it was priced on, and only that one', () => {
|
||||
if (sepCloth.lost !== 0) {
|
||||
throw new Error(`the pinned recipe lost ${sepCloth.lost}/4 on the shade cloth it was measured on ` +
|
||||
`(${sepCloth.order.join(' then ')}). The pin's heldMustExceed target assumes 0 lost, so either ` +
|
||||
`storm_02 hardened or porosity stopped bleeding the pressure term.`);
|
||||
}
|
||||
if (sepMembrane.lost !== 2) {
|
||||
throw new Error(`the same recipe on membrane lost ${sepMembrane.lost}/4, not 2. D's cold play and ` +
|
||||
`the site's own _playedReference both lost exactly p2 then p3 — this is the number that made ` +
|
||||
`gate 2 findable, and moving it silently loses the receipt.`);
|
||||
}
|
||||
if (sepMembrane.order.join(',') !== 'p2,p3') {
|
||||
throw new Error(`membrane broke ${sepMembrane.order.join(' then ') || 'nothing'}, not p2 then p3. ` +
|
||||
`The order is the mechanism: p2 goes at the southerly change and p3 catches the share it drops.`);
|
||||
}
|
||||
// and the mechanism itself, not just the verdict: full wind load is HEAVIER.
|
||||
const gain = sepMembrane.peak.p2 / sepCloth.peak.p2;
|
||||
if (!(gain > 1.05)) {
|
||||
throw new Error(`membrane's p2 peak is only ${gain.toFixed(3)}x the cloth's. Porosity scales the ` +
|
||||
`pressure coefficient by (1 - porosity), so 0.30 cloth must read materially lighter than a ` +
|
||||
`membrane. A ratio near 1.0 means setFabric is no longer reaching _accumulateWind.`);
|
||||
}
|
||||
return `same recipe, one variable: cloth ${sepCloth.lost}/4 (p2 ${(sepCloth.peak.p2 / 1000).toFixed(2)} kN) · ` +
|
||||
`membrane ${sepMembrane.lost}/4 lost ${sepMembrane.order.join(' then ')} ` +
|
||||
`(p2 ${(sepMembrane.peak.p2 / 1000).toFixed(2)} kN, ${gain.toFixed(2)}x) — the variable is POROSITY`;
|
||||
});
|
||||
|
||||
// ⚠️ SPRINT18 GATE 3 — WHY THE CLEAN BOUNDARY CANNOT BE A PERCENTAGE.
|
||||
//
|
||||
// One flight, two corners, identical $15 shackles, same 3.2 kN rating:
|
||||
//
|
||||
// p1 3.528 kN = 10.3% OVER -> HELD (over the rating for 0.150 s)
|
||||
// p2 3.493 kN = 9.2% OVER -> BROKE (over the rating for 0.400 s)
|
||||
//
|
||||
// p1 peaks HIGHER than p2 and survives it. Every rule of the shape "peak within
|
||||
// X% of the rating breaks" is therefore wrong at every X — 15% (the retired
|
||||
// AUDIT.MARGIN) condemns p1's perfectly adequate steel and demands $30 for it;
|
||||
// 9% blesses p2 and watches it let go. sail.js never used peak: it burns a
|
||||
// 0.4 s fuse while a corner is over, and DWELL is what decides. This assert is
|
||||
// the receipt, taken off the same flight gate 2 uses, so the audit's re-ruled
|
||||
// boundary (tools/site_audit/sweep.js — the fuse replayed per tier) has a
|
||||
// measured fact under it in the game's own suite rather than a tool's fixture.
|
||||
t.test('gate 3: the same steel at a HIGHER peak survives — so peak cannot price a corner', () => {
|
||||
const eff = sepMembrane.eff, peak = sepMembrane.peak;
|
||||
if (eff.p1 !== eff.p2) {
|
||||
throw new Error(`p1 and p2 are on different steel now (${eff.p1} vs ${eff.p2} N). This test needs two ` +
|
||||
`corners at the SAME rating for the comparison to mean anything — re-pick the pair or re-measure.`);
|
||||
}
|
||||
if (!(peak.p1 > eff.p1 && peak.p2 > eff.p2)) {
|
||||
throw new Error(`both corners must go OVER their rating for this to say anything: p1 ${peak.p1} and ` +
|
||||
`p2 ${peak.p2} against ${eff.p1} N.`);
|
||||
}
|
||||
if (!(peak.p1 > peak.p2)) {
|
||||
throw new Error(`p1 (${peak.p1} N) no longer peaks higher than p2 (${peak.p2} N) — the inversion that ` +
|
||||
`kills percentage pricing has gone, so re-measure before trusting the dwell rule's justification.`);
|
||||
}
|
||||
if (sepMembrane.order.includes('p1')) {
|
||||
throw new Error(`p1 broke as well. The whole point is that the HIGHER peak held: if both go, peak and ` +
|
||||
`outcome agree again and a percentage rule would be defensible.`);
|
||||
}
|
||||
const overP1 = ((peak.p1 / eff.p1) - 1) * 100, overP2 = ((peak.p2 / eff.p2) - 1) * 100;
|
||||
return `same ${(eff.p1 / 1000).toFixed(1)} kN steel: p1 ${overP1.toFixed(1)}% over HELD, ` +
|
||||
`p2 ${overP2.toFixed(1)}% over BROKE — dwell decides, not peak`;
|
||||
});
|
||||
|
||||
// GATE 2's WIRE, not its symptom. The bug was never in the physics: it was that
|
||||
// the only function which tells a sail what it is made of sat on a path the
|
||||
// player never walked. So assert the path, on the object the player's Enter key
|
||||
// reaches — the fabric must come OUT of commit(), or the sim cannot learn it.
|
||||
t.test('gate 2: the commit seam carries the FABRIC, not just the steel', () => {
|
||||
if (!seam.seen) throw new Error('the rigging UI committed nothing — four corners were rigged');
|
||||
if (!seam.flipped) throw new Error('setFabric refused the membrane; the control never changed');
|
||||
if (!seam.seen.fabric) {
|
||||
throw new Error(`commit() handed onCommit (ids, hw, tension) and NO fabric. That is exactly the ` +
|
||||
`sprint-18 gate-2 bug: main.js's rigSail can only call rig.setFabric with something it was ` +
|
||||
`given, so a dropped 4th argument means every night flies new SailRig's default porosity (0, ` +
|
||||
`the membrane) while every card in the game says shade cloth.`);
|
||||
}
|
||||
if (seam.seen.fabric.id !== seam.sessionFabric.id) {
|
||||
throw new Error(`commit forwarded fabric "${seam.seen.fabric.id}" while the session holds ` +
|
||||
`"${seam.sessionFabric.id}" — the paperwork and the sim would disagree again, one layer down.`);
|
||||
}
|
||||
if (!Number.isFinite(seam.seen.fabric.porosity)) {
|
||||
throw new Error('the forwarded fabric has no finite porosity — setFabric would clamp NaN and the ' +
|
||||
'sail would fly a fabric nobody chose.');
|
||||
}
|
||||
return `commit() -> onCommit(ids, hw, tension, fabric) with the session's own ` +
|
||||
`"${seam.seen.fabric.id}" (porosity ${seam.seen.fabric.porosity}) — the wire the game was missing`;
|
||||
});
|
||||
|
||||
// The fabric's SECOND edge, through the REAL garden-hail chain, not a copy of it.
|
||||
// `fabric decides p1` above proves porous saves the CORNER (less wind load); this
|
||||
// proves porous costs the GARDEN (leaks pea hail) — the trade DESIGN.md promises
|
||||
|
||||
@ -22,6 +22,9 @@ import {
|
||||
// SPRINT17, the second half of gate 1 (see weather.js's divider): what the
|
||||
// card PRINTS, and which yard a storm may be printed over.
|
||||
offerBand, PAIRING_LAW, pairingRefusal, pairingRefusals,
|
||||
// SPRINT18 gate 1 — the dispatch: the read with no paper behind it, and the
|
||||
// honesty gate extended to the storms nobody forecast.
|
||||
midStormRead, dispatchHonest,
|
||||
} from '../weather.js';
|
||||
import { loadSite, createWorld } from '../world.js';
|
||||
// SPRINT17 gate 1 — the board's weather side. NIGHTS ∪ POOL is every storm an
|
||||
@ -29,6 +32,11 @@ import { loadSite, createWorld } from '../world.js';
|
||||
// without a test edit the day D's pool yard lands.
|
||||
import { NIGHTS, nightAt } from '../week.js';
|
||||
import { POOL, exposureOf } from '../board.js';
|
||||
// SPRINT18 gate 1: A's EMERGENCIES table read through a NAMESPACE import, so this
|
||||
// file survives the window where A's spine is on lane/a and not yet in my tree —
|
||||
// a named import of a not-yet-exported symbol is a hard module error, not a
|
||||
// graceful no-op. A's own reasoning for `weather.midStormRead?.()`, applied back.
|
||||
import * as board from '../board.js';
|
||||
// GATE 2.3 — the GAME's own wind wiring, IMPORTED rather than re-typed. A pin
|
||||
// that copied main.js's two lines would agree with a copy of the game forever,
|
||||
// including on the day the game itself changed. "Two harnesses, one number"
|
||||
@ -208,6 +216,138 @@ export default async function run(t) {
|
||||
assert(gale.leafCount === 0, 'clear() left leaves flying into the aftermath card');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT18 gate 1 — THE MID-STORM ENTRY, debris half.
|
||||
//
|
||||
// The emergency callout arrives at t≈30. weather.selftest.js pins that the WIND
|
||||
// is the same storm however you got to t0 (it is closed-form, measured
|
||||
// order-independent). Debris was not, and it cost two determinism breaks that
|
||||
// I measured before writing a line — both recorded in debris.js beside the fix:
|
||||
//
|
||||
// · the same storm flown twice through one module spawned DIFFERENT crates
|
||||
// (wildnight crate height 1.582 m then 1.165 m). main.js builds one debris
|
||||
// module at boot and clear()s it per phase change; clear() rewound the leaf
|
||||
// accumulators and not the piece PRNG. Live on main today — night 2 of the
|
||||
// seven-night week never flew night 1's crates, and no test looked.
|
||||
// · an entry at t=45 (past the wildnight's t=38 event) gave every LATER crate
|
||||
// the skipped crate's height, spin axis and phase — the stream sat 5 draws
|
||||
// behind. Skipping history corrupted the FUTURE, not just the present.
|
||||
//
|
||||
// Both had one cause: a crate's jitter came off a shared stream whose POSITION
|
||||
// encoded how much history had happened. It is now keyed on the event itself.
|
||||
// These three cases are what stop it coming back.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Every piece the storm spawns, snapshotted AT BIRTH. */
|
||||
const crateLog = (dbg, from, to, dt = FIXED_DT) => {
|
||||
const born = [];
|
||||
const seen = new Set();
|
||||
for (let i = Math.round(from / dt); i < Math.round(to / dt); i++) {
|
||||
const time = i * dt;
|
||||
dbg.step(dt, time, {});
|
||||
for (const p of dbg.pieces) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
// y/sx/sy/sz/phase are the seeded jitter; x/z/vx come off the wind and
|
||||
// were never in doubt. Round only for the message — compare exactly.
|
||||
born.push({ model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase });
|
||||
}
|
||||
}
|
||||
return born;
|
||||
};
|
||||
const sameCrates = (a, b) => a.length === b.length && a.every((p, i) =>
|
||||
p.model === b[i].model && p.y === b[i].y && p.sx === b[i].sx
|
||||
&& p.sy === b[i].sy && p.sz === b[i].sz && p.phase === b[i].phase);
|
||||
const showCrates = (a) => a.map((p) => `${p.model}@y${p.y.toFixed(3)}/ph${p.phase.toFixed(3)}`).join(', ') || '(none)';
|
||||
|
||||
t.test('a crate is a pure function of its event, not of what came before it', () => {
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
const dur = def.duration ?? 90;
|
||||
|
||||
// two independently built modules on the same seed
|
||||
const one = crateLog(createDebris({ wind: createWind(def) }), 0, dur);
|
||||
const two = crateLog(createDebris({ wind: createWind(def) }), 0, dur);
|
||||
assert(sameCrates(one, two),
|
||||
`${name}: two freshly built debris modules spawned different crates — ${showCrates(one)} vs ${showCrates(two)}`);
|
||||
|
||||
// and the same module flown twice, clear()ed between: night 1 vs night 2
|
||||
const reused = createDebris({ wind: createWind(def) });
|
||||
const night1 = crateLog(reused, 0, dur);
|
||||
reused.clear();
|
||||
const night2 = crateLog(reused, 0, dur);
|
||||
assert(sameCrates(night1, night2),
|
||||
`${name}: the same storm flown twice through one module spawned different crates — night 1 ${showCrates(night1)}, night 2 ${showCrates(night2)}. clear() has stopped rewinding something.`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('an entry at t0 spawns the crates a t=0 run spawns after t0 (every storm, every t0)', () => {
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
const dur = def.duration ?? 90;
|
||||
const mk = () => createDebris({ wind: createWind(def) });
|
||||
|
||||
// the reference: watch the whole storm, and count how many crates are born
|
||||
// at or after each candidate entry time
|
||||
const whole = [];
|
||||
const seen = new Set();
|
||||
const ref = mk();
|
||||
for (let i = 0; i < Math.round(dur / FIXED_DT); i++) {
|
||||
const time = i * FIXED_DT;
|
||||
ref.step(FIXED_DT, time, {});
|
||||
for (const p of ref.pieces) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
whole.push({ at: time, model: p.model, y: p.y, sx: p.sx, sy: p.sy, sz: p.sz, phase: p.phase });
|
||||
}
|
||||
}
|
||||
|
||||
for (const t0 of [10, 30, 45, 60]) {
|
||||
const want = whole.filter((p) => p.at >= t0);
|
||||
const got = crateLog(mk(), t0, dur);
|
||||
assert(sameCrates(want, got),
|
||||
`${name}: entering at t=${t0} spawned ${showCrates(got)} where a t=0 run spawns ${showCrates(want)} — the crates a callout flies depend on whether anybody watched the first ${t0} s`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t.test('arriving mid-storm, the grass is already moving (and a cold entry proves it can fail)', () => {
|
||||
// DESIGN.md line 149: a gust front is readable because a wave of motion
|
||||
// crosses the grass a beat before it hits. The callout has NO forecast paper
|
||||
// (gate 1), so that motion is not decoration — it is the briefing. `leafDist`
|
||||
// and `leafEma` are genuine path integrals (a 2.5 s EMA off zero), so they are
|
||||
// the one part of my modules a mid-storm entry cannot teleport: measured, a
|
||||
// t=0 run has 6 leaves crossing at t=30 and a cold entry has ZERO, staying
|
||||
// becalmed for ~7 s while the EMA climbs. debris.enterAt() runs them.
|
||||
const def = storms.storm_02_wildnight;
|
||||
const T0 = 30;
|
||||
const mk = () => createDebris({ wind: createWind(def) });
|
||||
const look = (d) => ({ n: d.leafCount, at: d.leaves.map((p) => [p.x, p.y, p.z]) });
|
||||
const same = (a, b) => a.n === b.n && a.at.every((v, i) =>
|
||||
v[0] === b.at[i][0] && v[1] === b.at[i][1] && v[2] === b.at[i][2]);
|
||||
|
||||
const watched = mk();
|
||||
fixedLoop(T0, FIXED_DT, (dt, time) => watched.step(dt, time, {}));
|
||||
const want = look(watched);
|
||||
assert(want.n > 0, `the reference t=0 run has ${want.n} leaves flying at t=${T0} — pick a windier moment or this case proves nothing`);
|
||||
|
||||
const arrived = mk();
|
||||
arrived.enterAt(T0, FIXED_DT);
|
||||
const got = look(arrived);
|
||||
assert(same(want, got),
|
||||
`enterAt(${T0}) put ${got.n} leaves in the yard where a t=0 run has ${want.n}, or put them elsewhere — the arrival is not the storm the player would have been standing in`);
|
||||
|
||||
// NEGATIVE CONTROL. Without this the case above could be satisfied by a
|
||||
// do-nothing enterAt on a layer that happened to warm instantly. The naive
|
||||
// path — fresh module, first step lands at t0 — must be measurably wrong, and
|
||||
// is: 0 leaves against 6. This is also the guard against somebody later
|
||||
// "optimising" enterAt into a teleport.
|
||||
const cold = mk();
|
||||
cold.step(FIXED_DT, T0, {});
|
||||
assert(!same(want, look(cold)),
|
||||
`a cold module stepped straight to t=${T0} already matches the watched run — the ambient layer carries no history, so the case above cannot fail and must be rewritten`);
|
||||
});
|
||||
|
||||
// SPRINT13, the third time this repo learned it: the venturi lives in the
|
||||
// SITE json, not the storm def. B's site_audit flew site_02 funnel-OFF in
|
||||
// Sprint 11 (their sweep.selftest tells it); C's garden_bench did it again
|
||||
@ -1512,6 +1652,245 @@ export default async function run(t) {
|
||||
+ 'nobody flew is the same offence in the other direction');
|
||||
});
|
||||
|
||||
// ═══ SPRINT18 GATE 1 — THE DISPATCH: what an honest read is when no paper comes ═══
|
||||
//
|
||||
// `offerBand`'s sin was OMISSION. The dispatch's sin is INVENTION, and these
|
||||
// cases are aimed at that inversion. The sharpest of them states the rule as an
|
||||
// invariant rather than as a vocabulary check: A READ AT t MAY NOT MOVE WHEN THE
|
||||
// STORM'S FUTURE BEYOND t MOVES. If it does, the card is printing something
|
||||
// nobody standing in that yard could know.
|
||||
//
|
||||
// ⚠️ NOT pinned by diffing storm_03_southerly against storm_03b_earlybuster,
|
||||
// which was my first instinct and is wrong: THREADS has them "identical in every
|
||||
// number the band prints", but they carry DIFFERENT SEEDS (30717/30818), so their
|
||||
// gust timelines differ and their instantaneous wind differs at every t. They are
|
||||
// twins on the FORECAST's numbers, not on the sim's. Isolating the variable means
|
||||
// constructing it, so these cases mutate a def's future and hold everything else.
|
||||
const AT = 30; // A's authored arriveAt (§1), the number ROADMAP names
|
||||
|
||||
/** A copy of `def` whose FUTURE past `t` is different and whose present is not. */
|
||||
const futureChanged = (def, t) => {
|
||||
const d = JSON.parse(JSON.stringify(def));
|
||||
// Curve points strictly after the first point at/after t cannot affect the
|
||||
// value AT t (piecewise-linear interpolation there brackets t). Move those.
|
||||
for (const key of ['dirCurve', 'baseCurve']) {
|
||||
const c = d[key];
|
||||
if (!Array.isArray(c)) continue;
|
||||
let first = c.findIndex((p) => p[0] >= t);
|
||||
if (first < 0) continue;
|
||||
for (let i = first + 1; i < c.length; i++) c[i][1] = -(c[i][1] ?? 0) * 1.7 - 0.3;
|
||||
}
|
||||
// A hail burst that has not started by t cannot be falling at t.
|
||||
if (d.hail && Array.isArray(d.hail.bursts)) {
|
||||
d.hail.bursts = d.hail.bursts.filter((b) => b.t <= t);
|
||||
d.hail.bursts.push({ t: t + 5, ramp: 0.5, hold: 9, fade: 1, intensity: 1 });
|
||||
}
|
||||
// Events are announcements; the read never reads them. Move them anyway.
|
||||
if (Array.isArray(d.events)) d.events = d.events.filter((e) => e.t <= t);
|
||||
return d;
|
||||
};
|
||||
|
||||
t.test('GATE 1: the mid-storm read cannot know the future (change the future, the read holds)', () => {
|
||||
const same = (a, b) => a.length === b.length
|
||||
&& a.every((l, i) => l.key === b[i].key && l.text === b[i].text);
|
||||
let futuresActuallyChanged = 0;
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
for (const t of [8, 20, AT]) {
|
||||
const now = midStormRead(def, t);
|
||||
const altered = midStormRead(futureChanged(def, t), t);
|
||||
assert(same(now, altered),
|
||||
`${name}: the read at t=${t} MOVED when only the storm's future moved.\n was: `
|
||||
+ `${now.map((l) => l.key + '=' + l.text).join(' | ')}\n now: `
|
||||
+ `${altered.map((l) => l.key + '=' + l.text).join(' | ')}\n `
|
||||
+ 'a dispatch that reads past t is inventing the paper it says it has not got');
|
||||
// VACUITY GUARD: the mutation must actually alter the storm, or the case
|
||||
// above is comparing a def to itself. Read them both LATER than t.
|
||||
const later = Math.min((def.duration ?? 90) - 1, t + 12);
|
||||
if (!same(midStormRead(def, later), midStormRead(futureChanged(def, t), later))) {
|
||||
futuresActuallyChanged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(futuresActuallyChanged >= STORMS.length,
|
||||
`only ${futuresActuallyChanged} of ${STORMS.length * 3} mutations changed the storm at all — `
|
||||
+ 'the invariant above is being satisfied by a no-op mutation, not by an honest read');
|
||||
});
|
||||
|
||||
t.test('GATE 1: the read never carries a forecast-only fact, by key or by number', () => {
|
||||
for (const name of STORMS) {
|
||||
const def = storms[name];
|
||||
const s = stormStats(def);
|
||||
for (const t of [8, AT, 60]) {
|
||||
const read = midStormRead(def, t);
|
||||
for (const l of read) {
|
||||
// the offer band's forward-looking keys have no business on a dispatch
|
||||
assert(l.key !== 'change' && l.key !== 'confidence',
|
||||
`${name} @${t}: the read carries a '${l.key}' line — that is forecast paper`);
|
||||
}
|
||||
const all = read.map((l) => l.text).join(' ');
|
||||
// the two numbers only a bureau could hand you
|
||||
if (s.changeAt != null && s.changeAt > t) {
|
||||
assert(!all.includes(String(Math.round(s.changeAt))),
|
||||
`${name} @${t}: the read prints ${Math.round(s.changeAt)} and the change lands at `
|
||||
+ `${s.changeAt} — nobody told you when it swings`);
|
||||
}
|
||||
if (s.hailSeconds > 0) {
|
||||
assert(!all.includes(`${s.hailSeconds.toFixed(0)}s of it`),
|
||||
`${name} @${t}: the read prints the storm's TOTAL hail duration — that is the forecast's number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
t.test('GATE 1: the admission is unconditional — every storm, every t, same words', () => {
|
||||
// The subtle half of the design. On an offer card a missing hail line means
|
||||
// THE STORM DOES NOT HAIL. On a dispatch it means NOBODY KNOWS. Silence cannot
|
||||
// tell those apart, so the read says so out loud — and it must say it in the
|
||||
// SAME words every time, because a line that appears only when there is
|
||||
// something to not-know becomes the very fact it claims not to have.
|
||||
const texts = { nopaper: new Set(), unknown: new Set() };
|
||||
let reads = 0;
|
||||
for (const name of STORMS) {
|
||||
for (let t = 1; t < (storms[name].duration ?? 90); t += 7) {
|
||||
const read = midStormRead(storms[name], t);
|
||||
reads++;
|
||||
assert(read[0].key === 'nopaper',
|
||||
`${name} @${t}: the read does not OPEN with the no-forecast line — it frames every line under it`);
|
||||
assert(read[read.length - 1].key === 'unknown',
|
||||
`${name} @${t}: the read does not CLOSE with what you cannot know`);
|
||||
for (const l of read) if (texts[l.key]) texts[l.key].add(l.text);
|
||||
}
|
||||
}
|
||||
assert(reads > 60, `only ${reads} reads sampled — widen the grid`);
|
||||
for (const k of ['nopaper', 'unknown']) {
|
||||
assert(texts[k].size === 1,
|
||||
`the '${k}' line has ${texts[k].size} different wordings across the shipped storms — `
|
||||
+ `it is conditional on something, and its presence is then information the observer `
|
||||
+ `cannot have:\n ${[...texts[k]].join('\n ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
t.test('GATE 1: the honesty gate covers emergency storms — a storm nobody can describe cannot be dispatched', () => {
|
||||
// "A storm that cannot be described honestly cannot be DISPATCHED either."
|
||||
// The storm-side gate applies unchanged; the arrival adds its own three.
|
||||
for (const name of STORMS) {
|
||||
const r = dispatchHonest(storms[name], AT, name);
|
||||
assert(r.ok, `${name} cannot be honestly dispatched at t=${AT}:\n ${r.errors.join('\n ')}`);
|
||||
}
|
||||
|
||||
// NEGATIVE CONTROLS — four, because a gate that only ever says yes is decoration.
|
||||
const base = storms.storm_03_southerly;
|
||||
assert(!dispatchHonest(base, 0, 'x').ok,
|
||||
'arriveAt 0 was accepted — that is not a mid-storm arrival, it is an ordinary night');
|
||||
assert(!dispatchHonest(base, base.duration ?? 90, 'x').ok,
|
||||
'arriveAt at the storm\'s end was accepted — you would be dispatched to finished weather');
|
||||
assert(!dispatchHonest(base, 500, 'x').ok, 'arriveAt past the end was accepted');
|
||||
|
||||
// THE S17 FILING, CORRECTED BY MEASUREMENT. I filed "stoneWord has no floor,
|
||||
// size 0 words itself fine pea" — and that hole was already shut one layer up:
|
||||
// validateStorm refuses size <= 0, so this is the case proving the gate above
|
||||
// does the work, not a floor check in forecastHonest.
|
||||
const zero = JSON.parse(JSON.stringify(storms.storm_06_soaker));
|
||||
zero.hail.size = 0;
|
||||
assert(!dispatchHonest(zero, AT, 'zero').ok, 'hail.size 0 was dispatched');
|
||||
|
||||
// ⚠️ THE REAL HOLE, found by probing the layer that DOES pass: the validator
|
||||
// only checks a size that is PRESENT (`hd.size != null &&`), and hailSize reads
|
||||
// `size ?? 1`. So a hailing storm with NO authored size prints "marble stones"
|
||||
// — the middle of the vocabulary, stated as fact, authored by nobody. On the
|
||||
// soaker that turns a real 0.45 "fine pea" into a stone 2.2x bigger, on the one
|
||||
// axis the fabric bet is decided on.
|
||||
const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker));
|
||||
delete sizeless.hail.size;
|
||||
assert(stormStats(sizeless).hailSeconds > 1,
|
||||
'the sizeless fixture does not actually hail — this case would prove nothing');
|
||||
const sr = dispatchHonest(sizeless, AT, 'sizeless');
|
||||
assert(!sr.ok, 'a storm that hails with no authored hail.size was dispatched — the card '
|
||||
+ 'would state "marble stones" as fact for a stone nobody wrote');
|
||||
assert(sr.errors.some((e) => /hail\.size is not authored/.test(e)),
|
||||
`the refusal does not say WHY: ${sr.errors.join('; ')}`);
|
||||
// and the ceiling still bites, so the new check did not displace it
|
||||
const boulders = JSON.parse(JSON.stringify(storms.storm_06_soaker));
|
||||
boulders.hail.size = 2.6;
|
||||
assert(!dispatchHonest(boulders, AT, 'boulders').ok,
|
||||
'a 2.6 stone was dispatched — the vocabulary ceiling stopped biting');
|
||||
});
|
||||
|
||||
t.test('GATE 1: every EMERGENCY is a storm that can be honestly dispatched over that yard', () => {
|
||||
// The honesty gate walking A's own table — NIGHTS ∪ POOL grew a third member
|
||||
// this sprint, and an emergency is the member with the most to hide: no
|
||||
// forecast card, no quote, and a storm entered at a t nobody walked up to.
|
||||
//
|
||||
// ⚠️ Read through a NAMESPACE import (`board.EMERGENCIES`), not a named one, so
|
||||
// this file does not hard-error in the sprint-window where A's spine is on
|
||||
// lane/a and not yet in my tree — the same reasoning A gave for calling
|
||||
// `weather.midStormRead?.()` that way, applied back at them. And it walks a
|
||||
// FIXTURE mirroring A's §6 as well as the real table, so the gate is exercised
|
||||
// on known data TODAY rather than reading green off an absent import: the
|
||||
// calm-day vacuity that has now bitten this repo in three separate gates.
|
||||
const fixture = [{
|
||||
id: 'fixture:corner_block_emergency', night: 5,
|
||||
storm: 'storm_03_southerly', site: 'site_02_corner_block',
|
||||
emergency: { arriveAt: 30 },
|
||||
}];
|
||||
const real = Array.isArray(board.EMERGENCIES) ? board.EMERGENCIES : [];
|
||||
const walk = [...fixture, ...real];
|
||||
assert(walk.length >= 1, 'the emergency walk found nothing at all');
|
||||
|
||||
for (const e of walk) {
|
||||
const at = e.emergency?.arriveAt;
|
||||
const def = storms[e.storm];
|
||||
assert(def, `${e.id}: emergency storm '${e.storm}' is not in the loaded storm set — `
|
||||
+ 'an emergency whose weather nobody can load cannot be dispatched');
|
||||
assert(Number.isFinite(at), `${e.id}: no numeric emergency.arriveAt`);
|
||||
|
||||
// (1) the storm-and-instant gate
|
||||
const r = dispatchHonest(def, at, e.id);
|
||||
assert(r.ok, `${e.id} cannot be honestly dispatched at arriveAt=${at}:\n ${r.errors.join('\n ')}`);
|
||||
|
||||
// (2) THE PAIRING LAW, extended to the new job type. A read can be perfectly
|
||||
// honest about a storm and still be an invitation to a night nobody can
|
||||
// work — and that is worse on a callout, which the player cannot decline
|
||||
// once they are standing in it.
|
||||
const why = pairingRefusal(e.storm, e.site);
|
||||
assert(why === null,
|
||||
`${e.id}: dispatching ${e.storm} over ${e.site} is refused by measurement — ${why}`);
|
||||
}
|
||||
|
||||
// VACUITY GUARDS — three, because a walk that only ever says yes is decoration.
|
||||
assert(pairingRefusal('storm_06_soaker', 'backyard_01') !== null,
|
||||
'the pairing arm of this walk cannot say no — an emergency on a refused yard would pass');
|
||||
assert(!dispatchHonest(storms[fixture[0].storm], 95, 'late').ok,
|
||||
'an emergency arriving after its storm ended was accepted');
|
||||
const sizeless = JSON.parse(JSON.stringify(storms.storm_06_soaker));
|
||||
delete sizeless.hail.size;
|
||||
assert(!dispatchHonest(sizeless, 30, 'sizeless').ok, 'the storm arm of this walk cannot say no');
|
||||
});
|
||||
|
||||
t.test('GATE 1: every line of the read is reachable, and each one is earned', () => {
|
||||
// A vocabulary with an unreachable word is a vocabulary that has never been
|
||||
// read. Walk the shipped storms and prove each key fires somewhere — and that
|
||||
// the two conditional-on-weather keys do NOT fire where the weather is absent.
|
||||
const where = {};
|
||||
for (const name of STORMS) {
|
||||
for (let t = 0.5; t < (storms[name].duration ?? 90); t += 0.25) {
|
||||
for (const l of midStormRead(storms[name], t)) (where[l.key] ??= []).push(name);
|
||||
}
|
||||
}
|
||||
for (const k of ['nopaper', 'wind', 'unknown', 'lull', 'front', 'rain', 'veer', 'hail', 'ground']) {
|
||||
assert(where[k]?.length, `no shipped storm ever produces a '${k}' line — it is unreachable vocabulary`);
|
||||
}
|
||||
// storm_01_gentle is the calm-day preload: no hail, no change. It must not
|
||||
// grow a hail line or a swing, and this is the case that would catch a read
|
||||
// that manufactures weather to fill a card.
|
||||
const gentle = new Set();
|
||||
for (let t = 0.5; t < 90; t += 0.25) for (const l of midStormRead(storms.storm_01_gentle, t)) gentle.add(l.key);
|
||||
assert(!gentle.has('hail') && !gentle.has('ground'),
|
||||
'the gentle night reads hail or stones on the ground, and it has neither');
|
||||
assert(!gentle.has('veer'), 'the gentle night reads a wind swing, and it has no change');
|
||||
});
|
||||
|
||||
// ── GATE 1.2 (S17): THE EXPOSURE CROSS — A's exposureOf vs the failure
|
||||
// route, the S14 pin pattern (two harnesses, one number, by construction).
|
||||
// Prep up front (Suite.test can't await); the tests skip honestly offline.
|
||||
|
||||
@ -23,14 +23,24 @@ import { loadStorm, createWind } from '../weather.js';
|
||||
// SPRINT16 gate 4 / SPRINT17 gate 0.1: the week-law pins at the bottom of this
|
||||
// file read the ladder. week.js is contracts-only underneath, so this stays
|
||||
// headless-safe.
|
||||
import { NIGHTS, nightAt } from '../week.js';
|
||||
import { NIGHTS, nightAt, REP } from '../week.js';
|
||||
// SPRINT17 gate 3.2: the pool-yard pins at the bottom read the board's POOL
|
||||
// (pure data, zero THREE) and loadSite (fetch + validateSite — the teeth; a
|
||||
// site file this suite merely fetch()ed raw could rot invalid and stay green).
|
||||
// loadSite pulls world.js which pulls THREE — browser-only, same as the GLB
|
||||
// facts everywhere else in this repo; the node runner never imports this file.
|
||||
import { POOL, exposureOf } from '../board.js';
|
||||
import { POOL, exposureOf, EMERGENCIES } from '../board.js';
|
||||
// SPRINT18 gate 1.3 — the knife's physics half, imported for ONE reason: to check that the doors
|
||||
// main.js feature-detects on the emergency path exist. See that test.
|
||||
import { SailRig } from '../sail.js';
|
||||
import { loadSite } from '../world.js';
|
||||
// SPRINT18 gate 1.3 — the knife. knife.js is headless-importable for the same reason ladder.js is
|
||||
// (its GLB-free, and the panel guards on `typeof document`); the ledger halves come from main.js,
|
||||
// which a.test and c.test already import, because scoreRun lives behind a WebGL context and the only
|
||||
// way to assert its rulings is to have them extracted as pure functions.
|
||||
import { createKnife, createVerbPanel, cuttable, KNIFE_TUNE } from '../knife.js';
|
||||
import { answerableFor, collateralWalk, rigRecordFor, verdictFor } from '../main.js';
|
||||
import { createWeek } from '../week.js';
|
||||
|
||||
const DT = FIXED_DT;
|
||||
|
||||
@ -1328,4 +1338,350 @@ export default async function run(t) {
|
||||
assertEq(rustItem.cost, 45,
|
||||
'the rust is the cheapest structure you can break — the stake E sized the gamble in');
|
||||
});
|
||||
|
||||
// ================================================================ SPRINT18 gate 1.3 — THE KNIFE
|
||||
//
|
||||
// DESIGN.md line 165, and the signature decision under load: release, cut, or ride it out. B built
|
||||
// `rig.cutAway`; these pin the VERB — where it reaches, what interrupts it, what the read promises,
|
||||
// and (the half that would have shipped as a trap) what it costs on the paperwork.
|
||||
//
|
||||
// A note on the fixtures: they are the SHIPPED emergency's own geometry, not invented numbers.
|
||||
// site_02's cowboy rig is q3/q1/tr1b/cb1, and cb1 is a carport BEAM — `work:'bracket'`, y=2.36,
|
||||
// ratingHint 0.22, the corner that takes the $180 carport at t=35.9. It is the one corner the knife
|
||||
// must refuse and the whole reason the verb is a whole-sail cut.
|
||||
|
||||
/** A rig shaped like sail.js's: corners with `broken`/`cut`, and a cutAway that behaves like B's. */
|
||||
function fakeRig(corners) {
|
||||
const cuts = [];
|
||||
return {
|
||||
rigged: true,
|
||||
corners,
|
||||
cuts,
|
||||
cutAway(ids = null, reason = null) {
|
||||
const want = ids ?? corners.map((c) => c.anchorId);
|
||||
const cut = [];
|
||||
for (const id of want) {
|
||||
const c = corners.find((x) => x.anchorId === id);
|
||||
if (!c || c.broken) continue;
|
||||
c.broken = true; c.cut = true; cut.push(id);
|
||||
}
|
||||
if (!cut.length) return { ok: false, reason: 'the sail is already down' };
|
||||
cuts.push({ corners: cut, reason });
|
||||
return { ok: true, cut };
|
||||
},
|
||||
};
|
||||
}
|
||||
/** The corner block's real anchors, with the beam's mechanism as E baked it. */
|
||||
const YARD = {
|
||||
anchors: [
|
||||
{ id: 'q1', type: 'post', work: 'cloth', pos: { x: -3.5, y: 3.95, z: -2.5 } },
|
||||
{ id: 'q3', type: 'post', work: 'cloth', pos: { x: 2.0, y: 3.95, z: 4.0 } },
|
||||
{ id: 'tr1b', type: 'tree', pos: { x: -6.0, y: 5.05, z: 3.0 } },
|
||||
{ id: 'cb1', type: 'carport', work: 'bracket', pos: { x: -7.0, y: 2.36, z: -3.0 }, collateral: 'carport' },
|
||||
],
|
||||
gnome: { collateralValue: 25 },
|
||||
anchor(id) { return this.anchors.find((a) => a.id === id) ?? null; },
|
||||
collateralFor(key) { return key === 'carport' ? { cost: 180, label: 'the carport' } : null; },
|
||||
};
|
||||
const fourCorners = () => [
|
||||
{ anchorId: 'q3', broken: false, hw: { name: 'shackle', cost: 15, rating: 3200 } },
|
||||
{ anchorId: 'q1', broken: false, hw: { name: 'carabiner', cost: 5, rating: 1200 } },
|
||||
{ anchorId: 'tr1b', broken: false, hw: { name: 'carabiner', cost: 5, rating: 1200 } },
|
||||
{ anchorId: 'cb1', broken: false, hw: { name: 'shackle', cost: 15, rating: 3200 } },
|
||||
];
|
||||
const knifeAt = (p, rig, extra = {}) => createKnife({
|
||||
player: p, getRig: () => rig, getWorld: () => YARD,
|
||||
collateralAtRisk: collateralWalk, ...extra,
|
||||
});
|
||||
/** Stand on a cleat. The cleat is the anchor's x/z at ground level — see knife.js. */
|
||||
const standAt = (p, id) => { const a = YARD.anchor(id); p.pos.x = a.pos.x; p.pos.z = a.pos.z; };
|
||||
|
||||
t.test('knife: it reaches a post cleat from the grass and NEVER the bracket', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const corners = fourCorners();
|
||||
const K = knifeAt(p, fakeRig(corners));
|
||||
|
||||
standAt(p, 'q3');
|
||||
assertEq(K.target()?.anchorId, 'q3', 'standing at the post cleat, the knife is offered');
|
||||
standAt(p, 'tr1b');
|
||||
assertEq(K.target()?.anchorId, 'tr1b', 'and at the tree strop');
|
||||
|
||||
// THE ONE THAT MATTERS. cb1 is the corner about to take the $180 carport, and it is a bracket at
|
||||
// 2.36 m — the same rule that costs a fascia repair two trips and a ladder. You cannot cut there,
|
||||
// which is why the verb releases the WHOLE sail from a corner you can reach.
|
||||
standAt(p, 'cb1');
|
||||
assertEq(K.target(), null,
|
||||
'the carport BEAM is bracket work — a knife cannot reach it, and having no time to fetch the '
|
||||
+ 'ladder is the emergency\'s whole premise');
|
||||
assert(!cuttable(corners[3], YARD.anchor('cb1')), 'cuttable() states the same rule directly');
|
||||
|
||||
/**
|
||||
* THE REFUSAL SAYS WHICH OF THE TWO REASONS IT IS, and this pin exists because playing it
|
||||
* caught the version that did not: one constant that always blamed the bracket, so on a yard
|
||||
* with no bracket in the quad the game told me to fetch a ladder for a corner that does not
|
||||
* exist. `needsLadder`'s own SPRINT10 disease — a rule keyed on the wrong thing, failing
|
||||
* plausibly — on the surface built to explain it.
|
||||
*/
|
||||
assert(/cleat|strop/.test(K.read().whyNot ?? ''),
|
||||
`standing at the beam with two posts still up, the answer is WALK: ${K.read().whyNot}`);
|
||||
const bracketOnly = fakeRig([
|
||||
{ anchorId: 'cb1', broken: false, hw: { name: 'shackle', cost: 15, rating: 3200 } },
|
||||
]);
|
||||
const K2 = knifeAt(p, bracketOnly);
|
||||
assert(/bracket/.test(K2.read().whyNot ?? ''),
|
||||
`with every live corner up a wall, the answer is that the knife cannot reach it: ${K2.read().whyNot}`);
|
||||
|
||||
// out of range of everything: still a sail, still no cut
|
||||
p.pos.x = 0; p.pos.z = 0;
|
||||
assertEq(K.target(), null, 'mid-yard is not at a cleat');
|
||||
// up the ladder: both hands are on the rungs
|
||||
standAt(p, 'q3');
|
||||
p.climbY = 2.35;
|
||||
assertEq(K.target(), null, 'you cannot cut from up a ladder');
|
||||
});
|
||||
|
||||
t.test('knife: hold X for the full 1.2 s and the WHOLE sail goes — once', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const rig = fakeRig(fourCorners());
|
||||
const K = knifeAt(p, rig);
|
||||
standAt(p, 'q3');
|
||||
|
||||
// half a hold is no cut, and that is the deliberation the verb is made of
|
||||
fixedLoop(KNIFE_TUNE.cutSecs * 0.5, DT, (dt, tt) => K.step(dt, tt, true));
|
||||
assertEq(rig.cuts.length, 0, 'half a hold cuts nothing');
|
||||
assert(K.cutting, 'and the radial is running');
|
||||
|
||||
fixedLoop(KNIFE_TUNE.cutSecs * 0.6, DT, (dt, tt) => K.step(dt, 1 + tt, true));
|
||||
assertEq(rig.cuts.length, 1, 'the full hold cuts');
|
||||
assertEq(rig.cuts[0].corners.length, 4,
|
||||
'the WHOLE sail — DESIGN.md line 165, and B\'s cutAway() with no argument');
|
||||
assertEq(rig.cuts[0].reason, 'the carport',
|
||||
'the paperwork carries what it was FOR: the most expensive thing those corners were holding up');
|
||||
|
||||
// one press, one cut. Without the latch the held key re-arms and the ticker fills with refusals.
|
||||
fixedLoop(3, DT, (dt, tt) => K.step(dt, 5 + tt, true));
|
||||
assertEq(rig.cuts.length, 1, 'a held key must not try again every 1.2 s');
|
||||
assertEq(K.read().state, 'none', 'and with no live corner there is nothing left to offer');
|
||||
});
|
||||
|
||||
t.test('knife: letting go, walking off, or being put down all abort the cut', () => {
|
||||
const start = () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const rig = fakeRig(fourCorners());
|
||||
const K = knifeAt(p, rig);
|
||||
standAt(p, 'q3');
|
||||
fixedLoop(KNIFE_TUNE.cutSecs * 0.7, DT, (dt, tt) => K.step(dt, tt, true));
|
||||
assert(K.progress > 0.5, 'mid-cut');
|
||||
return { p, rig, K };
|
||||
};
|
||||
|
||||
const a = start();
|
||||
a.K.step(DT, 1, false); // let go
|
||||
assertEq(a.K.progress, 0, 'releasing X abandons the cut');
|
||||
const b = start();
|
||||
b.p.pos.x += 4; // walk off the cleat
|
||||
b.K.step(DT, 1, true);
|
||||
assertEq(b.K.progress, 0, 'walking away abandons it too');
|
||||
const c = start();
|
||||
c.p.knockdown(1, 1, 0); // the storm votes
|
||||
c.K.step(DT, 1, true);
|
||||
assertEq(c.K.progress, 0, 'a gust that floors you mid-cut takes the cut with it');
|
||||
assertEq(c.p.state, 'knocked',
|
||||
'and the knife did NOT stomp the state the world just set — interact.js owns `busy`, '
|
||||
+ 'nothing else writes it');
|
||||
// ...nor can a cut START while the player is locked in something else
|
||||
const d = start();
|
||||
d.p.setState('busy', 2);
|
||||
fixedLoop(2, DT, (dt, tt) => d.K.step(dt, 3 + tt, true));
|
||||
assertEq(d.rig.cuts.length, 0, 'you cannot cut mid-crank or mid-poke');
|
||||
});
|
||||
|
||||
t.test('knife: the read prints BOTH halves of the trade, and the saves side is priced', () => {
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const rig = fakeRig(fourCorners());
|
||||
const K = knifeAt(p, rig, { getGarden: () => ({ hp: 84 }), secsLeft: () => 52.4 });
|
||||
standAt(p, 'q3');
|
||||
const r = K.read();
|
||||
assertEq(r.state, 'ready');
|
||||
// $205: the carport 180 + the gnome 25 on the two-corner rule. The number the OFFER CARD printed
|
||||
// as "at risk" that morning — same walk, so it cannot disagree with the invoice.
|
||||
assertEq(r.savesTotal, 205, 'the saves side is the collateral the live corners are still holding');
|
||||
assertEq(r.hp, 84, 'and the costs side is the bed');
|
||||
assertEq(r.secsLeft, 52, 'for however long is left of the storm');
|
||||
|
||||
// The trade must survive on the glass, not just in the object — this panel is the thing that was
|
||||
// missing for five sprints, so it gets an assert rather than a screenshot.
|
||||
const panel = createVerbPanel({});
|
||||
panel.update({ target: { id: 'rerig_0' }, usable: true, label: 're-rig corner', holding: false, progress: 0 }, r);
|
||||
const text = panel.el.textContent;
|
||||
assert(text.includes('[E] re-rig corner'), `the hold-E prompt reaches the glass: ${text}`);
|
||||
assert(/CUT THE SAIL LOOSE/.test(text), 'so does the knife');
|
||||
assert(text.includes('$205') && text.includes('the carport'), 'with what it saves, named and priced');
|
||||
assert(/84%/.test(text) && /52s/.test(text), 'and what it costs, in the same breath');
|
||||
panel.dispose();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- the money (D's own filing, fixed)
|
||||
t.test('knife (the money): a corner you CUT is not a corner you are answerable for', () => {
|
||||
// The bug this pins: `_releaseCorner` sets `broken` for a cut exactly as for an overload, and
|
||||
// every ledger surface reads `broken`. So the shipped tree billed the knife on five surfaces at
|
||||
// once — the hardware, the $180 carport you cut the sail TO SAVE, the gnome's two-corner rule,
|
||||
// the clean bonus it voided, and (in week.js) warranty plus standing.
|
||||
const cut = fourCorners();
|
||||
cut.forEach((c) => { c.broken = true; c.cut = true; });
|
||||
const a = answerableFor(cut, null);
|
||||
assertEq(a.lost.length, 4, 'the record still says four corners are down — the sail IS on the lawn');
|
||||
assertEq(a.yours.length, 0, 'and none of them is a failure of yours');
|
||||
assertEq(a.cut.length, 4, 'they are named as cut, so the paper can say who put them there');
|
||||
assertEq(collateralWalk(a.yours, YARD).total, 0,
|
||||
'THE HEADLINE: cutting the sail to save the carport does not then bill you for the carport');
|
||||
|
||||
// The vacuity guard, and it is the whole reason this test can be trusted: the SAME four corners
|
||||
// broken by the storm bill everything. If both cases read $0 the walk is simply not working.
|
||||
const blown = fourCorners();
|
||||
blown.forEach((c) => { c.broken = true; });
|
||||
const b = answerableFor(blown, null);
|
||||
assertEq(b.yours.length, 4, 'an overload is still yours');
|
||||
assertEq(collateralWalk(b.yours, YARD).total, 205, 'and it still costs the carport and the gnome');
|
||||
|
||||
// one cut, one blown: the ledger splits them by name, not by count
|
||||
const mixed = fourCorners();
|
||||
mixed[3].broken = true; // cb1 blew — the carport goes, honestly
|
||||
mixed[0].broken = true; mixed[0].cut = true; // q3 cut afterwards
|
||||
const m = answerableFor(mixed, null);
|
||||
assertEq(m.yours.map((c) => c.anchorId).join(','), 'cb1', 'the one that failed is yours');
|
||||
assertEq(m.cut.map((c) => c.anchorId).join(','), 'q3', 'the one you cut is not');
|
||||
assertEq(collateralWalk(m.yours, YARD).total, 180,
|
||||
'the carport bills (its corner really did let go) and the gnome does not — one corner, not two');
|
||||
});
|
||||
|
||||
t.test('knife (the money): the walk that prices the panel is the walk that bills the invoice', () => {
|
||||
// Not "they agree today" — they are the SAME FUNCTION, injected. This asserts the wire, because a
|
||||
// second copy of the arithmetic is how the board came to advertise $57 against a $114 dispatch.
|
||||
const p = new PlayerSim({ start: { x: 0, y: 0, z: 0 } });
|
||||
const rig = fakeRig(fourCorners());
|
||||
const K = knifeAt(p, rig);
|
||||
standAt(p, 'q3');
|
||||
const promised = K.read();
|
||||
K.step(KNIFE_TUNE.cutSecs + DT, 0, true);
|
||||
assertEq(rig.cuts.length, 1, 'cut');
|
||||
const billed = collateralWalk(answerableFor(rig.corners, null).yours, YARD);
|
||||
assertEq(promised.savesTotal, 205, 'the panel promised $205 off the table');
|
||||
assertEq(billed.total, 0, 'and the invoice honoured it to the cent');
|
||||
// PURE: asking the question must not wreck the yard. (The wreck stays at scoreRun's call site.)
|
||||
let wrecks = 0;
|
||||
collateralWalk(fourCorners(), { ...YARD, anchor: YARD.anchor.bind(YARD),
|
||||
collateralFor: YARD.collateralFor, wreckStructure: () => { wrecks++; } });
|
||||
assertEq(wrecks, 0, 'the walk never swaps a structure for its wreck — the HUD asks it every frame');
|
||||
});
|
||||
|
||||
t.test('knife: the verdict says you cut it — and stands aside when something of yours failed too', () => {
|
||||
const dmg = { hail: 30, rain: 4 };
|
||||
const cut = fourCorners();
|
||||
cut.forEach((c) => { c.broken = true; c.cut = true; });
|
||||
const a = answerableFor(cut, null);
|
||||
|
||||
// The bug: with `lost` empty and hp under the line, verdictFor reached `uncovered` and told a
|
||||
// player who had just cut the sail down that "the hail fell where your sail wasn't". That is the
|
||||
// one sentence the verdict must never say here — it blames placement for a decision.
|
||||
const lostBed = verdictFor({ hp: 31, lost: a.yours, cut: a.cut, win: false, dmg, pondPeak: 0, pondDumped: 0 });
|
||||
assertEq(lostBed.mode, 'cut', `a cut night is its own mode, not '${lostBed.mode}'`);
|
||||
assert(/CUT IT LOOSE/.test(lostBed.verdict), 'and it says so');
|
||||
assert(!/sail wasn/.test(lostBed.verdict), 'never "the hail fell where your sail wasn\'t"');
|
||||
assert(/didn't/.test(lostBed.verdict) || /their property/.test(lostBed.verdict),
|
||||
'it names what the bed bought: their property');
|
||||
|
||||
const heldBed = verdictFor({ hp: 62, lost: a.yours, cut: a.cut, win: true, dmg, pondPeak: 0, pondDumped: 0 });
|
||||
assertEq(heldBed.mode, 'cut-held', 'cut early enough and the bed can still make it');
|
||||
|
||||
// And the composition rule: a corner of YOURS on the ground is the louder fact, so the failure
|
||||
// modes keep speaking and the knife stays a line on the ledger.
|
||||
const mixed = fourCorners();
|
||||
mixed[3].broken = true;
|
||||
mixed[0].broken = true; mixed[0].cut = true;
|
||||
const m = answerableFor(mixed, null);
|
||||
const both = verdictFor({ hp: 20, lost: m.yours, cut: m.cut, win: false, dmg, pondPeak: 0, pondDumped: 0 });
|
||||
assertEq(both.mode, 'corner', 'one corner of yours went: that verdict is true and it wins');
|
||||
assert(/CB1/.test(both.verdict), 'and it names the corner that actually let go');
|
||||
|
||||
// no knife, no change: every pre-existing mode is untouched by the new argument
|
||||
const plain = verdictFor({ hp: 31, lost: [], cut: [], win: false, dmg, pondPeak: 0, pondDumped: 0 });
|
||||
assertEq(plain.mode, 'uncovered', 'a night with no cut still reads exactly as it did');
|
||||
});
|
||||
|
||||
t.test('knife: the shipped emergency has something to CUT FOR — B\'s authored rig, not the placeholder', () => {
|
||||
// The night's whole premise, as DATA, because that is where it was wrong. On the merged tree the
|
||||
// rig was still A's flagged first cut with `cb1` (the carport beam) ARRIVING ALREADY DOWN — which
|
||||
// makes the $180 carport `theirs`, unbillable and unwreckable, and leaves DESIGN.md line 165's
|
||||
// signature decision with no object. Found by playing the night and reading the dispatch card.
|
||||
const em = EMERGENCIES.find((e) => e.id === 'corner_block_emergency');
|
||||
assert(em, 'the shipped emergency is still on the board');
|
||||
const by = (id) => em.emergency.rig.find((c) => c.anchorId === id);
|
||||
assert(by('cb1'), 'the carport beam is in the cowboy\'s rig — it is the bait the client has been '
|
||||
+ 'selling since night 3');
|
||||
assertEq(by('cb1').hw, 'shackle',
|
||||
'a SHACKLE on the 0.22 beam: 704 N holding their roof, over its ceiling t=35..42 (B, measured). '
|
||||
+ 'A carabiner there fails in the first second and there is nothing left to decide');
|
||||
assert(!by('cb1').broken,
|
||||
'AND IT IS FLYING WHEN YOU ARRIVE. A carport corner already on the ground is not a knife '
|
||||
+ 'decision, it is a receipt — this is the assert that stops the placeholder coming back');
|
||||
const down = em.emergency.rig.filter((c) => c.broken).map((c) => c.anchorId);
|
||||
assertEq(down.join(','), 'tr1b',
|
||||
'exactly one corner arrives gone, and it is the 0.88 branch on the cheapest steel');
|
||||
// B's ruling, which is also data in A's file and also had not landed.
|
||||
assertEq(em.pay?.garden, 0,
|
||||
'the garden BONUS is off on a callout (B): you cannot be paid per-HP for a bed you had no prep '
|
||||
+ 'phase to protect. The garden WIN stays, which is what makes the knife cost something');
|
||||
});
|
||||
|
||||
t.test('knife: every rig door the emergency path feature-detects actually EXISTS', () => {
|
||||
// ⚠️ This is the pin for the bug that cost this sprint its arrived-broken corner: main.js called
|
||||
// `rig.breakCorner?.(id)` and B had named the door `failCorner`, so an optional call to a
|
||||
// nonexistent method ran silently, forever, looking exactly like a call. The repo has a name for
|
||||
// it — D's own `rigging.setWorld` lesson, quoted in main.js — and it still happened, because
|
||||
// `?.()` makes a rename invisible. A feature-detect is only safe if something checks the feature.
|
||||
for (const door of ['failCorner', 'cutAway', 'seedClock', 'setFabric']) {
|
||||
assertEq(typeof SailRig.prototype[door], 'function',
|
||||
`SailRig has no ${door}() — main.js feature-detects this door on the emergency path, so a `
|
||||
+ 'rename here is a silent no-op, not an error');
|
||||
}
|
||||
// and the pre-fly argument, which is how the cloth gets into the storm it is standing in
|
||||
assert(/\{\s*at\s*=\s*0/.test(SailRig.prototype.attach.toString()),
|
||||
'attach() no longer takes {at} — the emergency\'s sail would fly the first second of a storm '
|
||||
+ 'it arrived 30s into (measured: clockSkew 30.02)');
|
||||
});
|
||||
|
||||
t.test('knife: warranty and standing do not chase a corner you chose to let go', () => {
|
||||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const cutRec = () => {
|
||||
const cs = fourCorners();
|
||||
cs.forEach((c) => { c.broken = true; c.cut = true; });
|
||||
return rigRecordFor(cs);
|
||||
};
|
||||
assert(cutRec().every((c) => c.cut && c.brokenAtDawn),
|
||||
'the record is honest: cut AND down at dawn, both true, neither hidden');
|
||||
|
||||
// night 1: cut the lot. night 2: the sheet must book NO warranty off it.
|
||||
const w = createWeek();
|
||||
const s1 = w.settle({ hp: 40, win: false, collateral: [], intactHardwareValue: 0,
|
||||
rigRecord: cutRec(), cutAways: [{ t: 33, corners: ['q3', 'q1', 'tr1b', 'cb1'], reason: 'the carport' }] }, def, 0);
|
||||
assertEq(s1.warranty.length, 0, 'nothing to warranty on the night you cut');
|
||||
assert(!s1.repReasons.some((r) => /down at dawn/.test(r)),
|
||||
`standing is not docked for the cut: ${s1.repReasons.join(' · ')}`);
|
||||
assertEq(s1.repDelta, REP.CLEAN,
|
||||
'a night where nothing FAILED is a clean night — the knife\'s price is the garden, not the star');
|
||||
w.advance();
|
||||
const s2 = w.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0);
|
||||
assertEq(s2.warranty.length, 0,
|
||||
'and no free callout tomorrow to go and re-hang a sail you deliberately spent');
|
||||
|
||||
// the vacuity guard: the same four corners BLOWN book four warranty lines and cost the standing
|
||||
const w2 = createWeek();
|
||||
const blownRec = () => { const cs = fourCorners(); cs.forEach((c) => { c.broken = true; }); return rigRecordFor(cs); };
|
||||
const b1 = w2.settle({ hp: 40, win: false, collateral: [], intactHardwareValue: 0, rigRecord: blownRec() }, def, 0);
|
||||
assert(b1.repDelta < 0, 'four blown corners DO cost standing');
|
||||
w2.advance();
|
||||
const b2 = w2.settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0, rigRecord: [] }, def, 0);
|
||||
assertEq(b2.warranty.length, 4, 'and they DO book four warranty lines — the pin is not vacuous');
|
||||
});
|
||||
}
|
||||
|
||||
@ -113,6 +113,16 @@ const ASSETS = [
|
||||
nodes: ['frame', 'panes', 'door', 'ridge_anchor'] },
|
||||
{ name: 'glasshouse_01_wrecked', h: [2.15, 2.40],
|
||||
nodes: ['frame', 'door', 'panes_left', 'shards'] },
|
||||
// SPRINT18 gate 4 — the glasshouse tier's live half. The height floor is a
|
||||
// design claim about the asset next to it: 2.70 sits ABOVE the glasshouse's
|
||||
// 2.40 ceiling, because a net frame that does not clear the ridge is resting
|
||||
// on the glass it exists to protect. The wreck shares the band — the +x
|
||||
// portal stands, so a portal frame folds at one end rather than falling.
|
||||
{ name: 'hail_net_frame_01', h: [2.70, 2.85],
|
||||
nodes: ['footings', 'legs', 'top_frame', 'net_roll',
|
||||
'net_anchor_01', 'net_anchor_02', 'net_anchor_03', 'net_anchor_04'] },
|
||||
{ name: 'hail_net_frame_01_wrecked', h: [2.70, 2.85],
|
||||
nodes: ['footings', 'legs', 'top_frame', 'net_down'] },
|
||||
{ name: 'pool_kit_01', h: [1.12, 1.32],
|
||||
nodes: ['pool_shell', 'pool_water', 'fence_rails', 'pool_gate',
|
||||
'fence_post_01', 'fence_post_14'] },
|
||||
@ -1484,5 +1494,313 @@ export default async function run(t) {
|
||||
'vacuous: no node-less declared anchors found across the shipped sites — this pin checked nothing');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT18 gate 4 — THE GLASSHOUSE TIER. Sprint 16 shipped the glass and
|
||||
// priced it at 320; the gate asked for the tier, and reading the gate found
|
||||
// that the price could not be REACHED. The comment two hundred lines up says
|
||||
// it in this file's own words: "nothing in the sim wrecks a glasshouse yet —
|
||||
// the value is inert data". main.js bills off a broken rig corner's baked
|
||||
// collateral string, and a glasshouse correctly offers no anchors, so the
|
||||
// most expensive object in the game could not cost anybody a cent.
|
||||
//
|
||||
// DESIGN.md line 179 named the missing asset: hail nets rigged OVER glass.
|
||||
// You tie to the frame that straddles it. The tier's teaching is new — the
|
||||
// frame's numbers are GOOD, and it is the most dangerous anchor in the game
|
||||
// because of where it stands. So the tell being pinned below is a POSITION,
|
||||
// measured off two GLBs, not a defect painted on one.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
t.test('the hail-net frame BRACKETS the glass — the tell is a position, and it is measured', () => {
|
||||
const frame = loaded.get('hail_net_frame_01');
|
||||
const glass = loaded.get('glasshouse_01');
|
||||
assert(frame && glass, 'hail_net_frame_01 or glasshouse_01 did not load');
|
||||
const f = new THREE.Box3().setFromObject(frame.scene);
|
||||
const g = new THREE.Box3().setFromObject(glass.scene);
|
||||
// Both assets are built at the origin, so a shared-origin placement is the
|
||||
// authored case and these comparisons are the real geometry.
|
||||
assert(f.min.x < g.min.x && f.max.x > g.max.x,
|
||||
`the frame spans x ${f.min.x.toFixed(2)}..${f.max.x.toFixed(2)} and the glasshouse `
|
||||
+ `${g.min.x.toFixed(2)}..${g.max.x.toFixed(2)} — the frame must STRADDLE the glass on x. `
|
||||
+ 'A frame that no longer brackets it is a frame whose anchors bill a building they are '
|
||||
+ 'not standing over, and the $320 becomes a number with no geometry behind it');
|
||||
assert(f.min.z < g.min.z && f.max.z > g.max.z,
|
||||
`the frame spans z ${f.min.z.toFixed(2)}..${f.max.z.toFixed(2)} and the glasshouse `
|
||||
+ `${g.min.z.toFixed(2)}..${g.max.z.toFixed(2)} — it must straddle on z as well; `
|
||||
+ 'legs standing ON the glass would be the joke version of this asset');
|
||||
assert(f.max.y > g.max.y + 0.35,
|
||||
`the frame heads at y=${f.max.y.toFixed(2)} and the ridge is at ${g.max.y.toFixed(2)} — `
|
||||
+ 'a net frame built to protect glass must CLEAR the ridge with room for the net to '
|
||||
+ 'drape, or it is resting on the thing it exists to keep hail off');
|
||||
// Every eye is above the ridge: the load path arcs OVER the glass, which is
|
||||
// the whole reason a failure here lands on panes.
|
||||
for (const n of ['net_anchor_01', 'net_anchor_02', 'net_anchor_03', 'net_anchor_04']) {
|
||||
const o = frame.scene.getObjectByName(n);
|
||||
assert(o, `${n} missing`);
|
||||
const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld);
|
||||
assert(p.y > g.max.y,
|
||||
`${n} sits at y=${p.y.toFixed(2)}, below the ridge (${g.max.y.toFixed(2)}) — every corner `
|
||||
+ 'of this frame must be above the glass or the tier is not about the glass');
|
||||
}
|
||||
});
|
||||
|
||||
t.test('the frame\'s 20 m read is METALNESS, not lightness — S17\'s lesson, applied on purpose', () => {
|
||||
// S17 measured this the hard way on the corroded post: galvanised ships at
|
||||
// metalness 0.90, and with no environment map a near-mirror has nothing to
|
||||
// mirror and renders NEAR-BLACK (rgb 49,51,49 at 20 m). So every steel the
|
||||
// player is allowed to trust is dark at yard distance. This asset's entire
|
||||
// long-range tell is being the opposite of that, and the mechanism is
|
||||
// metalness — pin the mechanism, or the docstring gets "fixed" the wrong
|
||||
// way by whoever repaints it next.
|
||||
const pick = (asset, node) => {
|
||||
let m = null;
|
||||
loaded.get(asset)?.scene.getObjectByName(node)?.traverse((o) => { if (o.material) m = o.material; });
|
||||
return m;
|
||||
};
|
||||
const paint = pick('hail_net_frame_01', 'top_frame');
|
||||
const gal = pick('sail_post', 'post');
|
||||
assert(paint && gal, 'could not reach the frame paint or the honest post steel');
|
||||
assert(paint.metalness <= 0.15,
|
||||
`the frame paints at metalness ${paint.metalness} — powder-coat is PAINT, and above ~0.15 `
|
||||
+ 'it starts mirroring an environment that does not exist and goes dark like every other '
|
||||
+ 'steel. The tell dies silently: the frame is still white in the palette and black on screen');
|
||||
assert(gal.metalness >= 0.6,
|
||||
`the honest post's steel is at metalness ${gal.metalness} — the control for this comparison `
|
||||
+ 'is that trusted steel IS a near-mirror; if that changed, re-measure the whole claim');
|
||||
const lum = (c) => c.r * 0.2126 + c.g * 0.7152 + c.b * 0.0722;
|
||||
assert(lum(paint.color) > lum(gal.color) * 1.3,
|
||||
`frame paint luminance ${lum(paint.color).toFixed(3)} vs galvanised ${lum(gal.color).toFixed(3)} — `
|
||||
+ 'the powder-coat must also be the brighter base colour, so the two halves of the tell '
|
||||
+ 'agree instead of fighting');
|
||||
});
|
||||
|
||||
t.test('the hail-net frame is a NEW RUNG at 0.80: good steel, worst place, resolvable node-less', () => {
|
||||
const T = FACTORY_ANCHOR_RATINGS.types;
|
||||
const entry = T.hail_net_frame;
|
||||
assert(entry, 'hail_net_frame is not in the ratings manifest — the type cannot resolve '
|
||||
+ 'node-less, and a site declaring it plainly would play at the default hint (the '
|
||||
+ 'sail_post bug shape, on the highest-collateral anchor in the game)');
|
||||
assert(!FACTORY_ANCHOR_RATINGS.ambiguous.hail_net_frame,
|
||||
'hail_net_frame went manifest-AMBIGUOUS — its four eyes disagree, and the runtime will '
|
||||
+ 'refuse to guess; they are one frame and must bake one number');
|
||||
// Pinned against the manifest, never a literal — a literal would stay green
|
||||
// through a whole reshuffle of the ladder.
|
||||
assert(entry.rating_hint > T.pergola.rating_hint,
|
||||
`the frame rates ${entry.rating_hint} and the pergola ${T.pergola.rating_hint} — the frame `
|
||||
+ 'must out-rate it by a distance: a deck screwed to a house flexes, this is an engineered '
|
||||
+ 'portal on four footings. De-rating honest steel for looking dangerous is the corroded '
|
||||
+ 'post\'s lie inverted');
|
||||
assert(entry.rating_hint < T.post.rating_hint,
|
||||
`the frame rates ${entry.rating_hint} against the honest post's ${T.post.rating_hint} — at or `
|
||||
+ 'above the post there is no de-rate left for "32 mm sized for mesh, not for 25 m² of sail"');
|
||||
// THE FIELD THAT MAKES IT A TIER: what it takes with it is the GLASS.
|
||||
assert(entry.collateral === 'glasshouse',
|
||||
`the manifest says this type bills ${JSON.stringify(entry.collateral)} — it must bill `
|
||||
+ '"glasshouse". That string is the entire tier: the anchor is fine, the thing underneath '
|
||||
+ 'it is $320, and a node-less declaration has to carry that or the yard is free to break');
|
||||
assert(entry.sources.length === 4,
|
||||
`the manifest cites ${entry.sources.length} eyes; the frame has four corners and a net `
|
||||
+ 'ties at all of them — a missing source means a corner baked something different');
|
||||
});
|
||||
|
||||
// ⚠️ THE GATE, AS ONE MEASUREMENT. Before this sprint this assert was RED by
|
||||
// construction: no node anywhere in the palette named the glasshouse's key,
|
||||
// so the collateral apex was unbillable and the 320 was decoration. It is the
|
||||
// free-failure bug's mirror image — RULE 2 catches a key with no price, and
|
||||
// this catches a price with no key that can reach it.
|
||||
t.test('⚠️ the collateral APEX is reachable: some anchor in the palette can actually bill the glass', () => {
|
||||
const g = loaded.get('glasshouse_01')?.scene.getObjectByName('glasshouse_01');
|
||||
assert(g, 'glasshouse_01 root missing');
|
||||
const key = g.userData?.collateral_key;
|
||||
assert(typeof key === 'string', 'the glasshouse carries no collateral_key to reach');
|
||||
const billers = [];
|
||||
for (const [asset, o] of allNodes()) {
|
||||
const u = o.userData ?? {};
|
||||
if (u.collateral !== key) continue;
|
||||
if (typeof u.rating_hint !== 'number' || u.tie_off === false) continue;
|
||||
billers.push(`${asset}/${o.name}`);
|
||||
}
|
||||
assert(billers.length > 0,
|
||||
`NOTHING in the palette can bill "${key}" ($${g.userData?.collateral_value}). The apex is `
|
||||
+ 'priced and unreachable: main.js bills collateral off a BROKEN RIG CORNER\'s baked key, '
|
||||
+ 'a glasshouse rightly offers no anchors, so the most expensive thing in the game costs '
|
||||
+ 'nobody anything. It needs an adoptable anchor whose collateral names the glass — that '
|
||||
+ 'is what the hail-net frame is for (SPRINT18 gate 4)');
|
||||
// ...and the corollary that keeps gate 5 disarmed on my own asset: the
|
||||
// frame POINTS at the key and must never CLAIM it. structFor() returns the
|
||||
// FIRST structure matching a key, so a frame carrying collateral_key
|
||||
// "glasshouse" would, in any yard authored frame-first, bill $320 for the
|
||||
// panes and then wreck THE FRAME — the bill and the wreck agreeing with
|
||||
// each other and both wrong, which is the only kind of wrong this repo
|
||||
// cannot see from the outside.
|
||||
const claimants = [];
|
||||
for (const [asset, gltf] of loaded) {
|
||||
const root = gltf.scene.getObjectByName(asset);
|
||||
if (root?.userData?.collateral_key === key) claimants.push(asset);
|
||||
}
|
||||
for (const c of claimants) {
|
||||
assert(c.startsWith('glasshouse_01'),
|
||||
`${c} claims collateral_key "${key}", and it is not the glasshouse. Exactly one structure `
|
||||
+ 'may OWN a key — the one that owns the price. structFor() takes the first match, so a '
|
||||
+ 'second claimant means some yard bills the glass and wrecks something else');
|
||||
}
|
||||
assert(claimants.length > 0, `vacuous: nothing owns "${key}" at all`);
|
||||
});
|
||||
|
||||
t.test('the net-frame wreck folds at ONE end: keeps its height, carries no glass, offers nothing', () => {
|
||||
const w = loaded.get('hail_net_frame_01_wrecked');
|
||||
const i = loaded.get('hail_net_frame_01');
|
||||
assert(w && i, 'the net frame or its wreck did not load');
|
||||
const bw = new THREE.Box3().setFromObject(w.scene);
|
||||
const bi = new THREE.Box3().setFromObject(i.scene);
|
||||
assert(Math.abs(bw.max.y - bi.max.y) < 0.10,
|
||||
`the wreck tops out at y=${bw.max.y.toFixed(2)} vs the intact ${bi.max.y.toFixed(2)} — the +x `
|
||||
+ 'portal stands and holds the eave rails, so a portal frame folds at one end. A wreck lying '
|
||||
+ 'flat is claiming a failure mode this thing does not have');
|
||||
assert((bw.max.x - bw.min.x) > (bi.max.x - bi.min.x) + 0.6,
|
||||
`the wreck spans ${(bw.max.x - bw.min.x).toFixed(2)} m on x against the intact `
|
||||
+ `${(bi.max.x - bi.min.x).toFixed(2)} — the net has to end up OUT past the footing, or your `
|
||||
+ 'own debris is tidier than the storm\'s and the tier stops teaching what it is for');
|
||||
// No glass here. The glasshouse has its own wreck variant and swaps
|
||||
// independently; shards in both would draw twice the night both swap, and
|
||||
// would quietly claim the glass's failure as this asset's.
|
||||
const glassy = [];
|
||||
w.scene.traverse((o) => {
|
||||
const m = o.material;
|
||||
if (m && m.transparent === true && m.opacity < 0.6) glassy.push(o.name);
|
||||
});
|
||||
assert(glassy.length === 0,
|
||||
`the net-frame wreck ships translucent geometry on [${glassy.join(', ')}] — the glass belongs `
|
||||
+ 'to glasshouse_01_wrecked, which swaps on its own; shards in both double-draw');
|
||||
// No anchor survives the wreck (the fascia rule, sixth application).
|
||||
const offers = [];
|
||||
w.scene.traverse((o) => {
|
||||
if (typeof o.userData?.rating_hint === 'number' || o.userData?.anchor_type !== undefined) {
|
||||
offers.push(o.name);
|
||||
}
|
||||
});
|
||||
assert(offers.length === 0,
|
||||
`the wreck still offers [${offers.join(', ')}] — you cannot clip to a folded frame, and an `
|
||||
+ 'anchor that outlives its structure is the free-failure bug in a costume');
|
||||
const wr = w.scene.getObjectByName('hail_net_frame_01_wrecked');
|
||||
assert(wr?.userData?.broken_variant_of === 'hail_net_frame_01',
|
||||
'the wreck must name its intact twin so A can pair the swap');
|
||||
// UNPRICED BY RULING — the bike/jacaranda pattern. One broken corner can
|
||||
// only name one collateral key, and the player is being taught about the
|
||||
// glass. Splitting a bill between the frame and the panes with no mechanism
|
||||
// to split it is exactly the guess the invoice exists to refuse.
|
||||
assert(wr?.userData?.collateral_value === undefined,
|
||||
'the net frame is UNPRICED BY RULING — build the mechanism that can bill two things off '
|
||||
+ 'one broken corner before pricing the second one');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT18 gate 5 — THE SECOND-RUST-POST LANDMINE (D's filing, E+A).
|
||||
//
|
||||
// Two structures in one yard sharing a collateral key is currently ARMED and
|
||||
// not firing: world.js's structFor() returns the FIRST structure whose key
|
||||
// matches, main.js's billing loop dedupes by key, and C's scorecard envelope
|
||||
// dedupes by key again — so the second one is invisible to the bill and to
|
||||
// the wreck, while board.js's exposureOf() counts per STRUCTURE and prices
|
||||
// both. D measured the split on a real two-post yard: $115 on the offer card
|
||||
// against $45 on the failure route.
|
||||
//
|
||||
// The pool yard dodges it by shipping exactly one corroded post ON PURPOSE.
|
||||
// That is a yard author remembering, which is not a defence — and gate 4 just
|
||||
// made it likelier, because a glasshouse yard carries a structure that OWNS
|
||||
// the key and a frame whose anchors POINT at it. Two glasshouses with a frame
|
||||
// each trips this on the first night.
|
||||
//
|
||||
// So: the asset/manifest half is to make it impossible to SHIP quietly. The
|
||||
// runtime half (a per-instance key a site can override) is A's and the exact
|
||||
// wiring is filed in THREADS.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const shippedSites = [];
|
||||
for (const name of ['backyard_01', 'site_02_corner_block', 'site_03_swing_lawn',
|
||||
'site_04_pool_yard']) {
|
||||
shippedSites.push({ name, site: await loadSite(name) });
|
||||
}
|
||||
|
||||
t.test('⚠️ no shipped yard gives two structures the same collateral key, and ids are unique', () => {
|
||||
// Mirrors world.js structKey() exactly: site JSON wins, then the baked
|
||||
// GLB key, then the structure's own id as a last resort.
|
||||
const resolvedKey = (s) => {
|
||||
if (s.collateralKey) return s.collateralKey;
|
||||
const base = String(s.model ?? '').replace(/_v1$/, '');
|
||||
return loaded.get(base)?.scene.getObjectByName(base)?.userData?.collateral_key ?? s.id;
|
||||
};
|
||||
let checked = 0;
|
||||
for (const { name, site } of shippedSites) {
|
||||
const structs = site.structures ?? [];
|
||||
const ids = structs.map((s) => s.id);
|
||||
const dupeIds = ids.filter((v, i) => ids.indexOf(v) !== i);
|
||||
assert(dupeIds.length === 0,
|
||||
`${name}: duplicate structure ids [${[...new Set(dupeIds)].join(', ')}]. world.js keys its `
|
||||
+ 'structures Map by id, so the second one SILENTLY REPLACES the first — it never renders, '
|
||||
+ 'never bills, and never wrecks, and nothing anywhere says a word');
|
||||
const keys = structs.map(resolvedKey);
|
||||
const dupeKeys = keys.filter((v, i) => keys.indexOf(v) !== i);
|
||||
assert(dupeKeys.length === 0,
|
||||
`${name}: two structures resolve to the same collateral key `
|
||||
+ `"${[...new Set(dupeKeys)].join('", "')}" (keys: [${keys.join(', ')}]). structFor() `
|
||||
+ 'returns the FIRST match, so the bill and the wreck would both land on the wrong one, '
|
||||
+ 'agreeing with each other; meanwhile exposureOf() prices per structure and the offer '
|
||||
+ 'card would quote a total the failure route can never reach (D measured $115 vs $45). '
|
||||
+ 'Give each instance its own key in the site JSON — and until A lands the per-anchor '
|
||||
+ 'override, the anchors bake the shared key, so the second one cannot be made to bill '
|
||||
+ 'at all: see THREADS [E] SPRINT18 gate 5');
|
||||
checked += keys.length;
|
||||
}
|
||||
assert(checked > 0, 'vacuous: no structures found across the shipped yards');
|
||||
});
|
||||
|
||||
// The other half of the same seam, from the asset side: an anchor names the
|
||||
// collateral it TAKES, and in a real yard something has to own that name. The
|
||||
// hail-net frame is the first asset where those are different objects, so a
|
||||
// frame placed in a yard with no glasshouse would bill a building that is not
|
||||
// there — collateralFor() returns null and the corner is FREE. RULE 2 proves
|
||||
// the key is priced SOMEWHERE in the palette; this proves it is owned in
|
||||
// every yard that names it.
|
||||
t.test('⚠️ every collateral an authored anchor names is OWNED by exactly one thing in that yard', () => {
|
||||
const types = FACTORY_ANCHOR_RATINGS.types;
|
||||
let checked = 0;
|
||||
for (const { name, site } of shippedSites) {
|
||||
const owners = new Map();
|
||||
const own = (key, what) => {
|
||||
if (typeof key !== 'string') return;
|
||||
owners.set(key, (owners.get(key) ?? 0) + 1);
|
||||
void what;
|
||||
};
|
||||
const houseBase = String(site.house?.model ?? '').replace(/_v1$/, '');
|
||||
own(site.house?.collateralKey
|
||||
?? loaded.get(houseBase)?.scene.getObjectByName(houseBase)?.userData?.collateral_key,
|
||||
'house');
|
||||
for (const s of site.structures ?? []) {
|
||||
const base = String(s.model ?? '').replace(/_v1$/, '');
|
||||
own(s.collateralKey
|
||||
?? loaded.get(base)?.scene.getObjectByName(base)?.userData?.collateral_key ?? s.id, s.id);
|
||||
}
|
||||
// What every authored anchor on a structure will actually bill: the baked
|
||||
// node's key when it names a node, else the manifest's key for its type.
|
||||
for (const s of site.structures ?? []) {
|
||||
const base = String(s.model ?? '').replace(/_v1$/, '');
|
||||
for (const a of s.anchors ?? []) {
|
||||
const baked = a.node
|
||||
? loaded.get(base)?.scene.getObjectByName(a.node)?.userData?.collateral
|
||||
: types[a.type]?.collateral;
|
||||
if (typeof baked !== 'string') continue; // bills nothing, by design
|
||||
assert(owners.get(baked) === 1,
|
||||
`${name}/${a.id} will bill "${baked}" on failure, and ${owners.get(baked) ?? 0} things `
|
||||
+ 'in this yard own that key. Zero means collateralFor() returns null and breaking '
|
||||
+ 'this corner is FREE (the gutter bug, RULE 2\'s runtime twin); more than one means '
|
||||
+ 'structFor() picks the first and the wreck lands on the wrong structure');
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert(checked > 0,
|
||||
'vacuous: no structure anchor in any shipped yard names collateral — this pin checked nothing');
|
||||
});
|
||||
|
||||
for (const { world } of siteWorlds) world.dispose();
|
||||
}
|
||||
|
||||
@ -901,6 +901,95 @@ export function weatherCases(storms) {
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT18 gate 1 — THE MID-STORM ENTRY SEAM.
|
||||
//
|
||||
// The emergency callout spawns the player at t≈30 in a storm nobody briefed
|
||||
// them on. That makes a NON-ZERO START a first-class case for the storm clock,
|
||||
// and "a number gathered from the wrong harness" is the exact class of bug this
|
||||
// repo hunts. The whole callout stands on one property: an entry at t0 must see
|
||||
// the storm a t=0 run sees at t0.
|
||||
//
|
||||
// For the WIND that property is structural — weather.core.js's header promises
|
||||
// everything is a closed-form function of (pos, t) — but promised is not pinned,
|
||||
// and the field is not as stateless as it looks: buildGustTimeline and the
|
||||
// advected-drift table are both built at construction, uniformSpeed early-exits
|
||||
// on a sorted scan, and stormStats memoises into a WeakMap. Any one of those
|
||||
// growing a cursor would make the storm depend on the order it was watched in.
|
||||
// Below: cold vs walked vs scrambled, and two independently built fields.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test('the wind at t=30 does not care how you got there (the callout seam)', () => {
|
||||
for (const [name, def] of Object.entries(storms)) {
|
||||
const read = (f, t) => [f.uniformSpeed(t), f.gustOnly(t), f.dirAt(t), f.rainAt(t), f.hailAt(t)];
|
||||
const T0 = 30;
|
||||
|
||||
const cold = createWindField(def);
|
||||
const want = read(cold, T0);
|
||||
|
||||
// walked: every step a t=0 run would have taken, in order, first
|
||||
const walked = createWindField(def);
|
||||
for (let i = 0; i < Math.round(T0 / DT); i++) read(walked, i * DT);
|
||||
const got = read(walked, T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === got[i],
|
||||
`${name}: field #${i} at t=${T0} reads ${got[i]} after being walked 0→30 but ${want[i]} cold — the wind remembers being watched, so a mid-storm entry cannot reproduce a t=0 run`);
|
||||
}
|
||||
|
||||
// scrambled: backwards, past the end, and repeated
|
||||
const scrambled = createWindField(def);
|
||||
for (const t of [90, 0, 45.5, 12, 88, T0, 1, T0, -5]) read(scrambled, t);
|
||||
const sc = read(scrambled, T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === sc[i],
|
||||
`${name}: field #${i} at t=${T0} moved after out-of-order sampling — ${sc[i]} vs ${want[i]}`);
|
||||
}
|
||||
|
||||
// two independently constructed fields must agree: the gust timeline and
|
||||
// the drift table are drawn from a PRNG at build, so this is the pin that
|
||||
// says "built twice" is the same storm as "built once".
|
||||
const twin = read(createWindField(def), T0);
|
||||
for (let i = 0; i < want.length; i++) {
|
||||
assert(want[i] === twin[i],
|
||||
`${name}: field #${i} at t=${T0} differs between two freshly built fields — ${twin[i]} vs ${want[i]}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the storm the dispatch quotes is the storm a BACKWARDS scan finds', () => {
|
||||
// Two harnesses, one number — the repo's oldest rule, aimed at the entry seam.
|
||||
// stormStats scans FORWARD from 0 and everything worded (the dispatch, the
|
||||
// offer band, the honesty gate) is built on its peaks. A mid-storm arrival is
|
||||
// the first consumer that reads the field at a t nobody walked up to, so scan
|
||||
// it in the one order no production code uses — downwards from the end — and
|
||||
// insist on the same peak. A field that remembers being watched fails here and
|
||||
// nowhere else: caught the resume-cursor mutation on uniformSpeed (14.6 m/s
|
||||
// against 18.58 on the wildnight, a 21% understatement of the gust peak).
|
||||
//
|
||||
// Deliberately NOT `stormStats(def)` twice: it memoises per def in a WeakMap,
|
||||
// so the second call hands back the SAME OBJECT and comparing them is
|
||||
// comparing an object to itself. That version of this case could not fail.
|
||||
const STEP = 0.05;
|
||||
for (const [name, def] of Object.entries(storms)) {
|
||||
const s = stormStats(def);
|
||||
const f = createWindField(def);
|
||||
const dur = def.duration ?? 90;
|
||||
let gustPeak = 0, rainPeak = 0, hailPeak = 0;
|
||||
for (let t = Math.floor(dur / STEP) * STEP; t >= 0; t -= STEP) {
|
||||
gustPeak = Math.max(gustPeak, f.uniformSpeed(t));
|
||||
rainPeak = Math.max(rainPeak, f.rainAt(t));
|
||||
hailPeak = Math.max(hailPeak, f.hailAt(t));
|
||||
}
|
||||
// Same grid, opposite direction: this is an equality, not a tolerance.
|
||||
assert(Math.abs(gustPeak - s.gustPeak) < 1e-9,
|
||||
`${name}: scanned backwards the gust peak is ${gustPeak.toFixed(3)} m/s, but the dispatch quotes ${s.gustPeak.toFixed(3)} — the field depends on the order it was read`);
|
||||
assert(Math.abs(rainPeak - s.rainPeak) < 1e-9,
|
||||
`${name}: backwards rain peak ${rainPeak.toFixed(4)} vs quoted ${s.rainPeak.toFixed(4)}`);
|
||||
assert(Math.abs(hailPeak - s.hailPeak) < 1e-9,
|
||||
`${name}: backwards hail peak ${hailPeak.toFixed(4)} vs quoted ${s.hailPeak.toFixed(4)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { cases, metrics };
|
||||
}
|
||||
|
||||
|
||||
@ -192,6 +192,33 @@ export function forecastHonest(def, name = def?.name ?? 'storm') {
|
||||
+ `(${STONE_WORD_CEILING}) — "${stoneWord(s.hailSize)}" would undersell what falls; `
|
||||
+ 'grow the word map before offering this storm');
|
||||
}
|
||||
// ⚠️ AND THE OTHER END. [SPRINT18 — the S17 filing, corrected by measuring it]
|
||||
//
|
||||
// I filed this forward in S17 as "stoneWord has no FLOOR: a def with
|
||||
// hail.size 0 words itself 'fine pea stones'". **That hole was already shut and
|
||||
// I had not checked one layer up**: validateStorm refuses `size <= 0` outright,
|
||||
// forecastHonest runs the validator FIRST and returns early, so a floor check
|
||||
// here would have been dead code. Recorded rather than quietly dropped —
|
||||
// filing a hole without testing the gate above it is the same mistake as
|
||||
// trusting a comment.
|
||||
//
|
||||
// Probing the layer that DOES pass turned up the real one, and it is worse:
|
||||
// `hd.size != null &&` — the validator only checks a size that is PRESENT, and
|
||||
// `hailSize` reads `hailDef.size ?? 1`. So a storm that hails with no authored
|
||||
// size passes every gate and the card prints **"marble stones"**: the middle of
|
||||
// the vocabulary, stated as fact, authored by nobody. Measured on the soaker —
|
||||
// delete one field and its real 0.45 "fine pea" becomes a confident "marble",
|
||||
// a stone 2.2× bigger, on the single axis the fabric bet is decided on. The
|
||||
// validator makes this exact argument two lines above about a hail block with
|
||||
// nothing to fire it: "that's a typo, not a design, so say so".
|
||||
//
|
||||
// It matters more now than in S17: the dispatch's stone line is the ONLY
|
||||
// description an emergency night gets, with no band beside it to hedge in.
|
||||
if (s.hailSeconds > 0 && def.hail?.size == null) {
|
||||
errors.push(`${name}: hails for ${s.hailSeconds.toFixed(1)}s but hail.size is not authored — `
|
||||
+ `it defaults to ${s.hailSize} and the card would state "${stoneWord(s.hailSize)} stones" `
|
||||
+ 'as fact on the axis the fabric bet turns on; a hailing storm must declare its stone');
|
||||
}
|
||||
|
||||
// Recompute the band promises from the def (structural today — see header).
|
||||
const contains = (b, val, what, lead) => {
|
||||
@ -404,6 +431,221 @@ export function pairingRefusals(pairings = []) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// THE DISPATCH — SPRINT18 gate 1, C's seam (A's §8).
|
||||
//
|
||||
// ⚖️ **THE CALLOUT HAS NO FORECAST, AND THAT IS A WORDING PROBLEM BEFORE IT IS
|
||||
// A FEATURE.** Every other night in this game is sold off paper: `offerBand`
|
||||
// prints a forecast, and its honesty rule is the one S17 settled — the band may
|
||||
// be vague, it may not rule out what happens, and it may not omit a fact it
|
||||
// holds. An emergency callout has no paper at all. You are standing in the
|
||||
// weather at t=30 with a phone call and a set of eyes.
|
||||
//
|
||||
// ⚠️ **THE INVERSION, which is the whole design.** The forecast's sin is
|
||||
// OMISSION — the board lied about night 7 by printing two of the fields it had.
|
||||
// The dispatch's sin is the opposite: INVENTION. Standing in a yard you know the
|
||||
// wind on your back to the km/h, better than any forecast ever told you — so the
|
||||
// present tense is EXACT here where the card was banded. What you do not know is
|
||||
// everything the bureau would have told you: how long this has to run, what the
|
||||
// peak will be, whether a southerly is coming at t=48. Printing any of that
|
||||
// would be inventing paper that does not exist.
|
||||
//
|
||||
// So the read is ruled by TENSE, and the permitted set is not "less than a
|
||||
// forecast" — it is a different, sharper set:
|
||||
// · **NOW** — exact, unhedged. You are standing in it.
|
||||
// · **THE VISIBLE IMMINENT** — a gust front crossing the grass (DESIGN.md line
|
||||
// 149: "readable — a wave of motion crosses the grass and trees a beat
|
||||
// before it hits"). The telegraph window is 1.5 s and that is the whole
|
||||
// horizon you have earned by looking.
|
||||
// · **THE PAST, AS WRITTEN ON THE YARD** — and this is the one that makes the
|
||||
// design work. You were not here for the first 30 seconds, so you have no
|
||||
// memory of them; but hail lying in the grass and water standing on the flat
|
||||
// are a RECORD of them, and reading a yard for what already went wrong is
|
||||
// literally this game's other job type (DESIGN.md's cowboy-job line: "half
|
||||
// the puzzle is finding what's about to fail"). Integrals over [0, t] are
|
||||
// honest. Anything over (t, ∞) is not.
|
||||
// · **THE FUTURE** — forbidden. Not hedged, not banded. Absent.
|
||||
//
|
||||
// ⚠️ **AND THE ADMISSION IS MANDATORY, which is the subtle half.** On a forecast
|
||||
// card a missing hail line means THE STORM DOES NOT HAIL — `offerBand` rules it
|
||||
// so, deliberately, and seven nights have taught the player to read it that way.
|
||||
// On a dispatch a missing hail line means NOBODY KNOWS. Those two cannot look
|
||||
// identical on a document, and silence cannot distinguish them: a player fresh
|
||||
// off six forecast cards will read an absent line as a reassurance nobody gave.
|
||||
// So the read SAYS it has no paper, first, and says what it cannot know, last.
|
||||
// That is the honest answer to "what does honest mean when the honest answer is
|
||||
// nobody told you" — you say the words "nobody told you", on the card, in the
|
||||
// order a person reads.
|
||||
//
|
||||
// ⚠️ **THE IGNORANCE LINE IS UNCONDITIONAL, and it has to be.** A tempting
|
||||
// version says "a change may still be coming" only when `def` HAS a change event
|
||||
// — which would make the line's PRESENCE the very fact it claims not to have.
|
||||
// The same trick in reverse to `offerBand`'s "hail line iff it hails": there,
|
||||
// presence is information the forecast legitimately holds; here, presence would
|
||||
// be information the observer cannot possibly hold. Constant text, every storm,
|
||||
// every t. It leaks nothing because it says nothing about the weather — it is a
|
||||
// statement about YOUR INFORMATION, and those you may always make.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Below this, no gust is "on it" — the base wind is all you are feeling. */
|
||||
const LULL_GUST = 0.4; // m/s of live gust envelope
|
||||
/** Above this the wind is visibly swinging, rad/s, measured over ±0.5 s. */
|
||||
const VEER_RATE = 0.06;
|
||||
/** Hail-seconds already delivered before stones are lying where you can see them. */
|
||||
const HAIL_ON_GROUND = 1.5;
|
||||
/** Real mm of rain already delivered before water is standing on the flat. */
|
||||
const WATER_STANDING = 6;
|
||||
|
||||
/**
|
||||
* ⚖️ **WHAT THE SKY AND THE GRASS TELL YOU WHEN NO PAPER DOES.**
|
||||
*
|
||||
* The dispatch's weather half — A owns `emergency.dispatch` (the phone call) and
|
||||
* this is everything under it. Same construction as `offerBand`: an ORDERED list
|
||||
* of `{key, text}`, so the card MAPS it and a fact added here cannot be dropped
|
||||
* by a card that predates the fact. Reading order is the order a tradie takes the
|
||||
* yard in — what you haven't got, what's hitting you, what's coming, what has
|
||||
* already happened, and what you still don't know.
|
||||
*
|
||||
* Reads ONLY the permitted tenses (see the divider above). It never touches
|
||||
* `stormStats` — that is the whole-storm truth, the thing the bureau would have
|
||||
* measured, and `gustPeak`/`hailSeconds`/`changeAt` are precisely the facts a
|
||||
* callout is not entitled to. Pinned two ways: `storm_03_southerly` and
|
||||
* `storm_03b_earlybuster` are identical in every number except when the change
|
||||
* lands (30 s against 18 s), so a read taken BEFORE both changes must be
|
||||
* character-identical — and one taken between them must DIFFER, because by then
|
||||
* the swing is something you can feel.
|
||||
*
|
||||
* @param {object} def parsed storm JSON
|
||||
* @param {number} t storm seconds at which you got out of the ute (`arriveAt`)
|
||||
* @returns {{key:string, text:string}[]} nopaper, wind, veer?, front?|lull?,
|
||||
* hail?, rain?, ground?, unknown
|
||||
*/
|
||||
export function midStormRead(def, t = 0) {
|
||||
const f = createWindField(def);
|
||||
const lines = [{ key: 'nopaper', text: 'no forecast on this one — nobody briefed it' }];
|
||||
const i = (v) => v.toFixed(0);
|
||||
|
||||
// NOW, exact. Speed is the one number a body measures better than a bureau: it
|
||||
// is on your back. Unhedged on purpose — the contrast with a forecast card is
|
||||
// the point, not an oversight.
|
||||
lines.push({ key: 'wind', text: `${i(kmh(f.uniformSpeed(t)))} km/h on it right now` });
|
||||
|
||||
// NOW: is it swinging? A wind backing round is felt and seen (the trees go
|
||||
// first), so it is fair game — and it is the fact that matters most on an
|
||||
// inherited rig, because a rig built for a westerly is loaded backwards by a
|
||||
// southerly. Central difference over ±0.5 s: short enough to be "right now",
|
||||
// wide enough not to read curve noise as a swing. It reports only that it IS
|
||||
// swinging, never that one is COMING, and never a compass word — the def alone
|
||||
// carries no reference frame the player shares, and inventing one would be the
|
||||
// same offence as inventing the forecast.
|
||||
const veer = f.dirAt(t + 0.5) - f.dirAt(t - 0.5);
|
||||
if (Math.abs(veer) > VEER_RATE) {
|
||||
lines.push({ key: 'veer', text: 'the wind is backing round on you as you stand there' });
|
||||
}
|
||||
|
||||
// THE VISIBLE IMMINENT — the only future you have earned, and you earned it by
|
||||
// looking at grass.
|
||||
const tele = f.telegraph ? f.telegraph(t) : null;
|
||||
if (tele && Number.isFinite(tele.eta)) {
|
||||
lines.push({
|
||||
key: 'front',
|
||||
text: `a gust front crossing the grass — on you in about ${tele.eta.toFixed(1)}s`,
|
||||
});
|
||||
} else if (f.gustOnly(t) < LULL_GUST) {
|
||||
// NOW: nothing on it this second. DESIGN.md line 149 — "lulls are repair
|
||||
// windows" — and on a triage job that is the most actionable line on the
|
||||
// card. Honest at an instant: it says nothing about how long the lull holds,
|
||||
// because nothing tells you that.
|
||||
lines.push({ key: 'lull', text: 'nothing on it this second — if you are moving, move now' });
|
||||
}
|
||||
|
||||
// NOW: what is falling. Present tense only — no duration and no total, because
|
||||
// those are the bureau's numbers.
|
||||
if (f.hailAt(t) > 0) {
|
||||
lines.push({ key: 'hail', text: `${stoneWord(f.hailSize)} stones coming through` });
|
||||
}
|
||||
const rain = f.rainMmPerHour(t);
|
||||
if (rain > 0.5) lines.push({ key: 'rain', text: `rain at ${i(rain)} mm/hr` });
|
||||
|
||||
// THE PAST, AS WRITTEN ON THE YARD. You did not see the first t seconds; the
|
||||
// yard did, and it kept the receipt. Integrals over [0, t] — never past t.
|
||||
const ground = [];
|
||||
let hailSoFar = 0;
|
||||
for (let s = 0; s <= t; s += 0.05) hailSoFar += f.hailAt(s) * 0.05;
|
||||
if (hailSoFar > HAIL_ON_GROUND) ground.push('stones lying in the grass');
|
||||
if (f.rainDepthMm(0, t) > WATER_STANDING) ground.push('water standing on the flat');
|
||||
if (ground.length) {
|
||||
lines.push({ key: 'ground', text: `${ground.join(' and ')} — it has been through here a while` });
|
||||
}
|
||||
|
||||
// THE ADMISSION. Unconditional, constant, and last, because it is what the
|
||||
// player carries into the yard. It says nothing about the weather — only about
|
||||
// what you were given, which is nothing.
|
||||
lines.push({
|
||||
key: 'unknown',
|
||||
text: 'how long it runs, what it does next, whether it swings again — no paper, no quote. You find that out standing in it.',
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚖️ **THE HONESTY GATE, EXTENDED TO EMERGENCY STORMS.** A storm that cannot be
|
||||
* described honestly cannot be DISPATCHED either — an emergency night still gets
|
||||
* worded, priced, invoiced, warrantied and rep'd through the surfaces it already
|
||||
* had (A's §10 acceptance criterion), so `forecastHonest`'s storm-side gate
|
||||
* applies unchanged. Having no forecast card is not a licence for a storm nobody
|
||||
* can describe; it RAISES the bar, because the read is the only description there
|
||||
* is and there is no band to hide vagueness in.
|
||||
*
|
||||
* On top of the storm gate, three things only an ARRIVAL can get wrong:
|
||||
* 1. **the arrival must be inside a storm that is still running.** `arriveAt`
|
||||
* past the end dispatches you to weather that has already finished.
|
||||
* 2. **the read must not come out empty or mute.** A dispatch whose weather half
|
||||
* renders nothing is the offer card picking two fields all over again.
|
||||
* 3. **the stone must be real AT THE INSTANT you arrive** — the S17 filing,
|
||||
* corrected by measuring it. `size: 0` turned out to be refused by the
|
||||
* validator a layer up, so the floor I filed for would have been dead code.
|
||||
* The REACHABLE hole is `size` ABSENT: it passes every gate and defaults to
|
||||
* the MIDDLE of the vocabulary, so the card states "marble stones" as fact for
|
||||
* a stone nobody authored. See the note beside the check in `forecastHonest`.
|
||||
*
|
||||
* @param {object} def parsed storm JSON
|
||||
* @param {number} t the authored `arriveAt`
|
||||
* @param {string} [name]
|
||||
* @returns {{ok:boolean, errors:string[]}}
|
||||
*/
|
||||
export function dispatchHonest(def, t, name = def?.name ?? 'storm') {
|
||||
const base = forecastHonest(def, name);
|
||||
const errors = [...base.errors];
|
||||
if (!base.ok) return { ok: false, errors };
|
||||
|
||||
const dur = def.duration ?? 90;
|
||||
if (!(Number.isFinite(t) && t > 0)) {
|
||||
errors.push(`${name}: arriveAt ${t} is not a time inside a storm — a callout arrives MID-storm`);
|
||||
} else if (t >= dur) {
|
||||
errors.push(`${name}: arriveAt ${t} is at or past the storm's end (${dur}s) — `
|
||||
+ 'you would be dispatched to weather that has already finished');
|
||||
} else {
|
||||
const read = midStormRead(def, t);
|
||||
const keys = new Set(read.map((l) => l.key));
|
||||
for (const must of ['nopaper', 'wind', 'unknown']) {
|
||||
if (!keys.has(must)) {
|
||||
errors.push(`${name}: the read at t=${t} has no '${must}' line — a dispatch that does not `
|
||||
+ 'say it has no paper teaches that a missing line is a reassurance');
|
||||
}
|
||||
}
|
||||
if (read.some((l) => !l.text || !l.text.trim())) {
|
||||
errors.push(`${name}: the read at t=${t} contains an empty line`);
|
||||
}
|
||||
const f = createWindField(def);
|
||||
if (f.hailAt(t) > 0 && !(f.hailSize > 0)) {
|
||||
errors.push(`${name}: hail is falling at t=${t} with size ${f.hailSize} — `
|
||||
+ `the read would print "${stoneWord(f.hailSize)} stones" for stones that do not exist`);
|
||||
}
|
||||
}
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// Resolved against this module, not the server root: server.py serves the repo
|
||||
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
|
||||
// import.meta.url is right under both, and under whatever Lane A does next.
|
||||
|
||||
@ -231,6 +231,24 @@ export function normaliseNight(entry) {
|
||||
* field is `[]` on all seven nights and nothing about the campaign moves.
|
||||
*/
|
||||
constraints: base.constraints ?? [],
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1 — THE EMERGENCY CALLOUT, as data on the night.
|
||||
*
|
||||
* `null` by default, and null rather than `{}` on purpose — the opposite
|
||||
* ruling to `constraints` above, for the opposite reason. Constraints are
|
||||
* ITERATED by four surfaces, so an empty array saves three of them a guard.
|
||||
* An emergency is BRANCHED ON (is tonight a dispatch or a job sheet?), and a
|
||||
* truthy empty object is the worst possible answer to that question: every
|
||||
* caller would read "yes" and then find no arriveAt and no rig. `null` makes
|
||||
* the branch honest.
|
||||
*
|
||||
* The whole point of it living HERE is that it rides `normaliseNight`, so an
|
||||
* emergency taken off the board is the same SHAPE as an authored night and
|
||||
* every downstream surface — sheet, quote, invoice, ledger, warranty, rep,
|
||||
* end card — reads it through the path it already had. That is the same
|
||||
* promise gate 1 of SPRINT17 made about the board, extended to a job type.
|
||||
*/
|
||||
emergency: base.emergency ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -259,12 +277,19 @@ export function nightAt(i) {
|
||||
* on the rep multiplier rather than on the raw fee — a ★5 tradie is paid more
|
||||
* for accepting the same ban, which is the right way round.
|
||||
*
|
||||
* 🚨 SPRINT18 gate 1 — the fourth term is EMERGENCY RATES, and it is deliberately
|
||||
* a term on THIS function rather than a second fee function. The offer card, the
|
||||
* dispatch and the invoice all price an emergency through here, so a night that
|
||||
* pays double on the board cannot pay single on the paper.
|
||||
*
|
||||
* @param {object} def tonight's storm def
|
||||
* @param {number} rep standing AS OF THIS MORNING
|
||||
* @param {object[]} [constraints] tonight's client constraints
|
||||
* @param {boolean} [emergency] mid-storm callout — double rates (PAY.emergencyRate)
|
||||
*/
|
||||
export function calloutFee(def, rep, constraints = []) {
|
||||
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep) * (1 + premiumFor(constraints)));
|
||||
export function calloutFee(def, rep, constraints = [], emergency = false) {
|
||||
return Math.round(PAY.feeFor(gustPeakOf(def)) * REP.multiplier(rep)
|
||||
* (1 + premiumFor(constraints)) * (emergency ? PAY.emergencyRate : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -341,6 +366,34 @@ export const PAY = {
|
||||
* and cutting it costs nothing but a row on the invoice.
|
||||
*/
|
||||
noCollateralBonus: 20,
|
||||
|
||||
/**
|
||||
* 🚨 **SPRINT18 gate 1 — EMERGENCY RATES. ⚖️ RULED ×2.0, and this one is CANON
|
||||
* rather than my taste.** DESIGN.md line 29 says *"mid-storm, right now, double
|
||||
* rates"* and ROADMAP arc 2 says *"you arrive MID-STORM at double rates"* — in
|
||||
* those words, since line one of the design. I am not going to re-rule a number
|
||||
* the document has been carrying for the whole project because it looks large
|
||||
* next to `feeFor`.
|
||||
*
|
||||
* And it is not free money, which is the check I actually ran on it. What you
|
||||
* give up for it:
|
||||
* · **no prep.** No shop, no quote, no chance to have bought good steel.
|
||||
* · **somebody else's hardware**, chosen by somebody cheaper than you, and NOT
|
||||
* yours to unclip at dawn (see scoreRun's ruling in main.js — an emergency's
|
||||
* intact hardware does not come home, so half the usual refund line is gone).
|
||||
* · **the yard's whole collateral exposure already live** from the second you
|
||||
* arrive, on corners you did not pick, with sixty seconds on the clock.
|
||||
* A southerly pays $57 rig-for-tonight and $114 as a callout — roughly one and
|
||||
* a half good rigs, for a night where the rig is already wrong and the $180
|
||||
* carport is the thing letting go.
|
||||
*
|
||||
* ⚠️ **UNMEASURED BY PLAY**, cut-first alongside `CONSTRAINT_PREMIUM` and REP,
|
||||
* and it is the FIRST thing to look at if gate 6 says the emergency is the
|
||||
* obviously-correct pick every time it appears. Per-job data would be the next
|
||||
* move (a `pay.emergencyRate` override, exactly as `pay.garden` works), not a
|
||||
* quiet trim of `feeFor`, which carries measured evidence and long reasons.
|
||||
*/
|
||||
emergencyRate: 2.0,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -387,6 +440,31 @@ export const REP = {
|
||||
WARRANTY: -0.5,
|
||||
/** Per collateral event. Down harder: their property beats your hardware. */
|
||||
COLLATERAL: -1.0,
|
||||
/**
|
||||
* ⚖️ **SPRINT18 gate 0 — PER CLIENT YOU STOOD UP. JOHN'S RULING: declining a
|
||||
* booked night costs you. "It adds pressure."**
|
||||
*
|
||||
* THE MIRROR OF `CLEAN`, exactly, and the full reasoning is in board.js beside
|
||||
* `isBooked` (the structural half lives there because board.js cannot import
|
||||
* this file). The short form: a stood-up client costs you what a flawless
|
||||
* night earns — one night of perfect work, cancelled by one phone call you
|
||||
* didn't make. Bounded above by `COLLATERAL` on purpose; ranking a broken
|
||||
* promise above a broken carport would make the invoice argue with itself.
|
||||
*
|
||||
* ⚠️ UNMEASURED, and it is the lever. If John's play says the price is wrong,
|
||||
* move THIS number — not `CLEAN`, which the rest of the ladder is measured
|
||||
* against. a.test pins `STOOD_UP === -CLEAN` so the mirror cannot rot into a
|
||||
* coincidence.
|
||||
*
|
||||
* WHEN it lands: at DAWN, with every other rep movement, and that ordering is
|
||||
* load-bearing. You stand a client up in the morning; the reckoning is on the
|
||||
* night's paper. Charging it inside take() would drop your standing between the
|
||||
* board and the job sheet, and the fee is priced off standing — so the offer
|
||||
* card would advertise $57 and the sheet one click later would quote $56. That
|
||||
* is the quote-vs-settle lie, moved one card earlier, which is the exact bug
|
||||
* calloutFee() exists to make impossible.
|
||||
*/
|
||||
STOOD_UP: -0.5,
|
||||
/** Callout-fee multiplier at rep r. Exact at 2dp for every half-step. */
|
||||
multiplier: (r) => 1 + 0.04 * (r - 3),
|
||||
};
|
||||
@ -485,6 +563,10 @@ export function gustPeakOf(def) {
|
||||
* last dawn's brokenAtDawn corners) {anchorId, hw, cost, night, site, addr, client}
|
||||
* @property {number} warrantyTotal
|
||||
* @property {number} feeMult rep multiplier the fee was priced at
|
||||
* @property {object[]} stoodUp SPRINT18 gate 0 — clients this night's take left
|
||||
* waiting {night, client, addr, offerId, took}; `standUpCost` is the standing
|
||||
* it cost, already multiplied.
|
||||
* @property {number} standUpCost
|
||||
* @property {number} rep after tonight's movement (repBefore/repDelta/repReasons too)
|
||||
* @property {number} warrantiesToDate cumulative items this week, for the end card
|
||||
*/
|
||||
@ -524,6 +606,21 @@ export function createWeek(opts = {}) {
|
||||
*/
|
||||
const taken = new Map();
|
||||
const jobAt = (i) => taken.get(i) ?? nightAt(i);
|
||||
/**
|
||||
* ⚖️ SPRINT18 gate 0 — **THE CLIENTS YOU STOOD UP.** John's ruling's third
|
||||
* clause: *"the stood-up client remembers."*
|
||||
*
|
||||
* Append-only, in night order, one entry per client stood up:
|
||||
* `{night, client, addr, offerId, took}` — `night` 1-based (it is printed),
|
||||
* `offerId` the job you took instead, `took` the client you took instead.
|
||||
*
|
||||
* A LEDGER, not a flag, and that is the whole of "your work follows you": the
|
||||
* board reads it to put the memory on that client's next offer card, the job
|
||||
* sheet reads it if you go back to them, and settle() reads TONIGHT'S entries
|
||||
* to move standing. One place, three surfaces, no surface deciding anything.
|
||||
* @type {{night:number,client:string|null,addr:string|null,offerId:string|null,took:string|null}[]}
|
||||
*/
|
||||
const standUps = [];
|
||||
/** @type {Settlement[]} */
|
||||
const log = [];
|
||||
|
||||
@ -543,7 +640,19 @@ export function createWeek(opts = {}) {
|
||||
function warrantyItems() {
|
||||
const prev = log[log.length - 1];
|
||||
if (!prev) return [];
|
||||
return (prev.rig ?? []).filter((c) => c.brokenAtDawn).map((c) => ({
|
||||
// 🚨 SPRINT18 gate 1 — `!c.theirs`: a corner that was already on the ground when
|
||||
// you arrived at an emergency callout books NO warranty. Without this line the
|
||||
// ledger sends you back, unpaid, to redo a cowboy's work — the game billing you
|
||||
// for a failure that predates you, which is the exact opposite of what the
|
||||
// warranty ledger was built to say. main.js's rigRecordFor sets the flag; the
|
||||
// rep block below skips it for the same reason.
|
||||
// 🚨 SPRINT18 gate 1.3 [Lane D] — `!c.cut`: THE KNIFE. B's ruling on `cutAway` in sail.js is
|
||||
// that a cut is a DECISION and emits no `break`, precisely so "warranty must never chase a
|
||||
// corner the player chose to let go" — but warranty is booked off `brokenAtDawn`, which a cut
|
||||
// sets, so without this line the ledger sent you back unpaid tomorrow to re-hang a sail you
|
||||
// deliberately spent to save the client's carport. Same shape as the `!c.theirs` line above it,
|
||||
// one verb along.
|
||||
return (prev.rig ?? []).filter((c) => c.brokenAtDawn && !c.theirs && !c.cut).map((c) => ({
|
||||
anchorId: c.anchorId, hw: c.hw, cost: c.hwCost,
|
||||
night: prev.night, site: prev.site, addr: prev.addr, client: prev.client,
|
||||
}));
|
||||
@ -587,7 +696,7 @@ export function createWeek(opts = {}) {
|
||||
// invoice. (Before this it was two hand-written copies of the same
|
||||
// formula agreeing by luck; adding a third term to both by hand is how
|
||||
// that luck runs out.)
|
||||
base: calloutFee(def, rep, j.constraints),
|
||||
base: calloutFee(def, rep, j.constraints, !!j.emergency),
|
||||
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
||||
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
||||
feeMult,
|
||||
@ -603,6 +712,23 @@ export function createWeek(opts = {}) {
|
||||
*/
|
||||
constraints: j.constraints ?? [],
|
||||
premium: premiumFor(j.constraints),
|
||||
/**
|
||||
* 🚨 SPRINT18 gate 1 — **THERE IS NO QUOTE ON AN EMERGENCY, and this block
|
||||
* is how the paper says so.** The DISPATCH renders the RATE and the fee it
|
||||
* applies to, because double rates are the reason you get in the ute — and
|
||||
* nothing else from this schedule. No garden bonus, no clean bonus, no
|
||||
* total: nobody costed this job, and a card printing a total under a job
|
||||
* nobody quoted would be inventing the one document the fiction says does
|
||||
* not exist. You find out on the invoice. That IS the drama.
|
||||
*
|
||||
* It stays ONE money function all the same (`quote()` for both papers, off
|
||||
* `calloutFee()`), because the alternative — a second `dispatch()` that
|
||||
* computes its own fee — is the quote-vs-settle divergence with a new name.
|
||||
* The dispatch is a VIEW of this object, never a rival to it.
|
||||
*/
|
||||
emergency: j.emergency
|
||||
? { arriveAt: j.emergency.arriveAt, rate: PAY.emergencyRate, dispatch: j.emergency.dispatch }
|
||||
: null,
|
||||
/**
|
||||
* SPRINT14 (B's pool item, offered S13 and accepted): the job sheet has
|
||||
* to say when the garden bonus is money the night cannot pay.
|
||||
@ -639,13 +765,29 @@ export function createWeek(opts = {}) {
|
||||
offers(priced = null) {
|
||||
return offersFor({
|
||||
scripted: nightAt(index), weekSeed: seed, nightIndex: index, priced,
|
||||
// SPRINT18 gate 1 — the ladder, so board.js can enforce the one-variable
|
||||
// law at the door of the real game and not only in the suite. board.js
|
||||
// cannot import this file (week imports board), so the law's input has to
|
||||
// travel down rather than the law travelling up.
|
||||
nights: NIGHTS,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* TAKE ONE. The other vanishes — no backlog, no decline cost (SPRINT18
|
||||
* decides whether saying no costs you; SPRINT17 deliberately does not
|
||||
* charge for a choice it hasn't designed the consequences of).
|
||||
* TAKE ONE. The other vanishes — and ⚖️ **SPRINT18 gate 0: if the other one
|
||||
* had a client expecting you, you have just stood them up.** John's ruling,
|
||||
* landed here because this is the one door both offers go through.
|
||||
*
|
||||
* The charge itself is at DAWN (settle reads tonight's `standUps` entries and
|
||||
* moves standing with the rest) — see REP.STOOD_UP for why that ordering is
|
||||
* not a detail. What happens HERE is the RECORD: who you left waiting, on
|
||||
* which night, for what.
|
||||
*
|
||||
* `offer.standsUp` comes off board.js, so the cost the player read on the
|
||||
* card is the cost that gets recorded. A hand-made offer with no board
|
||||
* context (a.test does this deliberately, and so does any future scripted
|
||||
* install) stands nobody up, which is the honest answer: nothing declined
|
||||
* anybody.
|
||||
*
|
||||
* Idempotent per night and only forward: taking twice on the same morning
|
||||
* is the same take, and there is no untake. That's not a limitation, it's
|
||||
@ -669,6 +811,17 @@ export function createWeek(opts = {}) {
|
||||
const night = normaliseNight(offer.night);
|
||||
Object.defineProperty(night, '_offerId', { value: offer.id, enumerable: false });
|
||||
taken.set(index, night);
|
||||
// ⚖️ gate 0 — the promise you just broke, written down before anything
|
||||
// else can happen. Inside the `if` so the idempotence above covers it
|
||||
// too: taking twice on one morning must not stand the same client up
|
||||
// twice, and a double charge for one decision would be the worst kind of
|
||||
// bug — invisible, and in the direction of punishment.
|
||||
for (const s of offer.standsUp ?? []) {
|
||||
standUps.push({
|
||||
night: index + 1, client: s.client, addr: s.addr,
|
||||
offerId: s.id, took: night.client ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return taken.get(index);
|
||||
},
|
||||
@ -676,6 +829,23 @@ export function createWeek(opts = {}) {
|
||||
/** Tonight's taken offer id, or null if the player is on the spine. */
|
||||
get takenOffer() { return taken.get(index)?._offerId ?? null; },
|
||||
|
||||
/**
|
||||
* ⚖️ SPRINT18 gate 0 — the stand-up ledger, read-only.
|
||||
*
|
||||
* A copy, because this is the one piece of week state a CARD reads directly
|
||||
* and a card that could push onto it would be a HUD taking a decision
|
||||
* (hud.js's own charter, first paragraph).
|
||||
*/
|
||||
get standUps() { return standUps.slice(); },
|
||||
/**
|
||||
* Has this client been stood up, and when? The memory, per client — the
|
||||
* board's offer card and the job sheet both ask this by name.
|
||||
* @param {string|null} client
|
||||
*/
|
||||
remembers(client) {
|
||||
return client ? standUps.filter((s) => s.client === client) : [];
|
||||
},
|
||||
|
||||
/** SPRINT17 — the week's draw seed, for the board and for a.test's pins. */
|
||||
get seed() { return seed; },
|
||||
|
||||
@ -693,6 +863,10 @@ export function createWeek(opts = {}) {
|
||||
// for the run), so clearing the takes is what makes "play it again"
|
||||
// actually replayable rather than handing you last run's choices.
|
||||
taken.clear();
|
||||
// gate 0: and the clients forget. A new week that opened with last run's
|
||||
// broken promises on the board would be the memory outliving the run that
|
||||
// earned it — the same defect as an uncleared `taken`, one surface along.
|
||||
standUps.length = 0;
|
||||
return week;
|
||||
},
|
||||
|
||||
@ -714,7 +888,7 @@ export function createWeek(opts = {}) {
|
||||
// SPRINT17 gate 2: the SAME calloutFee() the quote printed, constraints
|
||||
// and all — quote==settle by construction, extended to the premium the
|
||||
// same way it was extended to the warranty deduction.
|
||||
const fullFee = calloutFee(def, rep, jobAt(index).constraints);
|
||||
const fullFee = calloutFee(def, rep, jobAt(index).constraints, !!jobAt(index).emergency);
|
||||
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
|
||||
// Per-job override, PAY's default otherwise — and settle MUST read the same
|
||||
// source the quote does, or the job sheet promises a number the invoice
|
||||
@ -769,8 +943,15 @@ export function createWeek(opts = {}) {
|
||||
// cannot cost it BY CONSTRUCTION — the flag needs no exemption here, and
|
||||
// a.test pins that from both directions.
|
||||
const rigRec = run.rigRecord ?? [];
|
||||
const brokeCount = rigRec.filter((c) => c.broke).length;
|
||||
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn).length;
|
||||
// SPRINT18 gate 1 — their corners are not your record. A rig you inherited
|
||||
// mid-storm cannot cost you a clean night for breaking before you got there,
|
||||
// and cannot cost you standing for still being down at dawn.
|
||||
// SPRINT18 gate 1.3 — and a corner you CUT is not your record either. Standing is what the
|
||||
// client thinks of your work; the knife is the work. Its price is the garden (and the fee the
|
||||
// garden carries), ruled by B and measured by D — not a second, silent bite at the star the
|
||||
// same night. `!c.cut` twice, for the same reason `!c.theirs` is there twice.
|
||||
const brokeCount = rigRec.filter((c) => c.broke && !c.theirs && !c.cut).length;
|
||||
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn && !c.theirs && !c.cut).length;
|
||||
const collateralEvents = (run.collateral ?? []).length;
|
||||
const repBefore = rep;
|
||||
let repDelta = 0;
|
||||
@ -794,6 +975,18 @@ export function createWeek(opts = {}) {
|
||||
repDelta += REP.COLLATERAL * collateralEvents;
|
||||
repReasons.push(`broke ${collateralEvents > 1 ? 'things' : 'something'} of theirs`);
|
||||
}
|
||||
// ⚖️ SPRINT18 gate 0 — THE PRICE OF SAYING NO, settled with everything else
|
||||
// tonight's work earned or cost. take() recorded it this morning; the
|
||||
// invoice states it tonight, in the same repline that already explains
|
||||
// every other movement. Note it is ADDITIVE, not exclusive: standing a
|
||||
// client up and then having a clean night at the yard you went to instead
|
||||
// is +0.5 −0.5 = level, and that is exactly right — you did good work, for
|
||||
// somebody else, on a night you were promised elsewhere.
|
||||
const stoodUp = standUps.filter((s) => s.night === week.night);
|
||||
if (stoodUp.length > 0) {
|
||||
repDelta += REP.STOOD_UP * stoodUp.length;
|
||||
repReasons.push(`stood up ${stoodUp.map((s) => s.client ?? 'a client').join(' · ')}`);
|
||||
}
|
||||
rep = Math.min(REP.MAX, Math.max(REP.MIN, rep + repDelta));
|
||||
repDelta = rep - repBefore; // what actually moved, after the clamp
|
||||
|
||||
@ -815,6 +1008,13 @@ export function createWeek(opts = {}) {
|
||||
constraints: job.constraints ?? [],
|
||||
premium: premiumFor(job.constraints),
|
||||
taken: taken.has(index) ? (taken.get(index)._offerId ?? true) : false,
|
||||
// ⚖️ SPRINT18 gate 0, clause 4 — THE INVOICE STATES WHAT IT COST YOU TO
|
||||
// TAKE THIS JOB. Rides the settlement, like `constraints` and for the
|
||||
// identical reason: night 5's invoice must still name the client night 5
|
||||
// stood up when you re-read it on night 7. `standUpCost` is pre-multiplied
|
||||
// so no surface has to know the constant to print the number.
|
||||
stoodUp,
|
||||
standUpCost: REP.STOOD_UP * stoodUp.length,
|
||||
// SPRINT16 — the ledger. The rig record IS the work history (the night
|
||||
// computed all of it; the week just keeps it), the warranty items are
|
||||
// tonight's sheet answered on the invoice, and the rep block is the
|
||||
@ -822,6 +1022,13 @@ export function createWeek(opts = {}) {
|
||||
// end card's rich-with-warranties reading without the HUD walking the
|
||||
// log itself.
|
||||
rig: rigRec,
|
||||
// 🚨 SPRINT18 gate 1 — the emergency's own paperwork, carried on the
|
||||
// settlement like `constraints` so the invoice stays true when re-read:
|
||||
// whether tonight was a dispatch, what the rate was, whose corners were
|
||||
// already down (named, unbilled), and whether you used the knife.
|
||||
emergency: job.emergency ? { arriveAt: job.emergency.arriveAt, rate: PAY.emergencyRate } : null,
|
||||
inherited: run.inherited ?? [],
|
||||
cutAways: run.cutAways ?? [],
|
||||
warranty, warrantyTotal, feeMult,
|
||||
rep, repBefore, repDelta, repReasons,
|
||||
warrantiesToDate: log.reduce((n, x) => n + (x.warranty?.length ?? 0), 0)
|
||||
|
||||
@ -114,6 +114,27 @@ export function validateSite(site, name = site?.id ?? '?') {
|
||||
if (!ids.length) bad.push('no anchors at all — nothing to rig to');
|
||||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`);
|
||||
// SPRINT18 gate 5 (E specified, A blessed): two author-time refusals for the
|
||||
// second-rust-post landmine. A duplicate STRUCTURE id makes structures.set()
|
||||
// silently replace the first, which then never renders, bills, or wrecks;
|
||||
// two structures resolving to ONE collateral key make collateralFor bill and
|
||||
// wreck whichever matched first. Both are quiet until a yard has two of the
|
||||
// same thing, which is why they are refused here rather than measured later.
|
||||
const structIds = (site.structures ?? []).map((s) => s.id);
|
||||
const dupStructs = structIds.filter((id, i) => structIds.indexOf(id) !== i);
|
||||
if (dupStructs.length) {
|
||||
bad.push(`duplicate structure ids: ${[...new Set(dupStructs)].join(', ')}`);
|
||||
}
|
||||
const structKeys = (site.structures ?? [])
|
||||
.filter((s) => s.collateral != null)
|
||||
.map((s) => s.collateralKey ?? s.id);
|
||||
const dupKeys = structKeys.filter((k, i) => structKeys.indexOf(k) !== i);
|
||||
if (dupKeys.length) {
|
||||
bad.push(
|
||||
`two priced structures resolve to one collateral key: ${[...new Set(dupKeys)].join(', ')}`
|
||||
+ ' — give each a distinct collateralKey and point its anchors at it',
|
||||
);
|
||||
}
|
||||
// Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is
|
||||
// tensioned from a cleat at its base and a tree strop is thrown, but a
|
||||
// bracket bolted up a bare wall is the one you cannot fake — so that, and
|
||||
@ -542,6 +563,11 @@ export function createWorld(scene, opts = {}) {
|
||||
makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
|
||||
{ work: a.work },
|
||||
a.node ? null : factoryExtras(a.type ?? 'post'),
|
||||
// SPRINT18 gate 5 (E specified, A blessed): the site names its own
|
||||
// collateral target, applied AFTER factoryExtras so the override wins.
|
||||
// Without it, two same-type structures share one baked key and
|
||||
// collateralFor bills or wrecks whichever matched first.
|
||||
a.collateral ? { collateral: a.collateral } : null,
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -723,7 +749,10 @@ export function createWorld(scene, opts = {}) {
|
||||
if (!node || !anchor) return false;
|
||||
anchor.pos.setFromMatrixPosition(node.matrixWorld);
|
||||
anchor.ratingHint = node.userData?.rating_hint ?? 1;
|
||||
anchor.collateral = node.userData?.collateral ?? null;
|
||||
// SPRINT18 gate 5: the bake is the DEFAULT, never a clobber — a site that
|
||||
// named its own target above keeps it. With no override the chain is
|
||||
// identical, so every shipped site is byte-unchanged.
|
||||
anchor.collateral = anchor.collateral ?? node.userData?.collateral ?? null;
|
||||
// SPRINT14, E's gap 1: some baked anchor nodes are NOT tie-offs — a door
|
||||
// step, a bench top, a broom grip, a lighting hint. They carry no
|
||||
// `rating_hint`, and `?? 1` therefore rated them PERFECT: name one in a
|
||||
|
||||
BIN
web/world/models/hail_net_frame_01_v1.glb
Normal file
BIN
web/world/models/hail_net_frame_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/hail_net_frame_01_wrecked_v1.glb
Normal file
BIN
web/world/models/hail_net_frame_01_wrecked_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user