HardYards/web/world/js/week.js
m3ultra 0f5d69b32d The second site: corner block loads from JSON; night 3 moves to it
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>
2026-07-17 16:05:06 +10:00

238 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}