205 lines
8.9 KiB
JavaScript
205 lines
8.9 KiB
JavaScript
// 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;
|
||
}
|