Sprint 11 gate 2: the job sheet — five nights become five jobs
DESIGN.md has opened with this since line 3 — "every callout is a different site, a different client" — and the game had the site and not the client. The forecast card is a job sheet now and the aftermath is an invoice. THE WEEK, once it was asked to name a client, mostly wrote itself: four nights are the same yard, so they're one client on retainer while they're away (the Hendersons, in Cairns from Friday — SPRINT11's own line). Night 3 is a different SITE, so it is necessarily a different CLIENT: a short-notice callout to a corner block you've never rigged, on the night the change comes early. That isn't flavour bolted on, it's what `site: site_02_corner_block` already meant, finally said out loud. BUDGET Y IS THE FEATURE, not the letterhead. DESIGN.md's brief is "client wants X, budget Y, forecast Z" and this card had Z alone. The fee has existed since Sprint 8 and the player has never seen it until the money was already spent — you chose what to rig without knowing what the job was worth, which is a reveal, not a decision. The sheet quotes base + garden + clean up front, and a test pins that a perfect night pays exactly what the sheet advertised: a job sheet that over-promises is the game lying on paper. THE CLEAN BONUS is the new money, and it's the one number in week.js that isn't measured. It gives the invoice three axes that mean three different things — you turned up, their garden lived, you didn't wreck the place — and it's NOT gated on winning, because a lost night where you broke nothing of theirs is a different fact and deserves its own row. It also makes the carport bite twice: 180 + 20 = 200, inside E's own "over ~250 is a silent game over" ceiling. Measured: it adds ~$100 to a competent week (385 -> 485), which makes BROKE_BELOW's known runaway worse. I did NOT pay for it by quietly trimming feeFor or gardenBonusMax — those carry measured evidence, and re-tuning someone else's evidence to fund my own feature is the move this repo keeps catching. If gate 3 says the bank runs away, this is the lever to cut first and it's data. TWO BUGS, BOTH CAUGHT BY LOOKING AT THE CARD, neither by a test: · "THE HENDERSONS" over "The Backyard — Your own place". The blurb and the letterhead called each other liars on screen. The blurb gave way: "your own backyard" is a leftover from when the game had one yard, and DESIGN.md never wanted it. The line's job survives — night 1 is the tutorial because you know this yard, now from having worked it for years rather than owning it. · C's `lead` is a 0..1 haze dial (confidence = 1 − lead), not "nights out". I read it as nights, passed 1, and the sheet advertised "forecast confidence 0%". Now 0.6, because C's own hail rule calls L > 0.55 too distant to promise ice, and tomorrow is exactly that. C: the slot is mine, the number is yours. Lane C's forecastLines(def, lead) finally has a caller above 0 — two sprints after they built it. Lane E: .letterhead / .brief / .jobsheet / .tomorrow are structure only and yours to style. selftest 304/0/0 (was 300).
This commit is contained in:
parent
0f288beff3
commit
6bcc2540cf
@ -1,7 +1,18 @@
|
||||
{
|
||||
"id": "backyard_01",
|
||||
"name": "The Backyard",
|
||||
"blurb": "Your own place. You know where everything is, which is the only advantage you get.",
|
||||
"blurb": "You've worked this one for years. You know where everything is, which is the only advantage you get.",
|
||||
"_blurb": [
|
||||
"SPRINT11: this said 'Your own place' until the job sheet put a client's name at the top of the",
|
||||
"card and the two lines called each other liars on screen — THE HENDERSONS over 'your own place'.",
|
||||
"The blurb gave way, and it wasn't close: 'your own backyard' is a leftover from when the game had",
|
||||
"exactly one yard, and DESIGN.md has never wanted it — line 3 is 'every callout is a different",
|
||||
"site, a different client', and gate 2 is the sprint where that frame actually arrives.",
|
||||
"The line's JOB survives, which is why it's a rewrite and not a deletion: night 1 is the tutorial",
|
||||
"because you know this yard, and that advantage now comes from having worked it for years instead",
|
||||
"of owning it. Client-agnostic on purpose — the yard is the site's business, the client is the",
|
||||
"night's (week.js NIGHTS). Caught by looking at the card, not by a test. No assert reads a blurb."
|
||||
],
|
||||
|
||||
"yard": { "width": 30, "depth": 20 },
|
||||
|
||||
|
||||
@ -84,6 +84,26 @@ const CSS = `
|
||||
#hud-card .ledger .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
|
||||
#hud-card .ledger .row.total b { color:#fff; }
|
||||
|
||||
/* SPRINT11 — the job sheet and the invoice. STRUCTURE ONLY, deliberately: the
|
||||
letterhead and the invoice styling are Lane E's this sprint (§gate 2), and
|
||||
everything below is the plainest thing that reads correctly so E can restyle
|
||||
it without unpicking layout. The class names are the contract — .letterhead,
|
||||
.brief, .jobsheet, .tomorrow — and E owns what they look like. */
|
||||
#hud-card .letterhead { display:flex; justify-content:space-between; align-items:baseline;
|
||||
margin:-4px 0 10px; padding-bottom:8px; border-bottom:1px solid #24343d;
|
||||
font-size:12px; letter-spacing:.18em; text-transform:uppercase; color:#7ee0ff; }
|
||||
#hud-card .letterhead .inv { color:#8ba0ad; letter-spacing:.1em; }
|
||||
#hud-card .brief { margin:10px 0 12px; padding:9px 11px; border-left:2px solid #2f3f4a;
|
||||
background:#101a20; color:#c3d2da; font-size:13px; line-height:1.55; font-style:italic; }
|
||||
#hud-card .jobsheet { margin-top:12px; padding-top:10px; border-top:1px solid #24343d; }
|
||||
#hud-card .jobsheet .row b { color:#a8f0b8; }
|
||||
#hud-card .jobsheet .row.total { margin-top:6px; padding-top:8px; border-top:1px solid #24343d; }
|
||||
#hud-card .jobsheet .row.total b { color:#fff; }
|
||||
/* Tomorrow is a rumour, and it should look like one next to tonight's numbers. */
|
||||
#hud-card .tomorrow { display:flex; gap:10px; align-items:baseline; margin-top:9px;
|
||||
font-size:12px; color:#6d8494; }
|
||||
#hud-card .tomorrow .tlabel { letter-spacing:.14em; text-transform:uppercase; color:#55707f; }
|
||||
|
||||
/* E's diptych. The art is the card; the words sit in the left third they left
|
||||
clear for exactly this. */
|
||||
#hud-card .endcard { background-size:cover; background-position:center right;
|
||||
@ -355,7 +375,7 @@ export function createHud(d) {
|
||||
* @param {{key:string, def:object}[]} storms
|
||||
* @param {(key:string) => void} onPick
|
||||
*/
|
||||
showForecast({ key, def, site }, wk, onGo) {
|
||||
showForecast({ key, def, site, tomorrowDef }, wk, onGo) {
|
||||
// Integrator merge (Sprint 8): A's week card shape + C's forecastLines —
|
||||
// MEASURED numbers, banded by lead (the old inline estimate read 30 m/s
|
||||
// for a storm that really gusts 32.3). Tonight is lead 0 = exact; when
|
||||
@ -381,15 +401,64 @@ showForecast({ key, def, site }, wk, onGo) {
|
||||
? `<div class="stat" style="color:#7ee0ff">${site.name}${site.blurb ? ` — ${site.blurb}` : ''}</div>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
// SPRINT11 — THE JOB SHEET. DESIGN.md's core loop opens with "the brief:
|
||||
// client wants X, budget Y, forecast Z", and this card had Z and nothing
|
||||
// else. The client and the brief are data (week.js NIGHTS), so an
|
||||
// unbriefed night degrades to the old storm card rather than an empty
|
||||
// letterhead — which is also what keeps every existing test honest.
|
||||
const job = wk.job ?? {};
|
||||
const letterhead = job.client
|
||||
? `<div class="letterhead">${job.client}</div>` : '';
|
||||
const brief = job.brief
|
||||
? `<p class="brief">${job.brief}</p>` : '';
|
||||
|
||||
// BUDGET Y — and this is the actual feature, not the flavour. The fee has
|
||||
// existed since Sprint 8 and the player has never seen it until the money
|
||||
// was already spent: you decided what to rig without knowing what the job
|
||||
// was worth, which is a reveal, not a decision. Quoting the maxima up front
|
||||
// makes the shop a judgement ("is $45 of garden bonus worth a $30
|
||||
// shackle?") instead of a guess.
|
||||
const q = wk.quote;
|
||||
const schedule = q ? `
|
||||
<div class="jobsheet">
|
||||
<div class="row"><span>base — the job</span><b>$${q.base}</b></div>
|
||||
<div class="row"><span>garden bonus — if the bed lives</span><b>up to $${q.garden}</b></div>
|
||||
<div class="row"><span>clean bonus — nothing of theirs broken</span><b>$${q.clean}</b></div>
|
||||
<div class="row total"><span>the job's worth</span><b>up to $${q.total}</b></div>
|
||||
</div>` : '';
|
||||
|
||||
// Lane C's forecast lead (SPRINT11 gate 2): tomorrow, hedged. Their
|
||||
// `forecastLines(def, lead)` has carried the lead param for two sprints
|
||||
// with nothing ever calling it above 0 — this is the call site.
|
||||
//
|
||||
// ⚠️ C: THE SLOT IS MINE, THE NUMBER IS YOURS. `lead` is a 0..1 haze dial
|
||||
// (confidence = 1 − lead), NOT "nights out" — I read it as nights first,
|
||||
// passed 1, and the card cheerfully advertised "forecast confidence 0%".
|
||||
// 0.6 because your own hail rule calls L > 0.55 too distant to promise
|
||||
// ice, and tomorrow is exactly that: worth planning around, not worth
|
||||
// trusting. It reads "confidence 40%" and the band resolves to an exact
|
||||
// number when tomorrow becomes tonight at lead 0. Tune it.
|
||||
const TOMORROW_LEAD = 0.6;
|
||||
const tf = tomorrowDef ? forecastLines(tomorrowDef, TOMORROW_LEAD) : null;
|
||||
const tomorrow = tf ? `
|
||||
<div class="tomorrow">
|
||||
<span class="tlabel">tomorrow</span>
|
||||
<span class="tline">${tf.wind}${tf.confidence ? ` · ${tf.confidence}` : ''}</span>
|
||||
</div>` : '';
|
||||
|
||||
card.innerHTML = `<div class="card jobcard">
|
||||
${letterhead}
|
||||
<h1>NIGHT ${wk.night} OF ${wk.nights}</h1>
|
||||
<div class="pips">${pips}</div>
|
||||
<h2>${(f.name ?? key).replace(/_/g, ' ').toUpperCase()}${night ? ' · NIGHT' : ''}</h2>
|
||||
${siteLine}
|
||||
${brief}
|
||||
<div class="stat">${f.wind}</div>
|
||||
<div class="stat">${f.rain}${hail ? ' · hail forecast' : ''}</div>
|
||||
${f.confidence ? `<div class="stat" style="color:#8ba0ad">${f.confidence}</div>` : ''}
|
||||
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||||
${tomorrow}
|
||||
${schedule}
|
||||
<div class="row" style="margin-top:10px"><span>in the bank</span><b>$${wk.bank}</b></div>
|
||||
<div class="stat" style="color:#8ba0ad;margin-top:10px">
|
||||
${hail
|
||||
? 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.'
|
||||
@ -464,22 +533,40 @@ showForecast({ key, def, site }, wk, onGo) {
|
||||
*/
|
||||
showAftermath(r, onAgain) {
|
||||
const w = r.week;
|
||||
// What happened. The money is the ledger's business — collateral used to be
|
||||
// listed here AND priced there, which is one fact told twice; the invoice
|
||||
// itemises it now, so this stays the job and that stays the bill.
|
||||
const rows = [
|
||||
['garden', `${r.hp.toFixed(0)}%`],
|
||||
['corners intact', `${r.cornersTotal - r.cornersLost}/${r.cornersTotal}`],
|
||||
['what got through', r.hailBlocked],
|
||||
['hardware lost', r.bill ? `$${r.bill}` : 'none'],
|
||||
['collateral', r.collateral.length ? r.collateral.map((c) => `${c.what} ($${c.cost})`).join(', ') : 'none'],
|
||||
].map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
|
||||
|
||||
// The money, itemised. A settlement you can't read is a number you can't
|
||||
// argue with, and arguing with it is how you learn the shop.
|
||||
//
|
||||
// SPRINT11 — this is the INVOICE now, so it answers the job sheet line for
|
||||
// line: base, garden, clean. Each row names what it was FOR, and the two
|
||||
// that can come up empty say why rather than vanishing — a bonus that
|
||||
// silently isn't there teaches nothing, and "clean bonus — you took the
|
||||
// carport" is the sentence that makes the trap land. E's copy for the
|
||||
// collateral row, verbatim from THREADS: "the carport — $180".
|
||||
const collateralRows = w && w.collateral
|
||||
? (r.collateral ?? []).map((c) => `
|
||||
<div class="row bad"><span>${c.what}</span><b>−$${c.cost}</b></div>`).join('')
|
||||
: '';
|
||||
const cleanRow = w ? (w.clean
|
||||
? `<div class="row"><span>clean bonus — nothing of theirs broken</span><b>+$${w.clean}</b></div>`
|
||||
: `<div class="row bad"><span>clean bonus — forfeited</span><b>$0</b></div>`) : '';
|
||||
|
||||
const money = w ? `
|
||||
<div class="ledger">
|
||||
<div class="row"><span>${w.won ? 'fee' : `fee (night lost — ${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||||
<div class="row"><span>garden bonus</span><b>+$${w.bonus}</b></div>
|
||||
<div class="row"><span>${w.won ? 'base — the job' : `base — night lost (${Math.round(100 * w.fee / Math.max(1, w.fullFee))}%)`}</span><b>+$${w.fee}</b></div>
|
||||
<div class="row"><span>garden bonus — ${r.hp.toFixed(0)}% of the bed</span><b>+$${w.bonus}</b></div>
|
||||
${cleanRow}
|
||||
<div class="row"><span>gear recovered</span><b>+$${w.refund}</b></div>
|
||||
${w.collateral ? `<div class="row bad"><span>collateral</span><b>−$${w.collateral}</b></div>` : ''}
|
||||
${collateralRows}
|
||||
<div class="row"><span>spent on the rig</span><b>−$${w.spent}</b></div>
|
||||
<div class="row total"><span>in the bank</span><b>$${w.bankBefore} → $${w.bankAfter}</b></div>
|
||||
</div>` : '';
|
||||
@ -489,7 +576,8 @@ showForecast({ key, def, site }, wk, onGo) {
|
||||
: w.outcome === 'win' ? 'SEE THE WEEK'
|
||||
: 'SEE THE DAMAGE';
|
||||
|
||||
card.innerHTML = `<div class="card">
|
||||
card.innerHTML = `<div class="card jobcard">
|
||||
${w?.client ? `<div class="letterhead">${w.client}<span class="inv">invoice</span></div>` : ''}
|
||||
<h1>MORNING${w ? ` · NIGHT ${w.night} OF 5` : ''}</h1>
|
||||
<h2>${r.subtitle}</h2>
|
||||
${rows}
|
||||
|
||||
@ -728,9 +728,19 @@ export async function boot(opts = {}) {
|
||||
rigging.session._startBudget = week.bank;
|
||||
rigging.session.reset();
|
||||
spentThisNight = 0;
|
||||
// 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
|
||||
// def for Lane C's forecast lead — every storm is loaded up front, so
|
||||
// tomorrow costs nothing but an index. Null on the final night: there is no
|
||||
// tomorrow, and a card that hedges about one would be lying.
|
||||
const tomorrowDef = week.isFinalNight ? null : defs[nightAt(week.night).storm];
|
||||
hud.showForecast(
|
||||
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site] },
|
||||
{ night: week.night, nights: week.nights, bank: week.bank, log: week.log },
|
||||
{ key: week.stormKey, def: defs[week.stormKey], site: siteMeta[week.site], tomorrowDef },
|
||||
{
|
||||
night: week.night, nights: week.nights, bank: week.bank, log: week.log,
|
||||
job: week.job, quote: week.quote(defs[week.stormKey]), isFinalNight: week.isFinalNight,
|
||||
},
|
||||
() => { stormKey = week.stormKey; game.setPhase('prep'); },
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import { createCameraRig } from '../camera.js';
|
||||
import { createGame, createWindRouter, verdictFor } from '../main.js';
|
||||
import { orderRing } from '../sail.js';
|
||||
import { loadStorm, createWind } from '../weather.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW } from '../week.js';
|
||||
import { createWeek, NIGHTS, nightAt, gradeFor, BROKE_BELOW, PAY } from '../week.js';
|
||||
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
|
||||
|
||||
/** @param {import('../testkit.js').Suite} t */
|
||||
@ -86,12 +86,88 @@ export default async function run(t) {
|
||||
);
|
||||
assertEq(s.bankBefore, 80);
|
||||
assertEq(s.bankAfter, 80 - 70 + s.pay, 'bank = bank − spent + pay, nothing else');
|
||||
assertEq(s.pay, s.fee + s.bonus + s.refund - s.collateral, 'the ledger adds up as shown');
|
||||
// SPRINT11 — `clean` is the new term, and this assert is the reason it's
|
||||
// spelled out here rather than folded into the fee: the invoice shows four
|
||||
// lines, so pay must BE those four lines. It caught the clean bonus the
|
||||
// moment it landed (got 143, want 123), which is exactly its job — an
|
||||
// invoice whose rows don't sum to its total is the one bug a player will
|
||||
// definitely find.
|
||||
assertEq(s.pay, s.fee + s.bonus + s.refund + s.clean - s.collateral, 'the ledger adds up as shown');
|
||||
assertEq(s.refund, 30, 'gear comes home at half — a shackle that rode a gale is not new');
|
||||
w.advance();
|
||||
assertEq(w.bank, s.bankAfter, 'the bank IS the next shop');
|
||||
});
|
||||
|
||||
// --- SPRINT11 gate 2: the job sheet -------------------------------------
|
||||
|
||||
t.test('every night is a JOB — a client, a brief, and a yard', () => {
|
||||
for (let i = 0; i < NIGHTS.length; i++) {
|
||||
const j = nightAt(i);
|
||||
assert(j.client, `night ${i + 1} has a client`);
|
||||
assert(j.brief && j.brief.length > 20, `night ${i + 1} has a brief worth reading`);
|
||||
assert(j.site, `night ${i + 1} has a yard`);
|
||||
}
|
||||
// The week's shape, which is DATA and not decoration: four nights on
|
||||
// retainer at one yard while they're away, and ONE short-notice callout to
|
||||
// a stranger's corner block. Night 3 landing differently is the whole point
|
||||
// of the ladder — a different site MEANS a different client.
|
||||
const clients = NIGHTS.map((_, i) => nightAt(i).client);
|
||||
assertEq(new Set(clients).size, 2, 'two clients: the retainer and the one-off');
|
||||
assert(clients[2] !== clients[0], 'night 3 is somebody else');
|
||||
assertEq(nightAt(2).site, 'site_02_corner_block', "and it's their corner block");
|
||||
});
|
||||
|
||||
t.test('an unbriefed night is a job with no letterhead, not a crash', () => {
|
||||
// SPRINT10's promise was that a bare storm key still resolves. SPRINT11 must
|
||||
// keep it: the job sheet degrades to the old storm card rather than throwing
|
||||
// or printing "undefined" at a client.
|
||||
const j = nightAt(99); // off the end entirely
|
||||
assertEq(j.client, null, 'no client is null, not undefined');
|
||||
assertEq(j.brief, null, 'no brief is null');
|
||||
assertEq(j.site, 'backyard_01', 'and it still names a yard');
|
||||
});
|
||||
|
||||
t.test('the job sheet quotes what the night pays BEFORE you rig it', () => {
|
||||
// The feature, not the flavour. The fee has existed since Sprint 8 and was
|
||||
// only ever shown in the aftermath — you chose what to spend without knowing
|
||||
// what the job was worth. That's a reveal, not a decision.
|
||||
const w = createWeek();
|
||||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const q = w.quote(def);
|
||||
assertEq(q.base, PAY.feeFor(20), 'base is the fee, quoted at full');
|
||||
assertEq(q.garden, PAY.gardenBonusMax, 'the garden bonus at a perfect bed');
|
||||
assertEq(q.clean, PAY.noCollateralBonus, 'and the clean bonus');
|
||||
assertEq(q.total, q.base + q.garden + q.clean, 'the quote sums to what it promises');
|
||||
|
||||
// The quote must not lie: settle a perfect night and the invoice has to pay
|
||||
// what the sheet advertised. A job sheet that over-promises is worse than no
|
||||
// job sheet — it's the game lying on paper.
|
||||
const s = createWeek().settle({ hp: 100, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||||
assertEq(s.fee + s.bonus + s.clean, q.total, 'a perfect night pays exactly the quote');
|
||||
});
|
||||
|
||||
t.test('the clean bonus is about THEIR property, not your success', () => {
|
||||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const gnome = [{ what: 'garden gnome', cost: 25 }];
|
||||
|
||||
const cleanWin = createWeek().settle({ hp: 90, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||||
const dirtyWin = createWeek().settle({ hp: 90, win: true, collateral: gnome, intactHardwareValue: 0 }, def, 0);
|
||||
const cleanLoss = createWeek().settle({ hp: 10, win: false, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||||
|
||||
assertEq(cleanWin.clean, PAY.noCollateralBonus, 'broke nothing: paid');
|
||||
assertEq(dirtyWin.clean, 0, 'broke the gnome: forfeited');
|
||||
// The deliberate one. Lose the garden but break nothing of theirs and you
|
||||
// are still a tradesperson who didn't wreck the place. Different fact,
|
||||
// different row — that's what makes it an invoice and not a score.
|
||||
assertEq(cleanLoss.clean, PAY.noCollateralBonus, 'a lost night can still be a clean one');
|
||||
|
||||
// And it makes the trap bite twice: 180 + the forfeited bonus.
|
||||
const carport = createWeek().settle(
|
||||
{ hp: 90, win: true, collateral: [{ what: 'the carport', cost: 180 }], intactHardwareValue: 0 }, def, 0);
|
||||
assertEq(cleanWin.pay - carport.pay, 180 + PAY.noCollateralBonus,
|
||||
'the carport costs 200: the roof AND the bonus');
|
||||
});
|
||||
|
||||
t.test('a lost night pays a fraction of the fee, not zero and not all of it', () => {
|
||||
const def = { baseCurve: [[0, 10]], gusts: { powBase: 5, powRamp: 5 } };
|
||||
const won = createWeek().settle({ hp: 80, win: true, collateral: [], intactHardwareValue: 0 }, def, 0);
|
||||
|
||||
@ -30,19 +30,78 @@ import { START_BUDGET, HARDWARE } from './contracts.js';
|
||||
* and its storm carries the southerly change that makes the venturi scream. The
|
||||
* plain-string form is still accepted (defaults to backyard_01) so nothing that
|
||||
* reads NIGHTS[i] as a storm key breaks.
|
||||
*
|
||||
* SPRINT11: a night is now a JOB. Same ladder, same yards — what's new is the
|
||||
* frame DESIGN.md has had from line one: *"work arrives as callouts — each one a
|
||||
* site with existing conditions, a client brief, a budget, and a forecast
|
||||
* window."* You are not surviving five nights; you are working five jobs.
|
||||
*
|
||||
* The week that fell out of the data, once it was asked to name a client: the
|
||||
* Hendersons are away (SPRINT11's own line — they leave for Cairns), so four of
|
||||
* the five nights are the same yard, on retainer, through a bad week. Night 3 is
|
||||
* a different site, so it is necessarily a different client — a short-notice
|
||||
* callout to a yard you have never rigged, on the night the change comes early.
|
||||
* That is not flavour bolted on: it is what `site: site_02_corner_block` already
|
||||
* MEANT, finally said out loud. The corner block lands harder because it's a
|
||||
* stranger's place and you're there at short notice.
|
||||
*
|
||||
* Copy lives here as data, per SPRINT11's rule: client flavour is data, not code.
|
||||
*/
|
||||
export const NIGHTS = [
|
||||
{ storm: 'storm_01_gentle', site: 'backyard_01' },
|
||||
{ storm: 'storm_03_southerly', site: 'backyard_01' },
|
||||
{ storm: 'storm_03b_earlybuster', site: 'site_02_corner_block' },
|
||||
{ storm: 'storm_02_wildnight', site: 'backyard_01' },
|
||||
{ storm: 'storm_02b_icenight', site: 'backyard_01' },
|
||||
{
|
||||
storm: 'storm_01_gentle', site: 'backyard_01',
|
||||
client: 'the Hendersons',
|
||||
brief: 'They fly out Friday and the seedlings are the whole point — six weeks in, first season that '
|
||||
+ 'took. Get the sail up while it\'s calm. Nothing tonight will test it.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_03_southerly', site: 'backyard_01',
|
||||
client: 'the Hendersons',
|
||||
brief: 'A southerly, and hail with it. This is the one the seedlings can\'t take — cloth over the bed '
|
||||
+ 'or there\'s no bed on Monday.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_03b_earlybuster', site: 'site_02_corner_block',
|
||||
client: 'the Vasilaros place',
|
||||
brief: 'Short notice — corner block two streets over, never rigged it. Same southerly as Tuesday, '
|
||||
+ 'except the change is through before you\'ve finished your coffee. They said there\'s plenty '
|
||||
+ 'to tie off to.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_02_wildnight', site: 'backyard_01',
|
||||
client: 'the Hendersons',
|
||||
brief: 'Back at the Hendersons\', and this is the night the whole week was pointing at. Rig it like '
|
||||
+ 'they\'re watching, because Friday they will be.',
|
||||
},
|
||||
{
|
||||
storm: 'storm_02b_icenight', site: 'backyard_01',
|
||||
client: 'the Hendersons',
|
||||
brief: 'Less wind than last night and more ice, which is worse for the bed and easier on the rig. '
|
||||
+ 'Last night before they land.',
|
||||
},
|
||||
];
|
||||
|
||||
/** A night entry, either shape → {storm, site}. */
|
||||
/**
|
||||
* A night entry, every shape → a full JOB.
|
||||
*
|
||||
* The plain-string form still resolves (SPRINT10's promise), and now so does a
|
||||
* night with no client: an unbriefed job is a job with no letterhead, not a
|
||||
* crash. `pay` is per-job so the schedule is data — see PAY for the defaults and
|
||||
* for why the clean bonus is the one number here that is NOT yet measured.
|
||||
*
|
||||
* @param {number} i
|
||||
* @returns {{storm:string, site:string, client:string|null, brief:string|null, pay:object}}
|
||||
*/
|
||||
export function nightAt(i) {
|
||||
const n = NIGHTS[i];
|
||||
return typeof n === 'string' ? { storm: n, site: 'backyard_01' } : n;
|
||||
const base = typeof n === 'string' ? { storm: n, site: 'backyard_01' } : (n ?? {});
|
||||
return {
|
||||
storm: base.storm,
|
||||
site: base.site ?? 'backyard_01',
|
||||
client: base.client ?? null,
|
||||
brief: base.brief ?? null,
|
||||
pay: base.pay ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,6 +143,36 @@ export const PAY = {
|
||||
gardenBonusMax: 45,
|
||||
/** What surviving hardware is worth back. Fatigued, not new. */
|
||||
refundShare: 0.5,
|
||||
|
||||
/**
|
||||
* Nothing of theirs broken (SPRINT11 gate 2 — the third line on the invoice).
|
||||
*
|
||||
* It exists to give the schedule three axes that mean three different things,
|
||||
* which is what turns a scoreboard into an invoice:
|
||||
* base — you turned up and did the job (severity-scaled; less if it went wrong)
|
||||
* garden — how much of what they actually cared about survived
|
||||
* clean — you left their place the way you found it
|
||||
* Deliberately NOT gated on winning: it's about their property, not your
|
||||
* success. Lose the garden but break nothing and you're still a tradesperson
|
||||
* who didn't wreck the place — that's a different fact and it gets its own row.
|
||||
*
|
||||
* It also makes the carport bite twice, which is the point: take it and you
|
||||
* lose 180 AND this. Sized so that total sting is 200, still inside E's
|
||||
* "above ~250 and one mistake is a silent game over" ceiling.
|
||||
*
|
||||
* ⚠️ **THE ONE NUMBER IN THIS FILE THAT IS NOT MEASURED — for gate 3 to settle.**
|
||||
* Every other constant here was set by playing the week. This one couldn't be:
|
||||
* it's new money, and MEASURED (node, the model in BROKE_BELOW's note) it makes
|
||||
* a competent week bank 385 → 485, i.e. it adds ~$100 to a five-night run that
|
||||
* §BROKE_BELOW already flags as running away with itself. I did not fund it by
|
||||
* quietly trimming `feeFor` or `gardenBonusMax` — those two carry measured
|
||||
* evidence and long reasons, and re-tuning them on my taste to pay for my own
|
||||
* new feature is exactly the move this repo keeps catching.
|
||||
* So: if gate 3 says the bank runs away, THIS is the lever to cut first. It's
|
||||
* the newest and least load-bearing number in the economy, it's per-job data,
|
||||
* and cutting it costs nothing but a row on the invoice.
|
||||
*/
|
||||
noCollateralBonus: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -169,6 +258,30 @@ export function createWeek(opts = {}) {
|
||||
get stormKey() { return nightAt(index).storm; },
|
||||
/** SPRINT10: which yard tonight is on. main.js loads it. */
|
||||
get site() { return nightAt(index).site; },
|
||||
/** SPRINT11: tonight's job — client, brief, pay schedule. The job sheet reads this. */
|
||||
get job() { return nightAt(index); },
|
||||
|
||||
/**
|
||||
* What tonight pays, BEFORE you rig it — the "budget Y" half of DESIGN.md's
|
||||
* brief, and the reason the job sheet is worth building. Until now the fee
|
||||
* existed only in the aftermath: you found out what the job was worth after
|
||||
* you'd decided what to spend on it, which is not a decision, it's a reveal.
|
||||
*
|
||||
* Quotes the maxima honestly: base at full (what a held night pays), garden
|
||||
* at hp 100, clean at not-a-scratch. What you actually bank is the invoice's
|
||||
* business — a quote is what's on offer, not a promise.
|
||||
*
|
||||
* @param {object} def tonight's storm def
|
||||
*/
|
||||
quote(def) {
|
||||
const j = nightAt(index);
|
||||
return {
|
||||
base: PAY.feeFor(gustPeakOf(def)),
|
||||
garden: j.pay.garden ?? PAY.gardenBonusMax,
|
||||
clean: j.pay.clean ?? PAY.noCollateralBonus,
|
||||
get total() { return this.base + this.garden + this.clean; },
|
||||
};
|
||||
},
|
||||
get bank() { return bank; },
|
||||
get isFinalNight() { return index === NIGHTS.length - 1; },
|
||||
get over() { return done; },
|
||||
@ -191,14 +304,25 @@ export function createWeek(opts = {}) {
|
||||
const bankBefore = bank;
|
||||
const fullFee = PAY.feeFor(gustPeakOf(def));
|
||||
const fee = run.win ? fullFee : Math.round(fullFee * PAY.lostNightShare);
|
||||
const bonus = Math.round((Math.max(0, run.hp) / 100) * PAY.gardenBonusMax);
|
||||
// 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
|
||||
// doesn't pay. That's not a rounding difference, it's a lie on paper.
|
||||
const gardenMax = nightAt(index).pay.garden ?? PAY.gardenBonusMax;
|
||||
const bonus = Math.round((Math.max(0, run.hp) / 100) * gardenMax);
|
||||
|
||||
// Only hardware still on an unbroken corner comes home, and at half.
|
||||
const intact = (run.intactHardwareValue ?? 0);
|
||||
const refund = Math.round(intact * PAY.refundShare);
|
||||
|
||||
const collateral = (run.collateral ?? []).reduce((s, c) => s + c.cost, 0);
|
||||
const pay = fee + bonus + refund - collateral;
|
||||
|
||||
// The clean bonus. Per-job data first, PAY's default otherwise — so a job
|
||||
// that wants to say "there's a lot here to break" can, without code.
|
||||
const job = nightAt(index);
|
||||
const cleanMax = job.pay.clean ?? PAY.noCollateralBonus;
|
||||
const clean = collateral === 0 ? cleanMax : 0;
|
||||
|
||||
const pay = fee + bonus + refund + clean - collateral;
|
||||
|
||||
bank = Math.max(0, bankBefore - spent + pay);
|
||||
|
||||
@ -220,9 +344,10 @@ export function createWeek(opts = {}) {
|
||||
else outcome = 'continue';
|
||||
done = outcome !== 'continue';
|
||||
|
||||
const s = { fee, fullFee, bonus, refund, collateral, pay, spent,
|
||||
const s = { fee, fullFee, bonus, refund, clean, cleanMax, collateral, pay, spent,
|
||||
bankBefore, bankAfter: bank, outcome, night: week.night, held,
|
||||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win };
|
||||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win,
|
||||
client: job.client, site: job.site };
|
||||
log.push(s);
|
||||
return s;
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user