SPRINT10 gate 1b. A night is now {storm, site}; the week hands over a yard per
night and night three is the corner block. Nights 1/2/4/5 are the backyard; the
plain-string night form still works and defaults to backyard_01.
site_02_corner_block.json: a smaller 24x16 yard with E's carport as the trap it
was built to be — beam anchors rating_hint 0.22, posts 0.30, both below the
house fascia's 0.35, collateral "carport". The site IS winnable off the honest
anchors (one tree east, four ground posts): I enumerated the honest quads at
authoring time and there are four in-band ones covering the bed, so the carport
is temptation not necessity. Full load audit is Lane B's tools/site_audit.
The carport's beams are keyed work:"bracket" — D's field, the MECHANISM not the
type. A bracket up a bare beam is the one anchor you can't fake from the ground,
so it needs the ladder; keyed on type it would fail open (a carport isn't a
house) and the ladder mechanic would silently vanish, which is exactly the audit
D ran. C's venturi list is in the JSON with two gap coordinates they asked for.
The world is rebuilt on a site change, not re-pointed: createPlayer captures the
world into its ground clamp, collider, ladder and broom (Lane D's modules, no
setters), so a clean rebuild is honest — and a site change only happens at a
forecast, never mid-storm. world.dispose() frees the old yard so nothing leaks.
The re-point of rig + session lives inside loadSiteInto, keyed on the rebuild
itself, after a caller-side version proved fragile.
Verified end to end through real phase transitions: play night 1, hit the
aftermath, and the forecast into night 3 rebuilds into the corner block —
carport present, 10 anchors (cb/cp/q/tr, not h/t/p), smaller bed, player rebuilt,
no scene leak. Then back to the backyard, 12 anchors restored. Selftest 289/0/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
10 KiB
JavaScript
238 lines
10 KiB
JavaScript
/**
|
||
* SHADES — the week. Lane A owns this file.
|
||
*
|
||
* Five nights, one yard, one wallet. This is deliberately a thin wrapper around
|
||
* the phase machine rather than a new system: main.js already knows how to run
|
||
* forecast → prep → storm → aftermath, and the week's whole job is to answer
|
||
* three questions between rounds — which storm is tonight, how much money do you
|
||
* have, and is it over.
|
||
*
|
||
* Zero imports beyond contracts, and no THREE: the money and the ladder of
|
||
* nights are pure data, so they're testable at fixed cost with no browser. The
|
||
* only thing that touches the screen is main.js reading these numbers.
|
||
*/
|
||
|
||
import { START_BUDGET, HARDWARE } from './contracts.js';
|
||
|
||
/**
|
||
* The ladder. Escalating, and each rung introduces one new idea rather than just
|
||
* more wind — that's Lane C's authoring, not my ordering:
|
||
* 1 Sea Breeze gust 10.5, no hail — the tutorial: nothing can hurt you
|
||
* 2 Southerly Buster gust 20.0, hail — the change, and hail, arrive
|
||
* 3 Early Buster gust 20.0, hail — same teeth, change comes EARLY
|
||
* 4 Wild Night gust 30.0, hail — the one the whole repo tuned for
|
||
* 5 Ice Night gust 28.5, hail — less wind, more ice
|
||
* Night 3 is the trap: it looks like night 2 on the forecast card and isn't.
|
||
*
|
||
* SPRINT10: a night is now a STORM and a SITE. Nights 1/2/4/5 are the backyard;
|
||
* night 3 moves to the corner block — the same "looks like night 2 and isn't"
|
||
* beat, sharpened: it's a new yard you've never rigged, its carport is a trap,
|
||
* 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.
|
||
*/
|
||
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' },
|
||
];
|
||
|
||
/** A night entry, either shape → {storm, site}. */
|
||
export function nightAt(i) {
|
||
const n = NIGHTS[i];
|
||
return typeof n === 'string' ? { storm: n, site: 'backyard_01' } : n;
|
||
}
|
||
|
||
/**
|
||
* The economy, in one place because it is the thing most likely to need tuning
|
||
* once somebody actually plays five nights in a row.
|
||
*
|
||
* Shape (SPRINT8 §gate 1): pay = fee by severity + garden bonus + intact
|
||
* hardware refund − collateral. Two of those needed a judgement call and both
|
||
* are the difference between a week with stakes and a week you cannot lose:
|
||
*
|
||
* - **A lost night pays a fraction of the fee, not zero and not all of it.**
|
||
* Zero is unrecoverable and reads as punishment; full pay means failure costs
|
||
* only the broken shackle, and the bank only ever climbs. DESIGN.md's warranty
|
||
* callout is the source: you turn up, you're just not getting paid for it.
|
||
* - **Hardware comes back at half.** Full refund makes gear effectively free —
|
||
* the only sink left is breakage, the bank grows every night, and "broke =
|
||
* game over" becomes unreachable. Half is also the honest number: a shackle
|
||
* that rode out a gale has been fatigued, its pin worked loose, and DESIGN.md
|
||
* already says flogging unscrews unmoused pins. You can re-use it; you can't
|
||
* call it new.
|
||
*/
|
||
export const PAY = {
|
||
/**
|
||
* Client's fee for the job, scaled off the storm's worst gust.
|
||
*
|
||
* The constants are set by the shape of the week, not by taste — measured by
|
||
* playing all five nights at fixed skill. A fee has to be worth roughly
|
||
* two-thirds of a good rig ($75): any smaller and one bad night is instant
|
||
* game over (at `8 + gust` a disaster night paid $13 against a $75 rig and
|
||
* ended the week on night ONE, which is a punishment, not a difficulty); any
|
||
* larger and the bank runs away and night five is trivially affordable.
|
||
* At `25 + 1.6·gust` a disaster leaves you at ~$26 — alive, but rigging the
|
||
* next night on carabiners, which is the death spiral doing its job.
|
||
*/
|
||
feeFor: (gustPeak) => Math.round(25 + gustPeak * 1.6),
|
||
/** A lost night still pays this share — you turned up, it just went wrong. */
|
||
lostNightShare: 0.35,
|
||
/** Garden bonus at hp=100. Scales linearly; this is the main earner. */
|
||
gardenBonusMax: 45,
|
||
/** What surviving hardware is worth back. Fatigued, not new. */
|
||
refundShare: 0.5,
|
||
};
|
||
|
||
/**
|
||
* Below this you cannot rig four corners at all — four carabiners is the floor.
|
||
*
|
||
* ⚠️ MEASURED, OPEN, AND FOR A PLAYTEST TO SETTLE — not a guess, and written
|
||
* here because the numbers are only visible once you run five nights:
|
||
*
|
||
* 1. **A competent week runs away with the money.** Holding 4/4 at hp 70 every
|
||
* night takes the bank 80 → 116 → 167 → 218 → 285 → 350. By night three you
|
||
* can already afford four rated shackles ($120) and the wild night stops
|
||
* being a decision. The fee is the lever; it is deliberately NOT tuned down
|
||
* yet, because the fix might equally be that later nights should cost more
|
||
* to rig (a bigger sail, a second bed) — that's Sprint 9's content question,
|
||
* not a constant I should quietly pick tonight.
|
||
* 2. **A floor-scraping week may never actually go broke.** At $20 a cheap rig
|
||
* earns back roughly what it costs, so the bank can sit just above this line
|
||
* indefinitely, losing every garden. The grade fixes what the END CARD says
|
||
* about that (see gradeFor) but not whether 'gameover' is reachable at all
|
||
* from a slow bleed rather than one disaster.
|
||
*
|
||
* I could not settle either from the harness: my own bad-player model handed
|
||
* back a fixed $30 of intact hardware no matter what it spent, which flattered
|
||
* the bottom of the curve and is exactly the class of mistake this repo has
|
||
* caught me making all project. Both need a human playing five nights.
|
||
*/
|
||
export const BROKE_BELOW = HARDWARE[0].cost * 4;
|
||
|
||
/**
|
||
* How the week is remembered, by gardens actually held.
|
||
*
|
||
* SPRINT8 gave Lane A the win wording, and this is the ruling: the end card is
|
||
* the last thing SHADES says to a player, so it does not get to lie the way the
|
||
* aftermath verdict used to. Three grades, and E's copy carries two of them —
|
||
* their primary for a clean week, their own alternate for a scraped one. The
|
||
* third is mine, because E never wrote words for "you stayed solvent while the
|
||
* client's garden died four times", and until this was measured nobody knew the
|
||
* game could even produce that ending.
|
||
*
|
||
* @param {number} held gardens saved, 0..5
|
||
*/
|
||
export function gradeFor(held) {
|
||
if (held >= 5) return 'clean';
|
||
if (held >= 3) return 'scraped';
|
||
return 'solvent';
|
||
}
|
||
|
||
/** Worst gust a storm def can throw — the severity signal the fee reads. */
|
||
export function gustPeakOf(def) {
|
||
const base = Math.max(...def.baseCurve.map((p) => p[1]));
|
||
const g = def.gusts ?? {};
|
||
return base + (g.powBase ?? 0) + (g.powRamp ?? 0);
|
||
}
|
||
|
||
/**
|
||
* @typedef {object} Settlement
|
||
* @property {number} fee client's payment for the job
|
||
* @property {number} bonus garden bonus
|
||
* @property {number} refund intact hardware recovered, at half
|
||
* @property {number} collateral what your failure broke of theirs
|
||
* @property {number} pay fee + bonus + refund − collateral
|
||
* @property {number} bankBefore
|
||
* @property {number} bankAfter
|
||
* @property {'continue'|'win'|'gameover'} outcome
|
||
*/
|
||
|
||
/**
|
||
* @param {object} [opts]
|
||
* @param {number} [opts.bank=START_BUDGET]
|
||
*/
|
||
export function createWeek(opts = {}) {
|
||
const startBank = opts.bank ?? START_BUDGET;
|
||
let index = 0; // 0-based into NIGHTS
|
||
let bank = startBank;
|
||
let done = false;
|
||
/** @type {Settlement[]} */
|
||
const log = [];
|
||
|
||
const week = {
|
||
/** 1-based, for humans. "Night 3 of 5". */
|
||
get night() { return index + 1; },
|
||
get nights() { return NIGHTS.length; },
|
||
get stormKey() { return nightAt(index).storm; },
|
||
/** SPRINT10: which yard tonight is on. main.js loads it. */
|
||
get site() { return nightAt(index).site; },
|
||
get bank() { return bank; },
|
||
get isFinalNight() { return index === NIGHTS.length - 1; },
|
||
get over() { return done; },
|
||
get log() { return log; },
|
||
|
||
reset() {
|
||
index = 0; bank = startBank; done = false; log.length = 0;
|
||
return week;
|
||
},
|
||
|
||
/**
|
||
* Settle the night just played and move the ladder on.
|
||
*
|
||
* @param {object} run main.js's scoreRun() result
|
||
* @param {object} def tonight's storm def (for severity)
|
||
* @param {number} spent what prep actually cost out of the bank
|
||
* @returns {Settlement}
|
||
*/
|
||
settle(run, def, spent) {
|
||
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);
|
||
|
||
// 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;
|
||
|
||
bank = Math.max(0, bankBefore - spent + pay);
|
||
|
||
const held = log.filter((n) => n.won).length + (run.win ? 1 : 0);
|
||
|
||
// Outcome is decided AFTER the money moves: you can win the last night and
|
||
// still be broke, and the week should say you finished it.
|
||
//
|
||
// Reaching night five is NOT winning, and the first draft of this said it
|
||
// was. Measured: a player who rigs $20 of carabiners every night loses
|
||
// four gardens out of five, never dips under the broke line because a
|
||
// cheap rig barely costs anything, and got handed "THE WEEK HELD —
|
||
// everything's still where you put it" over four dead gardens. Outlasting
|
||
// the week isn't surviving it. So the WORDS scale with what you actually
|
||
// held (see grade); the outcome only says whether you're still trading.
|
||
let outcome;
|
||
if (week.isFinalNight) outcome = 'win';
|
||
else if (bank < BROKE_BELOW) outcome = 'gameover';
|
||
else outcome = 'continue';
|
||
done = outcome !== 'continue';
|
||
|
||
const s = { fee, fullFee, bonus, refund, collateral, pay, spent,
|
||
bankBefore, bankAfter: bank, outcome, night: week.night, held,
|
||
grade: gradeFor(held), stormKey: week.stormKey, won: !!run.win };
|
||
log.push(s);
|
||
return s;
|
||
},
|
||
|
||
/** Move to the next night. No-op once the week is done. */
|
||
advance() {
|
||
if (!done && index < NIGHTS.length - 1) index++;
|
||
return week;
|
||
},
|
||
};
|
||
return week;
|
||
}
|