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