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
101 lines
4.3 KiB
JavaScript
101 lines
4.3 KiB
JavaScript
// Same randomised magnitudes, but gates DERIVED from what a pack can actually
|
|
// reach rather than picked: for each gated path, simulate the ungated growth
|
|
// available and place thresholds inside that range.
|
|
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 randomizeMagnitudes(base) {
|
|
const pack = { ...base, events: clone(base.events) };
|
|
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 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; // direction kept, magnitude random
|
|
}
|
|
}
|
|
}
|
|
return pack;
|
|
}
|
|
|
|
/** Ceiling actually reachable on a path, measured by playing for it. */
|
|
function reachable(pack, path) {
|
|
const gain = (o) => (o.option.effects ?? [])
|
|
.filter((e) => e.path === path && e.op === 'add').reduce((s, e) => s + e.value, 0);
|
|
let best = 0;
|
|
for (let seed = 0; seed < 3; seed++) {
|
|
const st = play(pack, { strategy: 'random', seed: `r${seed}`, turns: 120 });
|
|
void st;
|
|
let peak = 0;
|
|
play(pack, { strategy: 'random', seed: `r${seed}`, turns: 120, visit: ({ state, turn }) => {
|
|
const v = path.split('.').reduce((o, k) => o?.[k], state);
|
|
if (typeof v === 'number' && v > peak) peak = v;
|
|
void gain; void turn;
|
|
}});
|
|
best = Math.max(best, peak);
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function deriveGates(pack) {
|
|
// Two passes: measure with gates removed, then place them in-range.
|
|
const stripped = { ...pack, events: clone(pack.events) };
|
|
const gatedPaths = new Set();
|
|
for (const e of stripped.events) for (const o of e.options ?? []) {
|
|
o.requires = (o.requires ?? []).filter((r) => {
|
|
const numericStat = typeof r.value === 'number' && !/^(turn|stage)/.test(r.path);
|
|
if (numericStat) gatedPaths.add(r.path);
|
|
return !numericStat;
|
|
});
|
|
if (o.requires.length === 0) delete o.requires;
|
|
}
|
|
const ceiling = {};
|
|
for (const p of gatedPaths) ceiling[p] = reachable(stripped, p);
|
|
|
|
const out = { ...pack, events: clone(pack.events) };
|
|
for (const e of out.events) for (const o of e.options ?? []) {
|
|
for (const r of o.requires ?? []) {
|
|
if (typeof r.value !== 'number' || /^(turn|stage)/.test(r.path)) continue;
|
|
const cap = ceiling[r.path] ?? 30;
|
|
// Place the gate in the reachable band: a door you can open, not a wall.
|
|
r.value = Math.max(1, Math.round(cap * (0.25 + Math.random() * 0.45)));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function score(pack) {
|
|
if (!validatePack(pack).valid) return { ok: false, why: 'invalid' };
|
|
try {
|
|
const { firedEvents, offeredOptions } = sweep(pack, { seeds: 4, turns: 200 });
|
|
const allE = pack.events.map((e) => e.id);
|
|
const allO = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
|
|
if (allE.some((i) => !firedEvents.has(i))) return { ok: false, why: 'dead events' };
|
|
if (allO.some((i) => !offeredOptions.has(i))) return { ok: false, why: 'dead options' };
|
|
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) return { ok: false, why: 'stuck below zero' };
|
|
if (Math.max(...counts.values()) / n >= 0.25) return { ok: false, why: 'one situation dominates' };
|
|
}
|
|
return { ok: true };
|
|
} catch (err) { return { ok: false, why: 'threw: ' + err.message.slice(0, 40) }; }
|
|
}
|
|
|
|
const tally = {}; let pass = 0; const N = 40;
|
|
for (let i = 0; i < N; i++) {
|
|
const r = score(deriveGates(randomizeMagnitudes(frontier)));
|
|
if (r.ok) pass++; else tally[r.why] = (tally[r.why] ?? 0) + 1;
|
|
}
|
|
console.log(`gates derived from reachable range: ${pass}/${N} passed`);
|
|
for (const [why, n] of Object.entries(tally).sort((a,b)=>b[1]-a[1])) console.log(' ', n, why);
|