A debt repayment took its whole instalment even when less was owed: four dollars owed at the store cost twenty. Content could not express "pay what is owed", so an add effect's amount may now be read from state, with a cap. Five repayments across both packs use it, and a conformance test holds every pack to it. The turn record now says which options were hidden, so the analyser stops counting doors nobody saw, and it reads only feedback logs. From the same session: wages are labelled as wages, a clamped change says where it stopped, two unreadable option labels are rewritten, the sale barn no longer offers what the player already owns, Ward hints at what the promotion waits for, and the office has five more events. DECISIONS #38-40. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
119 lines
4.7 KiB
JavaScript
119 lines
4.7 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;
|
|
};
|
|
|
|
const moneyGain = (resolved) => (resolved.option.effects ?? [])
|
|
.filter((e) => e.path === 'money' && e.op === 'add')
|
|
.reduce((sum, e) => sum + amountOf(e), 0);
|
|
|
|
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
|
|
.filter((e) => e.path.startsWith('relationships.'))
|
|
.reduce((sum, e) => sum + amountOf(e), 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)];
|