use include_dir::{Dir, include_dir};
use crate::ordering::Ordering;
use crate::{Pruning, SolveConfig};
use super::csp::{create_sudoku_csp, sudoku_csp_skeleton, sudoku_given};
use super::rng::SimpleRng;
use super::transform::{SudokuTransform, apply_random_transform};
static SUDOKU_PUZZLES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/data/sudoku_puzzles");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Difficulty {
Easy,
Medium,
Hard,
}
impl Difficulty {
pub(crate) fn dir_name(self) -> &'static str {
match self {
Difficulty::Easy => "easy",
Difficulty::Medium => "medium",
Difficulty::Hard => "hard",
}
}
}
pub fn measure_difficulty(board: &[u32], n: u32) -> u32 {
let (mut csp, given) = create_sudoku_csp(board, n);
let config = SolveConfig {
pruning: Pruning::ForwardChecking,
ordering: Ordering::FailFirst,
max_solutions: 1,
..Default::default()
};
csp.solve_with_given(&config, &given);
csp.stats().backtracks as u32
}
fn parse_puzzle_field(json: &str, total: usize) -> Vec<u32> {
let key = json
.find("\"puzzle\"")
.expect("template missing \"puzzle\" key");
let obj_start = json[key..].find('{').expect("puzzle object missing '{'") + key;
let obj_end = json[obj_start..]
.find('}')
.expect("puzzle object missing '}'")
+ obj_start;
let body = &json[obj_start + 1..obj_end];
let mut board = vec![0u32; total];
for entry in body.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (k, v) = entry.split_once(':').expect("malformed template entry");
let pos: usize = k
.trim()
.trim_matches('"')
.parse()
.expect("bad position key");
let val: u32 = v.trim().parse().expect("bad cell value");
if pos < total {
board[pos] = val;
}
}
board
}
pub fn embedded_templates(n: u32, difficulty: Difficulty) -> Vec<Vec<u32>> {
let total = (n * n * n * n) as usize;
let path = format!("{n}/{}", difficulty.dir_name());
let Some(dir) = SUDOKU_PUZZLES.get_dir(&path) else {
return Vec::new();
};
dir.files()
.filter(|f| f.path().extension().is_some_and(|e| e == "json"))
.map(|f| {
let contents = f.contents_utf8().expect("embedded template must be UTF-8");
parse_puzzle_field(contents, total)
})
.collect()
}
pub fn embedded_template_count(n: u32, difficulty: Difficulty) -> usize {
let path = format!("{n}/{}", difficulty.dir_name());
SUDOKU_PUZZLES.get_dir(&path).map_or(0, |dir| {
dir.files()
.filter(|f| f.path().extension().is_some_and(|e| e == "json"))
.count()
})
}
#[cfg(debug_assertions)]
fn expected_backtrack_band(n: u32, difficulty: Difficulty) -> (u32, u32) {
if n != 3 {
return (0, u32::MAX);
}
match difficulty {
Difficulty::Easy => (0, 0),
Difficulty::Medium => (1, u32::MAX),
Difficulty::Hard => (100, u32::MAX),
}
}
pub fn generate_board_with_templates(
n: u32,
difficulty: Difficulty,
templates: &[Vec<u32>],
) -> Vec<u32> {
if !templates.is_empty() {
let mut rng = SimpleRng::from_time();
let idx = rng.next_usize(templates.len());
let board = apply_random_transform(&templates[idx], n);
#[cfg(debug_assertions)]
{
let (min_bt, max_bt) = expected_backtrack_band(n, difficulty);
let bt = measure_difficulty(&board, n);
debug_assert!(
(min_bt..=max_bt).contains(&bt),
"template claimed difficulty {difficulty:?} but measured {bt} backtracks \
(expected {min_bt}..={max_bt}) for N={n} — the template list does not match \
its claimed difficulty; check the caller's template-loading path"
);
}
return board;
}
generate_board_slow(n, difficulty)
}
pub fn generate_board(n: u32, difficulty: Difficulty) -> Vec<u32> {
generate_board_slow(n, difficulty)
}
pub fn generate_board_seeded(n: u32, difficulty: Difficulty, seed: u64) -> Vec<u32> {
generate_board_slow_with_rng(n, difficulty, &mut SimpleRng::new(seed))
}
pub fn generate_board_with_templates_seeded(
n: u32,
difficulty: Difficulty,
templates: &[Vec<u32>],
seed: u64,
) -> Vec<u32> {
let mut rng = SimpleRng::new(seed);
if templates.is_empty() {
return generate_board_slow_with_rng(n, difficulty, &mut rng);
}
let idx = rng.next_usize(templates.len());
SudokuTransform::random_with_rng(n, &mut rng).apply(&templates[idx], n)
}
fn generate_board_slow(n: u32, difficulty: Difficulty) -> Vec<u32> {
generate_board_slow_with_rng(n, difficulty, &mut SimpleRng::from_time())
}
fn generate_board_slow_with_rng(n: u32, difficulty: Difficulty, rng: &mut SimpleRng) -> Vec<u32> {
let m = n * n;
let total = (m * m) as usize;
let mut csp = sudoku_csp_skeleton(n);
let config = SolveConfig {
pruning: Pruning::Ac3,
ordering: Ordering::FailFirst,
max_solutions: 1,
..Default::default()
};
let mut seed_board = vec![0u32; total];
let mut first_row: Vec<u32> = (1..=m).collect();
rng.shuffle(&mut first_row);
seed_board[..m as usize].copy_from_slice(&first_row);
let solution = csp
.solve_with_given(&config, &sudoku_given(&seed_board))
.into_iter()
.next()
.expect("seeded board must be solvable");
let target_holes = match difficulty {
Difficulty::Easy => total / 4,
Difficulty::Medium => (total as f64 / 1.75) as usize,
Difficulty::Hard => (total as f64 / 1.25) as usize,
};
let mut board = solution.clone();
let mut indices: Vec<usize> = (0..total).collect();
rng.shuffle(&mut indices);
let mut holes = 0usize;
let uniqueness_config = SolveConfig {
pruning: Pruning::Ac3,
ordering: Ordering::FailFirst,
max_solutions: 2,
..Default::default()
};
for &idx in &indices {
if holes >= target_holes {
break;
}
let saved = board[idx];
board[idx] = 0;
let solutions = csp.solve_with_given(&uniqueness_config, &sudoku_given(&board));
if solutions.len() == 1 {
holes += 1;
} else {
board[idx] = saved;
}
}
board
}