Five things from the second playtest. A status line at the top of every screen: the game, the pack, your current title, and the version plus the commit it came from. `tools/stamp.js` writes the commit into src/build-info.js before serving and before building, so a distributed dist/theladder.html states exactly which commit produced it. An uncommitted working tree gets a `+` suffix — "4cab9fc+" is honest in a way a bare SHA would not be. The whole game is now playable on Enter. Every screen marks one control and the app focuses it after each render: the first *available* option while choosing, the next-turn button once the result is in, "keep going" on the retrospective. Tab and Shift+Tab reach the other options. Locked options needed no special handling — the browser's tab order already skips disabled controls, so they stay visible without being in the way. "What is notable? under What changed?" — two fixes. `notable` is the retrospective's own list of moments; a display rule can now say `inLedger: false` and it is left out, because "Notable — changed" tells nobody anything. Flags are the opposite and stay, but as sentences the pack supplies rather than as booleans: "The envelope is in your locker." instead of "the envelope: now true". Exported logs live in logs/, gitignored, and `npm run analyze` reads the newest one there when given no argument. A log records what a real person did turn by turn, including whatever they typed into the feedback box — committing one once would put it in the history for good. Version set to 0.3.0 on the reasoning that the engine was 0.1 and the interface 0.2; say if you want a different scheme and I will renumber. 119 tests. Reasoning in docs/DECISIONS.md §25-28. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
207 lines
7.4 KiB
JavaScript
207 lines
7.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;
|
||
}
|
||
|
||
function reportRepetition(entries) {
|
||
const counts = new Map();
|
||
const firstSeen = new Map();
|
||
const lastSeen = new Map();
|
||
for (const entry of entries) {
|
||
counts.set(entry.event_id, (counts.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);
|
||
}
|
||
|
||
const distinct = counts.size;
|
||
const turns = entries.length;
|
||
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 gap = count > 1
|
||
? ` every ~${Math.round((lastSeen.get(eventId) - firstSeen.get(eventId)) / (count - 1))} turns`
|
||
: ` once, turn ${firstSeen.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(entries);
|
||
reportChoices(entries);
|
||
reportGates(entries);
|
||
reportFeedback(entries);
|
||
console.log('');
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(`could not analyse that log: ${error.message}`);
|
||
process.exitCode = 1;
|
||
});
|