// 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`); } });