Files
TheLadder/tools/story-to-pack/recovered/probe3.mjs
T
JesseMarkowitzandClaude Opus 5 fa3769d0fe Add story-to-pack research and a structured situation review
Research toward building a content pack from a story corpus, kept on its own
branch and independent of the game. Records the selection experiments against
blind labels, and settles selection as gate G2 followed by a human review:
review.py writes REVIEW.md and a review.json form, apply_review.py checks the
filled form and writes situations.json for the next stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
2026-09-15 06:59:35 -04:00

112 lines
4.5 KiB
JavaScript

// Does a feedback loop converge? Start from randomised magnitudes, then repair
// whatever the checks complain about, one diagnosis at a time.
import { frontier } from '../../../content/frontier/index.js';
import { validatePack } from '../../../src/engine/validate.js';
import { play, sweep, strategies } from '../../../test/fixtures/strategies.js';
const clone = (o) => JSON.parse(JSON.stringify(o));
const ri = (lo, hi) => lo + Math.floor(Math.random() * (hi - lo + 1));
function seedPack(base) {
const pack = { ...base, events: clone(base.events), upkeep: clone(base.upkeep) };
for (const e of pack.events) {
if (e.weight !== undefined && e.weight < 5000) e.weight = ri(20, 120);
if (e.cooldown !== undefined) e.cooldown = ri(2, 12);
for (const o of e.options ?? []) {
for (const r of o.requires ?? []) {
if (typeof r.value === 'number' && !/^(turn|stage|flags)/.test(r.path)) r.value = ri(5, 60);
}
for (const f of o.effects ?? []) {
if (f.op !== 'add' || typeof f.value !== 'number') continue;
const mag = f.path === 'money' ? ri(1, 25) : ri(1, 12);
f.value = Math.sign(f.value) * mag;
}
}
}
return pack;
}
/** Full diagnosis, not just the first failure — the repair needs all of it. */
function diagnose(pack) {
const problems = [];
if (!validatePack(pack).valid) return [{ kind: 'invalid' }];
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 4, turns: 200 });
for (const e of pack.events) {
if (!firedEvents.has(e.id)) problems.push({ kind: 'deadEvent', id: e.id });
for (const o of e.options ?? []) {
if (!offeredOptions.has(`${e.id}.${o.id}`)) {
problems.push({ kind: 'deadOption', event: e.id, option: o.id });
}
}
}
for (const s of Object.keys(strategies)) {
let red = 0, n = 0; const counts = new Map();
play(pack, { strategy: s, seed: 'probe', turns: 150, visit: ({ state, turn }) => {
n++; if (state.money < 0) red++;
counts.set(turn.event.id, (counts.get(turn.event.id) ?? 0) + 1);
}});
if (red / n >= 0.25) problems.push({ kind: 'broke', strategy: s, share: red / n });
const [id, c] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
if (c / n >= 0.25) problems.push({ kind: 'dominates', id, share: c / n });
}
return problems;
}
function repair(pack, problems) {
const next = { ...pack, events: clone(pack.events), upkeep: clone(pack.upkeep) };
const event = (id) => next.events.find((e) => e.id === id);
for (const p of problems) {
if (p.kind === 'deadOption') {
const o = event(p.event)?.options.find((x) => x.id === p.option);
for (const r of o?.requires ?? []) {
if (typeof r.value === 'number' && !/^(turn|stage|flags)/.test(r.path)) {
r.value = Math.max(1, Math.floor(r.value * 0.7)); // lower the door
}
}
}
if (p.kind === 'deadEvent') {
const e = event(p.id);
if (!e) continue;
if (e.cooldown) e.cooldown = Math.max(1, Math.floor(e.cooldown * 0.7));
if (e.weight !== undefined && e.weight < 5000) e.weight = Math.round(e.weight * 1.6);
for (const r of e.requires ?? []) {
if (typeof r.value === 'number' && !/^(turn|stage|flags)/.test(r.path)) {
r.value = Math.max(1, Math.floor(r.value * 0.7));
}
}
}
if (p.kind === 'dominates') {
const e = event(p.id);
if (!e) continue;
if (e.weight !== undefined && e.weight < 5000) e.weight = Math.max(5, Math.round(e.weight * 0.6));
e.cooldown = Math.max(2, (e.cooldown ?? 1) + 2);
}
if (p.kind === 'broke') {
// Lift the floor: the settlement is the pack's own lever on the baseline.
for (const u of next.upkeep) {
if (!/Settlement|pay/i.test(u.label ?? '')) continue;
for (const f of u.effects) if (f.path === 'money') f.value = Math.round(f.value * 1.15) + 1;
}
}
}
return next;
}
let converged = 0; const N = 12; const historyLens = [];
for (let run = 0; run < N; run++) {
let pack = seedPack(frontier);
let iterations = 0;
let problems = diagnose(pack);
while (problems.length > 0 && iterations < 40) {
pack = repair(pack, problems);
problems = diagnose(pack);
iterations++;
}
if (problems.length === 0) { converged++; historyLens.push(iterations); }
}
console.log(`repair loop converged: ${converged}/${N}`);
if (historyLens.length) {
console.log('iterations to clean:', historyLens.sort((a,b)=>a-b).join(', '));
console.log('median:', historyLens[Math.floor(historyLens.length/2)]);
}