A debt repayment took its whole instalment even when less was owed: four dollars owed at the store cost twenty. Content could not express "pay what is owed", so an add effect's amount may now be read from state, with a cap. Five repayments across both packs use it, and a conformance test holds every pack to it. The turn record now says which options were hidden, so the analyser stops counting doors nobody saw, and it reads only feedback logs. From the same session: wages are labelled as wages, a clamped change says where it stopped, two unreadable option labels are rewritten, the sale barn no longer offers what the player already owns, Ward hints at what the promotion waits for, and the office has five more events. DECISIONS #38-40. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
309 lines
15 KiB
JavaScript
309 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 ?? []) {
|
|
const effects = option.effects ?? [];
|
|
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],
|
|
);
|
|
});
|
|
}
|
|
}
|