// 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 } from './fixtures/strategies.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 and option is reachable by some player', () => { // 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. const { firedEvents, takenOptions } = sweep(pack, { seeds: 12, turns: 200 }); 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) => !takenOptions.has(o)), [], 'options that can never be taken'); }); test('how you play changes where you end up', () => { const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 }); const finalMoney = (name) => median(byStrategy[name].map((s) => s.money)); // The shape that matters: minding the money climbs, ignoring it sinks, and // playing without a plan roughly treads water. Exact figures will 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'); assert.ok(finalMoney('thrifty') > 800, `a careful player should be comfortably ahead, was ${finalMoney('thrifty')}`); }); 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'); });