#!/usr/bin/env node // verifyBookwormBoard.js — Bookworm board-quality harness // // Compares the plain weighted-random board (baseline) against the steering // layer (class steering + vowel band + word-completion boost) on: // • static boards — words available, dead boards, vowel share // • simulated sessions — play the longest word each turn, refill, repeat: // words available per turn, dead turns (no word to play), turn survival // // Deterministic (seeded RNG). The dictionary is the same ENABLE list // (3–15 letters) that /words/scrabble/validate accepts, so "available word" // means "word the player can actually submit". // // Run: node tools/verifyBookwormBoard.js import { readFileSync } from 'node:fs'; import { GRID_SIZE, makeGrid, clearAndRefill, getAdjacent, } from '../src/games/bookwork/BookworkLogic.js'; import { makeSteeredGrid, refillSteered, parseWordList, letterWeights, pickWeighted, DEFAULT_STEER, } from '../src/games/bookwork/BookworkSteering.js'; const N_BOARDS = 300; const N_SESSIONS = 60; const MAX_TURNS = 30; let pass = 0, fail = 0; function ok(label, cond) { if (cond) { console.log(` ✓ ${label}`); pass++; } else { console.error(` ✗ ${label}`); fail++; } } // ── deterministic rng ───────────────────────────────────────────────────────── function mulberry32(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ── word finding (boggle DFS with prefix pruning; one path per word) ───────── function buildPrefixSet(wordSet) { const pre = new Set(); for (const w of wordSet) for (let i = 1; i <= w.length; i++) pre.add(w.slice(0, i)); return pre; } function findWords(grid, wordSet, prefixSet) { const found = new Map(); // word -> [cells] const visited = Array.from({ length: GRID_SIZE }, () => new Array(GRID_SIZE).fill(false)); const dfs = (r, c, word, cells) => { const w = word + grid[r][c].letter; const isWord = w.length >= 3 && wordSet.has(w); if (isWord && !found.has(w)) found.set(w, [{ r, c }, ...cells]); if (!prefixSet.has(w)) return; if (cells.length + 1 >= GRID_SIZE * GRID_SIZE) return; for (const { r: nr, c: nc } of getAdjacent(r, c)) { if (visited[nr][nc]) continue; visited[nr][nc] = true; dfs(nr, nc, w, [...cells, { r, c }]); visited[nr][nc] = false; } }; for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) { visited[r][c] = true; dfs(r, c, '', []); visited[r][c] = false; } return found; } const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']); const vowelShare = (grid) => grid.flat().filter((c) => c.letter && VOWELS.has(c.letter)).length / (GRID_SIZE * GRID_SIZE); const quantile = (arr, q) => { const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(q * s.length))]; }; const mean = (arr) => arr.reduce((a, b) => a + b, 0) / Math.max(1, arr.length); // ── setup ───────────────────────────────────────────────────────────────────── console.log('Loading dictionary…'); const wordSet = parseWordList(readFileSync('./data/wordlists/enable1.txt', 'utf8')); const prefixSet = buildPrefixSet(wordSet); console.log(` ${wordSet.size} words (3–15 letters), ${prefixSet.size} prefixes\n`); const variants = { baseline: { make: (rng) => makeGrid(rng), refill: (g, cells, rng) => clearAndRefill(g, cells, rng), }, steered: { make: (rng) => makeSteeredGrid(rng, wordSet, DEFAULT_STEER), refill: (g, cells, rng) => refillSteered(g, cells, rng, wordSet, DEFAULT_STEER), }, }; // ── steering sanity ─────────────────────────────────────────────────────────── console.log('Steering sanity'); { const rng = mulberry32(1); const g = makeSteeredGrid(rng, wordSet, DEFAULT_STEER); ok('steered grid is 5×5', g.length === GRID_SIZE && g.every((r) => r.length === GRID_SIZE)); ok('all cells filled with A-Z', g.flat().every((c) => /^[A-Z]$/.test(c.letter))); ok('no Q (absent from base pool)', !g.flat().some((c) => c.letter === 'Q')); const w = letterWeights(g, 2, 2, wordSet, DEFAULT_STEER); ok('letterWeights: Q weight is 0', w['Q'.charCodeAt(0) - 65] === 0); ok('letterWeights: total weight > 0', w.reduce((a, b) => a + b, 0) > 0); const letter = pickWeighted(w, mulberry32(7)); ok('pickWeighted returns a letter in the pool', /^[A-Z]$/.test(letter) && letter !== 'Q'); const ref = refillSteered(g, [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }], mulberry32(3), wordSet, DEFAULT_STEER); ok('steered refill keeps 5×5', ref.length === GRID_SIZE && ref.flat().every((c) => /^[A-Z]$/.test(c.letter))); } // ── static board quality ────────────────────────────────────────────────────── console.log(`\nStatic boards (N=${N_BOARDS} each)`); const staticStats = {}; for (const [name, v] of Object.entries(variants)) { const wordCounts = [], shares = []; for (let i = 0; i < N_BOARDS; i++) { const g = v.make(mulberry32(1000 + i)); wordCounts.push(findWords(g, wordSet, prefixSet).size); shares.push(vowelShare(g)); } staticStats[name] = { wordCounts, dead: wordCounts.filter((n) => n === 0).length, min: Math.min(...wordCounts), p5: quantile(wordCounts, 0.05), median: quantile(wordCounts, 0.5), mean: mean(wordCounts), max: Math.max(...wordCounts), vowelMin: Math.min(...shares), vowelMax: Math.max(...shares), vowelMean: mean(shares), }; } const row = (label, f) => console.log(` ${label.padEnd(28)} ${String(f('baseline')).padStart(14)} ${String(f('steered')).padStart(14)}`); console.log(' ' + 'metric'.padEnd(28) + 'baseline'.padStart(14) + 'steered'.padStart(14)); row('words/board (min)', (n) => staticStats[n].min); row('words/board (p5)', (n) => staticStats[n].p5); row('words/board (median)', (n) => staticStats[n].median); row('words/board (mean)', (n) => staticStats[n].mean.toFixed(1)); row('words/board (max)', (n) => staticStats[n].max); row('dead boards (0 words)', (n) => `${staticStats[n].dead} (${(100 * staticStats[n].dead / N_BOARDS).toFixed(1)}%)`); row('vowel share (min–max)', (n) => `${(100 * staticStats[n].vowelMin).toFixed(0)}–${(100 * staticStats[n].vowelMax).toFixed(0)}%`); row('vowel share (mean)', (n) => `${(100 * staticStats[n].vowelMean).toFixed(1)}%`); // ── simulated sessions ──────────────────────────────────────────────────────── console.log(`\nSimulated sessions (N=${N_SESSIONS}, max ${MAX_TURNS} turns, greedy longest-word play)`); const sessionStats = {}; for (const [name, v] of Object.entries(variants)) { const totals = [], wpt = [], dead = [], initial = [], longest = []; for (let i = 0; i < N_SESSIONS; i++) { const rng = mulberry32(90000 + i); let grid = v.make(rng); let deadTurn = false, total = 0; for (let t = 0; t < MAX_TURNS; t++) { const words = findWords(grid, wordSet, prefixSet); if (t === 0) initial.push(words.size); if (words.size === 0) { deadTurn = true; break; } total += words.size; wpt.push(words.size); let best = null; for (const [w, cells] of words) if (!best || w.length > best.length) best = { w, cells }; longest.push(best.w.length); grid = v.refill(grid, best.cells, rng); } totals.push(total); if (deadTurn) dead.push(1); else dead.push(0); } sessionStats[name] = { deadRate: mean(dead), totalMedian: quantile(totals, 0.5), totalMean: mean(totals), wptMin: Math.min(...wpt), wptP5: quantile(wpt, 0.05), wptMedian: quantile(wpt, 0.5), initialMedian: quantile(initial, 0.5), longestMax: Math.max(...longest), }; } row('dead sessions (stuck)', (n) => `${Math.round(sessionStats[n].deadRate * N_SESSIONS)}/${N_SESSIONS} (${(100 * sessionStats[n].deadRate).toFixed(0)}%)`); row('initial words (median)', (n) => sessionStats[n].initialMedian); row('words/turn (min)', (n) => sessionStats[n].wptMin); row('words/turn (p5)', (n) => sessionStats[n].wptP5); row('words/turn (median)', (n) => sessionStats[n].wptMedian); row('total words (median)', (n) => sessionStats[n].totalMedian); row('total words (mean)', (n) => sessionStats[n].totalMean.toFixed(0)); row('longest word seen', (n) => sessionStats[n].longestMax); // ── comparisons ─────────────────────────────────────────────────────────────── console.log('\nComparison'); ok('steered median words/board ≥ baseline', staticStats.steered.median >= staticStats.baseline.median); ok('steered dead boards ≤ baseline', staticStats.steered.dead <= staticStats.baseline.dead); ok('steered p5 words/board ≥ baseline', staticStats.steered.p5 >= staticStats.baseline.p5); ok('steered dead-session rate ≤ baseline', sessionStats.steered.deadRate <= sessionStats.baseline.deadRate); ok('steered median words/turn ≥ baseline', sessionStats.steered.wptMedian >= sessionStats.baseline.wptMedian); ok('steered median total words ≥ baseline', sessionStats.steered.totalMedian >= sessionStats.baseline.totalMedian); console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed\n`); process.exit(fail > 0 ? 1 : 0);