diff --git a/cruciverb-cli/src/main.rs b/cruciverb-cli/src/main.rs new file mode 100644 index 0000000..43016cc --- /dev/null +++ b/cruciverb-cli/src/main.rs @@ -0,0 +1,367 @@ +use std::path::PathBuf; +use std::time::Instant; + +use clap::{Parser, ValueEnum}; + +use cruciverb_core::clue::seed::load_clue_database; +use cruciverb_core::config::{DifficultyPreset, GenerationConfig, SymmetryType}; +use cruciverb_core::dict::Dictionary; +use cruciverb_core::puzzle::generate_full_puzzle; +use cruciverb_core::validate::validate_puzzle; + +#[derive(Parser)] +#[command(name = "cruciverb", about = "Crossword puzzle generator")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(clap::Subcommand)] +enum Commands { + Generate(GenerateArgs), + ImportClues(ImportCluesArgs), +} + +#[derive(Parser)] +struct ImportCluesArgs { + #[arg(help = "Path to CSV file (columns: Date,Word,Clue)")] + csv: PathBuf, + + #[arg(short, long, default_value = "../data/clues.db")] + output: PathBuf, +} + +#[derive(Parser)] +struct GenerateArgs { + #[arg(short, long, default_value = "15")] + size: usize, + + #[arg(short, long, value_enum)] + difficulty: Option, + + #[arg(long, value_enum, default_value = "rotational180")] + symmetry: CliSymmetry, + + #[arg(short, long)] + output: Option, + + #[arg(long)] + seed: Option, + + #[arg(long, default_value = "0.22")] + density: f64, + + #[arg(long, default_value = "../data/wordlists/enable.txt")] + wordlist: PathBuf, + + #[arg(long)] + blocklist: Option, + + #[arg(long, default_value = "../data")] + data_dir: PathBuf, +} + +#[derive(Clone, ValueEnum)] +enum CliDifficulty { + Glyph, + Rune, + Scroll, + Codex, + Grimoire, +} + +#[derive(Clone, ValueEnum)] +enum CliSymmetry { + Rotational180, + Rotational90, + Diagonal, + Mirror, + None, +} + +impl From for SymmetryType { + fn from(s: CliSymmetry) -> Self { + match s { + CliSymmetry::Rotational180 => SymmetryType::Rotational180, + CliSymmetry::Rotational90 => SymmetryType::Rotational90, + CliSymmetry::Diagonal => SymmetryType::Diagonal, + CliSymmetry::Mirror => SymmetryType::Mirror, + CliSymmetry::None => SymmetryType::None, + } + } +} + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let cli = Cli::parse(); + + match cli.command { + Commands::Generate(args) => generate(args), + Commands::ImportClues(args) => import_clues(args), + } +} + +fn generate(args: GenerateArgs) { + let start = Instant::now(); + + let config = match &args.difficulty { + Some(d) => { + let preset = match d { + CliDifficulty::Glyph => DifficultyPreset::Glyph, + CliDifficulty::Rune => DifficultyPreset::Rune, + CliDifficulty::Scroll => DifficultyPreset::Scroll, + CliDifficulty::Codex => DifficultyPreset::Codex, + CliDifficulty::Grimoire => DifficultyPreset::Grimoire, + }; + let mut c = GenerationConfig::from_preset(preset); + c.seed = args.seed; + c.symmetry = args.symmetry.into(); + c + } + None => GenerationConfig { + width: args.size, + height: args.size, + symmetry: args.symmetry.into(), + black_cell_density: args.density, + seed: args.seed, + rand_top_base: GenerationConfig::rand_top_for_size(args.size), + ..Default::default() + }, + }; + + let difficulty: u8 = match &args.difficulty { + Some(CliDifficulty::Glyph) => 0, + Some(CliDifficulty::Rune) => 1, + Some(CliDifficulty::Scroll) => 2, + Some(CliDifficulty::Codex) => 3, + Some(CliDifficulty::Grimoire) => 3, + None => 1, + }; + + eprintln!("loading dictionary..."); + let mut dict = Dictionary::load( + &args.wordlist, + args.blocklist.as_deref(), + config.min_word_length, + ) + .unwrap_or_else(|e| { + eprintln!("failed to load wordlist: {}", e); + std::process::exit(1); + }); + eprintln!("loaded {} words", dict.word_count()); + + let clue_db = load_clue_database(&args.data_dir).unwrap_or_else(|e| { + eprintln!("warning: failed to load clue database: {}", e); + cruciverb_core::clue::ClueDatabase::empty() + }); + eprintln!("loaded {} clue entries", clue_db.word_count()); + + let clue_words = clue_db.all_words(); + eprintln!("boosting {} words with clue coverage", clue_words.len()); + dict.boost_from_clue_words(&clue_words); + + eprintln!("generating {}x{} puzzle...", config.width, config.height); + let puzzle = generate_full_puzzle(&config, &dict, &clue_db, difficulty).unwrap_or_else(|e| { + eprintln!("generation failed: {}", e); + std::process::exit(1); + }); + + eprintln!("solved in {:.2}s", start.elapsed().as_secs_f64()); + + let validation = validate_puzzle( + &puzzle.grid, + &puzzle.words, + &dict, + config.min_word_length, + config.obscurity_budget, + ); + if !validation.is_valid() { + eprintln!("validation warnings:"); + for err in &validation.errors { + eprintln!(" - {}", err); + } + } + + let json = puzzle.to_json().expect("failed to serialize puzzle"); + + match &args.output { + Some(path) => { + std::fs::write(path, &json).unwrap_or_else(|e| { + eprintln!("failed to write output: {}", e); + std::process::exit(1); + }); + eprintln!("puzzle written to {}", path.display()); + } + None => println!("{}", json), + } + + eprintln!( + "total time: {:.2}s | {} words placed | {}x{} grid", + start.elapsed().as_secs_f64(), + puzzle.words.len(), + config.width, + config.height, + ); +} + +fn import_clues(args: ImportCluesArgs) { + use rusqlite::Connection; + use std::collections::HashMap; + use std::io::{BufRead, BufReader}; + + eprintln!("importing clues from {}...", args.csv.display()); + + let db = Connection::open(&args.output).unwrap_or_else(|e| { + eprintln!("failed to open database: {}", e); + std::process::exit(1); + }); + + db.execute_batch( + " + DROP TABLE IF EXISTS clues; + DROP TABLE IF EXISTS word_difficulty; + CREATE TABLE clues ( + id INTEGER PRIMARY KEY, + word TEXT NOT NULL, + clue TEXT NOT NULL, + difficulty INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE word_difficulty ( + word TEXT PRIMARY KEY, + score REAL NOT NULL + ); + CREATE INDEX idx_clues_word ON clues(word); + CREATE INDEX idx_clues_diff ON clues(word, difficulty); + ", + ) + .unwrap(); + + let file = std::fs::File::open(&args.csv).unwrap_or_else(|e| { + eprintln!("failed to open CSV: {}", e); + std::process::exit(1); + }); + let reader = BufReader::new(file); + + // day-of-week difficulty: Mon=1 (easy) through Sat=6 (hard), Sun=3 (medium-large) + // map to 3 tiers: easy (1-2), medium (3-4), hard (5-6) + fn day_of_week(date_str: &str) -> Option { + let parts: Vec<&str> = date_str.split('/').collect(); + if parts.len() != 3 { + return None; + } + let m: i32 = parts[0].parse().ok()?; + let d: i32 = parts[1].parse().ok()?; + let y: i32 = parts[2].parse().ok()?; + let (y, m) = if m <= 2 { (y - 1, m + 12) } else { (y, m) }; + let dow = ((d + 13 * (m + 1) / 5 + y + y / 4 - y / 100 + y / 400) % 7 + 7) % 7; + let weight = match dow { + 2 => 1, // Monday + 3 => 2, // Tuesday + 4 => 3, // Wednesday + 5 => 4, // Thursday + 6 => 5, // Friday + 0 => 6, // Saturday + 1 => 3, // Sunday (medium) + _ => 3, + }; + Some(weight) + } + + fn weight_to_tier(w: u32) -> u8 { + if w <= 2 { + 1 + } else if w <= 4 { + 2 + } else { + 3 + } + } + + db.execute_batch("BEGIN").unwrap(); + + let mut count = 0u64; + let mut word_day_scores: HashMap> = HashMap::new(); + let mut stmt = db + .prepare("INSERT INTO clues (word, clue, difficulty) VALUES (?1, ?2, ?3)") + .unwrap(); + + for (i, line) in reader.lines().enumerate() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + if i == 0 && line.starts_with("Date") { + continue; + } + + let fields = parse_csv_line(&line); + let Some((date, word, clue)) = fields else { + continue; + }; + + if word.is_empty() || !word.bytes().all(|b| b.is_ascii_alphabetic()) { + continue; + } + + let upper = word.to_uppercase(); + let day_score = day_of_week(&date).unwrap_or(3); + let tier = weight_to_tier(day_score); + + stmt.execute(rusqlite::params![upper, clue, tier]).unwrap(); + word_day_scores.entry(upper).or_default().push(day_score); + count += 1; + + if count % 100_000 == 0 { + eprintln!(" {} clues imported...", count); + } + } + + // compute per-word difficulty scores + let mut diff_stmt = db + .prepare("INSERT INTO word_difficulty (word, score) VALUES (?1, ?2)") + .unwrap(); + for (word, scores) in &word_day_scores { + let avg: f64 = scores.iter().map(|&s| s as f64).sum::() / scores.len() as f64; + diff_stmt.execute(rusqlite::params![word, avg]).unwrap(); + } + + db.execute_batch("COMMIT").unwrap(); + eprintln!( + "imported {} clues for {} words to {}", + count, + word_day_scores.len(), + args.output.display() + ); +} + +fn parse_csv_line(line: &str) -> Option<(String, String, String)> { + let chars = line.chars(); + let mut fields: Vec = Vec::new(); + let mut field = String::new(); + let mut in_quotes = false; + + for ch in chars { + match ch { + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => { + fields.push(field.clone()); + field.clear(); + } + _ => field.push(ch), + } + } + fields.push(field); + + if fields.len() >= 3 { + Some(( + fields[0].trim().to_string(), + fields[1].trim().to_string(), + fields[2..].join(",").trim().to_string(), + )) + } else { + None + } +}