// What every content pack must do, whatever setting it is. // // This suite runs over `content/index.js` — every pack the build ships — and // asserts only things that are true of a pack because it is a pack, never // because it is about a mailroom or a freight yard. A new setting is therefore // tested the moment it appears in the registry, rather than by copying this // file and changing the nouns. // // Per-pack tuning lives with the pack: content.test.js and frontier.test.js // hold each one to its own economy, its own promotion and its own characters. // Anything a pack must declare for this suite to check it — the economic // baseline, for one — is a field on the pack, not a constant in here. // // 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. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { packs } from '../content/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 of `pack` with independently-seeded random choices. */ function randomRun(pack, runSeed, turns) { 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, `${pack.id}: no available option on event "${turn.event.id}"`); state = takeTurn(pack, state, rng.pick(available).option.id).state; } return state; } /** * Net movement of `money` from upkeep alone, per calendar cycle, at one stage. * * Derived rather than simulated: an entry worth `v` every `n` turns is worth * `v * cycleLength / n` per cycle, exactly, with no window to fall off the end * of. Simulating this over a fixed number of turns reads differently depending * on where the last rent lands — Corporate Ladder measures -19 a fortnight over * 280 turns and -42 over 560, and only one of those is the steady state. * * Entries gated on a debt path are excluded: the baseline is what a player * faces owing nothing, which is the floor the pack is designed around. */ function baselinePerCycle(pack, stage) { const cycle = pack.calendar?.cycleLength ?? 1; const debtish = (path) => /debt|owed|loan/i.test(path); return (pack.upkeep ?? []) .filter((entry) => { const gate = entry.requires ?? []; if (gate.some((c) => debtish(c.path))) return false; return !gate.some((c) => c.path === 'stage' && c.value !== stage); }) .reduce((total, entry) => { const money = entry.effects .filter((e) => e.path === 'money' && e.op === 'add') .reduce((sum, e) => sum + e.value, 0); return total + money * (cycle / (entry.every ?? 1)); }, 0); } for (const pack of packs) { const where = `[${pack.id}]`; test(`${where} the pack is valid, with no warnings`, () => { const result = validatePack(pack); assert.deepEqual(result.errors, []); assert.deepEqual(result.warnings, []); }); test(`${where} declares what the interface and this suite need from it`, () => { // Everything the picker shows and everything asserted below. A pack that // omits one of these is not a broken game, but it is a pack that will // render badly or go unchecked, and both should fail loudly at build time. assert.ok(pack.name, 'a display name'); assert.ok(pack.blurb, 'a one-line blurb for the setting picker'); assert.ok(pack.peopleHeading, 'a heading for the panel of characters'); assert.ok(pack.stages && Object.keys(pack.stages).length > 0, 'named stages'); assert.ok(pack.tuning?.baselinePerCycle, 'a declared economic baseline'); for (const stage of Object.keys(pack.stages)) { assert.ok(pack.stages[stage].title, `a title for stage "${stage}"`); assert.ok(pack.tuning.baselinePerCycle[stage], `a baseline for stage "${stage}"`); } }); test(`${where} no content is dead: every event fires and every option becomes available`, () => { // Across archetypes, not just random play: several things in a 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(`${where} every event leaves a player with nothing at least one way out`, () => { // A player with no skills, no money and no friends must still be able to // take a turn. The engine guarantees an event will be drawn; only content // can guarantee it is playable when everything is gated. const trapped = pack.events .filter((event) => !(event.options ?? []).some((option) => !option.requires)) .map((event) => event.id); assert.deepEqual(trapped, [], 'events whose every option is gated'); }); test(`${where} every turn always offers something to do, however badly it is going`, () => { for (let seed = 100; seed < 130; seed++) randomRun(pack, seed, 150); }); test(`${where} a played run replays identically from its seed`, () => { assert.deepEqual(randomRun(pack, 42, 80), randomRun(pack, 42, 80)); }); test(`${where} how you play changes where you end up`, () => { // Measured before promotions land, so this is about how a player handles // money rather than how fast they left the first rung. 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 === pack.startingStage), 'nobody should be promoted this early'); assert.ok(finalMoney('thrifty') > finalMoney('random'), 'a careful player should get ahead'); assert.ok(finalMoney('random') > finalMoney('spendthrift'), 'a careless player should struggle'); }); test(`${where} the upkeep baseline matches what the pack declares`, () => { // The guard that was missing when Corporate Ladder's rent was raised from // 385 to 455 without re-deriving what it meant: the baseline went from // -$3/day to -$13/day and trapped a real playtester below zero for 30% of // a 182-turn run. No archetype caught it — they all either optimised money // or were promoted out of the problem before it bit. for (const [stage, [low, high]] of Object.entries(pack.tuning.baselinePerCycle)) { const net = baselinePerCycle(pack, stage); assert.ok(net > low && net < high, `"${stage}" nets ${net} a cycle; the pack declares (${low}, ${high})`); } }); test(`${where} 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 service charge exceeds // anything the escape options can pay — inescapable by arithmetic. 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`); } }); test(`${where} a run that goes broke can climb back out`, () => { 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(`${where} 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. Variety is a property of the pack, and this 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 first stage cannot see the second, 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(`${where} the retrospective has something to read back`, () => { const state = randomRun(pack, 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(`${where} 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.ok(summary.stagesVisited.includes(pack.startingStage)); }); if (pack.calendar) { test(`${where} the calendar names the unit and counts the cycle`, () => { const { cycleLength, unitNames } = pack.calendar; let state = startGame(pack, { seed: 'calendar' }); assert.deepEqual( [state.calendar.name, state.calendar.cycle, state.calendar.index], [unitNames?.[0] ?? null, 1, 0], 'turn 1 is the first unit of the first cycle', ); // One full cycle on, the name comes round and the cycle has ticked. for (let t = 0; t < cycleLength; t++) { const turn = currentTurn(pack, state); state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state; } assert.equal(state.turn, cycleLength + 1); assert.deepEqual( [state.calendar.name, state.calendar.cycle, state.calendar.index], [unitNames?.[0] ?? null, 2, 0], ); }); } }