import fs from 'node:fs'; import wordlistEnglish from 'wordlist-english'; // Valid 2-letter English words accepted in standard games const validTwo = new Set([ 'am', 'an', 'as', 'at', 'be', 'by', 'do', 'go', 'he', 'hi', 'if', 'in', 'is', 'it', 'me', 'my', 'no', 'of', 'oh', 'on', 'or', 'so', 'to', 'up', 'us', 'we' ]); // Build standard clean dictionary (excluding vulgarities, numbers, non-alpha) const cleanWordSet = new Set(); for (const raw of wordlistEnglish.english) { const w = raw.toLowerCase().trim(); if (w.length === 2 && validTwo.has(w)) { cleanWordSet.add(w); } else if (w.length >= 3 && w.length <= 16 && /^[a-z]+$/.test(w)) { cleanWordSet.add(w); } } // Commonness tiers for intelligent word quality ranking const t10 = new Set(wordlistEnglish['english/10'] || []); const t20 = new Set(wordlistEnglish['english/20'] || []); const t35 = new Set(wordlistEnglish['english/35'] || []); const t50 = new Set(wordlistEnglish['english/50'] || []); function getCommonnessScore(word) { if (t10.has(word)) return 4; if (t20.has(word)) return 3; if (t35.has(word)) return 2; if (t50.has(word)) return 1; return 0; } // Build prefix set for super fast solver pruning (up to 6 letters) const prefixSet = new Set(); for (const word of cleanWordSet) { for (let len = 1; len <= Math.min(word.length, 6); len++) { prefixSet.add(word.slice(0, len)); } } // Function to solve a 20-tile board and return all valid words and paths export function solveBoard(quartets, maxWords = 30) { const tiles = quartets.flat(); const foundMap = new Map(); function search(prefix, usedIndices) { if (prefix.length > 0) { const checkLen = Math.min(prefix.length, 6); if (!prefixSet.has(prefix.slice(0, checkLen))) return; } if (prefix.length >= 2 && cleanWordSet.has(prefix)) { const existing = foundMap.get(prefix); // Keep first path found, but a single tile that is a word always wins if (!existing || (usedIndices.length === 1 && existing.length > 1)) { foundMap.set(prefix, [...usedIndices]); } } if (usedIndices.length >= 4) return; for (let i = 0; i < tiles.length; i++) { if (usedIndices.includes(i)) continue; usedIndices.push(i); search(prefix + tiles[i], usedIndices); usedIndices.pop(); } } search('', []); const quartilesList = quartets.map(q => q.join('').toLowerCase()); const quartileSet = new Set(quartilesList); // Target quartiles are always valid, even if missing from the dictionary quartilesList.forEach((word, qIdx) => { if (!foundMap.has(word)) { foundMap.set(word, [qIdx * 4, qIdx * 4 + 1, qIdx * 4 + 2, qIdx * 4 + 3]); } }); let words = []; for (const [word, path] of foundMap.entries()) { const tileSegments = path.map(idx => tiles[idx]); const tileCount = path.length; let points = tileCount === 4 ? 8 : tileCount === 3 ? 4 : tileCount; const isTargetQuartile = quartileSet.has(word); const isQuartile = tileCount === 4; words.push({ word, points, tileCount, tileIndices: path, tileSegments, isTargetQuartile, isQuartile }); } // Sort candidate words: // 1. Target quartiles always first (all 5 guaranteed) // 2. Tile count descending (4-tiles > 3-tiles > 2-tiles > 1-tile) // 3. Commonness score descending (familiar words first) // 4. Word length descending // 5. Alphabetical words.sort((a, b) => { if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1; if (a.tileCount !== b.tileCount) return b.tileCount - a.tileCount; const ca = getCommonnessScore(a.word); const cb = getCommonnessScore(b.word); if (ca !== cb) return cb - ca; if (b.word.length !== a.word.length) return b.word.length - a.word.length; return a.word.localeCompare(b.word); }); // Cap words at maxWords (default: 30). Target quartiles and single-tile words // (tiles that are words on their own) always keep their spot. if (maxWords && words.length > maxWords) { const reserved = words.filter(w => w.isTargetQuartile || w.tileCount === 1); const rest = words.filter(w => !w.isTargetQuartile && w.tileCount !== 1); words = [...reserved, ...rest.slice(0, Math.max(0, maxWords - reserved.length))]; } // Final display sorting: Target quartiles first, then points desc, then alphabetical words.sort((a, b) => { if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1; if (b.points !== a.points) return b.points - a.points; return a.word.localeCompare(b.word); }); const totalBaseScore = words.reduce((sum, w) => sum + w.points, 0); const maxPossibleScore = totalBaseScore + 40; // 40 bonus points for all 5 quartiles return { tiles, quartets, targetQuartiles: quartilesList, words, wordCount: words.length, quartileCount: 5, totalBaseScore, maxPossibleScore }; } console.log('Solver module ready. Clean words count:', cleanWordSet.size);