// Corporate Ladder's own tuning. // // What is true of this pack because it is *this* pack: the shape of its week, // when wages and rent land, how the loan behaves, when Dispatch opens and how // deep the Trevor chain is. Everything true of a pack because it is a pack — // validity, dead content, the economic baseline, variety, replay — is in // conformance.test.js, which runs over every pack in the registry. // // 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 { 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'); 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. 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}`); }); /** * 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`); // And the top of the ladder is not a plateau. A 96-turn session bought both // tiers, reached 100 standing by turn 84, and from there had nothing left to // spend on: every charge was switched off and the balance only climbed. // Standing costs money to carry at every tier, with nothing to buy that ends // it, so there is no position in this pack where money stops mattering. const arrived = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 2500 }); assert.ok(arrived < address, `standing itself should cost something: ${arrived} against ${address} a week`); // And it steepens with what there is to keep up, which is what stops the // balance climbing forever. A flat charge could not: late income scales with // how well the player plays, so flattening an optimiser's slope with one // meant putting ordinary play 44% of a run below zero. const wealthy = netPerCycle({ stage: 'dispatch', lifestyle: 2, reputation: 90, money: 6000 }); assert.ok(wealthy < arrived, `the charge should steepen with money: ${wealthy} against ${arrived} a week`); assert.ok(wealthy < 0, `and take a rich coordinator backwards: ${wealthy} a week`); // At every tier, not only the top one: a coordinator who bought nothing is // already paying for standing in standing, and pays in money besides. const plainSomebody = netPerCycle({ stage: 'dispatch', lifestyle: 0, reputation: 90, money: 6000 }); assert.ok(plainSomebody < plain, `standing should cost at tier 0 too: ${plainSomebody} against ${plain} a week`); // 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; 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('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('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'}`); });