wordsolver / solver.js
hansaka01's picture
Upload 11 files
771af3a verified
Raw
History Blame Contribute Delete
6.93 kB
/**
* solver.js β€” Word Grid solver with comprehensive lookalike tolerance
*
* Improvements over v1:
* ‒ Symmetric LOOKALIKES map (A→B implies B can match A)
* β€’ Wildcard (-) pattern matching ignores only dashes, not all unknowns
* β€’ Pattern matching collects ALL candidates with their actual grid characters
* β€’ charMatch is stricter: only matches confirmed lookalike pairs, not random guesses
* β€’ Grid boundary checks are consolidated in one place (no off-by-one)
* β€’ Deduplication of candidates by match string
*/
// ─── Directions ───────────────────────────────────────────────────────────────
const DIRECTIONS = [
{ r: 0, c: 1, name: 'LtoR' },
{ r: 0, c: -1, name: 'RtoL' },
{ r: 1, c: 0, name: 'UtoD' },
{ r: -1, c: 0, name: 'DtoU' },
{ r: 1, c: 1, name: 'diagDR'},
{ r: -1, c: -1, name: 'diagUL'},
{ r: 1, c: -1, name: 'diagDL'},
{ r: -1, c: 1, name: 'diagUR'},
];
// ─── Lookalike table (symmetric) ─────────────────────────────────────────────
// Each entry lists chars that can be confused FOR the key char by OCR.
const LOOKALIKES_RAW = {
'A': ['4', 'R'],
'B': ['8', '3', 'R', 'S', 'E'],
'C': ['G', 'O', 'Q', '(', 'L'],
'D': ['O', 'Q', 'B', '0'],
'E': ['F', 'B', '3', 'L'],
'F': ['E', 'P'],
'G': ['C', 'O', '6', 'Q', 'D'],
'H': ['N', 'M'],
'I': ['L', '1', 'T', 'J', '|'],
'J': ['I', 'L', '1'],
'K': ['R', 'X'],
'L': ['I', '1', 'T', '|', '[', 'J'],
'M': ['N', 'H', 'W'],
'N': ['M', 'H', 'R'],
'O': ['0', 'Q', 'G', 'C', 'D', 'U'],
'P': ['F', 'B', 'R'],
'Q': ['O', 'G', 'C', '0'],
'R': ['B', 'P', 'K', 'I', 'A', 'N'],
'S': ['5', '8', 'B', '6'],
'T': ['I', 'L', '7', '+'],
'U': ['V', 'W', 'O', 'Y'],
'V': ['U', 'Y', 'W'],
'W': ['M', 'V', 'U'],
'X': ['K', 'Y'],
'Y': ['V', 'U', 'X'],
'Z': ['2', '7'],
};
// Build symmetric version: if A can be confused as B, then B can be confused as A
const LOOKALIKES = {};
for (const [key, alts] of Object.entries(LOOKALIKES_RAW)) {
if (!LOOKALIKES[key]) LOOKALIKES[key] = new Set();
for (const alt of alts) {
LOOKALIKES[key].add(alt);
// symmetric
if (/^[A-Z]$/.test(alt)) {
if (!LOOKALIKES[alt]) LOOKALIKES[alt] = new Set();
LOOKALIKES[alt].add(key);
}
}
}
/**
* Does `gridChar` match `targetChar` considering OCR lookalikes?
* Both must be non-empty, non-space.
*/
function charMatch(target, gridChar) {
if (!gridChar || gridChar === ' ' || gridChar === '?') return false;
const t = target.toUpperCase();
const g = gridChar.toUpperCase();
if (t === g) return true;
const alts = LOOKALIKES[t];
return !!(alts && alts.has(g));
}
/**
* Check all cells are in bounds on a grid
*/
function inBounds(grid, r, c) {
return r >= 0 && r < grid.length && c >= 0 && c < (grid[r] ? grid[r].length : 0);
}
/**
* Solve the grid for a list of word/pattern objects.
*
* Each item in `words` is one of:
* { word: 'MATRIX' } β†’ exact word search
* { pattern: 'M---' } β†’ pattern search (first char + length)
*
* Returns an object:
* { 'M---': [ { r, c, dir, match, reliable } ], ... }
* { 'MATRIX': { r, c, dir, match } }
*/
function solve(grid, words) {
const results = {};
const rows = grid.length;
if (rows === 0) return results;
for (const wordObj of words) {
const isExact = wordObj.word && !wordObj.word.includes('-');
const isPattern = !!wordObj.pattern;
if (isExact) {
const target = wordObj.word.toUpperCase();
const len = target.length;
let found = false;
outer:
for (let r = 0; r < rows && !found; r++) {
const cols = grid[r].length;
for (let c = 0; c < cols && !found; c++) {
if (!charMatch(target[0], grid[r][c])) continue;
for (const dir of DIRECTIONS) {
// Quick bounds check for last character
const er = r + dir.r * (len - 1);
const ec = c + dir.c * (len - 1);
if (!inBounds(grid, er, ec)) continue;
let match = true;
let candidate = '';
for (let i = 0; i < len; i++) {
const nr = r + dir.r * i;
const nc = c + dir.c * i;
if (!inBounds(grid, nr, nc) || !charMatch(target[i], grid[nr][nc])) {
match = false;
break;
}
candidate += grid[nr][nc];
}
if (match) {
results[wordObj.word] = { r, c, dir: dir.name, match: candidate };
found = true;
break;
}
}
}
}
} else if (isPattern) {
const pattern = wordObj.pattern.toUpperCase(); // e.g. "M---"
const startChar = pattern[0];
const len = pattern.length;
const hits = [];
for (let r = 0; r < rows; r++) {
const cols = grid[r].length;
for (let c = 0; c < cols; c++) {
if (!charMatch(startChar, grid[r][c])) continue;
for (const dir of DIRECTIONS) {
// Bounds check for last char
const er = r + dir.r * (len - 1);
const ec = c + dir.c * (len - 1);
if (!inBounds(grid, er, ec)) continue;
let possible = true;
let candidate = '';
for (let i = 0; i < len; i++) {
const nr = r + dir.r * i;
const nc = c + dir.c * i;
if (!inBounds(grid, nr, nc)) { possible = false; break; }
const ch = grid[nr][nc];
if (!ch || ch === ' ') { possible = false; break; }
candidate += ch;
}
if (possible && candidate.length === len) {
hits.push({ r, c, dir: dir.name, match: candidate });
}
}
}
}
// Deduplicate by match string
const seen = new Set();
const unique = hits.filter(h => {
if (seen.has(h.match)) return false;
seen.add(h.match);
return true;
});
if (unique.length > 0) results[pattern] = unique;
}
}
return results;
}
// ─── Leaderboard ──────────────────────────────────────────────────────────────
let leaderboard = [];
function getWordScore(word) {
return word.length * 10;
}
function recordScore(userName, score) {
leaderboard.push({ name: userName, score, date: new Date().toISOString() });
leaderboard.sort((a, b) => b.score - a.score);
leaderboard = leaderboard.slice(0, 10);
}
function getLeaderboard() {
return leaderboard;
}
module.exports = { solve, charMatch, getWordScore, recordScore, getLeaderboard };