Files
TheLadder/test/content.test.js
T
JesseMarkowitzandClaude Opus 5 5c1784d990 Fix an inescapable debt trap the playtest logs exposed
A 182-turn session spent 30% of its turns below zero, went negative on turn 18
and never recovered, and chose the same hard-times option — "take every shift
going" — thirty-seven times.

The cause was a tuning change made without re-deriving what it meant. Rent went
from 385 to 455 while chasing a different problem, which took the mailroom
baseline from the designed -$3/day to -$13/day. Worse, at the debt that run
accumulated the weekly service charge came to $180 against an escape option
worth $120. The hole was inescapable by arithmetic, whatever the player did.

Nothing caught it. Every archetype either optimised money or got promoted out of
the problem before it bit, and the dominance test passed because rent_bounced
was only 17% of turns.

Fixes: mailroom wages 980 -> 1120, restoring the -$3/day baseline; the escape
option 120 -> 220, so it is worth more than a week's rent; rent_bounced from
weight 5000 with no cooldown to 900 on a cooldown of 3, because being broke
should colour a run rather than replace it; debt_collector from weight 25 to 45,
since at 25 it appeared six times in 262 turns and debt became a ratchet.

Three guards so this class of bug cannot recur quietly.

The baseline is now asserted directly: a test sums the pack's own upkeep over
twenty fortnights and requires the mailroom to net between -90 and +10, and
Dispatch to be better but not so much better that money stops mattering. That
would have failed the moment rent changed. Outcome tests over simulated play are
a slow and noisy way to detect a number that is simply wrong.

A `lifer` archetype refuses any option that would change stage — generically, by
looking for an effect on `stage` — and otherwise plays for people. It reproduces
the session that found this; no other archetype can.

And no archetype may spend more than a quarter of a run below zero. Hard times
is a state a player passes through; living in it is the failure mode.

Also fixed: the analyser reported "every ~-5 turns". One export can hold several
playthroughs and turn numbers restart with each, so spans are now accumulated
per run and pooled rather than measured across the seam between two.

121 tests. Reasoning in docs/DECISIONS.md §29-30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 06:01:33 -04:00

320 lines
14 KiB
JavaScript

// Tests for the shipped Corporate Ladder pack.
//
// These are content tests, not engine tests: they assert that the pack is
// playable, that none of it is dead, and that its economy behaves. Engine
// behaviour is covered against the fixture pack instead, so tuning the game
// cannot break the engine suite and vice versa.
//
// One trap, learned the hard way: the chooser's RNG must be seeded
// *differently* from the game's. Both draw one number per turn from the same
// generator, so sharing a seed correlates the choice index with the event
// draw and silently makes whole option combinations unreachable — which looks
// exactly like a content bug.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { validatePack } from '../src/engine/validate.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { createRng } from '../src/engine/rng.js';
import { play, sweep, median, strategies } from './fixtures/strategies.js';
import { summarize } from '../src/ui/summary.js';
/** Play `turns` turns with independently-seeded random choices. */
function randomRun(runSeed, turns, visit = () => {}) {
const rng = createRng(`chooser-${runSeed}`);
let state = startGame(pack, { seed: `run-${runSeed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
assert.ok(available.length > 0, `no available option on event "${turn.event.id}"`);
const choice = rng.pick(available).option.id;
visit(turn, state, choice);
state = takeTurn(pack, state, choice).state;
}
return state;
}
test('the pack is valid, with no warnings', () => {
const result = validatePack(pack);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.warnings, []);
});
test('no content is dead: every event fires and every option becomes available', () => {
// Across archetypes, not just random play: several things in the pack exist
// for a player pursuing something, and "broke but well-liked" in particular
// is a state no single-axis strategy ever reaches.
//
// Options are checked on *availability*, not on being taken. Whether a
// particular bot picks an option is an artefact of how the bot scores; what
// matters is whether a player is ever offered it unlocked.
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 12, turns: 250 });
const allEvents = pack.events.map((e) => e.id);
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
assert.deepEqual(allEvents.filter((e) => !firedEvents.has(e)), [], 'events that can never fire');
assert.deepEqual(allOptions.filter((o) => !offeredOptions.has(o)), [],
'options no player can ever be offered');
});
test('how you play changes where you end up', () => {
// Measured before promotions land, so this is about how a player handles
// money rather than about how fast they got out of the mailroom. Mixing the
// two hides the economy behind the career.
const { byStrategy } = sweep(pack, { seeds: 12, turns: 35 });
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
assert.ok(byStrategy.thrifty.every((s) => s.stage === 'mailroom'),
'nobody should be promoted this early');
// The shape that matters: minding the money climbs, ignoring it sinks, and
// playing without a plan roughly treads water. Exact figures move with
// tuning; the ordering is the design.
assert.ok(finalMoney('thrifty') > finalMoney('random'), 'a careful player should get ahead');
assert.ok(finalMoney('random') > finalMoney('spendthrift'), 'a careless player should struggle');
});
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');
// A player actually pursuing it should get there mid-run, not at the very
// end — the mailroom is a career, not a tutorial.
const timings = [];
for (let seed = 0; seed < 12; seed++) {
let at = null;
play(pack, { strategy: 'sociable', seed, turns: 200,
visit: ({ state }) => { if (state.stage === 'dispatch' && at === null) at = state.turn; } });
if (at) timings.push(at);
}
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
const typical = median(timings);
assert.ok(typical > 40 && typical < 90, `promotion landed at turn ${typical}`);
});
test('hard times is uncommon for a careful player and real for a careless one', () => {
const countHardTimes = (strategy) => {
let days = 0;
play(pack, { strategy, seed: 'ht', turns: 200,
visit: ({ turn }) => { if (turn.event.id === 'rent_bounced') days++; } });
return days;
};
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of trouble');
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('every turn always offers something to do, however badly the run is going', () => {
// randomRun asserts this on every turn; 40 long runs is the coverage.
for (let seed = 100; seed < 140; seed++) randomRun(seed, 150);
});
test('money arrives and leaves on a rhythm the player can plan around', () => {
const wages = [];
const rent = [];
let state = startGame(pack, { seed: 'rhythm' });
for (let t = 0; t < 30; t++) {
const turn = currentTurn(pack, state);
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
for (const change of result.record.upkeep) {
if (change.label === 'Wages') wages.push(state.turn);
if (change.label === 'Rent') rent.push(state.turn);
}
state = result.state;
}
assert.deepEqual(wages, [5, 19], 'paid on alternate Fridays');
assert.deepEqual(rent, [8, 15, 22, 29], 'rent every Monday from the second week');
});
test('the calendar names the day and counts the week', () => {
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
['Monday', 1, 0],
);
for (let t = 0; t < 11; t++) {
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, 12);
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
});
test('hard times is survivable without any relationship or skill', () => {
const event = pack.events.find((e) => e.id === 'rent_bounced');
const ungated = event.options.filter((o) => !o.requires);
assert.ok(ungated.length > 0, 'a player with nothing must still have a way out');
});
test('the run recovers from hard times rather than sticking there', () => {
let sawNegative = false;
let recovered = false;
play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200,
visit: ({ state }) => {
if (state.money < 0) sawNegative = true;
else if (sawNegative) recovered = true;
} });
assert.ok(sawNegative, 'a careless run should go broke');
assert.ok(recovered, 'and should be able to climb back out');
});
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
let peakDebt = 0;
for (const strategy of ['spendthrift', 'desperate', 'random']) {
for (let seed = 0; seed < 12; seed++) {
play(pack, { strategy, seed, turns: 200,
visit: ({ state }) => { peakDebt = Math.max(peakDebt, state.debt); } });
}
}
// The loan is gated at debt < 2000 and lends 500, so 2500 plus stalled
// interest is the ceiling. Well above that means the gate has stopped working.
assert.ok(peakDebt <= 3000, `debt reached ${peakDebt}; the borrowing ceiling is not holding`);
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Loan service charge'));
assert.ok(charges.length >= 3, 'the service charge should step up as debt grows');
});
test('a deliberate player can reach relationship-gated content in a reasonable run', () => {
// The Trevor chain is the deepest gate in the pack. A player cultivating him
// should get there well inside a normal session, not at turn 100.
const regard = (o) => (o.option.effects ?? [])
.filter((e) => e.path === 'relationships.trevor.likes_you')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: 'befriend-trevor' });
let reached = null;
for (let t = 0; t < 60 && reached === null; t++) {
const turn = currentTurn(pack, state);
if (turn.event.id === 'trevor_favor') { reached = state.turn; break; }
const best = turn.options.filter((o) => o.available).sort((a, b) => regard(b) - regard(a))[0];
state = takeTurn(pack, state, best.option.id).state;
}
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
});
test('a played run replays identically from its seed', () => {
const a = randomRun(42, 80);
const b = randomRun(42, 80);
assert.deepEqual(a, b);
});
test('the retrospective has something to read back', () => {
const state = randomRun(3, 60);
assert.ok(state.notable.length > 0, 'notable moments should accumulate');
assert.equal(state.history.length, 60, 'every turn is recorded');
assert.ok(state.history.every((h) => Array.isArray(h.upkeep)), 'upkeep is recorded per turn');
});
test('a long run does not collapse onto a handful of situations', () => {
// Motivated by a real 183-turn session. Before Dispatch was fleshed out, its
// two floor events were 46% of every promoted run — one every three turns.
// This is the guardrail on that: variety is a property of the pack, and it
// should fail loudly when a stage is too thin for the run it invites.
for (const strategy of ['sociable', 'thrifty', 'random']) {
const counts = new Map();
let turns = 0;
play(pack, { strategy, seed: 'variety', turns: 200,
visit: ({ turn }) => {
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
turns++;
} });
const [topEvent, topCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
const share = topCount / turns;
assert.ok(share < 0.25,
`${strategy}: "${topEvent}" was ${(share * 100).toFixed(0)}% of turns`);
// Measured against what was actually reachable: a run that never leaves
// the mailroom cannot see Dispatch, and counting that as thin content
// would be measuring career progress rather than variety.
const visited = new Set([...counts.keys()]
.map((id) => pack.events.find((e) => e.id === id).stage)
.filter(Boolean));
const reachable = pack.events
.filter((e) => e.stage === undefined || visited.has(e.stage)).length;
assert.ok(counts.size / reachable > 0.6,
`${strategy}: saw ${counts.size} of ${reachable} reachable situations in ${turns} turns`);
}
});
test('the retrospective reports how repetitive the run was', () => {
const state = play(pack, { strategy: 'sociable', seed: 'repeats', turns: 120 });
const summary = summarize(pack, state);
assert.equal(summary.turns, 120);
assert.ok(summary.eventsSeen > 0 && summary.eventsSeen <= summary.eventsTotal);
assert.ok(summary.averageTimesSeen > 1, 'a long run repeats situations');
assert.equal(
summary.averageTimesSeen.toFixed(2),
(summary.turns / summary.eventsSeen).toFixed(2),
'the average is turns over distinct situations',
);
assert.ok(summary.mostSeen.count >= summary.averageTimesSeen);
assert.ok(summary.repeatedSituations <= summary.eventsSeen);
assert.deepEqual(summary.stagesVisited, ['mailroom', 'dispatch'],
'the retrospective knows the career moved');
});
test('the mailroom baseline is a drift, not a bleed', () => {
// The guard that was missing. Rent was once raised from 385 to 455 without
// re-deriving this, which quietly took the baseline from -$3/day to -$13/day
// and trapped a real playtester below zero for 30% of a 182-turn session.
// Nothing caught it, because every archetype either optimised money or got
// promoted out of the problem.
// Averaged over many fortnights rather than measured on the first one: the
// opening turns sit before rent's offset, so a single early window sees one
// rent cheque where the steady state has two.
const FORTNIGHTS = 20;
const perFortnight = (stage) => {
let total = 0;
for (let turn = 1; turn <= 14 * FORTNIGHTS; turn++) {
for (const entry of pack.upkeep) {
const every = entry.every ?? 1;
const elapsed = turn - 1 - (entry.offset ?? 0);
if (elapsed < 0 || elapsed % every !== 0) continue;
// Baseline means no debt: only stage-gated entries count.
const gate = entry.requires ?? [];
if (gate.some((c) => c.path === 'debt')) continue;
if (gate.some((c) => c.path === 'stage' && c.value !== stage)) continue;
total += entry.effects
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
}
}
return Math.round(total / FORTNIGHTS);
};
const mailroom = perFortnight('mailroom');
assert.ok(mailroom > -90 && mailroom < 10,
`the mailroom fortnight nets ${mailroom}; it should be a small drift downward`);
const dispatch = perFortnight('dispatch');
assert.ok(dispatch > mailroom, 'the promotion should be worth having');
assert.ok(dispatch < 250, `Dispatch nets ${dispatch} a fortnight; money stops mattering above that`);
});
test('nobody spends the run stuck below zero', () => {
// Hard times is a state a player passes through, not one they live in. The
// failure this guards against is a debt whose weekly service charge exceeds
// anything the escape options can pay — inescapable by arithmetic, which is
// exactly what a real session hit.
for (const strategy of Object.keys(strategies)) {
const shares = [];
for (let seed = 0; seed < 8; seed++) {
let red = 0;
let turns = 0;
play(pack, { strategy, seed, turns: 150,
visit: ({ state }) => { turns++; if (state.money < 0) red++; } });
shares.push(red / turns);
}
const worst = Math.max(...shares);
assert.ok(worst < 0.25,
`${strategy} spent ${(worst * 100).toFixed(0)}% of a run below zero`);
}
});