fertig-classic-games/src/games/bookwork/BookworkSteering.js

205 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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].
// 315 matches the /words/scrabble/validate dictionary (ENABLE, 215 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;
}