Files
TheLadder/test/fixtures/strategies.js
JesseMarkowitzandClaude Opus 5 5c1784d990 Fix an inescapable debt trap the playtest logs exposed
A 182-turn session spent 30% of its turns below zero, went negative on turn 18
and never recovered, and chose the same hard-times option — "take every shift
going" — thirty-seven times.

The cause was a tuning change made without re-deriving what it meant. Rent went
from 385 to 455 while chasing a different problem, which took the mailroom
baseline from the designed -$3/day to -$13/day. Worse, at the debt that run
accumulated the weekly service charge came to $180 against an escape option
worth $120. The hole was inescapable by arithmetic, whatever the player did.

Nothing caught it. Every archetype either optimised money or got promoted out of
the problem before it bit, and the dominance test passed because rent_bounced
was only 17% of turns.

Fixes: mailroom wages 980 -> 1120, restoring the -$3/day baseline; the escape
option 120 -> 220, so it is worth more than a week's rent; rent_bounced from
weight 5000 with no cooldown to 900 on a cooldown of 3, because being broke
should colour a run rather than replace it; debt_collector from weight 25 to 45,
since at 25 it appeared six times in 262 turns and debt became a ratchet.

Three guards so this class of bug cannot recur quietly.

The baseline is now asserted directly: a test sums the pack's own upkeep over
twenty fortnights and requires the mailroom to net between -90 and +10, and
Dispatch to be better but not so much better that money stops mattering. That
would have failed the moment rent changed. Outcome tests over simulated play are
a slow and noisy way to detect a number that is simply wrong.

A `lifer` archetype refuses any option that would change stage — generically, by
looking for an effect on `stage` — and otherwise plays for people. It reproduces
the session that found this; no other archetype can.

And no archetype may spend more than a quarter of a run below zero. Hard times
is a state a player passes through; living in it is the failure mode.

Also fixed: the analyser reported "every ~-5 turns". One export can hold several
playthroughs and turn numbers restart with each, so spans are now accumulated
per run and pooled rather than measured across the seam between two.

121 tests. Reasoning in docs/DECISIONS.md §29-30.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-10 06:01:33 -04:00

108 lines
4.3 KiB
JavaScript

// Player archetypes for content probing.
//
// "Is this content reachable?" is not the same question as "does random play
// reach it?" — a random player is not a plausible player, and several things
// in a pack exist precisely for a player pursuing something. Coverage is
// therefore the union across archetypes, and each one is a rough caricature of
// a way somebody actually plays.
//
// NOTE: seed the chooser differently from the game. Both draw one number per
// turn from the same generator, so a shared seed correlates the choice with
// the event draw and silently makes combinations unreachable.
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
import { createRng } from '../../src/engine/rng.js';
const moneyGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path.startsWith('relationships.'))
.reduce((sum, e) => sum + e.value, 0);
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
export const strategies = {
/** No plan at all. */
random: (options, rng) => rng.pick(options),
/** Takes the money every time. */
thrifty: (options) => best(options, moneyGain),
/** Spends it every time. */
spendthrift: (options) => best(options, (o) => -moneyGain(o)),
/** Chases standing with people. */
sociable: (options) => best(options, relationshipGain),
/**
* Broke but well-liked — the state the "ask a friend for money" options were
* written for, and one no single-axis strategy ever reaches.
*/
desperate: (options, rng) => (rng.next() < 0.25
? rng.pick(options)
: best(options, (o) => relationshipGain(o) - moneyGain(o) / 40)),
/**
* Never leaves the first rung — refuses anything that would change stage,
* and otherwise plays for people. Added after a real 182-turn session that
* stayed in the mailroom the whole way and spent 30% of it below zero: every
* other archetype either optimised money or got promoted out of the problem,
* so none of them could see it.
*/
lifer: (options) => {
const stays = options.filter(
(o) => !(o.option.effects ?? []).some((e) => e.path === 'stage'),
);
return best(stays.length ? stays : options, relationshipGain);
},
};
/** Play one run to `turns`, calling `visit` before each choice. */
export function play(pack, { strategy = 'random', seed = 'run', turns = 200, visit } = {}) {
const choose = strategies[strategy];
const rng = createRng(`chooser-${strategy}-${seed}`);
let state = startGame(pack, { seed: `game-${strategy}-${seed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
if (available.length === 0) throw new Error(`no available option on "${turn.event.id}"`);
const chosen = choose(available, rng);
visit?.({ turn, state, chosen });
state = takeTurn(pack, state, chosen.option.id).state;
}
return state;
}
/** Play every archetype across several seeds, reporting what was reached. */
export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
const firedEvents = new Set();
const takenOptions = new Set();
// What a player *could* have picked. This, not what a bot happened to pick,
// is what "is this content reachable" means — a real player chooses freely,
// and an archetype's preferences are an artefact of the harness.
const offeredOptions = new Set();
const byStrategy = {};
for (const strategy of Object.keys(strategies)) {
const finals = [];
for (let seed = 0; seed < seeds; seed++) {
const state = play(pack, {
strategy,
seed,
turns,
visit: ({ turn, chosen }) => {
firedEvents.add(turn.event.id);
takenOptions.add(`${turn.event.id}.${chosen.option.id}`);
for (const resolved of turn.options) {
if (resolved.available) offeredOptions.add(`${turn.event.id}.${resolved.option.id}`);
}
},
});
finals.push(state);
}
byStrategy[strategy] = finals;
}
return { firedEvents, takenOptions, offeredOptions, byStrategy };
}
export const median = (values) =>
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];