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
+83 -2
View File
@@ -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;