Files
TheLadder/test/fixtures/strategies.js
T
JesseMarkowitzandClaude Opus 5.5 3108319a2b Add chance and a cast that changes between runs, and a pack to try them
An option used to do exactly what it said. Now it may carry a check: one of
four attributes (Nerve, Wits, Charm, Resolve) against a set difficulty or
against a named person's own attribute, leaning on how they regard you, with a
success and a failure. The player sees only the odds in words ("a long shot",
"likely"), never a number. The roll is one draw from the run's generator, so a
run still replays exactly from its seed and choices.

A character may be a role with several authored candidates, one drawn per run,
or a group of two to four drawn into numbered slots. Everyone carries a name, a
description and the same four attributes as the player, so a replayed pack
meets a different office. Text names people as {partner} or {clerk}, and an
event about "a clerk" binds one present clerk for the turn. The draw and the
binding are saved with the run, and a save from before a pack drew a cast gets
the same one every time it is loaded.

All of it is generic and optional: Corporate Ladder and the Frontier use none
of it and play as before. The Counting House, a one-stage office about 1910
built from the story-to-pack catalogue, exercises it, and was tuned against the
conformance suite by measurement. DECISIONS #41. Version 0.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MhABSrqkwTdcybXmheDPr
2026-09-23 22:12:26 -04:00

136 lines
5.5 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';
/**
* What an effect is worth to a bot. An amount read from state (DECISIONS #38)
* is scored at its cap: the most it could be, which is what a player weighing
* the option sees on the label.
*/
const amountOf = (effect) => {
if (typeof effect.value === 'number') return effect.value;
if (typeof effect.value?.cap === 'number') return effect.value.cap * (effect.value.times ?? 1);
return 0;
};
/**
* Every effect an option can have, with the weight a player reading the odds
* would give it: plain effects count in full, a check's success and failure by
* their chance (DECISIONS #41). An option with no check is just its effects.
*/
export const weightedEffects = (resolved) => {
const { option, odds } = resolved;
const plain = (option.effects ?? []).map((effect) => ({ effect, weight: 1 }));
if (!option.check) return plain;
const p = (odds?.chance ?? 50) / 100;
return [
...plain,
...(option.success?.effects ?? []).map((effect) => ({ effect, weight: p })),
...(option.failure?.effects ?? []).map((effect) => ({ effect, weight: 1 - p })),
];
};
const moneyGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path === 'money' && effect.op === 'add')
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
const relationshipGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path.startsWith('relationships.'))
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 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) => !weightedEffects(o).some(({ effect }) => effect.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)];