the puzzle model
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::clue::{ClueDatabase, CrypticClueDb};
|
||||
use crate::config::{GenerationConfig, SymmetryType};
|
||||
use crate::dict::Dictionary;
|
||||
use crate::error::{CrucivError, Result};
|
||||
use crate::grid::pattern::generate_pattern;
|
||||
use crate::grid::slot::{extract_slots, Direction, Slot};
|
||||
use crate::grid::Grid;
|
||||
use crate::solver::{self, SolveResult};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlacedWord {
|
||||
pub word: String,
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub direction: Direction,
|
||||
pub clue_number: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Clue {
|
||||
pub number: u32,
|
||||
pub direction: Direction,
|
||||
pub text: String,
|
||||
pub answer: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub definition: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wordplay_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Puzzle {
|
||||
pub grid: Grid,
|
||||
pub words: Vec<PlacedWord>,
|
||||
pub clues_across: Vec<Clue>,
|
||||
pub clues_down: Vec<Clue>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub symmetry: SymmetryType,
|
||||
pub difficulty_score: u32,
|
||||
pub generation_time_ms: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(default)]
|
||||
pub theme_entries: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_cryptic: bool,
|
||||
}
|
||||
|
||||
impl Puzzle {
|
||||
pub fn to_json(&self) -> serde_json::Result<String> {
|
||||
serde_json::to_string_pretty(self)
|
||||
}
|
||||
|
||||
pub fn from_json(json: &str) -> serde_json::Result<Self> {
|
||||
serde_json::from_str(json)
|
||||
}
|
||||
|
||||
pub fn apply_cryptic_clues(&mut self, cryptic_db: &CrypticClueDb) -> usize {
|
||||
let mut count = 0;
|
||||
for clue in self
|
||||
.clues_across
|
||||
.iter_mut()
|
||||
.chain(self.clues_down.iter_mut())
|
||||
{
|
||||
if let Some((text, definition, wordplay)) = cryptic_db.get_clue_with_meta(&clue.answer)
|
||||
{
|
||||
clue.text = text;
|
||||
clue.definition = definition;
|
||||
clue.wordplay_type = wordplay;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
self.is_cryptic = true;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
pub fn is_fully_filled(&self) -> bool {
|
||||
use crate::grid::Cell;
|
||||
for r in 0..self.height {
|
||||
for c in 0..self.width {
|
||||
if matches!(self.grid.get(r, c), Cell::Empty) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// also check every white cell is covered by a word in both directions
|
||||
let mut across_covered = vec![vec![false; self.width]; self.height];
|
||||
let mut down_covered = vec![vec![false; self.width]; self.height];
|
||||
for w in &self.words {
|
||||
let len = w.word.len();
|
||||
for i in 0..len {
|
||||
let (r, c) = match w.direction {
|
||||
Direction::Across => (w.row, w.col + i),
|
||||
Direction::Down => (w.row + i, w.col),
|
||||
};
|
||||
if r < self.height && c < self.width {
|
||||
match w.direction {
|
||||
Direction::Across => across_covered[r][c] = true,
|
||||
Direction::Down => down_covered[r][c] = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in 0..self.height {
|
||||
for c in 0..self.width {
|
||||
if matches!(self.grid.get(r, c), Cell::Letter(_)) {
|
||||
if !across_covered[r][c] || !down_covered[r][c] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assemble_puzzle(
|
||||
solve_result: &SolveResult,
|
||||
slots: &[Slot],
|
||||
dict: &Dictionary,
|
||||
clue_db: &ClueDatabase,
|
||||
config: &GenerationConfig,
|
||||
difficulty: u8,
|
||||
generation_time_ms: u64,
|
||||
theme: Option<&str>,
|
||||
theme_words: Option<&HashSet<String>>,
|
||||
) -> Puzzle {
|
||||
let mut clue_number = 1u32;
|
||||
let mut assigned_numbers: HashMap<(usize, usize), u32> = HashMap::new();
|
||||
let mut words = Vec::new();
|
||||
let mut clues_across = Vec::new();
|
||||
let mut clues_down = Vec::new();
|
||||
|
||||
let mut sorted_slots: Vec<_> = slots.iter().collect();
|
||||
sorted_slots.sort_by_key(|s| (s.row, s.col, matches!(s.direction, Direction::Down) as u8));
|
||||
|
||||
for slot in &sorted_slots {
|
||||
let num = *assigned_numbers
|
||||
.entry((slot.row, slot.col))
|
||||
.or_insert_with(|| {
|
||||
let n = clue_number;
|
||||
clue_number += 1;
|
||||
n
|
||||
});
|
||||
|
||||
let word_idx = solve_result
|
||||
.word_assignments
|
||||
.iter()
|
||||
.find(|(si, _)| *si == slot.index)
|
||||
.map(|(_, wi)| *wi);
|
||||
|
||||
if let Some(wi) = word_idx {
|
||||
let word_text = &dict.get(wi).text;
|
||||
|
||||
let clue_text = match clue_db.get_clue(word_text, difficulty) {
|
||||
Ok(text) => text,
|
||||
Err(_) => clue_db.get_clue(word_text, 3).unwrap_or_else(|_| {
|
||||
tracing::warn!("no clue found for word: {}", word_text);
|
||||
format!("___ ({} letter word)", word_text.len())
|
||||
}),
|
||||
};
|
||||
|
||||
words.push(PlacedWord {
|
||||
word: word_text.clone(),
|
||||
row: slot.row,
|
||||
col: slot.col,
|
||||
direction: slot.direction,
|
||||
clue_number: num,
|
||||
});
|
||||
|
||||
let clue = Clue {
|
||||
number: num,
|
||||
direction: slot.direction,
|
||||
text: clue_text,
|
||||
answer: word_text.clone(),
|
||||
definition: None,
|
||||
wordplay_type: None,
|
||||
};
|
||||
|
||||
match slot.direction {
|
||||
Direction::Across => clues_across.push(clue),
|
||||
Direction::Down => clues_down.push(clue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let theme_entries = if let Some(tw) = theme_words {
|
||||
words
|
||||
.iter()
|
||||
.filter(|w| tw.contains(&w.word))
|
||||
.map(|w| w.word.clone())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Puzzle {
|
||||
grid: solve_result.grid.clone(),
|
||||
words,
|
||||
clues_across,
|
||||
clues_down,
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
symmetry: config.symmetry,
|
||||
difficulty_score: 50,
|
||||
generation_time_ms,
|
||||
theme: theme.map(|s| s.to_string()),
|
||||
theme_entries,
|
||||
is_cryptic: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_full_puzzle(
|
||||
config: &GenerationConfig,
|
||||
dict: &Dictionary,
|
||||
clue_db: &ClueDatabase,
|
||||
difficulty: u8,
|
||||
) -> Result<Puzzle> {
|
||||
generate_themed_puzzle(config, dict, clue_db, difficulty, None, None)
|
||||
}
|
||||
|
||||
pub fn generate_themed_puzzle(
|
||||
config: &GenerationConfig,
|
||||
dict: &Dictionary,
|
||||
clue_db: &ClueDatabase,
|
||||
difficulty: u8,
|
||||
theme: Option<&str>,
|
||||
theme_words: Option<&HashSet<String>>,
|
||||
) -> Result<Puzzle> {
|
||||
let start = Instant::now();
|
||||
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
.min(8);
|
||||
|
||||
// parallel for larger grids with multiple cores
|
||||
if cores > 1 && config.width > 9 {
|
||||
return parallel_generate(
|
||||
config,
|
||||
dict,
|
||||
clue_db,
|
||||
difficulty,
|
||||
theme,
|
||||
theme_words,
|
||||
cores,
|
||||
start,
|
||||
);
|
||||
}
|
||||
|
||||
sequential_generate(config, dict, clue_db, difficulty, theme, theme_words, start)
|
||||
}
|
||||
|
||||
struct SolvedGrid {
|
||||
result: SolveResult,
|
||||
slots: Vec<Slot>,
|
||||
config: GenerationConfig,
|
||||
}
|
||||
|
||||
fn parallel_generate(
|
||||
config: &GenerationConfig,
|
||||
dict: &Dictionary,
|
||||
clue_db: &ClueDatabase,
|
||||
difficulty: u8,
|
||||
theme: Option<&str>,
|
||||
theme_words: Option<&HashSet<String>>,
|
||||
cores: usize,
|
||||
start: Instant,
|
||||
) -> Result<Puzzle> {
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<SolvedGrid>>();
|
||||
let dict = Arc::new(dict.clone());
|
||||
|
||||
let max_attempts = if config.width >= 25 {
|
||||
40
|
||||
} else if config.width >= 19 {
|
||||
30
|
||||
} else {
|
||||
20
|
||||
};
|
||||
let per_worker = (max_attempts + cores - 1) / cores;
|
||||
|
||||
let handles: Vec<_> = (0..cores)
|
||||
.map(|i| {
|
||||
let tx = tx.clone();
|
||||
let cancelled = cancelled.clone();
|
||||
let dict = dict.clone();
|
||||
let base_seed = config.seed;
|
||||
let mut os_rng = base_seed.is_none().then(StdRng::from_os_rng);
|
||||
let mut worker_config = config.clone();
|
||||
worker_config.cancelled = Some(cancelled.clone());
|
||||
|
||||
let min_word_length = config.min_word_length;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
for attempt in 0..per_worker {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut attempt_config = worker_config.clone();
|
||||
// seeded callers (daily puzzles) keep the deterministic series;
|
||||
// unseeded callers get fresh OS entropy per attempt instead of
|
||||
// silently sharing the fixed 0-based series
|
||||
attempt_config.seed = match base_seed {
|
||||
Some(s) => {
|
||||
let wseed = s.wrapping_add(i as u64 * 7919);
|
||||
Some(if attempt > 0 {
|
||||
wseed
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add((attempt as u64).wrapping_mul(1442695040888963407))
|
||||
} else {
|
||||
wseed
|
||||
})
|
||||
}
|
||||
None => Some(os_rng.as_mut().unwrap().random::<u64>()),
|
||||
};
|
||||
|
||||
let grid = match generate_pattern(&attempt_config) {
|
||||
Ok(g) => g,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let slots = extract_slots(&grid, min_word_length);
|
||||
|
||||
match solver::solve(&grid, &slots, &dict, &attempt_config) {
|
||||
Ok(result) => {
|
||||
let _ = tx.send(Ok(SolvedGrid {
|
||||
result,
|
||||
slots,
|
||||
config: attempt_config,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
drop(tx);
|
||||
|
||||
let mut last_err = None;
|
||||
for msg in rx {
|
||||
match msg {
|
||||
Ok(solved) => {
|
||||
let elapsed = start.elapsed().as_millis() as u64;
|
||||
let puzzle = assemble_puzzle(
|
||||
&solved.result,
|
||||
&solved.slots,
|
||||
&dict,
|
||||
clue_db,
|
||||
&solved.config,
|
||||
difficulty,
|
||||
elapsed,
|
||||
theme,
|
||||
theme_words,
|
||||
);
|
||||
if !puzzle.is_fully_filled() {
|
||||
tracing::warn!("rejecting puzzle: not fully filled or uncovered cells");
|
||||
continue;
|
||||
}
|
||||
cancelled.store(true, Ordering::Relaxed);
|
||||
return Ok(puzzle);
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wait for threads to finish
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| CrucivError::Generation(generation_hint(config))))
|
||||
}
|
||||
|
||||
fn sequential_generate(
|
||||
config: &GenerationConfig,
|
||||
dict: &Dictionary,
|
||||
clue_db: &ClueDatabase,
|
||||
difficulty: u8,
|
||||
theme: Option<&str>,
|
||||
theme_words: Option<&HashSet<String>>,
|
||||
start: Instant,
|
||||
) -> Result<Puzzle> {
|
||||
let max_attempts = if config.width >= 25 {
|
||||
40
|
||||
} else if config.width >= 19 {
|
||||
30
|
||||
} else {
|
||||
20
|
||||
};
|
||||
|
||||
for attempt in 0..max_attempts {
|
||||
let mut attempt_config = config.clone();
|
||||
if attempt > 0 {
|
||||
attempt_config.seed = Some(
|
||||
config
|
||||
.seed
|
||||
.unwrap_or(0)
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add((attempt as u64).wrapping_mul(1442695040888963407)),
|
||||
);
|
||||
}
|
||||
|
||||
let grid = match generate_pattern(&attempt_config) {
|
||||
Ok(g) => g,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let slots = extract_slots(&grid, config.min_word_length);
|
||||
|
||||
match solver::solve(&grid, &slots, &dict, &attempt_config) {
|
||||
Ok(result) => {
|
||||
let elapsed = start.elapsed().as_millis() as u64;
|
||||
let puzzle = assemble_puzzle(
|
||||
&result,
|
||||
&slots,
|
||||
dict,
|
||||
clue_db,
|
||||
config,
|
||||
difficulty,
|
||||
elapsed,
|
||||
theme,
|
||||
theme_words,
|
||||
);
|
||||
if !puzzle.is_fully_filled() {
|
||||
tracing::warn!("rejecting puzzle: not fully filled or uncovered cells");
|
||||
continue;
|
||||
}
|
||||
return Ok(puzzle);
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Err(CrucivError::Generation(generation_hint(config)))
|
||||
}
|
||||
|
||||
fn generation_hint(config: &GenerationConfig) -> String {
|
||||
let size = config.width;
|
||||
let density = config.black_cell_density;
|
||||
let fill_pct = ((1.0 - density) * 100.0).round() as u32;
|
||||
|
||||
if density < 0.12 {
|
||||
format!("Could not fill a {}x{} grid at {}% fill - try lowering the fill percentage or reducing the grid size", size, size, fill_pct)
|
||||
} else if size >= 19 && density < 0.18 {
|
||||
format!("A {}x{} grid at {}% fill is very hard to fill - try lowering the fill to around 75% or below", size, size, fill_pct)
|
||||
} else if size >= 15 && density < 0.15 {
|
||||
format!("Could not fill a {}x{} grid at {}% fill - large grids need more black cells. Try 78% fill or lower", size, size, fill_pct)
|
||||
} else {
|
||||
format!("Could not generate a {}x{} puzzle at {}% fill - try a different fill percentage or grid size", size, size, fill_pct)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user