// Server-side Word Ladder dictionary + puzzle generator + skill-based AI. // // Word Ladder: transform a START word into a TARGET word by changing exactly one // letter at a time, where every intermediate rung is a real word of the same // length (COLD → CORD → CARD → WARD → WARM). // // The word graph (one node per word, an edge between words differing in exactly // one position) is implicit: neighbours are generated on demand by trying all 25 // substitutions at each position and testing membership in a Set. The graphs are // small (a few thousand 3/4-letter words), so BFS over them is cheap. const LENGTHS = [3, 4]; const A = 'A'.charCodeAt(0); // Optimal-path length ranges that make a satisfying puzzle, per word length. const PAR_RANGE = { 3: [3, 5], 4: [4, 6], }; // Skill profiles for the AI opponent. `wander` = chance of stepping onto a // non-progress neighbour (a detour it must later recover from). `delay` = // "thinking" time [lo, hi] ms before each rung (pacing, not intelligence). const SKILL = { 1: { wander: 0.70, delay: [9000, 15000] }, 2: { wander: 0.55, delay: [7000, 11000] }, 3: { wander: 0.42, delay: [5000, 8000] }, 4: { wander: 0.28, delay: [3500, 6000] }, 5: { wander: 0.15, delay: [2500, 4500] }, }; const wordSets = {}; // length → Set const wordArrays = {}; // length → string[] export function initWordLadderDictionary(words3, words4) { setFor(3, words3); setFor(4, words4); } function setFor(length, words) { const arr = (words ?? []).map(w => String(w).toUpperCase()).filter(w => w.length === length); wordSets[length] = new Set(arr); wordArrays[length] = [...wordSets[length]]; } export function dictionaryReady() { return LENGTHS.every(L => wordArrays[L]?.length > 0); } // ── Graph ────────────────────────────────────────────────────────────────────── // Valid words differing from `word` in exactly one position. export function neighbors(word) { const W = String(word).toUpperCase(); const set = wordSets[W.length]; if (!set) return []; const out = []; for (let i = 0; i < W.length; i++) { const before = W.slice(0, i); const after = W.slice(i + 1); const orig = W[i]; for (let c = 0; c < 26; c++) { const ch = String.fromCharCode(A + c); if (ch === orig) continue; const cand = before + ch + after; if (set.has(cand)) out.push(cand); } } return out; } // BFS from `start`; returns Map over the connected component. // `maxDepth` (optional) bounds the search for puzzle generation. function bfsDistances(start, maxDepth = Infinity) { const dist = new Map([[start, 0]]); const queue = [start]; let i = 0; while (i < queue.length) { const w = queue[i++]; const d = dist.get(w); if (d >= maxDepth) continue; for (const n of neighbors(w)) { if (!dist.has(n)) { dist.set(n, d + 1); queue.push(n); } } } return dist; } // Shortest path between two words (inclusive of both ends), or null. export function shortestPath(from, to) { const start = String(from).toUpperCase(); const goal = String(to).toUpperCase(); if (start === goal) return [start]; if (start.length !== goal.length) return null; const prev = new Map([[start, null]]); const queue = [start]; let i = 0; while (i < queue.length) { const w = queue[i++]; for (const n of neighbors(w)) { if (prev.has(n)) continue; prev.set(n, w); if (n === goal) { const path = []; for (let cur = goal; cur !== null; cur = prev.get(cur)) path.unshift(cur); return path; } queue.push(n); } } return null; } // ── Puzzle generation ──────────────────────────────────────────────────────── // Build a puzzle whose optimal solution is exactly `par` steps. Retries with // fresh random starts; relaxes to the deepest reachable target if `par` proves // hard to hit for the chosen start. function generatePuzzleWithPar(length, par, attempts = 300) { const pool = wordArrays[length] ?? []; if (pool.length === 0) return null; for (let a = 0; a < attempts; a++) { const start = randomFrom(pool); const dist = bfsDistances(start, par); const exact = []; let deepest = null, deepestD = 0; for (const [word, d] of dist) { if (word === start) continue; if (d === par) exact.push(word); if (d > deepestD) { deepest = word; deepestD = d; } } if (exact.length > 0) { const target = randomFrom(exact); return { start, target, par }; } // Last few attempts: accept the deepest target we can reach (>= 2 steps). if (a >= attempts - 5 && deepest && deepestD >= 2) { return { start, target: deepest, par: deepestD }; } } return null; } function pickPar(length) { const [lo, hi] = PAR_RANGE[length] ?? [3, 5]; return lo + Math.floor(Math.random() * (hi - lo + 1)); } // Single puzzle (solo mode). export function generatePuzzle(length) { const L = LENGTHS.includes(length) ? length : 4; return generatePuzzleWithPar(L, pickPar(L)) ?? fallbackPuzzle(L); } // Two distinct puzzles of equal par (versus mode — fair race). export function generateVersusPuzzles(length) { const L = LENGTHS.includes(length) ? length : 4; const par = pickPar(L); const player = generatePuzzleWithPar(L, par) ?? fallbackPuzzle(L); let opponent = null; for (let a = 0; a < 20; a++) { const cand = generatePuzzleWithPar(L, player.par); if (cand && cand.start !== player.start && cand.target !== player.target) { opponent = cand; break; } } return { player, opponent: opponent ?? generatePuzzleWithPar(L, player.par) ?? fallbackPuzzle(L) }; } // Degenerate safety net: a start word and one neighbour (par 1). Only reached if // the dictionary is empty/broken. function fallbackPuzzle(length) { const pool = wordArrays[length] ?? []; const start = pool[0] ?? 'CATS'.slice(0, length); const ns = neighbors(start); return { start, target: ns[0] ?? start, par: ns.length ? 1 : 0 }; } // ── AI opponent ──────────────────────────────────────────────────────────────── function profileFor(skill) { return SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3]; } // Advance the AI one rung from `current` toward `target`. Skill sets both pace // (delayMs) and accuracy (chance of a detour that lengthens its path). export function chooseAIMove(current, target, skill) { const cur = String(current).toUpperCase(); const goal = String(target).toUpperCase(); if (cur === goal) return { word: cur, delayMs: 0, done: true }; const profile = profileFor(skill); const ns = neighbors(cur); if (ns.length === 0) return { word: cur, delayMs: 0, done: false }; // Distances to the goal, so we know which neighbours make progress. const dist = bfsDistances(goal); const d = dist.get(cur); const progress = ns.filter(n => dist.get(n) === d - 1); const detour = ns.filter(n => dist.get(n) !== d - 1); let word; if (detour.length > 0 && Math.random() < profile.wander) { word = randomFrom(detour); } else if (progress.length > 0) { word = randomFrom(progress); } else { word = randomFrom(ns); } const [lo, hi] = profile.delay; const delayMs = Math.round(lo + Math.random() * (hi - lo)); return { word, delayMs, done: word === goal }; } // ── Hint ───────────────────────────────────────────────────────────────────── // The next word on a shortest path from `current` to `target`. export function hintMove(current, target) { const path = shortestPath(current, target); return path && path.length > 1 ? path[1] : null; } // ── Misc ─────────────────────────────────────────────────────────────────────── export function validWordsOfLength(length) { return [...(wordArrays[length] ?? [])]; } function randomFrom(arr) { return arr[Math.floor(Math.random() * arr.length)]; }