// 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)];