From 5d645e64521f3f1ff499d7ec6a6d1d7795f69c73 Mon Sep 17 00:00:00 2001 From: JesseMarkowitz Date: Sat, 12 Sep 2026 21:53:03 -0400 Subject: [PATCH] Make standing cost money to keep, in both settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each pack declares a lifestyle tier, bought on the second rung and charged for every cycle after: Corporate Ladder's car and address, the Frontier's bay and the room over the Ellis place. Falling behind what the job expects costs standing, in steps that steepen as standing rises. A Dispatch coordinator keeping up fully nets -21 a week and earns the difference out of the days. No engine change; the only interface change is a tier display format. Sized by measurement, not feel (DECISIONS #36): a -2/week penalty is invisible against Dispatch's standing gains, and a player who chases standing every turn beats any flat penalty — 99 standing while buying nothing, and $2,245 richer than one who kept up. Hence the top step. Two defects fixed on the way. The conformance suite excluded debt from the economic baseline by matching /debt|owed|loan/i on path names, which would have counted every lifestyle tier into it; packs now declare tuning.baselineExcludes. And 'the promotion leaves a player better off' pooled end balances across archetypes that do not spend alike, so it measured the mix — it is now derived from the upkeep arithmetic. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g --- README.md | 13 ++-- content/corporateladder/events/dispatch.js | 59 +++++++++++++++ content/corporateladder/index.js | 83 +++++++++++++++++++++ content/frontier/events/deputy.js | 60 +++++++++++++++ content/frontier/index.js | 71 ++++++++++++++++++ docs/DECISIONS.md | 47 ++++++++++++ package.json | 2 +- src/ui/components.js | 4 + src/ui/format.js | 6 +- test/conformance.test.js | 20 ++++- test/content.test.js | 85 +++++++++++++++++++++- test/frontier.test.js | 72 +++++++++++++++++- 12 files changed, 507 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b3e0aac..c9f45fa 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,12 @@ 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. -- **Money stops mattering late, and it should not.** A careful player ends - around $8,300 over 200 turns. The direction is set: standing should cost - money to keep — a better car, a better place to live, better clothes — so - expenses climb as the player rises. Not built yet. +- **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. - **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. @@ -126,7 +128,8 @@ Open questions, in the order they are likely to matter: Waits until the Frontier has been played. Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and -the feedback widget stays as it is (DECISIONS #35). +the feedback widget stays as it is (DECISIONS #35). Money mattering late is +built rather than open (DECISIONS #36). ## Layout diff --git a/content/corporateladder/events/dispatch.js b/content/corporateladder/events/dispatch.js index 1e7cef4..33bf17f 100644 --- a/content/corporateladder/events/dispatch.js +++ b/content/corporateladder/events/dispatch.js @@ -485,6 +485,65 @@ export const dispatch = [ }, ], }, + + { + // The cost of standing, offered rather than imposed. Buying nothing is + // always playable and always cheaper; it is Dispatch's weekly "looking the + // part" charge that makes it a real decision instead of a shop. + id: 'the_car_lot', + stage: 'dispatch', + weight: 45, + cooldown: 6, + requires: [{ path: 'lifestyle', op: '<', value: 2 }], + description: + 'Marlene mentions, not quite to you, that the coordinator before last ' + + 'drove something with a roof rack, and that people noticed when he ' + + 'stopped. There is a lot on Ninth with a sign about payments.', + options: [ + { + id: 'buy_the_car', + label: 'Put money down on the car.', + requires: [ + { path: 'lifestyle', op: '==', value: 0 }, + { path: 'money', op: '>=', value: 450 }, + ], + result: + 'It is four years old and the radio only gets one station, and on ' + + 'Monday two people who have never spoken to you ask about it.', + effects: [ + { path: 'money', op: 'add', value: -450 }, + { path: 'lifestyle', op: 'set', value: 1 }, + { path: 'reputation', op: 'add', value: 3 }, + { path: 'notable', op: 'push', value: 'Bought a car with a roof rack.' }, + ], + }, + { + id: 'trade_up', + label: 'Trade up, and take the lease on the better side of the park.', + requires: [ + { path: 'lifestyle', op: '==', value: 1 }, + { path: 'money', op: '>=', value: 900 }, + ], + result: + 'The address is two stops closer and four hundred a month dearer. ' + + 'Nobody says anything about it, which is the entire point of it.', + effects: [ + { path: 'money', op: 'add', value: -900 }, + { path: 'lifestyle', op: 'set', value: 2 }, + { path: 'reputation', op: 'add', value: 5 }, + { path: 'notable', op: 'push', value: 'Moved to the better side of the park.' }, + ], + }, + { + id: 'keep_the_bus_pass', + label: 'Keep the bus pass. The bus is fine.', + result: + 'The bus is fine. You read two chapters a day on it, which is two ' + + 'more than anybody on Ninth manages.', + effects: [{ path: 'skills.logistics', op: 'add', value: 1 }], + }, + ], + }, ]; export default dispatch; diff --git a/content/corporateladder/index.js b/content/corporateladder/index.js index eb43d84..4561bc1 100644 --- a/content/corporateladder/index.js +++ b/content/corporateladder/index.js @@ -45,6 +45,10 @@ export const corporateLadder = { 'reputation': { initial: 50, min: 0, max: 100 }, 'skills.negotiation': { initial: 5, min: 0, max: 100 }, 'skills.logistics': { initial: 5, min: 0, max: 100 }, + // What standing costs to keep. Nothing forces a player to climb this, but + // Dispatch charges standing for staying at the bottom of it — see the + // "Looking the part" entries in upkeep, and DECISIONS #36. + 'lifestyle': { initial: 0, min: 0, max: 2 }, 'flags.took_loan': { initial: false }, 'flags.holds_the_envelope': { initial: false }, 'flags.covered_for_trevor': { initial: false }, @@ -96,6 +100,76 @@ export const corporateLadder = { effects: [{ path: 'money', op: 'add', value: 1260 }], }, { label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] }, + // What the job expects of you, and what meeting that expectation costs. + // Dispatch nets +49 a week; a coordinator with the car is +19, and one + // with the car and the address is -21 and has to earn the difference out + // of the days themselves. That is the answer to "money stops mattering + // late": it stops mattering only if standing is free to keep. + { + label: 'Car payment and parking', + every: 7, + offset: 7, + requires: [{ path: 'lifestyle', op: '>=', value: 1 }], + effects: [{ path: 'money', op: 'add', value: -30 }], + }, + { + label: 'The better address', + every: 7, + offset: 7, + requires: [{ path: 'lifestyle', op: '>=', value: 2 }], + effects: [{ path: 'money', op: 'add', value: -40 }], + }, + // The pressure that makes the spending a choice rather than a vanity. Both + // are gated on Dispatch: the mailroom pays too little for this to be + // anything but a trap, and "later in a career" is the point. + { + label: 'Looking the part', + every: 7, + offset: 7, + requires: [ + { path: 'stage', op: '==', value: 'dispatch' }, + { path: 'lifestyle', op: '<', value: 1 }, + ], + effects: [{ path: 'reputation', op: 'add', value: -4 }], + }, + { + // Standing above sixty-five is a thing you are seen to be, and being seen + // to be it runs on a better address than a coordinator's first one. + // + // Sized by measurement, not by feel. At -2 a week this was invisible: + // Dispatch hands out standing several points at a time, so a coordinator + // who bought nothing still finished pinned at 100 and the charge may as + // well not have existed. At -6, with the entry above, a non-buyer settles + // in the low seventies while somebody keeping up finishes at 100 — the + // ceiling is real and money is what lifts it. + label: 'Looking the part', + every: 7, + offset: 7, + requires: [ + { path: 'stage', op: '==', value: 'dispatch' }, + { path: 'reputation', op: '>=', value: 65 }, + { path: 'lifestyle', op: '<', value: 2 }, + ], + effects: [{ path: 'reputation', op: 'add', value: -6 }], + }, + { + // The top step, and the one that makes the whole thing a choice rather + // than a tax on carelessness. Without it, a player who spends every turn + // chasing standing simply out-earns the charge: measured, they held 99 + // while never buying anything, and finished $2,245 richer than a player + // who kept up — so the lifestyle was a pure loss to anybody paying + // attention. With it they plateau in the low eighties. What money buys + // up here is the last fifteen points, which cannot be worked for. + label: 'Looking the part', + every: 7, + offset: 7, + requires: [ + { path: 'stage', op: '==', value: 'dispatch' }, + { path: 'reputation', op: '>=', value: 80 }, + { path: 'lifestyle', op: '<', value: 2 }, + ], + effects: [{ path: 'reputation', op: 'add', value: -14 }], + }, // 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. @@ -132,6 +206,9 @@ 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'], }, // How the interface names and formats this pack's state. The UI reads only @@ -142,6 +219,12 @@ export const corporateLadder = { 'reputation': { label: 'Standing', format: 'meter', order: 3 }, 'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 }, 'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 }, + // Hidden until it is bought: a mailroom associate has no lifestyle to + // speak of, and a row reading "The bus" from day one is noise. + 'lifestyle': { + label: 'Lifestyle', format: 'tier', order: 6, hideWhenZero: true, + labels: ['The bus', 'A car', 'The address'], + }, // Kept out of the header, and out of the ledger too: `notable` is the // retrospective's own list, and "Notable — changed" tells nobody anything. 'notable': { hidden: true, inLedger: false }, diff --git a/content/frontier/events/deputy.js b/content/frontier/events/deputy.js index b66e7a9..64305c0 100644 --- a/content/frontier/events/deputy.js +++ b/content/frontier/events/deputy.js @@ -579,6 +579,66 @@ export const deputy = [ }, ], }, + + { + // Corporate Ladder's car lot, in Cold Fork's terms and at its scale. Same + // structure deliberately: an offer, never an obligation, with the weekly + // charge for falling short of it sitting in upkeep instead. + id: 'the_sale_barn', + stage: 'deputy', + weight: 45, + cooldown: 6, + requires: [{ path: 'lifestyle', op: '<', value: 2 }], + description: + 'The sale barn has a bay with four good years in him and a price on ' + + 'him. Ward says nothing about it, except that the county pays mileage ' + + 'on a horse a man owns and nothing at all on a hired one.', + options: [ + { + id: 'buy_the_bay', + label: 'Buy the bay.', + requires: [ + { path: 'lifestyle', op: '==', value: 0 }, + { path: 'money', op: '>=', value: 70 }, + ], + result: + 'He is a better animal than the livery has ever put under you, and ' + + 'the whole street watches you ride him down it once.', + effects: [ + { path: 'money', op: 'add', value: -70 }, + { path: 'lifestyle', op: 'set', value: 1 }, + { path: 'standing', op: 'add', value: 3 }, + { path: 'notable', op: 'push', value: 'Bought the bay out of the sale barn.' }, + ], + }, + { + id: 'take_the_room', + label: 'Take the room over the Ellis place, with the stove.', + requires: [ + { path: 'lifestyle', op: '==', value: 1 }, + { path: 'money', op: '>=', value: 150 }, + ], + result: + 'A door that locks, a stove, and a window over the street. The ' + + 'Cordwood does not hold it against you; the Cordwood holds nothing ' + + 'against anybody who pays up.', + effects: [ + { path: 'money', op: 'add', value: -150 }, + { path: 'lifestyle', op: 'set', value: 2 }, + { path: 'standing', op: 'add', value: 5 }, + { path: 'notable', op: 'push', value: 'Took the room with the stove over the Ellis place.' }, + ], + }, + { + id: 'ride_the_livery', + label: 'Ride what the livery gives you.', + result: + 'A different animal every week, and you learn something about each ' + + 'one of them in the first mile.', + effects: [{ path: 'skills.trade', op: 'add', value: 1 }], + }, + ], + }, ]; export default deputy; diff --git a/content/frontier/index.js b/content/frontier/index.js index 2fb53cf..7a18675 100644 --- a/content/frontier/index.js +++ b/content/frontier/index.js @@ -54,6 +54,10 @@ export const frontier = { 'standing': { initial: 50, min: 0, max: 100 }, 'skills.nerve': { initial: 5, min: 0, max: 100 }, 'skills.trade': { initial: 5, min: 0, max: 100 }, + // The same shape as Corporate Ladder's lifestyle, in this setting's own + // terms and at this setting's own scale: what the office expects a man to + // turn up on, and what keeping that up costs. See DECISIONS #36. + 'lifestyle': { initial: 0, min: 0, max: 2 }, 'flags.carries_iron': { initial: false }, 'flags.owes_the_store': { initial: false }, 'flags.covered_for_dutch': { initial: false }, @@ -112,6 +116,66 @@ export const frontier = { requires: [{ path: 'stage', op: '==', value: 'deputy' }], effects: [{ path: 'money', op: 'add', value: 38 }], }, + // What the star is expected to arrive on, and what that expectation costs + // to meet. The office nets +18 a run; a deputy on his own horse is +11, + // and one with the horse and the room is +4 and makes the rest on the + // days. Money is meant to keep mattering after the promotion — but the + // first pass charged 9 for the room and left a keeper's median balance at + // $8 after 200 turns, which is not pressure, it is the debt trap this pack + // was tuned twice to avoid. + { + label: 'Feed and shoeing', + every: 4, + offset: 4, + requires: [{ path: 'lifestyle', op: '>=', value: 1 }], + effects: [{ path: 'money', op: 'add', value: -7 }], + }, + { + label: 'The room over the Ellis place', + every: 4, + offset: 4, + requires: [{ path: 'lifestyle', op: '>=', value: 2 }], + effects: [{ path: 'money', op: 'add', value: -7 }], + }, + // Gated on the office for the same reason Corporate Ladder gates its + // equivalent on Dispatch: a hand cannot afford any of this, and being + // charged standing for that would be a trap rather than a choice. + { + label: 'What the star is expected to be', + every: 4, + offset: 4, + requires: [ + { path: 'stage', op: '==', value: 'deputy' }, + { path: 'lifestyle', op: '<', value: 1 }, + ], + effects: [{ path: 'standing', op: 'add', value: -2 }], + }, + { + label: 'What the star is expected to be', + every: 4, + offset: 4, + requires: [ + { path: 'stage', op: '==', value: 'deputy' }, + { path: 'standing', op: '>=', value: 65 }, + { path: 'lifestyle', op: '<', value: 2 }, + ], + effects: [{ path: 'standing', op: 'add', value: -2 }], + }, + { + // The top step, for the reason Corporate Ladder's has one: without it a + // man who plays for standing every single day holds a hundred of it on a + // livery horse, and the bay is money thrown away. With it he settles in + // the low eighties and the last stretch has to be bought. + label: 'What the star is expected to be', + every: 4, + offset: 4, + requires: [ + { path: 'stage', op: '==', value: 'deputy' }, + { path: 'standing', op: '>=', value: 80 }, + { path: 'lifestyle', op: '<', value: 2 }, + ], + effects: [{ path: 'standing', op: 'add', value: -14 }], + }, // Store credit costs more the deeper in you are, the same step function // Corporate Ladder uses for its loan — a shape, not a setting. { @@ -149,6 +213,9 @@ 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'], }, display: { @@ -157,6 +224,10 @@ export const frontier = { 'standing': { label: 'Standing', format: 'meter', order: 3 }, 'skills.nerve': { label: 'Nerve', format: 'meter', order: 4 }, 'skills.trade': { label: 'Trade', format: 'meter', order: 5 }, + 'lifestyle': { + label: 'What you keep', format: 'tier', order: 6, hideWhenZero: true, + labels: ['The livery horse', 'Your own horse', 'Horse and a room'], + }, 'notable': { hidden: true, inLedger: false }, 'flags.carries_iron': { label: 'The scattergun', hidden: true, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 498411f..cdcd4a3 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -547,3 +547,50 @@ ends around $8,300 over 200 turns, and money stops being a constraint. The direction is that rising standing brings rising expenses — the car, the place to live, the clothes a position expects. Upkeep entries are already gated conditions over state, so this is content work, not an engine change. + +## 36. Standing costs money to keep + +Built in both packs, with no engine change, exactly as #35 predicted. + +Each pack declares a `lifestyle` tier — Corporate Ladder's car and address, the +Frontier's own horse and the room over the Ellis place — bought at a second-rung +event and charged for every cycle thereafter. Dispatch nets +49 a week; with the +car +19; with both, **-21**, so a coordinator keeping up has to earn the +difference out of the days themselves. The Frontier runs the same shape at its +own scale: +18, +11, +4 a run. + +**The charge that makes it a choice is levied in standing, not money.** A player +whose lifestyle lags behind the job loses standing every cycle, in steps that +steepen as standing rises. Both are gated on the second stage: the first rung +pays too little for any of this, and charging a mailroom associate for a car +would be a trap rather than a decision. + +Three things were measured rather than guessed, and all three changed the design: + +- **At -2 a week the charge was invisible.** Dispatch hands out standing several + points at a time, so a coordinator who bought nothing still finished pinned at + 100. The step had to reach -4/-6 before ordinary play settled in the seventies. +- **A player who chased standing every turn beat it anyway** — 99 standing while + buying nothing, and $2,245 richer than one who kept up. The lifestyle was a + pure loss to anybody paying attention, which is the opposite of the intent. + The top step (-14 above 80 standing) puts that player's ceiling at 82, so the + last fifteen points are bought rather than worked for. +- **The Frontier's first numbers left a keeper on $8 after 200 turns.** That is + not pressure, it is the debt trap this pack was tuned twice to avoid; the room + went from 9 a run to 7. + +**Which upkeep counts as the baseline is now the pack's to declare.** The +conformance suite used to exclude debt-gated entries by matching +`/debt|owed|loan/i` against path names — the generic suite guessing at content's +vocabulary, which #31 exists to prevent, and which would have silently counted +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. + +**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 +promoted is not independent of how they spend, so the comparison mostly measured +the mix. It inverted the moment there was something to spend money on. It is now +derived from upkeep arithmetic, and the lifestyle's effect is tested with two +players who differ in exactly one habit. diff --git a/package.json b/package.json index 28fb522..ac63a7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theladder", - "version": "0.3.1", + "version": "0.3.2", "private": true, "description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.", "type": "module", diff --git a/src/ui/components.js b/src/ui/components.js index b4019fb..4b8a808 100644 --- a/src/ui/components.js +++ b/src/ui/components.js @@ -117,6 +117,10 @@ function deltaText(pack, change) { if (typeof change.to !== 'number' || typeof change.from !== 'number') { return typeof change.to === 'boolean' ? (change.to ? 'now true' : 'no longer true') : 'changed'; } + // A tier moved: "+1" says nothing, the name of the step says everything. + const rule = pack.display?.[change.path]; + if (rule?.format === 'tier') return rule.labels?.[change.to] ?? String(change.to); + const delta = change.to - change.from; const money = pack.display?.[change.path]?.format === 'money'; const magnitude = money diff --git a/src/ui/format.js b/src/ui/format.js index 2ee6c68..5d04b4c 100644 --- a/src/ui/format.js +++ b/src/ui/format.js @@ -34,7 +34,11 @@ export function labelFor(pack, path) { /** Money is written as money; everything else is a plain number. */ export function formatValue(pack, path, value) { if (typeof value === 'boolean') return value ? 'yes' : 'no'; - if (displayFor(pack, path)?.format === 'money') return formatMoney(value); + const rule = displayFor(pack, path); + if (rule?.format === 'money') return formatMoney(value); + // A tier is a number the player should never see as one: a pack declares + // what each step is called and the interface reads the name off the list. + if (rule?.format === 'tier') return rule.labels?.[value] ?? String(value); return String(value); } diff --git a/test/conformance.test.js b/test/conformance.test.js index 22d0d8d..4558a7e 100644 --- a/test/conformance.test.js +++ b/test/conformance.test.js @@ -48,16 +48,22 @@ function randomRun(pack, runSeed, turns) { * on where the last rent lands — Corporate Ladder measures -19 a fortnight over * 280 turns and -42 over 560, and only one of those is the steady state. * - * Entries gated on a debt path are excluded: the baseline is what a player - * faces owing nothing, which is the floor the pack is designed around. + * Entries gated on a path the pack calls elective are excluded: a debt it took + * on, a lifestyle it bought. The baseline is what a player faces having opted + * into none of them, which is the floor the pack is designed around. + * + * Which paths those are is the pack's to name, in `tuning.baselineExcludes`. + * This used to be a regex over path names — /debt|owed|loan/i — which is the + * generic suite guessing at content's vocabulary, exactly what DECISIONS #31 + * says it must not do. It also silently missed anything named otherwise. */ function baselinePerCycle(pack, stage) { const cycle = pack.calendar?.cycleLength ?? 1; - const debtish = (path) => /debt|owed|loan/i.test(path); + const elective = new Set(pack.tuning?.baselineExcludes ?? []); return (pack.upkeep ?? []) .filter((entry) => { const gate = entry.requires ?? []; - if (gate.some((c) => debtish(c.path))) return false; + if (gate.some((c) => elective.has(c.path))) return false; return !gate.some((c) => c.path === 'stage' && c.value !== stage); }) .reduce((total, entry) => { @@ -90,6 +96,12 @@ for (const pack of packs) { assert.ok(pack.stages[stage].title, `a title for stage "${stage}"`); assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline for stage "${stage}"`); } + // A typo here would not fail: the entry simply would not be excluded, and + // the baseline would quietly become a number nobody meant. + for (const path of pack.tuning.baselineExcludes ?? []) { + assert.ok(path in pack.state, + `baselineExcludes names "${path}", which the pack does not declare`); + } }); test(`${where} no content is dead: every event fires and every option becomes available`, () => { diff --git a/test/content.test.js b/test/content.test.js index 70580d8..1c6b820 100644 --- a/test/content.test.js +++ b/test/content.test.js @@ -19,16 +19,40 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { corporateLadder as pack } from '../content/corporateladder/index.js'; import { startGame, currentTurn, takeTurn } from '../src/engine/game.js'; +import { check } from '../src/engine/conditions.js'; import { play, sweep, median } from './fixtures/strategies.js'; +/** + * Net money from upkeep per week for a player in a given position: at a stage, + * at a lifestyle tier, owing nothing. + * + * Derived rather than simulated. Comparing simulated end-balances between + * promoted and unpromoted players looks like the obvious test and is + * confounded: which archetypes get promoted is not independent of how they + * spend, so the pooled medians mostly measure the archetype mix. It passed for + * two releases and then inverted the moment there was something to spend on. + */ +function netPerCycle(position) { + const state = { debt: 0, lifestyle: 0, reputation: 0, ...position }; + const cycle = pack.calendar?.cycleLength ?? 1; + return (pack.upkeep ?? []) + .filter((entry) => check(state, entry.requires).passed) + .reduce((total, entry) => total + entry.effects + .filter((e) => e.path === 'money' && e.op === 'add') + .reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0); +} + test('the promotion is worth taking, and reachable by a deliberate player', () => { const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 }); const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch'); const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom'); assert.ok(promoted.length > 0 && stayed.length > 0, 'both outcomes should occur'); - assert.ok(median(promoted.map((s) => s.money)) > median(stayed.map((s) => s.money)), - 'the coordinator wage should leave a player better off'); + + const mailroom = netPerCycle({ stage: 'mailroom' }); + const coordinator = netPerCycle({ stage: 'dispatch' }); + assert.ok(coordinator > mailroom + 40, + `dispatch nets ${coordinator} a week against the mailroom's ${mailroom}`); // A player actually pursuing it should get there mid-run, not at the very // end — the mailroom is a career, not a tutorial. @@ -44,6 +68,63 @@ test('the promotion is worth taking, and reachable by a deliberate player', () = assert.ok(typical > 40 && typical < 90, `promotion landed at turn ${typical}`); }); +/** + * Play to `turns` chasing standing, buying the lifestyle or refusing to. + * + * Two players differing in exactly one habit. The archetype sweep cannot + * answer this question: which archetypes get promoted is not independent of + * how they spend, so its pooled medians compare populations, not policies. + */ +function chaseStanding({ seed, keepUp, turns = 200 }) { + const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle'); + const gain = (r) => (r.option.effects ?? []) + .filter((e) => e.path === 'reputation' && e.op === 'add') + .reduce((sum, e) => sum + e.value, 0); + + let state = startGame(pack, { seed: `standing-${seed}` }); + for (let t = 0; t < turns; t++) { + const turn = currentTurn(pack, state); + const available = turn.options.filter((o) => o.available); + const buy = available.find(buys); + const chosen = (keepUp && buy) + ? buy + : [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0] + ?? available[0]; + state = takeTurn(pack, state, chosen.option.id).state; + } + return state; +} + +test('standing costs money to keep, and the top of it cannot be worked for', () => { + // Derived: each tier is a real weekly charge, and a coordinator keeping up + // fully is running at a loss on upkeep alone. That is the answer to "money + // stops mattering late" — it stopped mattering because standing was free. + const plain = netPerCycle({ stage: 'dispatch' }); + const car = netPerCycle({ stage: 'dispatch', lifestyle: 1 }); + const address = netPerCycle({ stage: 'dispatch', lifestyle: 2 }); + assert.ok(car < plain - 25, `the car costs ${plain - car} a week`); + assert.ok(address < car - 35, `the address costs ${car - address} a week`); + assert.ok(address < 0, + `a coordinator keeping up nets ${address} a week and has to earn the difference`); + + // 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. + const never = []; + const keeps = []; + for (let seed = 0; seed < 8; seed++) { + never.push(chaseStanding({ seed, keepUp: false })); + keeps.push(chaseStanding({ seed, keepUp: true })); + } + const standing = (runs) => median(runs.map((s) => s.reputation)); + assert.ok(standing(never) <= 90, + `a coordinator who buys nothing reached ${standing(never)} standing`); + assert.ok(standing(keeps) >= 95, + `a coordinator who keeps up reached only ${standing(keeps)} standing`); + assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)), + 'and the difference is paid for out of the balance'); +}); + test('hard times is uncommon for a careful player and real for a careless one', () => { const countHardTimes = (strategy) => { let days = 0; diff --git a/test/frontier.test.js b/test/frontier.test.js index f5c5044..d13aba9 100644 --- a/test/frontier.test.js +++ b/test/frontier.test.js @@ -10,8 +10,27 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { frontier as pack } from '../content/frontier/index.js'; import { startGame, currentTurn, takeTurn } from '../src/engine/game.js'; +import { check } from '../src/engine/conditions.js'; import { play, sweep, median } from './fixtures/strategies.js'; +/** + * Net money from upkeep per run for a player in a given position: at a stage, + * at a lifestyle tier, owing the store nothing. + * + * Derived rather than simulated, and for a reason worth keeping: comparing the + * end-balances of promoted and unpromoted players pools archetypes that do not + * spend alike, so it measures the mix rather than the wage. + */ +function netPerCycle(position) { + const state = { owed: 0, lifestyle: 0, standing: 0, ...position }; + const cycle = pack.calendar?.cycleLength ?? 1; + return (pack.upkeep ?? []) + .filter((entry) => check(state, entry.requires).passed) + .reduce((total, entry) => total + entry.effects + .filter((e) => e.path === 'money' && e.op === 'add') + .reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0); +} + test('the calendar is a four-day run, not a week', () => { // The point of this pack, structurally: the engine derives a cycle from // whatever length the pack declares, and nothing anywhere assumes seven. @@ -57,8 +76,11 @@ test('the star is worth taking, and reachable by a deliberate player', () => { const sworn = Object.values(byStrategy).flat().filter((s) => s.stage === 'deputy'); const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand'); assert.ok(sworn.length > 0 && stayed.length > 0, 'both outcomes should occur'); - assert.ok(median(sworn.map((s) => s.money)) > median(stayed.map((s) => s.money)), - 'deputy pay should leave a player better off'); + + const yard = netPerCycle({ stage: 'hand' }); + const office = netPerCycle({ stage: 'deputy' }); + assert.ok(office > yard + 10, + `the office nets ${office} a run against the yard's ${yard}`); // The yard is a career, not a tutorial: the offer is gated at turn 40 and // should land somewhere in the middle of a run rather than at the very end. @@ -74,6 +96,52 @@ test('the star is worth taking, and reachable by a deliberate player', () => { assert.ok(typical >= 41 && typical < 90, `the star landed at turn ${typical}`); }); +/** Play to `turns` playing for standing, buying what the star expects or not. */ +function chaseStanding({ seed, keepUp, turns = 200 }) { + const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle'); + const gain = (r) => (r.option.effects ?? []) + .filter((e) => e.path === 'standing' && e.op === 'add') + .reduce((sum, e) => sum + e.value, 0); + + let state = startGame(pack, { seed: `standing-${seed}` }); + for (let t = 0; t < turns; t++) { + const turn = currentTurn(pack, state); + const available = turn.options.filter((o) => o.available); + const buy = available.find(buys); + const chosen = (keepUp && buy) + ? buy + : [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0] + ?? available[0]; + state = takeTurn(pack, state, chosen.option.id).state; + } + return state; +} + +test('the star costs money to wear, and the top of standing cannot be worked for', () => { + // The same mechanism as Corporate Ladder's, at this pack's scale and in its + // own vocabulary — which is the whole claim the second setting exists to + // make. No engine code is involved in either. + const plain = netPerCycle({ stage: 'deputy' }); + const horse = netPerCycle({ stage: 'deputy', lifestyle: 1 }); + const room = netPerCycle({ stage: 'deputy', lifestyle: 2 }); + assert.ok(horse < plain - 5, `the bay costs ${plain - horse} a run`); + assert.ok(room < horse - 5, `the room costs ${horse - room} a run`); + assert.ok(room > 0, + `a deputy keeping up nets ${room} a run; the yard's own floor is barely above water`); + + const never = []; + const keeps = []; + for (let seed = 0; seed < 8; seed++) { + never.push(chaseStanding({ seed, keepUp: false })); + keeps.push(chaseStanding({ seed, keepUp: true })); + } + const standing = (runs) => median(runs.map((s) => s.standing)); + assert.ok(standing(never) <= 90, `a deputy on a livery horse reached ${standing(never)} standing`); + assert.ok(standing(keeps) >= 95, `a deputy keeping up reached only ${standing(keeps)} standing`); + assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)), + 'and it is paid for in money'); +}); + test('hard times is uncommon for a careful player and real for a careless one', () => { const countHardTimes = (strategy) => { let days = 0;