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
112 lines
4.5 KiB
JavaScript
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)]);
|
|
}
|