the cli driver
This commit is contained in:
@@ -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<CliDifficulty>,
|
||||||
|
|
||||||
|
#[arg(long, value_enum, default_value = "rotational180")]
|
||||||
|
symmetry: CliSymmetry,
|
||||||
|
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
seed: Option<u64>,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "0.22")]
|
||||||
|
density: f64,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "../data/wordlists/enable.txt")]
|
||||||
|
wordlist: PathBuf,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
blocklist: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[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<CliSymmetry> 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<u32> {
|
||||||
|
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<String, Vec<u32>> = 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::<f64>() / 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<String> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user