Files
TheLadder/test/conformance.test.js
T
JesseMarkowitzandClaude Opus 5.5 3108319a2b Add chance and a cast that changes between runs, and a pack to try them
An option used to do exactly what it said. Now it may carry a check: one of
four attributes (Nerve, Wits, Charm, Resolve) against a set difficulty or
against a named person's own attribute, leaning on how they regard you, with a
success and a failure. The player sees only the odds in words ("a long shot",
"likely"), never a number. The roll is one draw from the run's generator, so a
run still replays exactly from its seed and choices.

A character may be a role with several authored candidates, one drawn per run,
or a group of two to four drawn into numbered slots. Everyone carries a name, a
description and the same four attributes as the player, so a replayed pack
meets a different office. Text names people as {partner} or {clerk}, and an
event about "a clerk" binds one present clerk for the turn. The draw and the
binding are saved with the run, and a save from before a pack drew a cast gets
the same one every time it is loaded.

All of it is generic and optional: Corporate Ladder and the Frontier use none
of it and play as before. The Counting House, a one-stage office about 1910
built from the story-to-pack catalogue, exercises it, and was tuned against the
conformance suite by measurement. DECISIONS #41. Version 0.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MhABSrqkwTdcybXmheDPr
2026-09-23 22:12:26 -04:00

315 lines
15 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 path the pack calls elective are excluded: a debt it took
* on, a lifestyle it bought. The baseline is what a player faces having opted
* into none of them, which is the floor the pack is designed around.
*
* Which paths those are is the pack's to name, in `tuning.baselineExcludes`.
* This used to be a regex over path names — /debt|owed|loan/i — which is the
* generic suite guessing at content's vocabulary, exactly what DECISIONS #31
* says it must not do. It also silently missed anything named otherwise.
*/
function baselinePerCycle(pack, stage) {
const cycle = pack.calendar?.cycleLength ?? 1;
const elective = new Set(pack.tuning?.baselineExcludes ?? []);
return (pack.upkeep ?? [])
.filter((entry) => {
const gate = entry.requires ?? [];
if (gate.some((c) => elective.has(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}"`);
}
// A typo here would not fail: the entry simply would not be excluded, and
// the baseline would quietly become a number nobody meant.
for (const path of pack.tuning.baselineExcludes ?? []) {
assert.ok(path in pack.state,
`baselineExcludes names "${path}", which the pack does not declare`);
}
});
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} no repayment can take more than is owed`, () => {
// A fixed instalment against a smaller balance takes money for a debt that
// does not exist; the Frontier's store did it to a real player. A balance
// is any money-formatted stat other than money itself. An option that pays
// one down with money must read the amount from the balance, or be gated
// on the balance covering the whole instalment. See DECISIONS #38.
const balances = Object.entries(pack.display ?? {})
.filter(([path, rule]) => rule.format === 'money' && path !== 'money')
.map(([path]) => path);
const overcharging = [];
for (const event of pack.events) {
for (const option of event.options ?? []) {
// A check's branches pay and spend too (DECISIONS #41): each is judged
// together with the effects that apply either way.
const branches = option.check
? [option.success, option.failure].map((b) => [...(option.effects ?? []), ...(b?.effects ?? [])])
: [option.effects ?? []];
for (const effects of branches) {
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
if (!spends) continue;
for (const e of effects) {
if (!balances.includes(e.path) || e.op !== 'add') continue;
if (typeof e.value !== 'number' || e.value >= 0) continue;
const covered = (option.requires ?? [])
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
}
}
}
}
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
assert.deepEqual(overcharging, [], 'options that can charge for more than is owed');
});
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],
);
});
}
}