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
This commit is contained in:
co-authored by
Claude Opus 5
parent
d84ae495f4
commit
fa3769d0fe
@@ -0,0 +1,111 @@
|
||||
// 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)]);
|
||||
}
|
||||
Reference in New Issue
Block a user