the puzzle model

This commit is contained in:
2025-05-03 18:55:40 +03:00
parent e70a38b919
commit 55748b4bd7
+470
View File
@@ -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)
}
}