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