Adjustments to Word Games
This commit is contained in:
parent
1e153b34ef
commit
2a22bdbcde
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue