Files
TheLadder/test/conformance.test.js
T
JesseMarkowitzandClaude Opus 5 7f20be83e8 Add The Frontier, a second setting, and a picker to choose between them
Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and
its own relationship and skill vocabulary. src/engine/ was not touched. Adding it
needed three changes outside the engine: one save slot per pack, app.js holding
the current pack, and turn.js no longer naming the mailroom (DECISIONS #34).
build.js now handles imports wrapped across lines. A conformance suite runs over
every registered pack. DECISIONS #35 records three calls on open questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
2026-09-12 21:03:50 -04:00

268 lines
12 KiB
JavaScript

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