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