118 lines
3.8 KiB
JavaScript
118 lines
3.8 KiB
JavaScript
import { buildWordSet, getCommonnessScore } from './lexicon-sources.mjs';
|
|
import { capWordList } from '../src/engine/word-cap.js';
|
|
|
|
// Clean dictionary incl. regional spellings + proper-word allowlist
|
|
// (same source as src/data/lexicon.js)
|
|
const cleanWordSet = buildWordSet();
|
|
|
|
// 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) while keeping a fair share of 2-tile words.
|
|
words = capWordList(words, 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);
|