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:
co-authored by
Claude Opus 5
parent
4957c24e31
commit
7f20be83e8
+10
-206
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user