Files
TheLadder/test/game.test.js
T
JesseMarkowitzandClaude Opus 5 1d4ac0fdbd Add the Corporate Ladder pack, and two engine features it needed
Seventeen events across the mailroom: ordinary days, Marlene Hobb and Trevor
Pike, a misrouted envelope that can be delivered or kept, and a hard-times
branch that fires on negative money and always leaves an ungated way out.

Writing real content turned up two things the engine could not express.

Per-turn upkeep (`pack.upkeep`): the MVP spec wants recurring income and costs,
but effects only run when a player chooses something, and baking wages into
every option would be tedious and easy to get wrong. Upkeep entries carry their
own conditions, which turned out to be enough for the loan service charge to be
a three-step function that escalates with the size of the debt — no multiply
operation, no engine change. Upkeep changes are recorded separately from choice
changes so the UI can distinguish them.

Per-character starting relationships: Trevor starts below the pack default on
`likes_you` because he arrived two weeks earlier and has priced that in.
Characterisation as data rather than as an opening cutscene. Nobody starts
feared, either — `fears_you` defaults to 10, since a midpoint default would mean
every new hire arrives already intimidating.

The content suite plays 60 seeded runs and asserts every event fires and every
option is taken at least once. That caught real dead content: Trevor's favour
chain sat behind a gate no plausible player could reach, taking two events and a
hard-times escape route with it, and debt could pass 5,500 while the service
charge stayed flat, so the failure spiral flattened out instead of tightening.
Both fixed in the data — the gate softened and given another route up, the loan
capped and its charge tiered.

The validator also gained a real fix: it counted an event with a cooldown as a
stage's fallback, but a cooldown can exclude it, so a stage could still run dry.

86 tests. Reasoning in docs/DECISIONS.md §11-13, including the seeding trap that
makes a correlated chooser look exactly like a content bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-09 21:58:20 -04:00

205 lines
8.8 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startGame, currentTurn, takeTurn, MoveError } from '../src/engine/game.js';
import { getPath, setPath } from '../src/engine/paths.js';
import { testPack } from './fixtures/test-pack.js';
/** Play `turns` turns, always taking the first available option. */
function playThrough(seed, turns, chooser = (options) => options.find((o) => o.available).option.id) {
let state = startGame(testPack, { seed });
const records = [];
for (let i = 0; i < turns; i++) {
const turn = currentTurn(testPack, state);
const result = takeTurn(testPack, state, chooser(turn.options, turn.event, state));
state = result.state;
records.push(result.record);
}
return { state, records };
}
test('a new game starts on turn one with the pack defaults', () => {
const state = startGame(testPack, { seed: 'start' });
assert.equal(state.turn, 1);
assert.equal(state.stage, 'floor');
assert.equal(state.money, 100);
assert.equal(state.skills.talking, 1);
assert.equal(state.relationships.chief.likes_you, 50);
assert.equal(state.relationships.pat.fears_you, 50);
assert.ok(state.meta.currentEventId, 'an event is waiting before the first turn');
});
test('relationship state is generated per character, so adding one adds its stats', () => {
const state = startGame(testPack, { seed: 'chars' });
assert.deepEqual(Object.keys(state.relationships).sort(), ['chief', 'pat']);
assert.deepEqual(Object.keys(state.relationships.chief).sort(), ['fears_you', 'likes_you']);
});
test('taking a turn applies effects and advances the clock', () => {
let state = startGame(testPack, { seed: 'turn' });
const before = state.money;
const result = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id);
assert.equal(result.state.turn, 2);
assert.notEqual(result.state.meta.currentEventId, undefined);
assert.ok(result.state.money !== before || result.record.changes.length >= 0);
});
test('the same seed and the same choices replay identically', () => {
const a = playThrough('replay', 40);
const b = playThrough('replay', 40);
assert.deepEqual(a.state, b.state);
assert.deepEqual(a.records, b.records);
});
test('different seeds produce different runs', () => {
const a = playThrough('seed-a', 40);
const b = playThrough('seed-b', 40);
assert.notDeepEqual(a.records.map((r) => r.eventId), b.records.map((r) => r.eventId));
});
test('a saved game resumes into exactly the run it left', () => {
const uninterrupted = playThrough('save', 30);
let state = startGame(testPack, { seed: 'save' });
for (let i = 0; i < 15; i++) {
const turn = currentTurn(testPack, state);
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
}
// The whole save is one JSON round trip; nothing lives outside it.
state = JSON.parse(JSON.stringify(state));
for (let i = 0; i < 15; i++) {
const turn = currentTurn(testPack, state);
state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.deepEqual(state, uninterrupted.state);
});
test('a once-only event fires exactly once across a long run', () => {
const { records } = playThrough('once', 60);
assert.equal(records.filter((r) => r.eventId === 'first_day').length, 1);
assert.equal(records[0].eventId, 'first_day', 'its weight makes it the opener');
});
test('a locked option cannot be played', () => {
const state = startGame(testPack, { seed: 'locked' });
let probe = state;
// Walk to a turn that offers the gated option.
for (let i = 0; i < 10; i++) {
const turn = currentTurn(testPack, probe);
const charm = turn.options.find((o) => o.option.id === 'charm');
if (charm) {
assert.equal(charm.available, false);
assert.throws(() => takeTurn(testPack, probe, 'charm'), MoveError);
return;
}
probe = takeTurn(testPack, probe, turn.options.find((o) => o.available).option.id).state;
}
assert.fail('the gated option never came up');
});
test('an unknown option id is rejected', () => {
const state = startGame(testPack, { seed: 'bogus' });
assert.throws(() => takeTurn(testPack, state, 'no_such_option'), /has no option/);
});
test('a skill grows from choices and eventually unlocks its gate', () => {
const talkative = (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
};
const { state } = playThrough('skill', 20, talkative);
assert.ok(state.skills.talking >= 3, `expected talking to grow, got ${state.skills.talking}`);
});
test('money going negative brings the hard-times branch into the pool', () => {
const spendy = (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
};
let state = startGame(testPack, { seed: 'broke' });
let sawHardTimes = false;
for (let i = 0; i < 120; i++) {
const turn = currentTurn(testPack, state);
if (turn.event.id === 'hard_times') { sawHardTimes = true; break; }
state = takeTurn(testPack, state, spendy(turn.options, turn.event, state)).state;
}
assert.ok(state.money < 0 || sawHardTimes, 'the run should have gone broke');
assert.ok(sawHardTimes, 'hard times should surface once money is negative — the game never ends');
});
test('the turn record carries the diff and the options the player could not take', () => {
let state = startGame(testPack, { seed: 'record' });
state = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id).state;
const turn = currentTurn(testPack, state);
const { record } = takeTurn(testPack, state, 'work');
assert.equal(record.eventId, turn.event.id);
assert.equal(record.optionId, 'work');
assert.deepEqual(record.changes.map((c) => c.path), ['money', 'relationships.chief.likes_you']);
assert.equal(record.changes[0].from + 10, record.changes[0].to);
const locked = record.offered.find((o) => o.id === 'charm');
assert.equal(locked.available, false);
assert.equal(locked.failed[0].path, 'skills.talking');
});
test('history accumulates every turn and is what the retrospective reads', () => {
const { state, records } = playThrough('history', 12);
assert.equal(state.history.length, 12);
assert.deepEqual(state.history, records);
});
test('a stat cannot be pushed past its declared bound', () => {
const { state } = playThrough('clamp', 80, (options) => {
const practice = options.find((o) => o.option.id === 'practice' && o.available);
return (practice ?? options.find((o) => o.available)).option.id;
});
assert.ok(state.skills.talking <= 10, 'talking is capped at 10');
assert.ok(state.relationships.pat.likes_you <= 100, 'relationship stats are capped at 100');
});
test('the run never ends on its own', () => {
const { state } = playThrough('endless', 200);
assert.equal(state.turn, 201);
assert.ok(currentTurn(testPack, state).options.some((o) => o.available), 'there is always something to do');
});
test('upkeep runs every turn and is recorded separately from the choice', () => {
const upkeepPack = structuredClone(testPack);
upkeepPack.upkeep = [
{ label: 'wage', effects: [{ path: 'money', op: 'add', value: 20 }] },
{
label: 'debt service',
requires: [{ path: 'flags.in_debt', op: '==', value: true }],
effects: [{ path: 'money', op: 'add', value: -5 }],
},
];
let state = startGame(upkeepPack, { seed: 'upkeep' });
const before = state.money;
const first = takeTurn(upkeepPack, state, currentTurn(upkeepPack, state).options[0].option.id);
assert.equal(first.record.upkeep.length, 1, 'the conditional entry is skipped while it does not apply');
assert.equal(first.record.upkeep[0].label, 'wage');
const fromChoice = first.record.changes
.filter((c) => c.path === 'money')
.reduce((sum, c) => sum + (c.to - c.from), 0);
assert.equal(first.state.money, before + fromChoice + 20);
// Once the flag is set, the conditional entry starts applying.
const nowIndebted = setPath(first.state, 'flags.in_debt', true);
const nextOption = currentTurn(upkeepPack, nowIndebted).options.find((o) => o.available).option.id;
const indebted = takeTurn(upkeepPack, nowIndebted, nextOption);
assert.deepEqual(indebted.record.upkeep.map((c) => c.label), ['wage', 'debt service']);
});
test('a character can start somewhere other than the pack default', () => {
const pack = structuredClone(testPack);
pack.characters.find((c) => c.id === 'pat').startingRelationship = { likes_you: 20 };
const state = startGame(pack, { seed: 'starts' });
assert.equal(state.relationships.pat.likes_you, 20, 'the override applies');
assert.equal(state.relationships.pat.fears_you, 50, 'unnamed stats keep the pack default');
assert.equal(state.relationships.chief.likes_you, 50, 'other characters are unaffected');
});