diff --git a/README.md b/README.md index 6d035b8..aa6e3f4 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ menu categories: | **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi | | **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle | | **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines | -| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble | +| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookworm, Kiitos, Tri-Ominoes, Jumble | | **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter | | **Arcade, Console & PC** | 2 | Colorado Defense, Star Control | diff --git a/data/bookwork.json b/data/bookwork.json index b9eb391..bb1021a 100644 --- a/data/bookwork.json +++ b/data/bookwork.json @@ -1,5 +1,18 @@ { "playerBaseHp": 100, + "steer": { + "wordBoostK": 1.0, + "classThreshold": 2, + "classDamp": 0.35, + "classBoost": 1.8, + "vowelBand": [0.30, 0.45] + }, + "specialTiles": { + "startLevel": 1, + "endLevel": 10, + "start": { "gold": 0.10, "diamond": 0.06 }, + "end": { "gold": 0.03, "diamond": 0.02 } + }, "milestones": [ { "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" }, { "afterLevel": 10, "maxHpBonus": 10 }, diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 76805c8..c359188 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -99,7 +99,7 @@ registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame: registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' }); registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 }); registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 }); -registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 }); +registerGame({ slug: 'bookwork', name: 'Bookworm', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 }); registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 }); registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 }); registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' }); diff --git a/src/games/bookwork/BookworkGame.js b/src/games/bookwork/BookworkGame.js index f444c4a..fe8f6c6 100644 --- a/src/games/bookwork/BookworkGame.js +++ b/src/games/bookwork/BookworkGame.js @@ -9,9 +9,10 @@ import { GRID_SIZE, makeGrid, getAdjacent, isAdjacent, wordFromCells, computeDamage, computeSelfDamage, clearAndRefill, dropSpecialTile, countPoisonTiles, - computeMaxHp, isPotionUnlocked, + computeMaxHp, isPotionUnlocked, specialTileChances, } from './BookworkLogic.js'; import { getAttackDamage, getSpecialTile } from './BookworkAI.js'; +import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js'; const CELL = 96; const GRID_W = CELL * GRID_SIZE; @@ -39,7 +40,7 @@ export default class BookworkGame extends Phaser.Scene { constructor() { super('BookworkGame'); } init(data) { - this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookwork' }; + this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookworm' }; this.config = { playerBaseHp: 100, milestones: [], levels: [] }; this.bank = []; this.roster = []; @@ -56,6 +57,9 @@ export default class BookworkGame extends Phaser.Scene { this.bgTex = null; // Battle state this.grid = null; + this.wordSet = null; // steering dictionary (ENABLE words 3–15); null → unsteered + this.steerOpts = {}; + this.specialSpawn = { goldChance: 0.03, diamondChance: 0.02 }; this.tileObjs = null; this.selection = []; this.selGraphics = null; @@ -85,8 +89,16 @@ export default class BookworkGame extends Phaser.Scene { const raw = this.cache.json.get('bookwork'); if (raw) this.config = raw; + this.steerOpts = this.config.steer ?? {}; this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level); + // Load the steering dictionary (same ENABLE list the word validator uses). + // Non-fatal: if it fails, boards fall back to plain weighted-random. + try { + const res = await fetch('data/wordlists/enable1.txt'); + if (res.ok) this.wordSet = parseWordList(await res.text()); + } catch (_) { this.wordSet = null; } + try { const res = await fetch('data/opponents.json'); const json = await res.json(); @@ -279,7 +291,7 @@ export default class BookworkGame extends Phaser.Scene { this.clearLayer(); const cx = GAME_WIDTH / 2; - const title = this.add.text(cx, 84, 'BOOKWORK', { + const title = this.add.text(cx, 84, 'BOOKWORM', { fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, }).setOrigin(0.5); const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', { @@ -514,6 +526,7 @@ export default class BookworkGame extends Phaser.Scene { this.level = level; this.levelDef = lv; this.opponent = this.opponentFor(lv); + this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {}); this.clearLayer(); this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted); @@ -523,7 +536,7 @@ export default class BookworkGame extends Phaser.Scene { this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted); this.potionUsed = false; this.turnPhase = 'player'; - this.grid = makeGrid(); + this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid(); this.selection = []; this.drawBattleUI(); @@ -832,7 +845,9 @@ export default class BookworkGame extends Phaser.Scene { } // Refill grid - this.grid = clearAndRefill(this.grid, cells); + this.grid = this.wordSet + ? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn }) + : clearAndRefill(this.grid, cells, Math.random, this.specialSpawn); await this.animateRefill(cells); this.redrawAllTiles(); diff --git a/src/games/bookwork/BookworkLogic.js b/src/games/bookwork/BookworkLogic.js index 14238d0..8c585f0 100644 --- a/src/games/bookwork/BookworkLogic.js +++ b/src/games/bookwork/BookworkLogic.js @@ -1,7 +1,7 @@ export const GRID_SIZE = 5; // Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed) -const LETTER_WEIGHTS = { +export const LETTER_WEIGHTS = { A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2, L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2, X:1, Y:3, Z:1, @@ -16,6 +16,29 @@ export function randomLetter(rng = Math.random) { return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)]; } +// Default spawn chances for multiplier tiles on refills (late-game floor). +export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 }; + +// Level schedule for multiplier-tile spawn chances. Interpolates linearly +// from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then +// holds cfg.end. Returns { goldChance, diamondChance }. +// cfg = { startLevel: 1, endLevel: 10, +// start: { gold: 0.10, diamond: 0.06 }, +// end: { gold: 0.03, diamond: 0.02 } } +export function specialTileChances(level, cfg = {}) { + const start = cfg.start ?? SPECIAL_TILE_CHANCES; + const end = cfg.end ?? SPECIAL_TILE_CHANCES; + const from = cfg.startLevel ?? 1; + const to = cfg.endLevel ?? from; + const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0; + if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond }; + if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond }; + return { + goldChance: start.gold + (end.gold - start.gold) * t, + diamondChance: start.diamond + (end.diamond - start.diamond) * t, + }; +} + export function makeGrid(rng = Math.random) { const grid = []; for (let r = 0; r < GRID_SIZE; r++) { @@ -69,9 +92,12 @@ export function computeSelfDamage(cells, grid) { return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5; } -// Cascade tiles down in each column, fill top with new random tiles -export function clearAndRefill(grid, usedCells, rng = Math.random) { +// Cascade tiles down in each column, fill top with new random tiles. +// opts may override spawn chances: { goldChance, diamondChance }. +export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) { const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`)); + const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold; + const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond; const next = grid.map((row) => row.map((cell) => ({ ...cell }))); for (let c = 0; c < GRID_SIZE; c++) { @@ -82,8 +108,8 @@ export function clearAndRefill(grid, usedCells, rng = Math.random) { } // Fill remainder with new normal tiles while (survive.length < GRID_SIZE) { - const gold = rng() < 0.03; - const diamond = !gold && rng() < 0.02; + const gold = rng() < goldChance; + const diamond = !gold && rng() < diamondChance; const type = gold ? 'gold' : diamond ? 'diamond' : 'normal'; survive.push({ letter: randomLetter(rng), type }); } diff --git a/src/games/bookwork/BookworkSteering.js b/src/games/bookwork/BookworkSteering.js new file mode 100644 index 0000000..d17f363 --- /dev/null +++ b/src/games/bookwork/BookworkSteering.js @@ -0,0 +1,204 @@ +// BookworkSteering.js — context-aware letter placement for Bookworm +// +// The base engine (BookworkLogic.js) draws every letter independently from a +// weighted pool. That works, but it occasionally produces "dead" boards (no +// 3+ letter word findable) and vowel/consonant clumps, and those clumps +// persist through refills. +// +// This module layers a steering policy on the SAME pool: when a letter is +// chosen for a specific cell, each candidate's weight is adjusted by: +// 1. class steering — damp the class (vowel/consonant) already +// over-represented among the cell's filled neighbours +// 2. vowel band — keep the board-wide vowel share inside a target band +// 3. word boost — boost letters that complete real 3-letter words given +// the letters already placed around this cell +// +// Design notes: +// • Sampling, never argmax — boards stay varied and the injected-rng +// testability of BookworkLogic is preserved. +// • No full words are pre-placed. Steering only biases a single letter by +// what is already on the board, so the "word-friendly" property is +// re-established inductively after every refill — the board stays good +// as the game is played, not just at deal time. +// • With `wordSet` null every helper degrades to the plain weighted pool. + +import { GRID_SIZE, LETTER_WEIGHTS, randomLetter, SPECIAL_TILE_CHANCES, getAdjacent, isAdjacent } from './BookworkLogic.js'; + +const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']); + +export const DEFAULT_STEER = { + wordBoostK: 1.0, // weight multiplier per word completed: w *= (1 + K * count) + classThreshold: 2, // neighbour class imbalance (|vowels - consonants|) that triggers steering + classDamp: 0.35, // multiplier applied to the over-represented class + classBoost: 1.8, // multiplier applied to the under-represented class + vowelBand: [0.30, 0.45], // target band for board-wide vowel share + bandMinFilled: 10, // only apply the vowel band once this many cells are filled +}; + +// Parse an ENABLE-style word list into a Set of A-Z words within [minLen, maxLen]. +// 3–15 matches the /words/scrabble/validate dictionary (ENABLE, 2–15 letters) +// and the game's minimum playable word length. +export function parseWordList(text, minLen = 3, maxLen = 15) { + const set = new Set(); + for (const raw of text.split('\n')) { + const w = raw.trim().toUpperCase(); + if (w.length >= minLen && w.length <= maxLen && /^[A-Z]+$/.test(w)) set.add(w); + } + return set; +} + +// Context-adjusted weight for every candidate letter at grid[r][c]. +// `grid` may contain unfilled cells ({letter: null}) — they are ignored. +export function letterWeights(grid, r, c, wordSet = null, opts = {}) { + const o = { ...DEFAULT_STEER, ...opts }; + + // ── class steering: neighbour balance ─────────────────────────────── + let v = 0, k = 0; + for (const nb of getAdjacent(r, c)) { + const l = grid[nb.r][nb.c]?.letter; + if (!l) continue; + if (VOWELS.has(l)) v++; else k++; + } + let vowelMult = 1, consonantMult = 1; + if (o.classSteer !== false) { + if (v >= k + o.classThreshold) { vowelMult = o.classDamp; consonantMult = o.classBoost; } + else if (k >= v + o.classThreshold) { consonantMult = o.classDamp; vowelMult = o.classBoost; } + } + + // ── vowel band: board-wide share ──────────────────────────────────── + if (o.vowelBand) { + let filled = 0, vTotal = 0; + for (let rr = 0; rr < GRID_SIZE; rr++) { + for (let cc = 0; cc < GRID_SIZE; cc++) { + if (rr === r && cc === c) continue; + const l = grid[rr][cc]?.letter; + if (!l) continue; + filled++; + if (VOWELS.has(l)) vTotal++; + } + } + if (filled >= o.bandMinFilled) { + const share = vTotal / filled; + if (share > o.vowelBand[1]) { vowelMult *= o.classDamp; consonantMult *= o.classBoost; } + else if (share < o.vowelBand[0]) { consonantMult *= o.classDamp; vowelMult *= o.classBoost; } + } + } + + // ── word boost: 3-letter words completed through this cell ───────── + // For candidate L, count real words of the forms a-L-b (a,b filled + // neighbours), L-a-b and a-b-L (a,b filled neighbours, b adjacent a). + let counts = null; + if (wordSet && o.wordBoostK > 0) { + const N = []; + for (const nb of getAdjacent(r, c)) { + const l = grid[nb.r][nb.c]?.letter; + if (l) N.push({ l, r: nb.r, c: nb.c }); + } + if (N.length >= 2) { + counts = new Array(26).fill(0); + for (let li = 0; li < 26; li++) { + const L = String.fromCharCode(65 + li); + if (!LETTER_WEIGHTS[L]) continue; + const seen = new Set(); + for (const a of N) { + for (const b of N) { + if (a === b) continue; + const ab = isAdjacent(a, b); + const w1 = a.l + L + b.l; + if (wordSet.has(w1) && !seen.has(w1)) { seen.add(w1); counts[li]++; } + if (ab) { + const w2 = L + a.l + b.l; + if (wordSet.has(w2) && !seen.has(w2)) { seen.add(w2); counts[li]++; } + const w3 = a.l + b.l + L; + if (wordSet.has(w3) && !seen.has(w3)) { seen.add(w3); counts[li]++; } + } + } + } + } + } + } + + // ── final weights ─────────────────────────────────────────────────── + const weights = new Array(26).fill(0); + for (let li = 0; li < 26; li++) { + const L = String.fromCharCode(65 + li); + const base = LETTER_WEIGHTS[L]; + if (!base) continue; + let w = base * (VOWELS.has(L) ? vowelMult : consonantMult); + if (counts) w *= 1 + o.wordBoostK * counts[li]; + weights[li] = w; + } + return weights; +} + +// Sample a letter from a 26-entry weight array (A..Z). +export function pickWeighted(weights, rng) { + let total = 0; + for (const w of weights) total += w; + if (total <= 0) return String.fromCharCode(65 + Math.floor(rng() * 26)); + let x = rng() * total; + for (let i = 0; i < 26; i++) { + x -= weights[i]; + if (x <= 0) return String.fromCharCode(65 + i); + } + return 'Z'; +} + +// Build a full 5×5 board with context steering (cells placed in random order, +// each steered by the already-placed neighbours). +export function makeSteeredGrid(rng = Math.random, wordSet = null, opts = {}) { + const grid = Array.from({ length: GRID_SIZE }, () => + Array.from({ length: GRID_SIZE }, () => ({ letter: null, type: 'normal' }))); + if (!wordSet) { + // Degrade to the plain weighted pool. + for (const row of grid) for (const cell of row) cell.letter = randomLetter(rng); + return grid; + } + const cells = []; + for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) cells.push({ r, c }); + for (let i = cells.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [cells[i], cells[j]] = [cells[j], cells[i]]; + } + for (const { r, c } of cells) { + grid[r][c].letter = pickWeighted(letterWeights(grid, r, c, wordSet, opts), rng); + } + return grid; +} + +// Same cascade semantics as BookworkLogic.clearAndRefill (survivors fall to +// the bottom of each column, fresh tiles drop into the top rows), except the +// fresh letters are chosen with context steering against the partially +// rebuilt board. Fresh tiles land ABOVE their column's survivors, so this +// also completes vertical word fragments the survivors left behind. +export function refillSteered(grid, usedCells, rng = Math.random, wordSet = null, opts = {}) { + const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`)); + const next = grid.map((row) => row.map((cell) => ({ ...cell }))); + + // Pass 1: cascade survivors down each column; mark fresh slots as empty. + const newRows = new Array(GRID_SIZE).fill(0); + for (let c = 0; c < GRID_SIZE; c++) { + const survive = []; + for (let r = GRID_SIZE - 1; r >= 0; r--) { + if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] }); + } + newRows[c] = GRID_SIZE - survive.length; + for (let i = 0; i < survive.length; i++) next[GRID_SIZE - 1 - i][c] = survive[i]; + for (let r = 0; r < newRows[c]; r++) next[r][c] = { letter: null, type: 'normal' }; + } + + // Pass 2: place fresh tiles — per column bottom-up, left→right across + // columns — so each new tile sees survivors + already-placed tiles. + const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold; + const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond; + for (let c = 0; c < GRID_SIZE; c++) { + for (let r = newRows[c] - 1; r >= 0; r--) { + const weights = wordSet ? letterWeights(next, r, c, wordSet, opts) : null; + const letter = weights ? pickWeighted(weights, rng) : randomLetter(rng); + const gold = rng() < goldChance; + const diamond = !gold && rng() < diamondChance; + next[r][c] = { letter, type: gold ? 'gold' : diamond ? 'diamond' : 'normal' }; + } + } + return next; +} diff --git a/tools/verifyBookwork.js b/tools/verifyBookwork.js index ef2e4b3..c34ea51 100644 --- a/tools/verifyBookwork.js +++ b/tools/verifyBookwork.js @@ -1,13 +1,14 @@ #!/usr/bin/env node -// verifyBookwork.js — engine tests for Bookwork +// verifyBookwork.js — engine tests for Bookworm import { readFileSync } from 'node:fs'; import { GRID_SIZE, makeGrid, getAdjacent, isAdjacent, wordFromCells, computeDamage, computeSelfDamage, clearAndRefill, dropSpecialTile, countPoisonTiles, - computeMaxHp, isPotionUnlocked, + computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES, } from '../src/games/bookwork/BookworkLogic.js'; +import { refillSteered } from '../src/games/bookwork/BookworkSteering.js'; import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js'; let pass = 0, fail = 0; @@ -92,6 +93,34 @@ const after = clearAndRefill(gridR, used); ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE)); ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type)); +// ── special tile schedule ──────────────────────────────────────────────────── +console.log('\nspecial tile schedule'); +const SCHED = { + startLevel: 1, endLevel: 10, + start: { gold: 0.10, diamond: 0.06 }, + end: { gold: 0.03, diamond: 0.02 }, +}; +ok('no cfg → current defaults', specialTileChances(1) .goldChance === 0.03 && specialTileChances(1).diamondChance === 0.02); +const at = (lv) => specialTileChances(lv, SCHED); +ok('level 1 = boosted start', at(1).goldChance === 0.10 && at(1).diamondChance === 0.06); +ok('level 10 = current rates', at(10).goldChance === 0.03 && at(10).diamondChance === 0.02); +ok('level 20 holds at current rates', at(20).goldChance === 0.03 && at(20).diamondChance === 0.02); +ok('clamps below startLevel', at(0).goldChance === 0.10); +const ramp = Array.from({ length: 10 }, (_, i) => specialTileChances(i + 1, SCHED).goldChance); +ok('gold chance monotonically decreases L1→L10', ramp.every((v, i) => i === 0 || v <= ramp[i - 1])); +ok('ramp stays within [end, start]', ramp.every((v) => v >= 0.03 && v <= 0.10)); + +const forcedGold = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 1 }); +const newCount = 3; +ok('goldChance:1 → every fresh tile is gold', + forcedGold.flat().filter((c) => c.type === 'gold').length === newCount); +const forcedDiamond = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 0, diamondChance: 1 }); +ok('diamondChance:1 → every fresh tile is diamond', + forcedDiamond.flat().filter((c) => c.type === 'diamond').length === newCount); +const steeredGold = refillSteered(makeGrid(), used, Math.random, null, { goldChance: 1 }); +ok('refillSteered honors goldChance:1', + steeredGold.flat().filter((c) => c.type === 'gold').length === newCount); + // ── dropSpecialTile ──────────────────────────────────────────────────────────── console.log('\ndropSpecialTile'); const gridS = makeGrid(() => 0.5); @@ -175,6 +204,10 @@ if (bwData) { ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp); ok('playerBaseHp present', bwData.playerBaseHp > 0); ok('milestones array present', Array.isArray(bwData.milestones)); + const st = bwData.specialTiles; + ok('specialTiles schedule well-formed', !!st && st.startLevel < st.endLevel + && st.start.gold > st.end.gold > 0 && st.start.diamond > st.end.diamond > 0 + && st.end.gold === SPECIAL_TILE_CHANCES.gold && st.end.diamond === SPECIAL_TILE_CHANCES.diamond); } // ── Summary ─────────────────────────────────────────────────────────────────── diff --git a/tools/verifyBookwormBoard.js b/tools/verifyBookwormBoard.js new file mode 100644 index 0000000..0662040 --- /dev/null +++ b/tools/verifyBookwormBoard.js @@ -0,0 +1,210 @@ +#!/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);