Files
quartiles/scripts/solver.mjs
2026-10-24 00:38:55 -04:00

139 lines
4.2 KiB
JavaScript

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)) {
if (!foundMap.has(prefix)) {
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);
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)
if (maxWords && words.length > maxWords) {
words = words.slice(0, maxWords);
}
// 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);