From 2a22bdbcde766f29d44eb53674bca8a23fac74bb Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 16 Jun 2026 19:28:56 -0600 Subject: [PATCH] Adjustments to Word Games --- public/src/games/kiitos/KiitosGame.js | 20 +++++--- .../src/games/spellingbee/SpellingBeeGame.js | 3 +- .../src/games/spellingbee/SpellingBeeLogic.js | 10 +++- server/words/kiitosEngine.js | 45 +++++++++++++++-- server/words/wordLadderEngine.js | 49 ++++++++++++++----- server/words/wordRoutes.js | 22 ++++++++- 6 files changed, 123 insertions(+), 26 deletions(-) diff --git a/public/src/games/kiitos/KiitosGame.js b/public/src/games/kiitos/KiitosGame.js index 3c21e42..421c317 100644 --- a/public/src/games/kiitos/KiitosGame.js +++ b/public/src/games/kiitos/KiitosGame.js @@ -612,24 +612,30 @@ export default class KiitosGame extends Phaser.Scene { promptNameWord(newBuilt) { (this.humanBtns ?? []).forEach((b) => b.destroy()); this.promptTxt.setText(`Name your word — it must start with "${newBuilt}".`); - const cy = CENTER_Y + 200; - this.humanInput = new TextInput(this, GAME_WIDTH / 2 - 130, cy, { - width: 360, height: 56, value: newBuilt.toLowerCase(), + + // Two-row layout: input centered, then Cancel | Announce below. + // All three share the same outer extents (cx ± 230) for clean alignment. + const cx = GAME_WIDTH / 2; + const inputY = CENTER_Y + 196; + const btnY = inputY + 72; + + this.humanInput = new TextInput(this, cx, inputY, { + width: 460, height: 56, value: newBuilt.toLowerCase(), placeholder: `word starting "${newBuilt}"…`, maxLength: 12, autocomplete: 'off', }); this.humanInput.focus(); this.humanInput.on('keydown', (e) => { if (e.key === 'Enter') this.confirmTentativeWord(); }); this.feedbackTxt?.destroy(); - this.feedbackTxt = this.add.text(GAME_WIDTH / 2, cy + 60, '', { + this.feedbackTxt = this.add.text(cx, btnY + 62, '', { fontFamily: 'Righteous', fontSize: '24px', color: THEME.negativeHex, }).setOrigin(0.5).setDepth(D.ui); this.humanBtns = [ - new Button(this, GAME_WIDTH / 2 + 130, cy, 'Announce', () => this.confirmTentativeWord(), + new Button(this, cx + 130, btnY, 'Announce', () => this.confirmTentativeWord(), { width: 200, height: 56, fontSize: 24, bg: THEME.accent }).setDepth(D.ui), - new Button(this, GAME_WIDTH / 2 - 360, cy, 'Cancel', () => this.cancelTentative(), - { variant: 'ghost', width: 150, height: 56, fontSize: 22 }).setDepth(D.ui), + new Button(this, cx - 130, btnY, 'Cancel', () => this.cancelTentative(), + { variant: 'ghost', width: 200, height: 56, fontSize: 22 }).setDepth(D.ui), ]; } diff --git a/public/src/games/spellingbee/SpellingBeeGame.js b/public/src/games/spellingbee/SpellingBeeGame.js index 4d9d168..142fda5 100644 --- a/public/src/games/spellingbee/SpellingBeeGame.js +++ b/public/src/games/spellingbee/SpellingBeeGame.js @@ -126,8 +126,9 @@ export default class SpellingBeeGame extends Phaser.Scene { this.letters = data.letters ?? []; this.validWords = new Set((data.validWords ?? []).map((w) => w.toUpperCase())); this.pangramSet = new Set((data.pangrams ?? []).map((w) => w.toUpperCase())); + this.difficulty = difficulty; this.maxScore = data.maxScore ?? 0; - this.tiers = buildTiers(this.maxScore); + this.tiers = buildTiers(this.maxScore, difficulty); this.buildBoard(); } diff --git a/public/src/games/spellingbee/SpellingBeeLogic.js b/public/src/games/spellingbee/SpellingBeeLogic.js index 61fe7b8..f237ad9 100644 --- a/public/src/games/spellingbee/SpellingBeeLogic.js +++ b/public/src/games/spellingbee/SpellingBeeLogic.js @@ -2,6 +2,7 @@ // Rank tiers as a fraction of the puzzle's maximum possible score (NYT scale). // Queen Bee (100%) is intentionally omitted — Genius is the achievable goal. +// Genius threshold scales down on easier difficulties so it's actually reachable. const TIER_TABLE = [ { name: 'Beginner', pct: 0.00 }, { name: 'Good Start', pct: 0.02 }, @@ -14,6 +15,9 @@ const TIER_TABLE = [ { name: 'Genius', pct: 0.70 }, ]; +// Fraction of maxScore required to reach Genius per difficulty. +const GENIUS_PCT = { easy: 0.40, normal: 0.55, hard: 0.70 }; + // Number of distinct letters in a word. function distinctCount(word) { return new Set(word.toUpperCase()).size; @@ -33,10 +37,12 @@ export function scoreWord(word) { } // Concrete tier thresholds (rounded score needed to reach each tier). -export function buildTiers(maxScore) { +export function buildTiers(maxScore, difficulty = 'normal') { + const geniusPct = GENIUS_PCT[difficulty] ?? GENIUS_PCT.normal; + const scale = geniusPct / TIER_TABLE[TIER_TABLE.length - 1].pct; return TIER_TABLE.map((t) => ({ name: t.name, - threshold: Math.round(t.pct * maxScore), + threshold: Math.round(t.pct * scale * maxScore), })); } diff --git a/server/words/kiitosEngine.js b/server/words/kiitosEngine.js index e6a3495..38937c9 100644 --- a/server/words/kiitosEngine.js +++ b/server/words/kiitosEngine.js @@ -12,11 +12,13 @@ const ABS_MIN = 4; // shortest legal Kiitos word (round 1 minimum) const MAX_LEN = 12; // cap word length so play stays snappy -let root = null; // trie root: { word: boolean, kids: Map } -let wordSet = null; // Set of every legal Kiitos word +let root = null; // trie root: { word: boolean, kids: Map } +let wordSet = null; // Set of every legal Kiitos word +let commonWordSet = null; // Set of recognizable common words (AI prefers these) const completionCache = new Map(); // `${prefix}|${minLen}` -> boolean const wordCache = new Map(); // `${prefix}|${minLen}` -> string|null +const commonWordCache = new Map(); // `${prefix}|${minLen}` -> string|null function makeNode() { return { word: false, kids: new Map() }; } @@ -25,6 +27,7 @@ export function initKiitosDictionary(words) { wordSet = new Set(); completionCache.clear(); wordCache.clear(); + commonWordCache.clear(); for (const raw of words) { const w = String(raw).toUpperCase(); if (w.length < ABS_MIN || w.length > MAX_LEN || !/^[A-Z]+$/.test(w)) continue; @@ -40,6 +43,18 @@ export function initKiitosDictionary(words) { return { words: wordSet.size }; } +// Called after initKiitosDictionary with words from a frequency-filtered common list. +// Only words already in wordSet are kept (common list may include short words not in trie). +export function initKiitosCommonWords(words) { + commonWordSet = new Set(); + commonWordCache.clear(); + for (const raw of words) { + const w = String(raw).toUpperCase(); + if (wordSet && wordSet.has(w)) commonWordSet.add(w); + } + return { common: commonWordSet.size }; +} + export const MIN_WORD_LEN = ABS_MIN; // ── Lookups ───────────────────────────────────────────────────────────────────── @@ -108,6 +123,30 @@ export function findWord(prefix, minLen = ABS_MIN) { return result; } +// Like findWord but only returns words in the common word set (AI prefers these). +// Falls back to null if no common word extends `prefix`. +function findCommonWord(prefix, minLen = ABS_MIN) { + if (!commonWordSet) return null; + const key = `${prefix}|${minLen}`; + const cached = commonWordCache.get(key); + if (cached !== undefined) return cached; + const start = nodeFor(prefix); + let result = null; + if (start) { + let frontier = [[start, prefix]]; + while (frontier.length && !result) { + const next = []; + for (const [n, s] of frontier) { + if (n.word && s.length >= minLen && commonWordSet.has(s)) { result = s; break; } + if (s.length < MAX_LEN) for (const [ch, c] of n.kids) next.push([c, s + ch]); + } + frontier = next; + } + } + commonWordCache.set(key, result); + return result; +} + // ── AI move selection ───────────────────────────────────────────────────────── // Returns one of: // { type: 'play', letter, forced: true } — mandatory next letter @@ -154,7 +193,7 @@ export function chooseMove({ if (seen.has(prefix)) return; seen.add(prefix); if (!hasCompletion(prefix, minLen)) return; - const word = findWord(prefix, minLen); + const word = findCommonWord(prefix, minLen) ?? findWord(prefix, minLen); if (word) cands.push({ letter, prefix, word }); }; for (const L of new Set(H)) { diff --git a/server/words/wordLadderEngine.js b/server/words/wordLadderEngine.js index d621b8a..f84443f 100644 --- a/server/words/wordLadderEngine.js +++ b/server/words/wordLadderEngine.js @@ -31,12 +31,26 @@ const SKILL = { const wordSets = {}; // length → Set const wordArrays = {}; // length → string[] +const commonSets = {}; // length → Set (frequency-filtered subset) export function initWordLadderDictionary(words3, words4) { setFor(3, words3); setFor(4, words4); } +// Called after the common word list is loaded so puzzle generation can prefer +// paths where every rung is a recognisable word (not just start and target). +export function initCommonWordSets(words3, words4) { + if (words3?.length) { + const arr = words3.map(w => String(w).toUpperCase()).filter(w => w.length === 3); + commonSets[3] = new Set(arr); + } + if (words4?.length) { + const arr = words4.map(w => String(w).toUpperCase()).filter(w => w.length === 4); + commonSets[4] = new Set(arr); + } +} + function setFor(length, words) { const arr = (words ?? []).map(w => String(w).toUpperCase()).filter(w => w.length === length); wordSets[length] = new Set(arr); @@ -70,8 +84,9 @@ export function neighbors(word) { } // BFS from `start`; returns Map over the connected component. -// `maxDepth` (optional) bounds the search for puzzle generation. -function bfsDistances(start, maxDepth = Infinity) { +// `maxDepth` bounds the search for puzzle generation. +// `nodeSet` restricts traversal to words in that set (for common-only BFS). +function bfsDistances(start, maxDepth = Infinity, nodeSet = null) { const dist = new Map([[start, 0]]); const queue = [start]; let i = 0; @@ -80,7 +95,7 @@ function bfsDistances(start, maxDepth = Infinity) { const d = dist.get(w); if (d >= maxDepth) continue; for (const n of neighbors(w)) { - if (!dist.has(n)) { + if (!dist.has(n) && (!nodeSet || nodeSet.has(n))) { dist.set(n, d + 1); queue.push(n); } @@ -119,14 +134,15 @@ export function shortestPath(from, to) { // 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] ?? []; +// hard to hit for the chosen start. When `nodeSet` is provided, BFS and the +// candidate pool are both restricted to that set so every rung is a word in it. +function generatePuzzleWithPar(length, par, attempts = 300, nodeSet = null) { + const pool = nodeSet ? [...nodeSet].filter(w => w.length === length) : (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 dist = bfsDistances(start, par, nodeSet); const exact = []; let deepest = null, deepestD = 0; for (const [word, d] of dist) { @@ -151,26 +167,37 @@ function pickPar(length) { return lo + Math.floor(Math.random() * (hi - lo + 1)); } +// Try to generate a common-word-only puzzle first; fall back to the full +// dictionary if the common subgraph is too sparse or too small. +function makePuzzle(length, par, nodeSet = null) { + if (nodeSet?.size >= 20) { + const p = generatePuzzleWithPar(length, par, 200, nodeSet); + if (p) return p; + } + return generatePuzzleWithPar(length, par) ?? fallbackPuzzle(length); +} + // Single puzzle (solo mode). export function generatePuzzle(length) { const L = LENGTHS.includes(length) ? length : 4; - return generatePuzzleWithPar(L, pickPar(L)) ?? fallbackPuzzle(L); + return makePuzzle(L, pickPar(L), commonSets[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); + const cs = commonSets[L]; + const player = makePuzzle(L, par, cs); let opponent = null; for (let a = 0; a < 20; a++) { - const cand = generatePuzzleWithPar(L, player.par); + const cand = makePuzzle(L, player.par, cs); if (cand && cand.start !== player.start && cand.target !== player.target) { opponent = cand; break; } } - return { player, opponent: opponent ?? generatePuzzleWithPar(L, player.par) ?? fallbackPuzzle(L) }; + return { player, opponent: opponent ?? makePuzzle(L, player.par, cs) }; } // Degenerate safety net: a start word and one neighbour (par 1). Only reached if diff --git a/server/words/wordRoutes.js b/server/words/wordRoutes.js index 3de66bf..4f65530 100644 --- a/server/words/wordRoutes.js +++ b/server/words/wordRoutes.js @@ -6,6 +6,7 @@ import { initScrabbleDictionary, isValidWord, chooseMove } from './scrabbleEngin import { initGhostDictionary, judge as ghostJudge, chooseLetter as ghostChooseLetter, suggestWords as ghostSuggestWords } from './ghostEngine.js'; import { initWordLadderDictionary, + initCommonWordSets as ladderInitCommon, generatePuzzle as ladderGeneratePuzzle, generateVersusPuzzles as ladderGenerateVersus, chooseAIMove as ladderChooseAIMove, @@ -22,12 +23,14 @@ import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js'; import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js'; import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.js'; import { - initKiitosDictionary, isValidKiitosWord, hasCompletion as kiitosHasCompletion, - findWord as kiitosFindWord, chooseMove as kiitosChooseMove, MIN_WORD_LEN as KIITOS_MIN, + initKiitosDictionary, initKiitosCommonWords, isValidKiitosWord, + hasCompletion as kiitosHasCompletion, findWord as kiitosFindWord, + chooseMove as kiitosChooseMove, MIN_WORD_LEN as KIITOS_MIN, } from './kiitosEngine.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt'); +const COMMON_PATH = path.join(__dirname, '../data/wordlists/common.txt'); // Common words used to build a player-friendly Wordle answer pool. // These are well-known 5-letter English words that make fair Wordle puzzles. @@ -170,6 +173,21 @@ function loadWordLists() { const kiitosStats = initKiitosDictionary(kiitosWords); console.log(`[words] loaded ${kiitosStats.words} Kiitos words (4–12 letters)`); + // Kiitos AI common word preference: load frequency-filtered word list so the AI + // targets recognizable words instead of obscure ENABLE entries. + try { + const commonRaw = fs.readFileSync(COMMON_PATH, 'utf8'); + const commonWords = commonRaw.split('\n').map(w => w.trim().toUpperCase()).filter(Boolean); + const commonStats = initKiitosCommonWords(commonWords); + console.log(`[words] loaded ${commonStats.common} Kiitos common words for AI targeting`); + const common3 = commonWords.filter(w => /^[A-Z]{3}$/.test(w)); + const common4 = commonWords.filter(w => /^[A-Z]{4}$/.test(w)); + ladderInitCommon(common3, common4); + console.log(`[words] loaded Word Ladder common words (${common3.length} 3-letter, ${common4.length} 4-letter)`); + } catch (err) { + console.warn('[words] common.txt not found; Kiitos AI will use full ENABLE list'); + } + // Answer pool: prefer curated common words that are also in ENABLE; // supplement with additional ENABLE words up to a healthy pool size. const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));