Files
TheLadder/test/content.test.js
T
JesseMarkowitzandClaude Opus 5 7f20be83e8 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
2026-09-12 21:03:50 -04:00

124 lines
5.6 KiB
JavaScript

// Corporate Ladder's own tuning.
//
// 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
// generator, so sharing a seed correlates the choice index with the event
// draw and silently makes whole option combinations unreachable — which looks
// exactly like a content bug.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
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 });
const promoted = Object.values(byStrategy).flat().filter((s) => s.stage === 'dispatch');
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'mailroom');
assert.ok(promoted.length > 0 && stayed.length > 0, 'both outcomes should occur');
assert.ok(median(promoted.map((s) => s.money)) > median(stayed.map((s) => s.money)),
'the coordinator wage should leave a player better off');
// A player actually pursuing it should get there mid-run, not at the very
// end — the mailroom is a career, not a tutorial.
const timings = [];
for (let seed = 0; seed < 12; seed++) {
let at = null;
play(pack, { strategy: 'sociable', seed, turns: 200,
visit: ({ state }) => { if (state.stage === 'dispatch' && at === null) at = state.turn; } });
if (at) timings.push(at);
}
assert.equal(timings.length, 12, 'a sociable player should always get the offer');
const typical = median(timings);
assert.ok(typical > 40 && typical < 90, `promotion landed at turn ${typical}`);
});
test('hard times is uncommon for a careful player and real for a careless one', () => {
const countHardTimes = (strategy) => {
let days = 0;
play(pack, { strategy, seed: 'ht', turns: 200,
visit: ({ turn }) => { if (turn.event.id === 'rent_bounced') days++; } });
return days;
};
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of trouble');
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('money arrives and leaves on a rhythm the player can plan around', () => {
const wages = [];
const rent = [];
let state = startGame(pack, { seed: 'rhythm' });
for (let t = 0; t < 30; t++) {
const turn = currentTurn(pack, state);
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
for (const change of result.record.upkeep) {
if (change.label === 'Wages') wages.push(state.turn);
if (change.label === 'Rent') rent.push(state.turn);
}
state = result.state;
}
assert.deepEqual(wages, [5, 19], 'paid on alternate Fridays');
assert.deepEqual(rent, [8, 15, 22, 29], 'rent every Monday from the second week');
});
test('the calendar names the day and counts the week', () => {
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
['Monday', 1, 0],
);
for (let t = 0; t < 11; t++) {
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, 12);
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
});
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
let peakDebt = 0;
for (const strategy of ['spendthrift', 'desperate', 'random']) {
for (let seed = 0; seed < 12; seed++) {
play(pack, { strategy, seed, turns: 200,
visit: ({ state }) => { peakDebt = Math.max(peakDebt, state.debt); } });
}
}
// The loan is gated at debt < 2000 and lends 500, so 2500 plus stalled
// interest is the ceiling. Well above that means the gate has stopped working.
assert.ok(peakDebt <= 3000, `debt reached ${peakDebt}; the borrowing ceiling is not holding`);
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Loan service charge'));
assert.ok(charges.length >= 3, 'the service charge should step up as debt grows');
});
test('a deliberate player can reach relationship-gated content in a reasonable run', () => {
// The Trevor chain is the deepest gate in the pack. A player cultivating him
// should get there well inside a normal session, not at turn 100.
const regard = (o) => (o.option.effects ?? [])
.filter((e) => e.path === 'relationships.trevor.likes_you')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: 'befriend-trevor' });
let reached = null;
for (let t = 0; t < 60 && reached === null; t++) {
const turn = currentTurn(pack, state);
if (turn.event.id === 'trevor_favor') { reached = state.turn; break; }
const best = turn.options.filter((o) => o.available).sort((a, b) => regard(b) - regard(a))[0];
state = takeTurn(pack, state, best.option.id).state;
}
assert.ok(reached && reached < 45, `Trevor's chain opened at turn ${reached ?? 'never'}`);
});