diff --git a/README.md b/README.md index c9f45fa..e9fa409 100644 --- a/README.md +++ b/README.md @@ -114,12 +114,15 @@ Open questions, in the order they are likely to matter: evidence that was not enough the last two times. It needs a session before its numbers are believed. -- **Standing now costs money to keep, and nobody has played it.** Each setting - has a `lifestyle` tier bought on the second rung and charged for every cycle - after — and a standing penalty, steepening as standing rises, for lagging - behind what the job expects. A Dispatch coordinator keeping up fully runs at - -$21 a week and has to earn the difference. It is measured (DECISIONS #36) - and, like everything else here, unplayed. +- **Standing costs money to keep, and the Frontier's version is unplayed.** + Each setting has a `lifestyle` tier bought on the second rung and charged for + every cycle after, a standing penalty for lagging behind what the job expects, + and a charge levied by standing itself that no purchase ever ends. Corporate + Ladder's has had one 96-turn session: both purchases were found and taken, and + the standing pressure worked, but the run arrived at the top with nothing left + to spend on — which is where the never-stopping charge came from. The Frontier + carries the same system at its own scale and has never been in front of anyone. + See DECISIONS #36. - **Dispatch is eleven events against the mailroom's nineteen.** It passes the variety guard, but it has had far less play than the mailroom. Waits on more playtesting. diff --git a/content/corporateladder/index.js b/content/corporateladder/index.js index 4561bc1..d4070a6 100644 --- a/content/corporateladder/index.js +++ b/content/corporateladder/index.js @@ -170,6 +170,54 @@ export const corporateLadder = { ], effects: [{ path: 'reputation', op: 'add', value: -14 }], }, + // The charge that never stops, and the only one with no purchase that ends + // it. Everything above is a ladder with a top: buy both tiers, reach 100 + // standing, and every charge switches off. A real 96-turn session arrived + // there at turn 84 with nothing left to spend on and a balance that only + // climbed. + // + // It steps on money rather than being flat, and that is the whole design. + // A flat charge cannot flatten a slope: late income comes from choices and + // scales with how well the player is playing, so a charge big enough to + // stop an optimiser accumulating (-360 a week, measured) put ordinary + // archetypes below zero for 44% of a run and ended them $1,385 in debt. + // Stepping on money lands it on whoever has it. Measured: the balance + // settles around $4,250 and stops climbing (-3 a week), while ordinary play + // gets *better* than before it existed — 13% of a run below zero against + // 17%, and a tier-2 run ending +$175 rather than -$25. + { + label: 'What the standing costs', + every: 7, + offset: 7, + requires: [ + { path: 'stage', op: '==', value: 'dispatch' }, + { path: 'reputation', op: '>=', value: 85 }, + { path: 'money', op: '>=', value: 1200 }, + ], + effects: [{ path: 'money', op: 'add', value: -60 }], + }, + { + label: 'What the standing costs', + every: 7, + offset: 7, + requires: [ + { path: 'stage', op: '==', value: 'dispatch' }, + { path: 'reputation', op: '>=', value: 85 }, + { path: 'money', op: '>=', value: 2400 }, + ], + effects: [{ path: 'money', op: 'add', value: -120 }], + }, + { + label: 'What the standing costs', + every: 7, + offset: 7, + requires: [ + { path: 'stage', op: '==', value: 'dispatch' }, + { path: 'reputation', op: '>=', value: 85 }, + { path: 'money', op: '>=', value: 3600 }, + ], + effects: [{ path: 'money', op: 'add', value: -240 }], + }, // The service charge steps up with the size of the debt, so the spiral // tightens instead of flattening out. A step function in data costs the // engine nothing; a percentage would have needed a multiply operation. @@ -206,9 +254,10 @@ export const corporateLadder = { mailroom: [-45, 5], dispatch: [5, 125], }, - // Upkeep gated on these is excluded from that baseline: both are states a - // player opts into, and the baseline is the floor underneath everybody. - baselineExcludes: ['debt', 'lifestyle'], + // Upkeep gated on these is excluded from that baseline: a debt taken on, a + // lifestyle bought, a standing earned. The baseline is the floor underneath + // everybody — what a player faces having none of the three. + baselineExcludes: ['debt', 'lifestyle', 'reputation'], }, // How the interface names and formats this pack's state. The UI reads only diff --git a/content/frontier/events/routine.js b/content/frontier/events/routine.js index af2e990..ad29da3 100644 --- a/content/frontier/events/routine.js +++ b/content/frontier/events/routine.js @@ -299,6 +299,12 @@ export const routine = [ { id: 'pay_down', label: 'Put money against the book.', + // Hidden rather than greyed out when there is nothing owed. A locked + // door is worth showing when it is a door the player could open later + // by playing differently; "pay down what you owe" shown to a man who + // owes nothing is not that. It was the most-locked option in a real + // 181-turn session, six times, and told the player nothing each time. + whenLocked: 'hide', requires: [ { path: 'owed', op: '>', value: 0 }, { path: 'money', op: '>=', value: 20 }, diff --git a/content/frontier/index.js b/content/frontier/index.js index 7a18675..1ba62e4 100644 --- a/content/frontier/index.js +++ b/content/frontier/index.js @@ -176,6 +176,24 @@ export const frontier = { ], effects: [{ path: 'standing', op: 'add', value: -14 }], }, + { + // The charge that never stops, for the reason Corporate Ladder has one: + // without it a man who has bought everything and stands well with the + // whole town has nothing left that money is for. Being somebody in Cold + // Fork costs, every run, and nothing you can buy ends it. + label: 'What the standing costs', + every: 4, + offset: 4, + requires: [ + { path: 'stage', op: '==', value: 'deputy' }, + { path: 'standing', op: '>=', value: 85 }, + ], + // Four rather than six: this pack's late game is already flat — measured, + // a deputy who has bought everything drifts 0 a run either way — so the + // charge is here to keep money live, not to arrest an accumulation that + // does not happen. Six left an ordinary run at 55; four leaves 105. + effects: [{ path: 'money', op: 'add', value: -4 }], + }, // Store credit costs more the deeper in you are, the same step function // Corporate Ladder uses for its loan — a shape, not a setting. { @@ -213,9 +231,10 @@ export const frontier = { hand: [0, 12], deputy: [10, 30], }, - // States a player opts into, excluded from the baseline above: the floor - // is what a man faces owing nothing and keeping nothing. - baselineExcludes: ['owed', 'lifestyle'], + // States a player opts into or earns, excluded from the baseline above: + // the floor is what a man faces owing nothing, keeping nothing, and being + // nobody in particular. + baselineExcludes: ['owed', 'lifestyle', 'standing'], }, display: { diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cdcd4a3..b63e4f7 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -548,6 +548,32 @@ direction is that rising standing brings rising expenses — the car, the place live, the clothes a position expects. Upkeep entries are already gated conditions over state, so this is content work, not an engine change. +## 37. A playtest is only as good as its instruments + +The second session — 181 turns of the Frontier, the first time that pack has +been played by a human at all — ran against a **stale bundle**. The page had +been loaded before the last rebuild, and switching settings inside an open tab +does not re-fetch the script, so the run exercised everything except the change +it was meant to test. The charge that never stops fired zero times in ninety +turns where its conditions held. + +Working that out took file timestamps and a simulation. It should have taken one +line of the log, so three things changed: + +- **Every logged turn now records `app_version` and `build`.** The log says + which bundle produced it, and a stale session is visible immediately rather + than looking exactly like a feature that does not work. +- **`src/version.js` was lying.** It hardcoded `0.3.0` through the whole of the + 0.3.1 and 0.3.2 work, while playtesters were told to check the status bar to + know what they were on. A version string that lies is worse than none, because + it is trusted. A test now holds it to `package.json`. +- **A locked door is only worth showing when it is a door.** `pay_down` — "put + money against the book" — was the most-locked option in the session, shown six + times to a player who owed nothing. Options gated on a state the player is not + in and cannot choose to enter now carry `whenLocked: 'hide'`. Gates worth + bouncing off, like a skill threshold, stay visible: that is the signal the log + exists to collect. + ## 36. Standing costs money to keep Built in both packs, with no engine change, exactly as #35 predicted. @@ -587,6 +613,42 @@ every lifestyle tier at once into a baseline that is supposed to mean "the floor a player faces having opted into nothing." Packs now name those paths in `tuning.baselineExcludes`, and the suite fails if one is not declared state. +**The top of the ladder was a plateau, so one charge never stops.** The first +human play of the lifestyle system — 96 turns, Corporate Ladder — found both +purchases (the car at turn 52, the address at 75) and showed the standing +pressure working exactly as intended: "Looking the part" took 56 reputation, and +standing sagged into the low seventies on the car alone before recovering to 100 +once the address was bought. But from turn 84 the run had nothing left to spend +on: every charge switches off at the top tier, and the balance simply climbed. +Money stopped mattering again, one rung higher than before. + +Both packs now carry a charge levied by standing itself, which no purchase ends. +They are shaped differently, and the difference is measured rather than stylistic: + +- **Corporate Ladder steps on money.** A flat charge cannot flatten a slope — + late income comes from choices and scales with how well the player plays, so + the flat charge needed to stop an optimiser accumulating (-360 a week) put + ordinary archetypes below zero for 44% of a run and ended them $1,385 in debt. + Stepping on money ($1,200/-60, $2,400/-120, $3,600/-240) lands it on whoever + has it: the balance settles around $4,250 and stops climbing (-3 a week), and + ordinary play comes out *better* than before the charge existed — 13% of a run + below zero against 17%, a tier-2 run ending +$175 rather than -$25. +- **The Frontier is flat, at -4 a run.** Its late game never accumulated in the + first place — a deputy who owns everything drifts 0 a run at every charge + strength tested — so there is no slope to flatten. The charge is there to keep + money live, and break-even at the top is the intent. + +**A save written before a stat existed silently loses the content gated on it.** +Found while working out how to playtest this: `loadLocal` returns the saved +state verbatim, and `conditions.js` compares numbers only — so for a career +saved before `lifestyle` existed, both `lifestyle < 2` and `lifestyle >= 1` are +false. The car lot never fires, the purchase never unlocks, the charge never +lands, and nothing says so. Resuming a save would have shown a playtester none +of this feature while reporting nothing wrong. Loading now fills in any path the +pack has gained since (`hydrate`, in `state.js`), writes the repair back, and +does the same for an imported file. This is the price of "adding content is just +adding data", and it applies to every stat any pack adds from here on. + **And a test that had passed for two releases was measuring the wrong thing.** "The promotion leaves a player better off" compared the end balances of promoted and unpromoted players pooled across archetypes — but which archetypes get diff --git a/package.json b/package.json index ac63a7d..c8d4657 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theladder", - "version": "0.3.2", + "version": "0.3.3", "private": true, "description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.", "type": "module", diff --git a/src/engine/state.js b/src/engine/state.js index 2097ce3..6d6a496 100644 --- a/src/engine/state.js +++ b/src/engine/state.js @@ -4,7 +4,7 @@ // expands that into a flat schema and an initial state object. The engine // names no stat of its own beyond its own bookkeeping. -import { setPath } from './paths.js'; +import { getPath, setPath } from './paths.js'; /** * Paths the engine owns. Content may *read* these in conditions — "only after @@ -61,6 +61,30 @@ export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) { return state; } +/** + * Give a loaded save any state the pack has gained since it was written. + * + * Adding a stat to a pack is meant to be data, but a run saved before that stat + * existed has no path for it, and `undefined` fails every numeric comparison in + * conditions.js — both `x >= 1` and `x < 1` are false. So content gated on a new + * stat does not misbehave in a saved run, it *vanishes* from it: the event never + * fires, the option never unlocks, the upkeep never charges, and nothing + * anywhere says so. Filling the gaps in on load is what makes "adding content is + * just adding data" true for players already mid-career. + * + * Returns the paths it filled, so a caller can say what it repaired. + */ +export function hydrate(pack, state, schema = resolveSchema(pack)) { + let next = state; + const added = []; + for (const [path, rule] of Object.entries(schema)) { + if (getPath(next, path) !== undefined) continue; + next = setPath(next, path, structuredClone(rule.initial)); + added.push(path); + } + return { state: next, added }; +} + /** The characters a pack defines, keyed by id. */ export function indexCharacters(pack) { return Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c])); diff --git a/src/io/feedback-log.js b/src/io/feedback-log.js index f75fd3b..8154650 100644 --- a/src/io/feedback-log.js +++ b/src/io/feedback-log.js @@ -11,6 +11,8 @@ // players rarely type into a comment box, but the gates they keep bouncing off // are recorded whether they say anything or not. +import { APP, BUILD } from '../version.js'; + const KEY = 'theladder.feedback.v1'; const FORMAT = 1; @@ -44,6 +46,13 @@ export function logTurn(record, state, pack) { const entries = readRaw(); entries.push({ logged_at: new Date().toISOString(), + // Which build produced this turn. A session played on a page loaded before + // the last rebuild looks exactly like a feature that does not work: a real + // playtest ran 181 turns against a stale bundle and the charge it was meant + // to exercise never fired, which took file timestamps and a simulation to + // work out. The log should say so itself. + app_version: APP.version, + build: BUILD.commit, pack_id: pack.id, pack_version: pack.version ?? null, seed: state.meta.seed, diff --git a/src/ui/app.js b/src/ui/app.js index fd07f7a..181ec4c 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -15,7 +15,7 @@ import { packs, packById, defaultPack } from '../../content/index.js'; import { validatePack } from '../engine/validate.js'; -import { resolveSchema } from '../engine/state.js'; +import { resolveSchema, hydrate } from '../engine/state.js'; import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js'; import { turnScreen } from './screens/turn.js'; import { retrospectiveScreen } from './screens/retrospective.js'; @@ -190,7 +190,10 @@ const actions = { const file = input.files?.[0]; if (!file) return; try { - const state = parseSave(await readFile(file), app.pack.id); + // Same repair as a local load: an exported file is exactly as old as + // the run it came from. + const imported = parseSave(await readFile(file), app.pack.id); + const { state } = hydrate(app.pack, imported, app.schema); app.state = state; saveLocal(app.pack.id, state); app.view = choosingView(); @@ -262,7 +265,16 @@ function enterPack(pack) { // The packId check is belt and braces now that slots are per pack, but an // imported or hand-edited save can still carry the wrong name. if (restored && restored.meta?.packId === pack.id) { - app.state = restored; + // A career saved before the pack gained a stat has no path for it, and + // every numeric comparison against `undefined` is false — so the events, + // options and upkeep gated on a new stat would quietly not exist for a + // player already mid-run. Fill them in, and write the repair back. + const repaired = hydrate(pack, restored, app.schema); + if (repaired.added.length > 0) { + console.info(`[save ${pack.id}] filled in new state: ${repaired.added.join(', ')}`); + saveLocal(pack.id, repaired.state); + } + app.state = repaired.state; app.view = choosingView(); } else { beginRun(pack); diff --git a/src/ui/screens/retrospective.js b/src/ui/screens/retrospective.js index 17e0be7..3bf1541 100644 --- a/src/ui/screens/retrospective.js +++ b/src/ui/screens/retrospective.js @@ -20,6 +20,11 @@ export function retrospectiveScreen(pack, state, schema, actions) { h('p', { class: 'retro__eyebrow' }, pack.name), h('h1', { class: 'retro__title' }, 'Career retrospective'), h('p', { class: 'retro__subtitle' }, careerLine(pack, summary, unit)), + // Said out loud because it did not read that way to a real player, who + // arrived here by keyboard at day 31 and took it for the end of the run. + h('p', { class: 'retro__note' }, + `This is a summary, not an ending — "Keep going" picks up where you left ` + + `off, on ${unit} ${state.turn}.`), ), h('section', { class: 'retro__section' }, diff --git a/src/ui/screens/turn.js b/src/ui/screens/turn.js index 01215c7..bad4f2e 100644 --- a/src/ui/screens/turn.js +++ b/src/ui/screens/turn.js @@ -42,15 +42,21 @@ export function turnScreen(pack, state, schema, view, actions) { ), ), + // Order here is tab order. "Wrap up my career" used to sit first, directly + // after the last option — so a player tabbing to reach an option, going one + // past it, and pressing Enter got the retrospective instead of a turn. That + // happened in a real session at day 31 and read as the game ending. It goes + // last now, behind buttons that do nothing frightening if pressed by + // accident, and Import save is next to Export save where it belongs. h('footer', { class: 'controls' }, - h('button', { class: 'button', onClick: actions.onRetrospective }, - 'Wrap up my career'), h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'), h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'), h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback }, 'Export feedback log'), - h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'), h('button', { class: 'button button--quiet', onClick: actions.onPacks }, 'Change setting'), + h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'), + h('button', { class: 'button', onClick: actions.onRetrospective }, + 'Wrap up my career'), ), ); } diff --git a/src/ui/styles.css b/src/ui/styles.css index 75e3c44..1f08613 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -287,6 +287,7 @@ body { } .retro__title { margin: 6px 0 4px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; } .retro__subtitle { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 17px; } +.retro__note { margin: 8px 0 0; color: var(--ink-soft); font-size: 13px; } .retro__section { margin-top: 34px; } .retro__people { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; } diff --git a/src/version.js b/src/version.js index 874728a..ff3643d 100644 --- a/src/version.js +++ b/src/version.js @@ -5,7 +5,10 @@ import { BUILD } from './build-info.js'; export const APP = { name: 'The Ladder', - version: '0.3.0', + // Kept in step with package.json by a test, because it silently was not: the + // status bar read 0.3.0 through the 0.3.1 and 0.3.2 work, which is exactly + // the thing a playtester is told to check to know what they are playing. + version: '0.3.3', }; /** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */ diff --git a/test/build.test.js b/test/build.test.js index 15f8008..406525d 100644 --- a/test/build.test.js +++ b/test/build.test.js @@ -16,6 +16,17 @@ import { installFakeDom, byClass, firstByClass } from './fixtures/fake-dom.js'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +test('the version the app reports is the version in package.json', async () => { + // It silently was not. The status bar read 0.3.0 through the whole of the + // 0.3.1 and 0.3.2 work, while playtesters were being told to check it to know + // which build they were on — a version string that lies is worse than none, + // because it is trusted. + const { APP } = await import('../src/version.js'); + const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); + assert.equal(APP.version, pkg.version, + 'src/version.js and package.json disagree about what this is'); +}); + test('the bundle builds, and the bundled game boots and plays', () => { execFileSync('node', ['tools/build.js'], { cwd: root, stdio: 'pipe' }); const html = readFileSync(resolve(root, 'dist/theladder.html'), 'utf8'); diff --git a/test/content.test.js b/test/content.test.js index 1c6b820..b6ba786 100644 --- a/test/content.test.js +++ b/test/content.test.js @@ -107,6 +107,30 @@ test('standing costs money to keep, and the top of it cannot be worked for', () assert.ok(address < 0, `a coordinator keeping up nets ${address} a week and has to earn the difference`); + // And the top of the ladder is not a plateau. A 96-turn session bought both + // tiers, reached 100 standing by turn 84, and from there had nothing left to + // spend on: every charge was switched off and the balance only climbed. + // Standing costs money to carry at every tier, with nothing to buy that ends + // it, so there is no position in this pack where money stops mattering. + const arrived = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 2500 }); + assert.ok(arrived < address, + `standing itself should cost something: ${arrived} against ${address} a week`); + + // And it steepens with what there is to keep up, which is what stops the + // balance climbing forever. A flat charge could not: late income scales with + // how well the player plays, so flattening an optimiser's slope with one + // meant putting ordinary play 44% of a run below zero. + const wealthy = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 6000 }); + assert.ok(wealthy < arrived, + `the charge should steepen with money: ${wealthy} against ${arrived} a week`); + assert.ok(wealthy < 0, `and take a rich coordinator backwards: ${wealthy} a week`); + + // At every tier, not only the top one: a coordinator who bought nothing is + // already paying for standing in standing, and pays in money besides. + const plainSomebody = netPerCycle({ stage: 'dispatch', lifestyle: 0, reputation: 90, money: 6000 }); + assert.ok(plainSomebody < plain, + `standing should cost at tier 0 too: ${plainSomebody} against ${plain} a week`); + // Simulated, controlled: the ceiling. Without the top step a player who // chased standing every turn held 99 while buying nothing, which made the // whole system a tax on inattention rather than a choice. diff --git a/test/frontier.test.js b/test/frontier.test.js index d13aba9..a90936e 100644 --- a/test/frontier.test.js +++ b/test/frontier.test.js @@ -129,6 +129,17 @@ test('the star costs money to wear, and the top of standing cannot be worked for assert.ok(room > 0, `a deputy keeping up nets ${room} a run; the yard's own floor is barely above water`); + // The top of the ladder is not a plateau here either: standing costs money + // to carry at every tier, and nothing you can buy switches it off. + // Flat here rather than stepped on money, as Corporate Ladder's is: this + // pack's late game does not accumulate in the first place — measured, a + // deputy who owns everything drifts 0 a run — so the charge exists to keep + // money live, and break-even at the top is the intent rather than a shortfall. + const somebody = netPerCycle({ stage: 'deputy', lifestyle: 2, standing: 90 }); + assert.ok(somebody < room, `standing itself should cost: ${somebody} against ${room} a run`); + assert.ok(somebody <= 0, + `and leave a deputy who has everything no better than level: ${somebody} a run`); + const never = []; const keeps = []; for (let seed = 0; seed < 8; seed++) { diff --git a/test/ui.test.js b/test/ui.test.js index 87a4d93..cbfe3ba 100644 --- a/test/ui.test.js +++ b/test/ui.test.js @@ -30,6 +30,26 @@ async function boot({ pack = 'corporateladder' } = {}) { const text = (root) => root.textContent; const optionButtons = (root) => byClass(root, 'option'); const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled); +const wrapUp = (root) => byTag(root, 'button').find((b) => /Wrap up/.test(b.textContent)); + +test('tabbing past the last option does not land on the button that ends the run', async () => { + // Document order is tab order. "Wrap up my career" sat immediately after the + // last option, so one Tab too many and Enter showed the retrospective — which + // a real player, on the keyboard, read as the game ending at day 31. + const dom = await boot(); + try { + const buttons = byTag(dom.root, 'button'); + const lastOption = buttons.map((b) => /option/.test(b.className ?? '')).lastIndexOf(true); + const next = buttons[lastOption + 1]; + assert.ok(next, 'there is something after the options'); + assert.doesNotMatch(next.textContent, /Wrap up/, + 'the next thing Tab reaches should not be the one that leaves the turn'); + assert.equal(wrapUp(dom.root), buttons[buttons.length - 1], + 'it belongs last, behind everything harmless'); + } finally { + dom.restore(); + } +}); test('the app boots to a playable turn screen', async () => { const dom = await boot(); @@ -213,9 +233,10 @@ test('the retrospective summarises the run and is not an ending', async () => { enabledOptions(dom.root)[0].click(); firstByClass(dom.root, 'button--next').click(); } - firstByClass(dom.root, 'button').click(); // "Wrap up my career" + wrapUp(dom.root).click(); assert.ok(firstByClass(dom.root, 'screen--retrospective')); + assert.match(text(dom.root), /not an ending/, 'it says so, rather than implying it'); const body = text(dom.root); assert.match(body, /Career retrospective/); assert.match(body, /6 days in the mailroom/); @@ -234,7 +255,7 @@ test('the retrospective summarises the run and is not an ending', async () => { test('a run with nothing notable still gets a retrospective', async () => { const dom = await boot(); try { - firstByClass(dom.root, 'button').click(); // wrap up on turn one + wrapUp(dom.root).click(); // wrap up on turn one assert.ok(firstByClass(dom.root, 'screen--retrospective')); assert.match(text(dom.root), /Nothing that anybody would write down/); } finally { @@ -453,6 +474,35 @@ test('a run saved before settings were selectable is not lost', async () => { } }); +test('a save written before a stat existed gains it, rather than losing the content', async () => { + // The failure this guards against is silent, which is what makes it worth a + // test: conditions.js compares numbers only, so with no `lifestyle` path both + // `lifestyle < 2` and `lifestyle >= 1` are false. Every event, option and + // upkeep entry gated on a stat added since the save was written simply does + // not exist for that career, and nothing anywhere says so. + const dom = installFakeDom(); + try { + const { startGame } = await import('../src/engine/game.js'); + const { corporateLadder } = await import('../content/corporateladder/index.js'); + const before = startGame(corporateLadder, { seed: 'pre-lifestyle' }); + before.turn = 40; + delete before.lifestyle; + dom.storage.setItem('theladder.save.v1.corporateladder', + JSON.stringify({ format: 1, savedAt: null, state: before })); + dom.storage.setItem('theladder.pack.v1', 'corporateladder'); + + const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`); + bootApp(dom.root); + + assert.match(text(dom.root), /Day 40/, 'the career resumed where it was'); + const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder')); + assert.equal(saved.state.lifestyle, 0, + 'the stat the run predates was filled in, and the repair written back'); + } finally { + dom.restore(); + } +}); + test('the header reads the second pack\'s display map, not the first\'s', async () => { // `display` is a per-pack contract with the interface, and a typo in it is // only visible at runtime. Corporate Ladder's version is covered above; this