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
315 lines
15 KiB
JavaScript
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],
|
|
);
|
|
});
|
|
}
|
|
}
|