// 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'; /** 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 can be taken', () => { const firedEvents = new Set(); const takenOptions = new Set(); for (let seed = 0; seed < 60; seed++) { randomRun(seed, 200, (turn, _state, choice) => { firedEvents.add(turn.event.id); takenOptions.add(`${turn.event.id}.${choice}`); }); } 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('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('the economy bleeds, so doing nothing about money eventually bites', () => { const turns = []; for (let seed = 0; seed < 30; seed++) { let firstHardTimes = null; randomRun(seed, 120, (turn, state) => { if (turn.event.id === 'rent_bounced' && firstHardTimes === null) firstHardTimes = state.turn; }); assert.ok(firstHardTimes, `run ${seed} never hit hard times in 120 turns`); turns.push(firstHardTimes); } turns.sort((a, b) => a - b); const median = turns[Math.floor(turns.length / 2)]; assert.ok(median > 10 && median < 45, `hard times should land mid-run, median was turn ${median}`); }); 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; randomRun(7, 200, (_turn, state) => { if (state.money < 0) sawNegative = true; else if (sawNegative) recovered = true; }); assert.ok(sawNegative && recovered, 'money should go negative and come back'); }); test('debt cannot spiral without limit: lending stops and the charge escalates', () => { let peakDebt = 0; for (let seed = 0; seed < 40; seed++) { randomRun(seed, 200, (_turn, 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'); });