Adjustments to Word Games

This commit is contained in:
Brian Fertig 2026-06-16 19:28:56 -06:00
parent 1e153b34ef
commit 2a22bdbcde
6 changed files with 123 additions and 26 deletions

View File

@ -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),
];
}

View File

@ -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();
}

View File

@ -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),
}));
}

View File

@ -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<char, node> }
let wordSet = null; // Set<string> of every legal Kiitos word
let root = null; // trie root: { word: boolean, kids: Map<char, node> }
let wordSet = null; // Set<string> of every legal Kiitos word
let commonWordSet = null; // Set<string> 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)) {

View File

@ -31,12 +31,26 @@ const SKILL = {
const wordSets = {}; // length → Set<string>
const wordArrays = {}; // length → string[]
const commonSets = {}; // length → Set<string> (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<word, distance> 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

View File

@ -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 (412 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));