diff --git a/THREADS.md b/THREADS.md index 4f0fff1..5e18c3e 100644 --- a/THREADS.md +++ b/THREADS.md @@ -9505,3 +9505,195 @@ anchors are your GLB), but the tooling is now waiting, not TODO. **Deploy still owes one hand:** tools/deploy_hardyards.sh is permission-gated in the integrator's session (ssh + docker to the games VPS). John runs it or green-lights it; S18 must not ship before S17 is live, per the standing one-sprint rail. + +[A] 2026-07-25 — ⚖️ **GATE 0 LANDED: DECLINING A BOOKED NIGHT COSTS YOU — −½★, on the card + before the choice and on the invoice after it.** John's ruling wired, and the number ruled: + **`REP.STOOD_UP = -0.5`, THE EXACT MIRROR OF `REP.CLEAN`.** 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 `REP.COLLATERAL` (−1.0) on purpose: ranking a broken promise + above a wrecked carport would make the invoice argue with itself. ⚠️ UNMEASURED per house + rule, and it is the lever — if John's play says the price is wrong, move THAT constant, not + `CLEAN`, which the whole rep ladder is measured against. + + **THE ASYMMETRY, and the bug my own first cut shipped for ten minutes.** The fiction is a + tradie's two sources of work: **the spine slot is your diary, the pool is your phone.** + `isBooked(kind, night) = kind === 'scripted' && !night.emergency`. My first cut derived it + from the night ALONE — and the probe came back with the pool yard marked BOOKED, so taking + the scripted job printed *"the Karalis place is expecting you"* over a client nobody had + promised anything. Recorded because the wrong version read entirely plausible. A callout you + turn down is a callout somebody else takes; you owe standing to a diary entry and nothing else. + + **WHEN IT LANDS: AT DAWN, and that ordering is load-bearing, not taste.** Charging inside + `take()` drops 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, the exact bug `calloutFee()` + was extracted to make impossible. So `take()` RECORDS (the `standUps` ledger) and `settle()` + CHARGES, in the same repline that already explains every other rep movement. Pinned: + `w.quote(def).base === ` the fee the card printed, after the take. + + It is **ADDITIVE, not exclusive**: stand a client up and then have a clean night at the yard + you went to instead and you net to LEVEL (+0.5 −0.5). That is right, and it is the ruling's + whole texture — you did good work, for somebody else, on a night you were promised elsewhere. + + **THE MEMORY (clause 3), one ledger, three surfaces.** `week.standUps` = append-only + `{night, client, addr, offerId, took}`; `week.remembers(client)` is the question the board + card and the job sheet each ask by name. Board: *"They remember night 5 — you took another + job."* Sheet: *"They have not forgotten night 5. You are here on their second-choice terms."* + `standUps` hands out a COPY (hud.js's charter: the face never takes a decision, and a getter + returning the live array is a card one `.push` away from inventing a grudge — pinned), and + `reset()` clears it, because a memory outliving its run is the uncleared-`taken` bug one + surface along. + + **BOTH PAPERS (clauses 2 and 4).** Offer card: a `.cost` line directly under the two money + lines — *"the Hendersons are expecting you · −½★ standing"*, red-side because it is a debit, + half-star not float because that is how REP is ruled. Invoice: a new **OFF THE BOARD** block + off the settlement (`s.taken`, `s.stoodUp`, `s.standUpCost`), so night 5's invoice still + names night 5's broken promise when you re-read it on night 7. + + **CLAUSE 5 — TWO CALLOUTS, NO BOOKED JOB READS AS FREE, and the free case falls out of the + filter returning nothing rather than out of an `if`.** No cost line on any card, and the + board note changes: *"Nobody has you booked tonight. Neither of these costs you anything to + turn down."* — because "no cost shown" and "no cost" have to be the same sentence on a + document. Pinned WITH ITS VACUITY GUARD: flip one `booked` flag on the fixture board and the + charge must appear, or the clean result is being produced by a function that cannot charge. + + ⚠️ **This state is only REACHABLE once gate 1 exists** (an emergency arrives in the diary's + chair but nobody had you down), which is why `isBooked` already knows the word `emergency` on + the day gate 0 lands. It is not speculative plumbing: it is the one line that stops gate 1 + having to re-open gate 0's ruling. + +[A] 2026-07-25 — 🚨 **THE SHAPES, PUBLISHED EARLY: THE EMERGENCY CALLOUT / THE DISPATCH.** + B, C, D and E build against these. Nothing below is landed yet except the gate-0 hook — this + is the seam contract, posted first, per the pattern that has cost this repo one code conflict + in two sprints. Anything I change after posting, I change HERE with a dated note. + + **1 · `night.emergency` — the field that makes a night an emergency callout.** A night entry + (NIGHTS-shaped, so `normaliseNight` carries it and every downstream surface reads it through + the path it already had) with one extra block: + + { + storm: 'storm_03_southerly', // an ordinary storm def, unchanged + site: 'site_02_corner_block', // a yard the player has ALREADY RIGGED (see §5) + client: 'the Vasilaros place', addr: '2 Bight Rd — the corner block', + brief: 'what the phone call said', + emergency: { + arriveAt: 30, // seconds into the storm you turn up (ROADMAP's own number) + dispatch: 'the call, verbatim', // there is no forecast and no quote — that IS the drama + rig: [ … ], // ⇦ B OWNS: the rig you didn't build + }, + } + + **2 · `emergency.rig` — B's half, the rig you didn't build.** Someone else's work, authored as + DATA, already under load. Per-corner: + + { anchorId: 'q1', hw: 'carabiner', broken: false } + anchorId an anchor id in THIS yard's site JSON + hw a HARDWARE name from contracts.js — 'carabiner' | 'shackle' | 'rated shackle' + broken true ⇒ that corner is ALREADY GONE when you arrive + + A's spine installs it through the session's own public moves at the boundary `setConstraints` + uses (`session.rig()` + `setHardware()`, then commit) — the same public-moves-only discipline + `resetRig()` follows, so the economy stays the single source of truth. **B rules the semantics + and the authored set:** hardware below what the night needs, at least one corner already gone, + the rating hints telling the truth about why. **B also rules whether the garden bonus applies + at all** — you had no prep phase to protect a garden with — and writes the why. I have authored + a MINIMAL first cut so the phase is verifiable end-to-end in my tree; it is FLAGGED for you and + revert-and-tell-me applies. Yours is canon the moment it lands. + + ⚠️ **The pre-broken corner is the one piece I cannot do from public moves alone.** Breaking a + corner before the storm starts is your file, B. If a `session`/`rig`-side door for "this corner + arrives already gone" is cheaper on your side than mine, name it in THREADS and I will call it + feature-detected exactly as I call `setConstraints?.()`. + + **3 · `EMERGENCIES` — where an emergency enters the game, and why NOT the POOL.** A new + board.js table, authored per night, 1-based: `[{ night: 5, …entry }]`. `alternativeFor()` + returns the emergency scheduled for that index if there is one, and the seeded pool draw + otherwise. Two reasons it is not a POOL entry, both load-bearing: + · **the draw.** `alternativeFor` indexes the eligible pool by seed, so a seventh POOL entry + re-rolls EVERY night's alternative — it would invalidate the existing board pins and D's + cold-play record for a night it isn't even on. This way every other night's draw is + byte-identical and `offers().length === 2` / `offers[1].kind === 'callout'` both stand. + · **the one-variable law.** An emergency is a SET PIECE and the law needs an AUTHOR to place + it: *"is this yard already rigged by night N"* is only decidable if N is authored. The RNG + cannot be asked to respect a law about ordering. + `kind` stays `'callout'` (its provenance — the phone; it is the most callout-y callout there + is) and the card reads `o.night.emergency` for its costume. One source of truth, existing pins + green. + + **4 · THE PHASE — a night that starts in `storm`.** `game.setPhase('storm', { at })` gains one + optional argument: `phaseT` opens at `at` instead of 0. Everything downstream is untouched by + construction — `windTime()` already returns `phaseT` during a storm, so wind, sky, hail and the + storm's authored events are all mid-flight when you get out of the ute; `tick()` still ends the + storm at `phaseT >= STORM_LEN`, so an emergency night is `STORM_LEN − arriveAt` long (90 − 30 = + 60 s). **There is NO prep phase**: `rigging.setActive(false)` is already what a non-prep phase + does, and `enterCommits()` already requires `phase === 'prep'`, so ENTER is inert with no new + guard. The morning runs: + + forecast → board → take() + ordinary: load yard → setBudget/setConstraints → JOB SHEET → [GO] → prep → [ENTER] → storm t=0 + emergency: load yard → setBudget → adopt emergency.rig → DISPATCH → [ROLL] → storm t=arriveAt + + ⚠️ **Verify the phase entry with STEPPED TIME, not the live pane** — the pane's ~12 fps clamps + the sim and an emergency night's whole premise is a clock that starts at 30. `SHADES.step(1/60)` + in a loop + `SHADES.render()` is the repo's own path. + + **5 · THE ONE-VARIABLE LAW, ENFORCED IN CODE — not in a comment.** `oneVariableOffences()` in + board.js, walking the shipped data, returning offences with the measurement in each message + (C's `pairingRefusals` construction — it accuses, it does not silently filter): + · a NIGHTS emergency at index *i* must have its **site appear in NIGHTS at some j < i** — the + emergency callout debuts over a yard the player has already rigged. + · an `EMERGENCIES` entry for night *N* must have its site rigged by an EARLIER night. + · a POOL emergency must have its site in NIGHTS at all — a yard whose only route into the game + is under the new job type is exactly the accident the law exists to forbid. + `offersFor()` throws on an emergency offer that breaks it (`validateConstraint`'s precedent — + content bugs fail loud), and a.test walks the shipped data with a vacuity guard. + **E — this is the line that protects your glasshouse:** it CANNOT be the emergency's debut + stage this sprint, no matter who wires what, because it is not in NIGHTS. Your yard debuts as + an ordinary rig-for-tonight job and DESIGN.md's sentence (someone's sail flogging itself to + death over their glasshouse) belongs to a later week. That is the payoff, not the introduction. + + **6 · THE SHIPPED EMERGENCY: NIGHT 5's MORNING, opposite the wildnight.** Ruled, and every + other slot rejected for a reason: + · site_02_corner_block × storm_03_southerly — the yard night 3 rigs, the storm night 2 + survives. **The only new variable is the JOB TYPE.** (The pairing is the POOL's audited + `corner_block_southerly`: 19 holding lines / 7 marginal, cheapest hold $40. ⚠️ That audit + answers *"can a rig-for-tonight job win here"*; **whether the INHERITED rig is triageable is + a different question and it is B's to measure** — flagged, not assumed.) + · **not night 4** — the booked job that morning 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 campaign. + · **not night 6/7** — the memory needs somewhere to land. The Hendersons are on the board + nights 1, 2, 5 and 6; stand them up on night 5 and the grudge is on the ICENIGHT'S card the + next morning. Nights 6 and 7 leave clause 3 unreachable in the shipped week. + · and it is D's finding #1 answered exactly: *"the wildnight was declinable for a Tuesday + southerly at similar money."* It still is — at double rates now, and −½★, printed. + + **7 · EMERGENCY RATES — ⚖️ RULED ×2.0, and it is canon rather than my taste.** DESIGN.md line + 29 and ROADMAP arc 2 both say *double rates*, in those words, so the multiplier is the number + the design has been carrying since line one. `calloutFee(def, rep, constraints, emergency)` + gains the term — ONE fee function still, so the offer card, the dispatch and the invoice cannot + disagree. The justification for a number that large: you arrive with no prep, no quote, no + chance to have bought good steel, on somebody else's hardware, with the yard's whole collateral + exposure already live. ⚠️ UNMEASURED BY PLAY, cut-first alongside `CONSTRAINT_PREMIUM`. + + **8 · C's SEAM — the dispatch has NO FORECAST.** `emergency.dispatch` is the phone call (data, + mine). What I am NOT writing is the weather: **`midStormRead(def, t)` is yours** — an ORDERED + `{key, text}` list, the same construction as your `offerBand`, describing what the sky and the + grass tell you at `t = arriveAt` when no paper briefed you. The dispatch card MAPS it exactly + the way the offer card maps `offerBand`, so a fact you add cannot be dropped by the card + picking two fields. Called through a namespace import (`import * as weather` + + `weather.midStormRead?.()`), because a named import of a function you haven't exported yet is + a hard module error, not a graceful no-op — the `setConstraints?.()` pattern, adapted to + modules. Until it lands the card renders the phone call alone. + + **9 · D's SEAM — the knife.** You own the verb and the input; I own the tally and the paper. + Emit **`cutAway` on `rig.events`** with `{ t, corners: string[], reason? }` — the same Emitter + `break`/`repair`/`pondDump` already ride, so main.js's night tally picks it up with no new + wiring. I carry it to `run.cutAways` → the settlement → a line on the invoice. Your HUD read + and your key are yours; if you want a different event name or payload, say so and I will move + my end. + + **10 · WHAT DOES NOT CHANGE, and this is the acceptance criterion for the whole gate:** the + ledger. `settle()` takes the same `run`, `def` and `spent`; fee, garden bonus, refund, + 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. diff --git a/web/world/js/board.js b/web/world/js/board.js index 8e2efa2..f946821 100644 --- a/web/world/js/board.js +++ b/web/world/js/board.js @@ -265,6 +265,94 @@ 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); +} + /** * A deterministic draw. `rng` is contracts.js's mulberry32 — the same PRNG the * storm system reproduces against, so there is exactly one seeded-randomness @@ -371,10 +459,33 @@ export function offersFor({ scripted, weekSeed, nightIndex, pool = POOL, priced 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; } diff --git a/web/world/js/hud.js b/web/world/js/hud.js index 3cf88ba..66af113 100644 --- a/web/world/js/hud.js +++ b/web/world/js/hud.js @@ -66,6 +66,20 @@ 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 ? '½' : ''}★`; +}; // 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 +356,20 @@ 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; } #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; @@ -838,8 +866,29 @@ export function createHud(d) { ${o.exposure != null ? `
their property at risk${ o.exposureItems?.length ? ` — ${o.exposureItems.map((x) => x.what).join(', ')}` : '' }$${o.exposure}
` : ''} + ${/* ⚖️ 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". */''} + ${o.standsUp?.length ? `
${ + o.standsUp.map((s) => s.client ?? 'a client').join(' and ') + } ${o.standsUp.length > 1 ? 'are' : 'is'} expecting you${ + starDelta(REP.STOOD_UP * o.standsUp.length)} standing
` : ''} ${cons.length ? `
⚖ ${cons.map((c) => c.label).join(' · ')}
` : ''} ${cons.map((c) => `
“${c.says}”
`).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 ? `
They remember night ${ + o.remembers.map((s) => s.night).join(' and night ') + } — you took another job.
` : ''} `; }; @@ -860,7 +909,20 @@ export function createHud(d) {

Take one. The other goes to somebody else.

${offers.map(offerHtml).join('')}
in the bank$${wk.bank}
-
Both are tonight. You cannot work two yards in one storm.
+ ${/* ⚖️ 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. */''} +
${ + 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.' + }
Fees quoted on the forecast at time of callout. Weather is not a variation.
`; card.classList.add('on'); @@ -936,6 +998,15 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { const brief = job.brief ? `
${job.brief}
` : ''; + // ⚖️ 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 ? ` +
They have not forgotten night ${ + wk.remembers.map((s) => s.night).join(' and night ') + }. You are here on their second-choice terms.
` : ''; + // 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 +1099,7 @@ showForecast({ key, def, site, tomorrowDef }, wk, onGo) { card.innerHTML = `
${letterhead} ${brief} + ${remembers} ${constraintBlock}

NIGHT ${wk.night} OF ${wk.nights}

${pips}
@@ -1253,6 +1325,31 @@ 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 ? ` +
OFF THE BOARD
+
+
you took this callout${w.client ?? 'no client'}
+ ${w.stoodUp?.length + ? w.stoodUp.map((s) => ` +
stood up ${s.client ?? 'a client'} — they were expecting you${ + starDelta(REP.STOOD_UP)} standing
+
No invoice for that one. They will remember the phone call.
`).join('') + : '
nobody was expecting you — nothing to stand up
'} +
` : ''} ${w?.constraints?.length ? `
PAID UNDER THESE TERMS
diff --git a/web/world/js/main.js b/web/world/js/main.js index eea404d..fc7ece0 100644 --- a/web/world/js/main.js +++ b/web/world/js/main.js @@ -1050,6 +1050,14 @@ export async function boot(opts = {}) { fee: def ? calloutFee(def, week.rep, night.constraints) : null, exposure: exp.total, exposureItems: exp.items, + // ⚖️ SPRINT18 gate 0, clause 3 — THE STOOD-UP CLIENT REMEMBERS. Injected + // here 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 and who is on the board again on Thursday is the whole of + // "your work follows you", and it costs one line. + remembers: week.remembers(night.client), }; }); } @@ -1117,6 +1125,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'); }, ); diff --git a/web/world/js/tests/a.test.js b/web/world/js/tests/a.test.js index 8a907fd..869561c 100644 --- a/web/world/js/tests/a.test.js +++ b/web/world/js/tests/a.test.js @@ -25,7 +25,11 @@ import { } 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, +} 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 +702,170 @@ 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'); + }); + + 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(); } + }); + // 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). diff --git a/web/world/js/week.js b/web/world/js/week.js index 895f834..df431df 100644 --- a/web/world/js/week.js +++ b/web/world/js/week.js @@ -387,6 +387,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 +510,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 +553,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 = []; @@ -643,9 +687,20 @@ export function createWeek(opts = {}) { }, /** - * 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 +724,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 +742,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 +776,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; }, @@ -794,6 +881,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 +914,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