Lane A S18 gate 1 spine: 🚨 THE EMERGENCY CALLOUT — a night that starts in storm, a DISPATCH, double rates, the ledger unchanged
setPhase('storm', {at}) is the whole engine change: windTime() already returns
phaseT during a storm, tick() already ends at STORM_LEN, so an emergency night
is 60s long inside weather that is already working. No new phase.
EMERGENCIES, scheduled per night, not a POOL entry — a seventh pool entry would
re-roll every night's draw, and the one-variable law needs an author to place a
set-piece. Diffed the whole week against HEAD: exactly one line moved, and what
it replaced was corner_block_southerly — the same yard and the same storm as an
ordinary job. The job type is literally the only variable.
⚖️ Rulings: emergency rate ×2.0 (DESIGN.md line 29, canon, UNMEASURED-by-play);
a corner that ARRIVED broken bills nothing, wrecks nothing, books no warranty
and costs no standing (`theirs` on the rig record — without it the game charges
you for a cowboy's carabiner and books you a free callout to fix his work); the
inherited steel does not come home; the ute carries one free spare, or triage is
a verb with no object.
THE ONE-VARIABLE LAW IS NOW CODE: oneVariableOffences() accuses, offersFor()
throws, three-way vacuity guard. E's glasshouse cannot be the emergency's debut
stage no matter who wires what.
The DISPATCH is defined by its refusals: no schedule, no total, no AMOUNT DUE —
nobody costed this job. It prints the call, C's mid-storm read (feature-detected
through a namespace import), the rig that is already up with the down corners
marked ON THE GROUND, and the rate. Pinned in DOM, refusals included.
Seams live and waiting: B's breakCorner door, C's midStormRead, D's cutAway
event (tallied, carried to the settlement, printed on the invoice).
Selftest 535/0/0 — 528 + 7, the exact sum.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
27d9ebcc63
commit
82a52aa014
90
THREADS.md
90
THREADS.md
@ -9697,3 +9697,93 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
collateral, clean, warranty, rep, grade, the end card and the warranty chain all run on an
|
||||
emergency night through the code they already had. If any of them needed a branch on "was this
|
||||
an emergency", the shape above is wrong and I want to know before you build against it.
|
||||
|
||||
[A] 2026-07-25 — 🚨 **GATE 1 SPINE LANDED, AND THREE AMENDMENTS TO THE SHAPES I PUBLISHED THIS
|
||||
MORNING** (I said I would amend here rather than quietly; these are those notes). The
|
||||
emergency callout runs end to end: a night that starts in storm, a DISPATCH instead of a job
|
||||
sheet, double rates, and the whole existing ledger unchanged.
|
||||
|
||||
**AMENDMENT 1 — ⚖️ THE RULING THAT MAKES AN EMERGENCY INVOICE HONEST: A CORNER THAT WAS
|
||||
ALREADY DOWN WHEN YOU ARRIVED IS THE JOB, NOT YOUR FAILURE.** I did not have this when I
|
||||
published §2 this morning, and building it found it. Without the ruling, 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 go and fix his work. Four separate
|
||||
surfaces, all agreeing, all wrong.
|
||||
|
||||
So: **`rigRecordFor(corners, tally, theirs)`** now stamps `theirs: true` on the arrived-broken
|
||||
corners, and the ledger reads it in four places — `bill`, the collateral walk, `warrantyItems()`
|
||||
and the rep block. The corner is still HONESTLY RECORDED as down (the record does not lie, and
|
||||
the HUD's "corners intact" count still reads it); what changes is who is answerable. Two lists
|
||||
in scoreRun, deliberately: `lost` for REPORTING, `yours` for BILLING. B, C, D — this is the
|
||||
only change to a shape you may already be building against, and the field name is `theirs`.
|
||||
|
||||
**⚠️ B, THE ONE DOOR I STILL NEED FROM YOU.** `broken: true` cannot be honoured from public
|
||||
moves: sail.js's own comment says 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`. I call **`rig.breakCorner?.(anchorId, { reason: 'arrived' })`**
|
||||
feature-detected — the `setConstraints?.()` pattern — and **the night does not depend on it**:
|
||||
the authored corner is a $5 carabiner on the carport beam (~264 N effective under a southerly),
|
||||
so it fails within seconds through the REAL overload path, with the real event and the real
|
||||
invMass release, whether or not the door exists. Emergent beats poked. Yours to open or to
|
||||
rename.
|
||||
|
||||
**AMENDMENT 2 — ⚖️ THE UTE CARRIES ONE SPARE, FREE.** Without it, triage is a verb with no
|
||||
object: you arrive at a broken corner unable to do the only thing the job is for. You had no
|
||||
prep phase so you bought nothing, but a tradie's ute has a shackle rolling around in it.
|
||||
Implemented in VISIBLE ARITHMETIC through public moves rather than a private door — bank the
|
||||
session at `week.bank + SPARE_COST`, buy the spare, and the budget lands back on exactly
|
||||
`week.bank`, so `spentThisNight` reads $0, which is the truth. (B: a `grantSpare()` would say
|
||||
it better than three lines of mine — name it and I will move.)
|
||||
|
||||
**AMENDMENT 3 — ⚖️ THE INTACT HARDWARE DOES NOT COME HOME.** On an emergency, the steel on the
|
||||
unbroken corners is the cowboy's, in the client's yard, and you leave it there; only your own
|
||||
unused spare is refunded. This is also the check that stops `emergencyRate: 2.0` being free
|
||||
money — without it a double-rate callout ALSO hands you half of somebody else's four rated
|
||||
shackles at dawn, and the best-paying strategy in the game becomes turning up to other
|
||||
people's disasters and salvaging them.
|
||||
|
||||
**THE RECEIPT I DID NOT EXPECT, and it is a good one.** I diffed the whole week's board draws
|
||||
against HEAD to prove the scheduled emergency changed nothing it shouldn't. **Exactly one line
|
||||
moved** — night 5's alternative — and what it *replaced* was `corner_block_southerly`: the
|
||||
SAME yard and the SAME storm, offered as an ordinary rig-for-tonight job. So on the one night
|
||||
the new job type debuts, the board's own alternative was already site_02 × southerly, and the
|
||||
emergency changes literally nothing about that morning except the JOB TYPE. The one-variable
|
||||
law is not being obeyed by argument here; it is obeyed by arithmetic.
|
||||
|
||||
**THE DISPATCH, as built.** Wears E's letterhead (one outfit, four papers now: JOB / INV /
|
||||
THE BOARD / **CALL**) and is defined by what it REFUSES: no schedule, no garden bonus, no clean
|
||||
bonus, no total, no AMOUNT DUE — 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. What it
|
||||
does print: the phone call as the brief · C's mid-storm read (or one honest italic line saying
|
||||
nobody briefed you) · **the rig that is already up, corner by corner, with the ones on the
|
||||
ground marked "ON THE GROUND" in words** — DESIGN.md's tell rule, because a trap is allowed to
|
||||
be brutal and is not allowed to be a surprise · the RATE and the fee it applies to, because
|
||||
double rates are the reason you get in the ute. Pinned in DOM, including the refusals (a
|
||||
refusal is only checkable by rendering it — the S12 lesson).
|
||||
|
||||
**STILL ONE MONEY FUNCTION.** `calloutFee(def, rep, constraints, emergency)` gained a fourth
|
||||
term rather than growing a rival, so the offer card, the dispatch and the invoice cannot
|
||||
disagree about a night that pays double. `quote()` gained an `emergency` block and the dispatch
|
||||
is a VIEW of it — a second `dispatch()` computing its own fee is the quote-vs-settle divergence
|
||||
with a new name.
|
||||
|
||||
**D — YOUR SEAM IS LIVE AND WAITING.** `rig.events.emit('cutAway', {t, corners, reason?})` is
|
||||
already subscribed: I tally it, clear it at storm start, carry it to `run.cutAways` → the
|
||||
settlement → **a line on the invoice** ("sail cut loose at CB1 — 34s in, deliberate · *You
|
||||
spent the sail to keep the steel. That is the trade the knife is for.*"). Emit nothing and every
|
||||
surface renders nothing. **And the shipped emergency was authored FOR your knife:** the cowboy
|
||||
tied the sail to the CARPORT BEAM — the worst steel in the game, $180 of collateral, the anchor
|
||||
the client has been selling as "plenty to tie off to" since night 3 — and that corner is failing
|
||||
while you walk up the drive. Release, cut, or ride it out, with $180 on the line and one spare
|
||||
in the ute. DESIGN.md line 165 over the one piece of collateral this game has already taught the
|
||||
player to fear.
|
||||
|
||||
**E — the one-variable law now protects your glasshouse in code, not in a comment.**
|
||||
`oneVariableOffences()` refuses any emergency over a yard no scripted night rigs, so the
|
||||
glasshouse CANNOT be the emergency's debut stage this sprint no matter who wires what. Pinned
|
||||
with a three-way vacuity guard (a NIGHTS emergency, an EMERGENCIES entry, and a POOL emergency
|
||||
over an unrigged yard each produce their own accusation). Your yard debuts as an ordinary
|
||||
rig-for-tonight job; DESIGN.md's sentence belongs to a later week. Flag me when it is placeable
|
||||
and gate 5 is next on my list.
|
||||
|
||||
@ -353,6 +353,215 @@ 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.
|
||||
*/
|
||||
rig: [
|
||||
{ anchorId: 'cb1', hw: 'carabiner', broken: true },
|
||||
{ anchorId: 'tr1', hw: 'carabiner' },
|
||||
{ anchorId: 'tr1b', hw: 'carabiner' },
|
||||
{ anchorId: 'q3', hw: 'carabiner' },
|
||||
],
|
||||
},
|
||||
_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deterministic draw. `rng` is contracts.js's mulberry32 — the same PRNG the
|
||||
* storm system reproduces against, so there is exactly one seeded-randomness
|
||||
@ -386,7 +595,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));
|
||||
@ -451,10 +672,27 @@ 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 ?? [],
|
||||
|
||||
@ -370,6 +370,40 @@ const CSS = `
|
||||
margin-top:7px; font-size:11px; font-style:italic; color:#8a7f74;
|
||||
}
|
||||
#hud-card .board-note.free { color:#8ba0ad; }
|
||||
|
||||
/* --- 🚨 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;
|
||||
@ -935,6 +969,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
|
||||
@ -1350,6 +1470,31 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
<div class="cond">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('')}
|
||||
${(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</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">
|
||||
|
||||
@ -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';
|
||||
@ -322,10 +329,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,6 +352,7 @@ export function rigRecordFor(corners, tally = new Map()) {
|
||||
broke,
|
||||
repaired: broke && !c.broken,
|
||||
brokenAtDawn: !!c.broken,
|
||||
theirs: theirs.has(c.anchorId),
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -361,13 +377,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 });
|
||||
},
|
||||
|
||||
@ -849,12 +885,41 @@ export async function boot(opts = {}) {
|
||||
|
||||
/** What the storm cost, assembled at the moment it ends. */
|
||||
function scoreRun() {
|
||||
/**
|
||||
* 🚨 **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 = new Set((em?.rig ?? []).filter((c) => c.broken).map((c) => c.anchorId));
|
||||
const lost = rig.corners.filter((c) => c.broken);
|
||||
const bill = lost.reduce((s, c) => s + c.hw.cost, 0);
|
||||
const yours = lost.filter((c) => !theirs.has(c.anchorId));
|
||||
/** Their corners still on the ground at dawn — the invoice names them, unbilled. */
|
||||
const inherited = lost.filter((c) => theirs.has(c.anchorId))
|
||||
.map((c) => ({ anchorId: c.anchorId, hw: c.hw.name }));
|
||||
const bill = yours.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 });
|
||||
// worst debris in any storm is your own failed work. `yours`, not `lost`:
|
||||
// two corners on the ground when you ARRIVED is not the sail you dropped.
|
||||
if (yours.length >= 2) collateral.push({ what: 'garden gnome', cost: world.gnome.collateralValue });
|
||||
|
||||
/**
|
||||
* SPRINT11 — the carport trap finally BILLS (§gate 1).
|
||||
@ -875,7 +940,7 @@ export async function boot(opts = {}) {
|
||||
* gone, not $360. It bills once and the wreck swaps once.
|
||||
*/
|
||||
const taken = new Set();
|
||||
for (const c of lost) {
|
||||
for (const c of yours) {
|
||||
const key = world.anchor(c.anchorId)?.collateral;
|
||||
const priced = key ? world.collateralFor(key) : null;
|
||||
if (!priced || taken.has(key)) continue;
|
||||
@ -931,9 +996,13 @@ 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.`
|
||||
: 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 +1013,31 @@ 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.
|
||||
*/
|
||||
intactHardwareValue: em
|
||||
? (rigging.session.spares ?? 0) * SPARE_COST
|
||||
: rig.corners.filter((c) => !c.broken).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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1082,6 +1166,56 @@ 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();
|
||||
s.setBudget(week.bank + 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. Feature-detected; paper-only until B's door lands.
|
||||
for (const c of em.rig ?? []) {
|
||||
if (c.broken) rig.breakCorner?.(c.anchorId, { reason: 'arrived' });
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@ -1109,6 +1243,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
|
||||
@ -1166,6 +1350,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
|
||||
@ -1186,7 +1395,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);
|
||||
|
||||
@ -29,6 +29,8 @@ 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,
|
||||
} from '../board.js';
|
||||
import { createHud } from '../hud.js';
|
||||
import { PALETTE, boundsFaults, emptyTemplate, exportSiteJSON, placeEntry } from '../editor.js';
|
||||
@ -774,6 +776,23 @@ export default async function run(t) {
|
||||
// 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', () => {
|
||||
@ -866,6 +885,220 @@ export default async function run(t) {
|
||||
} 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(); }
|
||||
});
|
||||
|
||||
// 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).
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -587,7 +640,13 @@ 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.
|
||||
return (prev.rig ?? []).filter((c) => c.brokenAtDawn && !c.theirs).map((c) => ({
|
||||
anchorId: c.anchorId, hw: c.hw, cost: c.hwCost,
|
||||
night: prev.night, site: prev.site, addr: prev.addr, client: prev.client,
|
||||
}));
|
||||
@ -631,7 +690,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,
|
||||
@ -647,6 +706,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.
|
||||
@ -683,6 +759,11 @@ 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,
|
||||
});
|
||||
},
|
||||
|
||||
@ -801,7 +882,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
|
||||
@ -856,8 +937,11 @@ 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.
|
||||
const brokeCount = rigRec.filter((c) => c.broke && !c.theirs).length;
|
||||
const dawnBroken = rigRec.filter((c) => c.brokenAtDawn && !c.theirs).length;
|
||||
const collateralEvents = (run.collateral ?? []).length;
|
||||
const repBefore = rep;
|
||||
let repDelta = 0;
|
||||
@ -928,6 +1012,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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user