Files
TheLadder/test/frontier.test.js
T
JesseMarkowitzandClaude Opus 5 d84ae495f4 Keep money mattering at the top, and fix the instruments that hid it
Both settings now carry a charge levied by standing itself, which no purchase
ends. Corporate Ladder's steps on money (1200/-60, 2400/-120, 3600/-240): a flat
charge cannot flatten a slope, since late income scales with how well the player
plays, and the flat charge needed to stop an optimiser accumulating put ordinary
archetypes below zero for 44% of a run. Stepped, the balance settles around
$4,250 and ordinary play comes out better than before it existed. The Frontier's
is flat at -4 a run, because its late game never accumulated in the first place.

Found in the first two human sessions of this work:

- A career saved before a pack gained a stat had no path for it, and conditions
  compare numbers only, so every event, option and upkeep entry gated on a new
  stat silently did not exist for that run. Loading now fills those in.
- 'Wrap up my career' sat immediately after the last option in tab order, so one
  Tab too many and Enter read as the game ending at day 31. It goes last, and
  the retrospective says plainly that it is a summary.
- A 181-turn session ran against a stale bundle and the change under test never
  fired. Every logged turn now records app_version and build.
- src/version.js had reported 0.3.0 since 0.3.0, while playtesters were told to
  check the status bar. A test now holds it to package.json.
- 'Put money against the book' was shown locked to a player who owed nothing,
  six times. Options gated on a state the player cannot choose to enter are now
  hidden rather than greyed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
2026-09-12 23:07:35 -04:00

270 lines
12 KiB
JavaScript

// The Frontier's own tuning.
//
// What is true of this pack because it is *this* pack: a four-day run instead
// of a week, a settlement that barely covers the room, a store ledger rather
// than a loan, and when the star is offered. Everything true of a pack because
// it is a pack is in conformance.test.js, which runs over every pack in the
// registry — including this one.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { frontier as pack } from '../content/frontier/index.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { check } from '../src/engine/conditions.js';
import { play, sweep, median } from './fixtures/strategies.js';
/**
* Net money from upkeep per run for a player in a given position: at a stage,
* at a lifestyle tier, owing the store nothing.
*
* Derived rather than simulated, and for a reason worth keeping: comparing the
* end-balances of promoted and unpromoted players pools archetypes that do not
* spend alike, so it measures the mix rather than the wage.
*/
function netPerCycle(position) {
const state = { owed: 0, lifestyle: 0, standing: 0, ...position };
const cycle = pack.calendar?.cycleLength ?? 1;
return (pack.upkeep ?? [])
.filter((entry) => check(state, entry.requires).passed)
.reduce((total, entry) => total + entry.effects
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0) * (cycle / (entry.every ?? 1)), 0);
}
test('the calendar is a four-day run, not a week', () => {
// The point of this pack, structurally: the engine derives a cycle from
// whatever length the pack declares, and nothing anywhere assumes seven.
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
['Stage day', 1, 0],
);
const names = [];
for (let t = 0; t < 7; t++) {
names.push(state.calendar.name);
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.deepEqual(names,
['Stage day', 'Haul', 'Yard', 'Rest', 'Stage day', 'Haul', 'Yard']);
assert.equal(state.calendar.cycle, 2, 'the second run has begun');
});
test('the settlement and the room land on the days the pack says', () => {
const settlement = [];
const room = [];
let state = startGame(pack, { seed: 'rhythm' });
for (let t = 0; t < 16; 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 === 'Settlement') settlement.push(state.turn);
if (change.label === 'Room at the Cordwood') room.push(state.turn);
}
state = result.state;
}
// The boarding house collects on the Rest day, the night before the outfit
// settles up on the Stage day. That ordering is the whole reason a hand is
// never quite ahead.
assert.deepEqual(room, [4, 8, 12, 16], 'the room is collected every Rest day');
assert.deepEqual(settlement, [5, 9, 13], 'the outfit settles up every Stage day');
});
test('the star is worth taking, and reachable by a deliberate player', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
const sworn = Object.values(byStrategy).flat().filter((s) => s.stage === 'deputy');
const stayed = Object.values(byStrategy).flat().filter((s) => s.stage === 'hand');
assert.ok(sworn.length > 0 && stayed.length > 0, 'both outcomes should occur');
const yard = netPerCycle({ stage: 'hand' });
const office = netPerCycle({ stage: 'deputy' });
assert.ok(office > yard + 10,
`the office nets ${office} a run against the yard's ${yard}`);
// The yard is a career, not a tutorial: the offer is gated at turn 40 and
// should land somewhere in the middle of a run rather than at the very end.
const timings = [];
for (let seed = 0; seed < 12; seed++) {
let at = null;
play(pack, { strategy: 'sociable', seed, turns: 200,
visit: ({ state }) => { if (state.stage === 'deputy' && 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 >= 41 && typical < 90, `the star landed at turn ${typical}`);
});
/** Play to `turns` playing for standing, buying what the star expects or not. */
function chaseStanding({ seed, keepUp, turns = 200 }) {
const buys = (r) => (r.option.effects ?? []).some((e) => e.path === 'lifestyle');
const gain = (r) => (r.option.effects ?? [])
.filter((e) => e.path === 'standing' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: `standing-${seed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
const buy = available.find(buys);
const chosen = (keepUp && buy)
? buy
: [...available.filter((o) => keepUp || !buys(o))].sort((a, b) => gain(b) - gain(a))[0]
?? available[0];
state = takeTurn(pack, state, chosen.option.id).state;
}
return state;
}
test('the star costs money to wear, and the top of standing cannot be worked for', () => {
// The same mechanism as Corporate Ladder's, at this pack's scale and in its
// own vocabulary — which is the whole claim the second setting exists to
// make. No engine code is involved in either.
const plain = netPerCycle({ stage: 'deputy' });
const horse = netPerCycle({ stage: 'deputy', lifestyle: 1 });
const room = netPerCycle({ stage: 'deputy', lifestyle: 2 });
assert.ok(horse < plain - 5, `the bay costs ${plain - horse} a run`);
assert.ok(room < horse - 5, `the room costs ${horse - room} a run`);
assert.ok(room > 0,
`a deputy keeping up nets ${room} a run; the yard's own floor is barely above water`);
// The top of the ladder is not a plateau here either: standing costs money
// to carry at every tier, and nothing you can buy switches it off.
// Flat here rather than stepped on money, as Corporate Ladder's is: this
// pack's late game does not accumulate in the first place — measured, a
// deputy who owns everything drifts 0 a run — so the charge exists to keep
// money live, and break-even at the top is the intent rather than a shortfall.
const somebody = netPerCycle({ stage: 'deputy', lifestyle: 2, standing: 90 });
assert.ok(somebody < room, `standing itself should cost: ${somebody} against ${room} a run`);
assert.ok(somebody <= 0,
`and leave a deputy who has everything no better than level: ${somebody} a run`);
const never = [];
const keeps = [];
for (let seed = 0; seed < 8; seed++) {
never.push(chaseStanding({ seed, keepUp: false }));
keeps.push(chaseStanding({ seed, keepUp: true }));
}
const standing = (runs) => median(runs.map((s) => s.standing));
assert.ok(standing(never) <= 90, `a deputy on a livery horse reached ${standing(never)} standing`);
assert.ok(standing(keeps) >= 95, `a deputy keeping up reached only ${standing(keeps)} standing`);
assert.ok(median(keeps.map((s) => s.money)) < median(never.map((s) => s.money)),
'and it is paid for in money');
});
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 === 'store_cut_off') days++; } });
return days;
};
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of the book');
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('the store ledger cannot spiral without limit', () => {
let peak = 0;
for (const strategy of ['spendthrift', 'desperate', 'random']) {
for (let seed = 0; seed < 12; seed++) {
play(pack, { strategy, seed, turns: 200,
visit: ({ state }) => { peak = Math.max(peak, state.owed); } });
}
}
// He stops carrying you at 120 and the largest single line is 32, so 152
// plus a few stalled charges is the ceiling. Well above that means the gate
// has stopped working.
assert.ok(peak <= 220, `the ledger reached ${peak}; the credit ceiling is not holding`);
const charges = pack.upkeep.filter((u) => u.label?.startsWith('Store credit'));
assert.ok(charges.length >= 3, 'the charge should step up as the ledger grows');
});
test('a skill gate is never gated behind itself', () => {
// The lesson Corporate Ladder's logs taught, applied here as a rule rather
// than as a hope. Four of its options need negotiation 15 and several of its
// largest negotiation gains sit behind that same gate, so a player who is
// not deliberately chasing the skill bounces off the wall for thirty turns.
//
// The structural property that prevents it: for every skill threshold in the
// pack, the growth available *without* passing that threshold must be able
// to reach it, and repeatably — a single one-off award is not a route.
const gates = new Map();
for (const event of pack.events) {
for (const option of event.options ?? []) {
for (const requirement of option.requires ?? []) {
if (!requirement.path.startsWith('skills.')) continue;
const highest = gates.get(requirement.path) ?? 0;
gates.set(requirement.path, Math.max(highest, requirement.value));
}
}
}
assert.ok(gates.size > 0, 'the pack should gate something on a skill');
for (const [path, highest] of gates) {
const start = pack.state[path].initial;
let repeatable = 0;
for (const event of pack.events) {
if (event.once) continue;
for (const option of event.options ?? []) {
// Ungated on this skill: a route a player has open from turn one.
if ((option.requires ?? []).some((r) => r.path === path)) continue;
const gain = (option.effects ?? [])
.filter((e) => e.path === path && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
if (gain > 0) repeatable += gain;
}
}
assert.ok(start + repeatable > highest,
`${path} tops out at ${highest}, but only ${repeatable} is reachable without it`);
}
});
test('a player who wants a skill does not wait long for the first door', () => {
// Measured rather than assumed: a player steering for a skill should be
// through its lowest gate inside the first two runs of the stage.
const firstGate = 12;
for (const path of ['skills.nerve', 'skills.trade']) {
const gain = (o) => (o.option.effects ?? [])
.filter((e) => e.path === path && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
const times = [];
for (let seed = 0; seed < 12; seed++) {
let state = startGame(pack, { seed: `gate-${seed}` });
let at = null;
for (let t = 0; t < 120 && at === null; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
const best = [...available].sort((a, b) => gain(b) - gain(a))[0];
state = takeTurn(pack, state, best.option.id).state;
const value = path.split('.').reduce((o, k) => o?.[k], state);
if (value >= firstGate) at = state.turn;
}
times.push(at ?? Infinity);
}
const typical = median(times);
assert.ok(typical <= 20,
`${path} reached ${firstGate} at turn ${typical}; the first door is too far off`);
}
});
test('a deliberate player can put Dutch in their debt', () => {
// The deepest relationship gate in the pack: `send_for_dutch` in the
// marshal's office needs a favour banked two rungs earlier.
const owed = (o) => (o.option.effects ?? [])
.filter((e) => e.path === 'relationships.dutch.owes_you')
.reduce((sum, e) => sum + e.value, 0);
let state = startGame(pack, { seed: 'dutch' });
let reached = null;
for (let t = 0; t < 60 && reached === null; t++) {
if (state.relationships.dutch.owes_you >= 25) { reached = state.turn; break; }
const turn = currentTurn(pack, state);
const best = turn.options.filter((o) => o.available).sort((a, b) => owed(b) - owed(a))[0];
state = takeTurn(pack, state, best.option.id).state;
}
assert.ok(reached && reached < 45, `Dutch's book opened at turn ${reached ?? 'never'}`);
});