// Finds common English words that can be built from exactly 4 existing tiles // (chunks) in the target-word pool, but aren't target words yet. // // Usage: node scripts/find-chunk-words.mjs [--max-tier=35] [--json] import wl from 'wordlist-english'; import { DICTIONARY_SET } from '../src/data/lexicon.js'; import { TARGET_WORDS_POOL } from '../src/data/puzzles.js'; const args = Object.fromEntries(process.argv.slice(2).map(a => a.replace(/^--/, '').split('='))); const MAX_TIER = Number(args['max-tier'] || 35); const TIERS = [10, 20, 35, 40, 50].filter(t => t <= MAX_TIER); // Every chunk currently used by a target word const chunks = new Set(TARGET_WORDS_POOL.flatMap(w => w.tiles.map(t => t.toLowerCase()))); const existing = new Set(TARGET_WORDS_POOL.map(w => w.word)); // Common words only (frequency tiers from wordlist-english) const candidates = new Set(); for (const t of TIERS) for (const w of wl[`english/${t}`] || []) candidates.add(w); // Try to split a word into exactly 4 distinct chunks (each >= 2 letters). // Returns the most balanced split, or null. function split(word) { let best = null; const n = word.length; for (let a = 2; a <= n - 6; a++) { const t1 = word.slice(0, a); if (!chunks.has(t1)) continue; for (let b = a + 2; b <= n - 4; b++) { const t2 = word.slice(a, b); if (!chunks.has(t2)) continue; for (let c = b + 2; c <= n - 2; c++) { const t3 = word.slice(b, c); const t4 = word.slice(c); if (!chunks.has(t3) || !chunks.has(t4)) continue; const tiles = [t1, t2, t3, t4]; if (new Set(tiles).size !== 4) continue; const lens = tiles.map(t => t.length); const spread = Math.max(...lens) - Math.min(...lens); if (!best || spread < best.spread) best = { tiles, spread }; } } } return best && best.tiles; } // Skip inflected forms whose base word exists (walked, happier, quickly, kittens...) const allWords = new Set(wl.english); function isInflection(w) { const bases = []; if (w.endsWith('ies')) bases.push(w.slice(0, -3) + 'y'); if (w.endsWith('es')) bases.push(w.slice(0, -2)); if (w.endsWith('s')) bases.push(w.slice(0, -1)); if (w.endsWith('ed')) bases.push(w.slice(0, -2), w.slice(0, -1), w.slice(0, -3)); if (w.endsWith('ied')) bases.push(w.slice(0, -3) + 'y'); if (w.endsWith('ing')) bases.push(w.slice(0, -3), w.slice(0, -3) + 'e', w.slice(0, -4)); if (w.endsWith('ly')) bases.push(w.slice(0, -2), w.slice(0, -3) + 'e'); if (w.endsWith('ily')) bases.push(w.slice(0, -3) + 'y'); if (w.endsWith('er')) bases.push(w.slice(0, -2), w.slice(0, -1), w.slice(0, -3)); if (w.endsWith('est')) bases.push(w.slice(0, -3), w.slice(0, -2), w.slice(0, -4)); if (w.endsWith('ness')) bases.push(w.slice(0, -4)); return bases.some(b => b.length >= 3 && allWords.has(b)); } // Skip near-duplicates of words already in the pool (e.g. "dolphin" vs "dolphins") function isInflectionOfExisting(w) { const stems = [w.replace(/s$/, ''), w.replace(/es$/, ''), w.replace(/ies$/, 'y'), w + 's', w + 'es', w.replace(/y$/, 'ies')]; return stems.some(s => s !== w && existing.has(s)); } const found = []; for (const word of candidates) { if (word.length < 8 || word.length > 14) continue; if (!/^[a-z]+$/.test(word)) continue; if (existing.has(word)) continue; if (word.endsWith('tion')) continue; if (!DICTIONARY_SET.has(word)) continue; if (isInflectionOfExisting(word)) continue; if (!('keep-inflections' in args) && isInflection(word)) continue; const tiles = split(word); if (tiles) found.push({ word, tiles }); } found.sort((a, b) => a.word.localeCompare(b.word)); if ('json' in args) { console.log(JSON.stringify(found)); } else { for (const f of found) console.log(`${f.word}|${f.tiles.join('-')}`); console.error(`\n${chunks.size} chunks, ${found.length} new words found (tiers <= ${MAX_TIER})`); }