Make standing cost money to keep, in both settings

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
This commit is contained in:
JesseMarkowitz
2026-09-12 21:53:03 -04:00
co-authored by Claude Opus 5
parent 7f20be83e8
commit 5d645e6452
12 changed files with 507 additions and 15 deletions
+8 -5
View File
@@ -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 evidence that was not enough the last two times. It needs a session before its
numbers are believed. numbers are believed.
- **Money stops mattering late, and it should not.** A careful player ends - **Standing now costs money to keep, and nobody has played it.** Each setting
around $8,300 over 200 turns. The direction is set: standing should cost has a `lifestyle` tier bought on the second rung and charged for every cycle
money to keep — a better car, a better place to live, better clothes — so after — and a standing penalty, steepening as standing rises, for lagging
expenses climb as the player rises. Not built yet. 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 - **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 variety guard, but it has had far less play than the mailroom. Waits on more
playtesting. playtesting.
@@ -126,7 +128,8 @@ Open questions, in the order they are likely to matter:
Waits until the Frontier has been played. Waits until the Frontier has been played.
Settled, and not open: Corporate Ladder's negotiation gate stays as it is, and 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 ## Layout
@@ -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; export default dispatch;
+83
View File
@@ -45,6 +45,10 @@ export const corporateLadder = {
'reputation': { initial: 50, min: 0, max: 100 }, 'reputation': { initial: 50, min: 0, max: 100 },
'skills.negotiation': { initial: 5, min: 0, max: 100 }, 'skills.negotiation': { initial: 5, min: 0, max: 100 },
'skills.logistics': { 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.took_loan': { initial: false },
'flags.holds_the_envelope': { initial: false }, 'flags.holds_the_envelope': { initial: false },
'flags.covered_for_trevor': { initial: false }, 'flags.covered_for_trevor': { initial: false },
@@ -96,6 +100,76 @@ export const corporateLadder = {
effects: [{ path: 'money', op: 'add', value: 1260 }], effects: [{ path: 'money', op: 'add', value: 1260 }],
}, },
{ label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] }, { 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 // 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 // tightens instead of flattening out. A step function in data costs the
// engine nothing; a percentage would have needed a multiply operation. // engine nothing; a percentage would have needed a multiply operation.
@@ -132,6 +206,9 @@ export const corporateLadder = {
mailroom: [-45, 5], mailroom: [-45, 5],
dispatch: [5, 125], 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 // 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 }, 'reputation': { label: 'Standing', format: 'meter', order: 3 },
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 }, 'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 }, '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 // Kept out of the header, and out of the ledger too: `notable` is the
// retrospective's own list, and "Notable — changed" tells nobody anything. // retrospective's own list, and "Notable — changed" tells nobody anything.
'notable': { hidden: true, inLedger: false }, 'notable': { hidden: true, inLedger: false },
+60
View File
@@ -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; export default deputy;
+71
View File
@@ -54,6 +54,10 @@ export const frontier = {
'standing': { initial: 50, min: 0, max: 100 }, 'standing': { initial: 50, min: 0, max: 100 },
'skills.nerve': { initial: 5, min: 0, max: 100 }, 'skills.nerve': { initial: 5, min: 0, max: 100 },
'skills.trade': { 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.carries_iron': { initial: false },
'flags.owes_the_store': { initial: false }, 'flags.owes_the_store': { initial: false },
'flags.covered_for_dutch': { initial: false }, 'flags.covered_for_dutch': { initial: false },
@@ -112,6 +116,66 @@ export const frontier = {
requires: [{ path: 'stage', op: '==', value: 'deputy' }], requires: [{ path: 'stage', op: '==', value: 'deputy' }],
effects: [{ path: 'money', op: 'add', value: 38 }], 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 // Store credit costs more the deeper in you are, the same step function
// Corporate Ladder uses for its loan — a shape, not a setting. // Corporate Ladder uses for its loan — a shape, not a setting.
{ {
@@ -149,6 +213,9 @@ export const frontier = {
hand: [0, 12], hand: [0, 12],
deputy: [10, 30], 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: { display: {
@@ -157,6 +224,10 @@ export const frontier = {
'standing': { label: 'Standing', format: 'meter', order: 3 }, 'standing': { label: 'Standing', format: 'meter', order: 3 },
'skills.nerve': { label: 'Nerve', format: 'meter', order: 4 }, 'skills.nerve': { label: 'Nerve', format: 'meter', order: 4 },
'skills.trade': { label: 'Trade', format: 'meter', order: 5 }, '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 }, 'notable': { hidden: true, inLedger: false },
'flags.carries_iron': { 'flags.carries_iron': {
label: 'The scattergun', hidden: true, label: 'The scattergun', hidden: true,
+47
View File
@@ -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 direction is that rising standing brings rising expenses — the car, the place to
live, the clothes a position expects. Upkeep entries are already gated live, the clothes a position expects. Upkeep entries are already gated
conditions over state, so this is content work, not an engine change. 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "theladder", "name": "theladder",
"version": "0.3.1", "version": "0.3.2",
"private": true, "private": true,
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.", "description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
"type": "module", "type": "module",
+4
View File
@@ -117,6 +117,10 @@ function deltaText(pack, change) {
if (typeof change.to !== 'number' || typeof change.from !== 'number') { if (typeof change.to !== 'number' || typeof change.from !== 'number') {
return typeof change.to === 'boolean' ? (change.to ? 'now true' : 'no longer true') : 'changed'; 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 delta = change.to - change.from;
const money = pack.display?.[change.path]?.format === 'money'; const money = pack.display?.[change.path]?.format === 'money';
const magnitude = money const magnitude = money
+5 -1
View File
@@ -34,7 +34,11 @@ export function labelFor(pack, path) {
/** Money is written as money; everything else is a plain number. */ /** Money is written as money; everything else is a plain number. */
export function formatValue(pack, path, value) { export function formatValue(pack, path, value) {
if (typeof value === 'boolean') return value ? 'yes' : 'no'; 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); return String(value);
} }
+16 -4
View File
@@ -48,16 +48,22 @@ function randomRun(pack, runSeed, turns) {
* on where the last rent lands — Corporate Ladder measures -19 a fortnight over * 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. * 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 * Entries gated on a path the pack calls elective are excluded: a debt it took
* faces owing nothing, which is the floor the pack is designed around. * 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) { function baselinePerCycle(pack, stage) {
const cycle = pack.calendar?.cycleLength ?? 1; 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 ?? []) return (pack.upkeep ?? [])
.filter((entry) => { .filter((entry) => {
const gate = entry.requires ?? []; 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); return !gate.some((c) => c.path === 'stage' && c.value !== stage);
}) })
.reduce((total, entry) => { .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.stages[stage].title, `a title for stage "${stage}"`);
assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline 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`, () => { test(`${where} no content is dead: every event fires and every option becomes available`, () => {
+83 -2
View File
@@ -19,16 +19,40 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { corporateLadder as pack } from '../content/corporateladder/index.js'; import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.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'; 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', () => { test('the promotion is worth taking, and reachable by a deliberate player', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 }); const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch'); const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch');
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom'); 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(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 // A player actually pursuing it should get there mid-run, not at the very
// end — the mailroom is a career, not a tutorial. // 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}`); 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', () => { test('hard times is uncommon for a careful player and real for a careless one', () => {
const countHardTimes = (strategy) => { const countHardTimes = (strategy) => {
let days = 0; let days = 0;
+70 -2
View File
@@ -10,8 +10,27 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { frontier as pack } from '../content/frontier/index.js'; import { frontier as pack } from '../content/frontier/index.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.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'; 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', () => { test('the calendar is a four-day run, not a week', () => {
// The point of this pack, structurally: the engine derives a cycle from // The point of this pack, structurally: the engine derives a cycle from
// whatever length the pack declares, and nothing anywhere assumes seven. // 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 sworn = Object.values(byStrategy).flat().filter((s) => s.stage === 'deputy');
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand'); 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(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 // 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. // 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}`); 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', () => { test('hard times is uncommon for a careful player and real for a careless one', () => {
const countHardTimes = (strategy) => { const countHardTimes = (strategy) => {
let days = 0; let days = 0;