- Archive previous implementation attempt (src -> src_failed) - Create comprehensive IMPLEMENTATION_PLAN.md with phased approach - Create IMPLEMENTATION.md to track implementation progress - Update example app to match vim-maze patterns: - Simplify UI to desktop-only (no mobile support) - Add score metrics display (time, keystrokes, final score) - Update instructions for sudoku-specific keys (hjkl, i for edit) - Remove unnecessary mobile hooks Phase 5 complete. Ready to begin Phase 10 (Setup & Core Types).
316 lines
8.6 KiB
TypeScript
316 lines
8.6 KiB
TypeScript
export type DifficultyName = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
|
|
export type Difficulty = DifficultyName | number;
|
|
|
|
const DIGITS = '123456789';
|
|
const ROWS = 'ABCDEFGHI';
|
|
const COLS = DIGITS;
|
|
let SQUARES: string[] = [];
|
|
let UNITS: string[][] = [];
|
|
let SQUARE_UNITS_MAP: Record<string, string[][]> = {};
|
|
let SQUARE_PEERS_MAP: Record<string, string[]> = {};
|
|
|
|
const MIN_GIVENS = 17;
|
|
const NR_SQUARES = 81;
|
|
|
|
const DIFFICULTY: Record<DifficultyName, number> = {
|
|
easy: 62,
|
|
medium: 53,
|
|
hard: 44,
|
|
'very-hard': 35,
|
|
insane: 26,
|
|
inhuman: 17,
|
|
};
|
|
|
|
const BLANK_CHAR = '.';
|
|
const BLANK_BOARD = BLANK_CHAR.repeat(NR_SQUARES);
|
|
|
|
function initialize() {
|
|
SQUARES = cross(ROWS, COLS);
|
|
UNITS = getAllUnits(ROWS, COLS);
|
|
SQUARE_UNITS_MAP = getSquareUnitsMap(SQUARES, UNITS);
|
|
SQUARE_PEERS_MAP = getSquarePeersMap(SQUARES, SQUARE_UNITS_MAP);
|
|
}
|
|
|
|
function cross(a: string, b: string) {
|
|
const res: string[] = [];
|
|
for (const ai of a) {
|
|
for (const bi of b) {
|
|
res.push(ai + bi);
|
|
}
|
|
}
|
|
return res;
|
|
}
|
|
|
|
function inStr(v: string, seq: string) {
|
|
return seq.indexOf(v) !== -1;
|
|
}
|
|
|
|
function shuffle<T>(seq: T[]): T[] {
|
|
const arr = [...seq];
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
function randRange(max: number, min = 0) {
|
|
return Math.floor(Math.random() * (max - min)) + min;
|
|
}
|
|
|
|
function stripDups<T>(seq: T[]): T[] {
|
|
return Array.from(new Set(seq));
|
|
}
|
|
|
|
function forceRange(nr: number, max: number, min = 0) {
|
|
return Math.min(Math.max(nr || 0, min), max);
|
|
}
|
|
|
|
function getAllUnits(rows: string, cols: string) {
|
|
const units: string[][] = [];
|
|
for (const r of rows) units.push(cross(r, cols));
|
|
for (const c of cols) units.push(cross(rows, c));
|
|
const row_blocks = ['ABC', 'DEF', 'GHI'];
|
|
const col_blocks = ['123', '456', '789'];
|
|
for (const rb of row_blocks) {
|
|
for (const cb of col_blocks) {
|
|
units.push(cross(rb, cb));
|
|
}
|
|
}
|
|
return units;
|
|
}
|
|
|
|
function getSquareUnitsMap(squares: string[], units: string[][]) {
|
|
const map: Record<string, string[][]> = {};
|
|
for (const s of squares) {
|
|
const list: string[][] = [];
|
|
for (const u of units) {
|
|
if (u.includes(s)) list.push(u);
|
|
}
|
|
map[s] = list;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function getSquarePeersMap(squares: string[], units_map: Record<string, string[][]>) {
|
|
const map: Record<string, string[]> = {};
|
|
for (const s of squares) {
|
|
const peers: string[] = [];
|
|
for (const unit of units_map[s]) {
|
|
for (const sq of unit) {
|
|
if (sq !== s && !peers.includes(sq)) peers.push(sq);
|
|
}
|
|
}
|
|
map[s] = peers;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function getSquareValsMap(board: string) {
|
|
const map: Record<string, string> = {};
|
|
if (board.length !== SQUARES.length) throw 'Board length mismatch';
|
|
for (let i = 0; i < SQUARES.length; i++) {
|
|
map[SQUARES[i]] = board[i];
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function validateBoard(board: string): true | string {
|
|
if (!board) return 'Empty board';
|
|
if (board.length !== NR_SQUARES) return 'Invalid board size';
|
|
for (let i = 0; i < board.length; i++) {
|
|
const c = board[i];
|
|
if (!inStr(c, DIGITS) && c !== BLANK_CHAR) {
|
|
return `Invalid char at ${i}: ${c}`;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function getCandidatesMap(board: string): Record<string, string> | false {
|
|
const report = validateBoard(board);
|
|
if (report !== true) throw report;
|
|
|
|
const map: Record<string, string> = {};
|
|
const squares_values = getSquareValsMap(board);
|
|
|
|
for (const s of SQUARES) map[s] = DIGITS;
|
|
|
|
for (const s in squares_values) {
|
|
const val = squares_values[s];
|
|
if (inStr(val, DIGITS)) {
|
|
const new_candidates = assign(map, s, val);
|
|
if (!new_candidates) return false;
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function assign(candidates: Record<string, string>, square: string, val: string) {
|
|
const others = candidates[square].replace(val, '');
|
|
for (const v of others) {
|
|
const next = eliminate(candidates, square, v);
|
|
if (!next) return false;
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
function eliminate(candidates: Record<string, string>, square: string, val: string) {
|
|
if (!inStr(val, candidates[square])) return candidates;
|
|
|
|
candidates[square] = candidates[square].replace(val, '');
|
|
const n = candidates[square].length;
|
|
|
|
if (n === 1) {
|
|
const target_val = candidates[square];
|
|
for (const peer of SQUARE_PEERS_MAP[square]) {
|
|
const next = eliminate(candidates, peer, target_val);
|
|
if (!next) return false;
|
|
}
|
|
} else if (n === 0) return false;
|
|
|
|
for (const unit of SQUARE_UNITS_MAP[square]) {
|
|
const places: string[] = [];
|
|
for (const sq of unit) {
|
|
if (inStr(val, candidates[sq])) places.push(sq);
|
|
}
|
|
if (places.length === 0) return false;
|
|
else if (places.length === 1) {
|
|
const next = assign(candidates, places[0], val);
|
|
if (!next) return false;
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
function search(candidates: Record<string, string>, reverse: boolean): any {
|
|
if (!candidates) return false;
|
|
reverse = reverse || false;
|
|
|
|
let max_nr_candidates = 0;
|
|
for (const s of SQUARES) {
|
|
const n = candidates[s].length;
|
|
if (n > max_nr_candidates) {
|
|
max_nr_candidates = n;
|
|
}
|
|
}
|
|
if (max_nr_candidates === 1) return candidates;
|
|
|
|
let min_nr_candidates = 10;
|
|
let min_square = '';
|
|
for (const s of SQUARES) {
|
|
const n = candidates[s].length;
|
|
if (n < min_nr_candidates && n > 1) {
|
|
min_nr_candidates = n;
|
|
min_square = s;
|
|
}
|
|
}
|
|
|
|
const min_candidates = candidates[min_square];
|
|
if (!reverse) {
|
|
for (const v of min_candidates) {
|
|
const copy = JSON.parse(JSON.stringify(candidates));
|
|
const next = search(assign(copy, min_square, v), reverse);
|
|
if (next) return next;
|
|
}
|
|
} else {
|
|
for (let i = min_candidates.length - 1; i >= 0; --i) {
|
|
const v = min_candidates[i];
|
|
const copy = JSON.parse(JSON.stringify(candidates));
|
|
const next = search(assign(copy, min_square, v), reverse);
|
|
if (next) return next;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export class SudokuGenerator {
|
|
static generate(difficulty: Difficulty, unique?: boolean): string {
|
|
let numericDifficulty: number;
|
|
if (typeof difficulty === 'string' || typeof difficulty === 'undefined') {
|
|
numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
|
|
} else {
|
|
numericDifficulty = difficulty;
|
|
}
|
|
|
|
numericDifficulty = forceRange(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
|
|
unique = unique ?? true;
|
|
|
|
const blank_board = BLANK_BOARD;
|
|
let candidates = getCandidatesMap(blank_board);
|
|
|
|
if (!candidates) return SudokuGenerator.generate(numericDifficulty);
|
|
|
|
const shuffled_squares = shuffle(SQUARES);
|
|
|
|
for (const square of shuffled_squares) {
|
|
const rand_candidate_idx = randRange(candidates[square].length);
|
|
const rand_candidate = candidates[square][rand_candidate_idx];
|
|
if (!assign(candidates, square, rand_candidate)) break;
|
|
|
|
const single_candidates: string[] = [];
|
|
for (const s of SQUARES) {
|
|
if (candidates[s].length === 1) single_candidates.push(candidates[s]);
|
|
}
|
|
|
|
if (
|
|
single_candidates.length >= numericDifficulty &&
|
|
stripDups(single_candidates).length >= 8
|
|
) {
|
|
let board = '';
|
|
let givens_idxs: number[] = [];
|
|
|
|
for (let i = 0; i < SQUARES.length; i++) {
|
|
const s = SQUARES[i];
|
|
if (candidates[s].length === 1) {
|
|
board += candidates[s];
|
|
givens_idxs.push(i);
|
|
} else {
|
|
board += BLANK_CHAR;
|
|
}
|
|
}
|
|
|
|
if (givens_idxs.length > numericDifficulty) {
|
|
givens_idxs = shuffle(givens_idxs);
|
|
for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
|
|
const target = givens_idxs[i];
|
|
board =
|
|
board.substring(0, target) +
|
|
BLANK_CHAR +
|
|
board.substring(target + 1);
|
|
}
|
|
}
|
|
|
|
if (SudokuGenerator.solve(board)) return board;
|
|
}
|
|
}
|
|
|
|
return SudokuGenerator.generate(numericDifficulty);
|
|
}
|
|
|
|
static solve(board: string, reverse?: boolean): string | false {
|
|
const report = validateBoard(board);
|
|
if (report !== true) throw report;
|
|
|
|
let nr_givens = 0;
|
|
for (const ch of board) {
|
|
if (ch !== BLANK_CHAR && inStr(ch, DIGITS)) nr_givens++;
|
|
}
|
|
if (nr_givens < MIN_GIVENS) throw 'Too few givens';
|
|
|
|
reverse = reverse || false;
|
|
const candidates = getCandidatesMap(board);
|
|
if (!candidates) return false;
|
|
|
|
const result = search(candidates, reverse);
|
|
|
|
if (result) {
|
|
let solution = '';
|
|
for (const s of SQUARES) solution += result[s];
|
|
return solution;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
initialize(); |