A 182-turn session spent 30% of its turns below zero, went negative on turn 18 and never recovered, and chose the same hard-times option — "take every shift going" — thirty-seven times. The cause was a tuning change made without re-deriving what it meant. Rent went from 385 to 455 while chasing a different problem, which took the mailroom baseline from the designed -$3/day to -$13/day. Worse, at the debt that run accumulated the weekly service charge came to $180 against an escape option worth $120. The hole was inescapable by arithmetic, whatever the player did. Nothing caught it. Every archetype either optimised money or got promoted out of the problem before it bit, and the dominance test passed because rent_bounced was only 17% of turns. Fixes: mailroom wages 980 -> 1120, restoring the -$3/day baseline; the escape option 120 -> 220, so it is worth more than a week's rent; rent_bounced from weight 5000 with no cooldown to 900 on a cooldown of 3, because being broke should colour a run rather than replace it; debt_collector from weight 25 to 45, since at 25 it appeared six times in 262 turns and debt became a ratchet. Three guards so this class of bug cannot recur quietly. The baseline is now asserted directly: a test sums the pack's own upkeep over twenty fortnights and requires the mailroom to net between -90 and +10, and Dispatch to be better but not so much better that money stops mattering. That would have failed the moment rent changed. Outcome tests over simulated play are a slow and noisy way to detect a number that is simply wrong. A `lifer` archetype refuses any option that would change stage — generically, by looking for an effect on `stage` — and otherwise plays for people. It reproduces the session that found this; no other archetype can. And no archetype may spend more than a quarter of a run below zero. Hard times is a state a player passes through; living in it is the failure mode. Also fixed: the analyser reported "every ~-5 turns". One export can hold several playthroughs and turn numbers restart with each, so spans are now accumulated per run and pooled rather than measured across the seam between two. 121 tests. Reasoning in docs/DECISIONS.md §29-30. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
232 lines
8.4 KiB
JavaScript
232 lines
8.4 KiB
JavaScript
// Read an exported feedback log and report what actually happened.
|
||
//
|
||
// node tools/analyze-log.js theladder-feedback-2026-09-10-11-04-22.json
|
||
//
|
||
// The question this exists to answer: how many turns were played against how
|
||
// many distinct situations, and how often each one came round again. A pack
|
||
// that invites a 180-turn run needs to be judged on what turn 180 looked like,
|
||
// not on what turn 5 did.
|
||
//
|
||
// It also surfaces the two things worth more than any rating — the gates
|
||
// players kept bouncing off, and anything they said they wanted to do.
|
||
|
||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||
import { resolve, dirname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const logsDir = resolve(root, 'logs');
|
||
|
||
/** The most recently modified log in logs/, when none was named. */
|
||
async function newestLog() {
|
||
let names;
|
||
try {
|
||
names = (await readdir(logsDir)).filter((name) => name.endsWith('.json'));
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!names.length) return null;
|
||
|
||
const timed = await Promise.all(names.map(async (name) => {
|
||
const path = join(logsDir, name);
|
||
return { path, at: (await stat(path)).mtimeMs };
|
||
}));
|
||
return timed.sort((a, b) => b.at - a.at)[0].path;
|
||
}
|
||
|
||
const bar = (fraction, width = 24) =>
|
||
'█'.repeat(Math.max(1, Math.round(fraction * width))).padEnd(width, '·');
|
||
|
||
const pct = (n, total) => `${((n / total) * 100).toFixed(1)}%`;
|
||
|
||
function section(title) {
|
||
console.log(`\n${title}`);
|
||
console.log('─'.repeat(title.length));
|
||
}
|
||
|
||
/** Split the log into playthroughs. One export can hold several. */
|
||
function byRun(entries) {
|
||
const runs = new Map();
|
||
for (const entry of entries) {
|
||
if (!runs.has(entry.seed)) runs.set(entry.seed, []);
|
||
runs.get(entry.seed).push(entry);
|
||
}
|
||
return runs;
|
||
}
|
||
|
||
/**
|
||
* How often each situation came round.
|
||
*
|
||
* Gaps are pooled *within* runs and never across them. An export can hold
|
||
* several playthroughs, and turn numbers restart with each — measuring a gap
|
||
* across that seam produced "every ~-5 turns", which is how this was caught.
|
||
*/
|
||
function reportRepetition(runs) {
|
||
const counts = new Map();
|
||
const spans = new Map(); // summed (last - first) within each run
|
||
const pairs = new Map(); // summed (appearances - 1) within each run
|
||
const onlyTurn = new Map();
|
||
let turns = 0;
|
||
|
||
for (const run of runs.values()) {
|
||
const firstSeen = new Map();
|
||
const lastSeen = new Map();
|
||
const inRun = new Map();
|
||
for (const entry of run) {
|
||
turns++;
|
||
counts.set(entry.event_id, (counts.get(entry.event_id) ?? 0) + 1);
|
||
inRun.set(entry.event_id, (inRun.get(entry.event_id) ?? 0) + 1);
|
||
if (!firstSeen.has(entry.event_id)) firstSeen.set(entry.event_id, entry.turn);
|
||
lastSeen.set(entry.event_id, entry.turn);
|
||
}
|
||
for (const [eventId, appearances] of inRun) {
|
||
if (appearances > 1) {
|
||
spans.set(eventId, (spans.get(eventId) ?? 0) + (lastSeen.get(eventId) - firstSeen.get(eventId)));
|
||
pairs.set(eventId, (pairs.get(eventId) ?? 0) + (appearances - 1));
|
||
} else if (!onlyTurn.has(eventId)) {
|
||
onlyTurn.set(eventId, firstSeen.get(eventId));
|
||
}
|
||
}
|
||
}
|
||
|
||
const distinct = counts.size;
|
||
section('Repetition');
|
||
console.log(`${turns} turns across ${distinct} distinct situations ` +
|
||
`— each seen ${(turns / distinct).toFixed(1)} times on average.`);
|
||
const repeated = [...counts.values()].filter((n) => n > 1).length;
|
||
console.log(`${repeated} of ${distinct} came up more than once; ` +
|
||
`${distinct - repeated} were one-offs.`);
|
||
|
||
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||
const most = ranked[0][1];
|
||
console.log('');
|
||
for (const [eventId, count] of ranked) {
|
||
const pairCount = pairs.get(eventId) ?? 0;
|
||
const gap = pairCount > 0
|
||
? ` every ~${Math.max(1, Math.round(spans.get(eventId) / pairCount))} turns`
|
||
: ` once${runs.size > 1 ? ' per run' : `, turn ${onlyTurn.get(eventId)}`}`;
|
||
console.log(` ${eventId.padEnd(24)} ${String(count).padStart(3)} ` +
|
||
`${bar(count / most)} ${pct(count, turns).padStart(6)}${gap}`);
|
||
}
|
||
}
|
||
|
||
function reportChoices(entries) {
|
||
const counts = new Map();
|
||
for (const entry of entries) {
|
||
const key = `${entry.event_id}.${entry.action_taken}`;
|
||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||
}
|
||
section('Choices');
|
||
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||
console.log(`${counts.size} distinct options taken. Most used:`);
|
||
for (const [key, count] of ranked.slice(0, 8)) {
|
||
console.log(` ${String(count).padStart(3)}× ${key}`);
|
||
}
|
||
}
|
||
|
||
function reportGates(entries) {
|
||
// What players were shown but could not take. The strongest signal that a
|
||
// choice set is too narrow, and it arrives whether or not anyone types.
|
||
const blocked = new Map();
|
||
for (const entry of entries) {
|
||
for (const option of entry.offered ?? []) {
|
||
if (option.available) continue;
|
||
const reasons = (option.failed ?? [])
|
||
.map((c) => `${c.path} ${c.op} ${JSON.stringify(c.value)}`)
|
||
.join(', ') || 'unstated';
|
||
const key = `${entry.event_id}.${option.id} — needs ${reasons}`;
|
||
blocked.set(key, (blocked.get(key) ?? 0) + 1);
|
||
}
|
||
}
|
||
section('Doors that stayed shut');
|
||
if (blocked.size === 0) {
|
||
console.log('Nothing was ever locked.');
|
||
return;
|
||
}
|
||
const ranked = [...blocked.entries()].sort((a, b) => b[1] - a[1]);
|
||
console.log(`${ranked.reduce((s, [, n]) => s + n, 0)} locked options shown, ` +
|
||
`${blocked.size} distinct. Most often:`);
|
||
for (const [key, count] of ranked.slice(0, 10)) {
|
||
console.log(` ${String(count).padStart(3)}× ${key}`);
|
||
}
|
||
}
|
||
|
||
function reportFeedback(entries) {
|
||
section('What the player said');
|
||
const rated = entries.filter((e) => e.rating);
|
||
const tally = { up: 0, neutral: 0, down: 0 };
|
||
for (const entry of rated) tally[entry.rating] = (tally[entry.rating] ?? 0) + 1;
|
||
|
||
console.log(rated.length
|
||
? `${rated.length} of ${entries.length} turns rated — ` +
|
||
`👍 ${tally.up} 😐 ${tally.neutral} 👎 ${tally.down}`
|
||
: 'No turns were rated.');
|
||
|
||
const rateFor = (id) => {
|
||
const seen = entries.filter((e) => e.event_id === id && e.rating);
|
||
if (!seen.length) return null;
|
||
const score = seen.reduce((s, e) => s + (e.rating === 'up' ? 1 : e.rating === 'down' ? -1 : 0), 0);
|
||
return { id, score, n: seen.length };
|
||
};
|
||
const scored = [...new Set(entries.map((e) => e.event_id))]
|
||
.map(rateFor).filter(Boolean).sort((a, b) => b.score - a.score);
|
||
if (scored.length > 1) {
|
||
console.log(`\n best received: ${scored[0].id} (${scored[0].score >= 0 ? '+' : ''}${scored[0].score} over ${scored[0].n})`);
|
||
const worst = scored.at(-1);
|
||
console.log(` worst received: ${worst.id} (${worst.score >= 0 ? '+' : ''}${worst.score} over ${worst.n})`);
|
||
}
|
||
|
||
const comments = entries.filter((e) => e.comment);
|
||
if (comments.length) {
|
||
console.log('\n Comments:');
|
||
for (const entry of comments) {
|
||
console.log(` turn ${entry.turn} (${entry.event_id}): ${entry.comment}`);
|
||
}
|
||
}
|
||
|
||
const wanted = entries.filter((e) => e.desired_but_unavailable_action);
|
||
if (wanted.length) {
|
||
console.log('\n Wanted to do, but could not:');
|
||
for (const entry of wanted) {
|
||
console.log(` turn ${entry.turn} (${entry.event_id}): ${entry.desired_but_unavailable_action}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const path = process.argv[2] ?? await newestLog();
|
||
if (!path) {
|
||
console.error('usage: npm run analyze -- <exported-feedback-log.json>');
|
||
console.error(' or: drop the export into logs/ and run npm run analyze');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
if (!process.argv[2]) console.log(`reading ${path.replace(`${root}/`, '')}\n`);
|
||
|
||
const log = JSON.parse(await readFile(path, 'utf8'));
|
||
const entries = log.entries ?? [];
|
||
if (!entries.length) {
|
||
console.log('The log has no entries.');
|
||
return;
|
||
}
|
||
|
||
const runs = byRun(entries);
|
||
console.log(`${entries.length} turns logged across ${runs.size} run${runs.size === 1 ? '' : 's'} ` +
|
||
`(pack ${entries[0].pack_id} ${entries[0].pack_version ?? ''})`.trimEnd());
|
||
for (const [seed, run] of runs) {
|
||
const stages = [...new Set(run.map((e) => e.stage))];
|
||
console.log(` seed ${seed}: ${run.length} turns, ${stages.join(' → ')}`);
|
||
}
|
||
|
||
reportRepetition(runs);
|
||
reportChoices(entries);
|
||
reportGates(entries);
|
||
reportFeedback(entries);
|
||
console.log('');
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(`could not analyse that log: ${error.message}`);
|
||
process.exitCode = 1;
|
||
});
|