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
This commit is contained in:
JesseMarkowitz
2026-09-12 21:03:50 -04:00
co-authored by Claude Opus 5
parent 4957c24e31
commit 7f20be83e8
25 changed files with 3015 additions and 300 deletions
+10 -206
View File
@@ -1,9 +1,13 @@
// Tests for the shipped Corporate Ladder pack.
// Corporate Ladder's own tuning.
//
// These are content tests, not engine tests: they assert that the pack is
// playable, that none of it is dead, and that its economy behaves. Engine
// behaviour is covered against the fixture pack instead, so tuning the game
// cannot break the engine suite and vice versa.
// What is true of this pack because it is *this* pack: the shape of its week,
// when wages and rent land, how the loan behaves, when Dispatch opens and how
// deep the Trevor chain is. Everything true of a pack because it is a pack —
// validity, dead content, the economic baseline, variety, replay — is in
// conformance.test.js, which runs over every pack in the registry.
//
// Engine behaviour is covered against the fixture pack instead, so tuning the
// game cannot break the engine suite and vice versa.
//
// 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
@@ -14,65 +18,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { corporateLadder as pack } from '../content/corporateladder/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 with independently-seeded random choices. */
function randomRun(runSeed, turns, visit = () => {}) {
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, `no available option on event "${turn.event.id}"`);
const choice = rng.pick(available).option.id;
visit(turn, state, choice);
state = takeTurn(pack, state, choice).state;
}
return state;
}
test('the pack is valid, with no warnings', () => {
const result = validatePack(pack);
assert.deepEqual(result.errors, []);
assert.deepEqual(result.warnings, []);
});
test('no content is dead: every event fires and every option becomes available', () => {
// Across archetypes, not just random play: several things in the 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('how you play changes where you end up', () => {
// Measured before promotions land, so this is about how a player handles
// money rather than about how fast they got out of the mailroom. 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 === 'mailroom'),
'nobody should be promoted this early');
// The shape that matters: minding the money climbs, ignoring it sinks, and
// playing without a plan roughly treads water. Exact figures move with
// tuning; the ordering is the design.
assert.ok(finalMoney('thrifty') > finalMoney('random'), 'a careful player should get ahead');
assert.ok(finalMoney('random') > finalMoney('spendthrift'), 'a careless player should struggle');
});
import { play, sweep, median } from './fixtures/strategies.js';
test('the promotion is worth taking, and reachable by a deliberate player', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
@@ -108,11 +55,6 @@ test('hard times is uncommon for a careful player and real for a careless one',
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('every turn always offers something to do, however badly the run is going', () => {
// randomRun asserts this on every turn; 40 long runs is the coverage.
for (let seed = 100; seed < 140; seed++) randomRun(seed, 150);
});
test('money arrives and leaves on a rhythm the player can plan around', () => {
const wages = [];
const rent = [];
@@ -145,24 +87,6 @@ test('the calendar names the day and counts the week', () => {
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
});
test('hard times is survivable without any relationship or skill', () => {
const event = pack.events.find((e) => e.id === 'rent_bounced');
const ungated = event.options.filter((o) => !o.requires);
assert.ok(ungated.length > 0, 'a player with nothing must still have a way out');
});
test('the run recovers from hard times rather than sticking there', () => {
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('debt cannot spiral without limit: lending stops and the charge escalates', () => {
let peakDebt = 0;
for (const strategy of ['spendthrift', 'desperate', 'random']) {
@@ -197,123 +121,3 @@ test('a deliberate player can reach relationship-gated content in a reasonable r
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
});
test('a played run replays identically from its seed', () => {
const a = randomRun(42, 80);
const b = randomRun(42, 80);
assert.deepEqual(a, b);
});
test('the retrospective has something to read back', () => {
const state = randomRun(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('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.
// This is the guardrail on that: variety is a property of the pack, and it
// 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 mailroom cannot see Dispatch, 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('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.deepEqual(summary.stagesVisited, ['mailroom', 'dispatch'],
'the retrospective knows the career moved');
});
test('the mailroom baseline is a drift, not a bleed', () => {
// The guard that was missing. Rent was once raised from 385 to 455 without
// re-deriving this, which quietly took the baseline from -$3/day to -$13/day
// and trapped a real playtester below zero for 30% of a 182-turn session.
// Nothing caught it, because every archetype either optimised money or got
// promoted out of the problem.
// Averaged over many fortnights rather than measured on the first one: the
// opening turns sit before rent's offset, so a single early window sees one
// rent cheque where the steady state has two.
const FORTNIGHTS = 20;
const perFortnight = (stage) => {
let total = 0;
for (let turn = 1; turn <= 14 * FORTNIGHTS; turn++) {
for (const entry of pack.upkeep) {
const every = entry.every ?? 1;
const elapsed = turn - 1 - (entry.offset ?? 0);
if (elapsed < 0 || elapsed % every !== 0) continue;
// Baseline means no debt: only stage-gated entries count.
const gate = entry.requires ?? [];
if (gate.some((c) => c.path === 'debt')) continue;
if (gate.some((c) => c.path === 'stage' && c.value !== stage)) continue;
total += entry.effects
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
}
}
return Math.round(total / FORTNIGHTS);
};
const mailroom = perFortnight('mailroom');
assert.ok(mailroom > -90 && mailroom < 10,
`the mailroom fortnight nets ${mailroom}; it should be a small drift downward`);
const dispatch = perFortnight('dispatch');
assert.ok(dispatch > mailroom, 'the promotion should be worth having');
assert.ok(dispatch < 250, `Dispatch nets ${dispatch} a fortnight; money stops mattering above that`);
});
test('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 weekly service charge exceeds
// anything the escape options can pay — inescapable by arithmetic, which is
// exactly what a real session hit.
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`);
}
});