clue blocklist and seeds
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
pub mod seed;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClueEntry {
|
||||
pub text: String,
|
||||
pub difficulty: u8,
|
||||
}
|
||||
|
||||
pub struct ClueDatabase {
|
||||
json_clues: HashMap<String, Vec<ClueEntry>>,
|
||||
db: Option<std::sync::Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl ClueDatabase {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
json_clues: HashMap::new(),
|
||||
db: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_json(path: &Path) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let raw: HashMap<String, Vec<ClueEntry>> = serde_json::from_str(&content)?;
|
||||
let json_clues = raw
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_uppercase(), v))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
json_clues,
|
||||
db: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_sqlite(db_path: &Path) -> Result<Self> {
|
||||
let db = Connection::open(db_path)
|
||||
.map_err(|e| crate::error::CrucivError::Dictionary(e.to_string()))?;
|
||||
Ok(Self {
|
||||
json_clues: HashMap::new(),
|
||||
db: Some(std::sync::Mutex::new(db)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(data_dir: &Path) -> Result<Self> {
|
||||
let db_path = data_dir.join("clues.db");
|
||||
let json_path = data_dir.join("clues.json");
|
||||
|
||||
if db_path.exists() {
|
||||
Self::load_sqlite(&db_path)
|
||||
} else if json_path.exists() {
|
||||
Self::load_json(&json_path)
|
||||
} else {
|
||||
Ok(Self::empty())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_clue(&self, word: &str, difficulty: u8) -> Result<String> {
|
||||
let upper = word.to_uppercase();
|
||||
|
||||
// try sqlite with difficulty matching
|
||||
if let Some(db) = &self.db {
|
||||
let conn = db.lock().unwrap();
|
||||
if let Some(clue) = query_sqlite(&conn, &upper, difficulty) {
|
||||
return Ok(clue);
|
||||
}
|
||||
}
|
||||
|
||||
// fall back to json
|
||||
if let Some(entries) = self.json_clues.get(&upper) {
|
||||
if let Some(entry) = entries.first() {
|
||||
return Ok(entry.text.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// last resort placeholder so unclued words still render as fill-in-the-blanks
|
||||
Ok(format!("___ ({} letter word)", word.len()))
|
||||
}
|
||||
|
||||
/// Get word difficulty scores for dictionary boosting.
|
||||
/// Returns a map of word -> difficulty score (1.0=easy to 6.0=hard).
|
||||
pub fn word_difficulties(&self) -> std::collections::HashMap<String, f64> {
|
||||
let mut result = std::collections::HashMap::new();
|
||||
if let Some(db) = &self.db {
|
||||
let conn = db.lock().unwrap();
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT word, score FROM word_difficulty") {
|
||||
if let Ok(rows) = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
|
||||
}) {
|
||||
for r in rows.flatten() {
|
||||
result.insert(r.0, r.1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn word_count(&self) -> usize {
|
||||
let json_count = self.json_clues.len();
|
||||
let db_count = self
|
||||
.db
|
||||
.as_ref()
|
||||
.map(|db| {
|
||||
let conn = db.lock().unwrap();
|
||||
conn.query_row("SELECT COUNT(DISTINCT word) FROM clues", [], |row| {
|
||||
row.get::<_, usize>(0)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
json_count + db_count
|
||||
}
|
||||
|
||||
pub fn all_words(&self) -> std::collections::HashSet<String> {
|
||||
let mut words: std::collections::HashSet<String> = self
|
||||
.json_clues
|
||||
.keys()
|
||||
.filter(|w| w.chars().all(|c| c.is_alphabetic()))
|
||||
.cloned()
|
||||
.collect();
|
||||
if let Some(db) = &self.db {
|
||||
let conn = db.lock().unwrap();
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT DISTINCT word FROM clues") {
|
||||
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) {
|
||||
for w in rows.flatten() {
|
||||
if w.chars().all(|c| c.is_alphabetic()) {
|
||||
words.insert(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// Words that have at least one non-cryptic clue.
|
||||
/// Use this for dictionary filtering to avoid placing words
|
||||
/// that can't actually be clued in normal puzzles.
|
||||
pub fn clueable_words(&self) -> std::collections::HashSet<String> {
|
||||
let mut words: std::collections::HashSet<String> = self
|
||||
.json_clues
|
||||
.keys()
|
||||
.filter(|w| w.chars().all(|c| c.is_alphabetic()))
|
||||
.cloned()
|
||||
.collect();
|
||||
if let Some(db) = &self.db {
|
||||
let conn = db.lock().unwrap();
|
||||
if let Ok(mut stmt) =
|
||||
conn.prepare("SELECT DISTINCT word FROM clues WHERE source != 'cryptic'")
|
||||
{
|
||||
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) {
|
||||
for w in rows.flatten() {
|
||||
if w.chars().all(|c| c.is_alphabetic()) {
|
||||
words.insert(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
pub fn suggestions(&self, word: &str, limit: usize) -> Vec<String> {
|
||||
let upper = word.to_uppercase();
|
||||
let mut result = Vec::new();
|
||||
if let Some(entries) = self.json_clues.get(&upper) {
|
||||
for e in entries.iter().take(limit) {
|
||||
result.push(e.text.clone());
|
||||
}
|
||||
}
|
||||
if let Some(db) = &self.db {
|
||||
let conn = db.lock().unwrap();
|
||||
if let Ok(mut stmt) =
|
||||
conn.prepare("SELECT DISTINCT clue FROM clues WHERE word = ?1 LIMIT ?2")
|
||||
{
|
||||
if let Ok(rows) = stmt.query_map(
|
||||
rusqlite::params![upper, (limit - result.len()) as i64],
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
for r in rows.flatten() {
|
||||
if !result.contains(&r) {
|
||||
result.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
result.truncate(limit);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn lookup(&self, word: &str, difficulty: u8) -> Option<ClueEntry> {
|
||||
let upper = word.to_uppercase();
|
||||
|
||||
if let Some(entries) = self.json_clues.get(&upper) {
|
||||
if let Some(entry) = entries.iter().find(|e| e.difficulty == difficulty) {
|
||||
return Some(entry.clone());
|
||||
}
|
||||
if let Some(entry) = entries.first() {
|
||||
return Some(entry.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(db) = &self.db {
|
||||
let conn = db.lock().unwrap();
|
||||
if let Some(clue) = query_sqlite(&conn, &upper, difficulty) {
|
||||
return Some(ClueEntry {
|
||||
text: clue,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn db_ref(&self) -> Option<std::sync::MutexGuard<'_, Connection>> {
|
||||
self.db.as_ref().map(|m| m.lock().unwrap())
|
||||
}
|
||||
|
||||
pub fn find_word_for_clue(&self, clue_text: &str) -> Option<String> {
|
||||
for (word, entries) in &self.json_clues {
|
||||
for e in entries {
|
||||
if e.text == clue_text {
|
||||
return Some(word.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn random_clues(&self, count: usize) -> Vec<(String, String)> {
|
||||
let mut result = Vec::new();
|
||||
if let Some(db) = &self.db {
|
||||
let conn = db.lock().unwrap();
|
||||
// pick random word+clue pairs, prefer common words (3-8 letters)
|
||||
if let Ok(mut stmt) = conn.prepare(
|
||||
"SELECT word, clue FROM clues WHERE length(word) BETWEEN 3 AND 8 ORDER BY RANDOM() LIMIT ?1"
|
||||
) {
|
||||
if let Ok(rows) = stmt.query_map(rusqlite::params![count as i64], |row| {
|
||||
Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?))
|
||||
}) {
|
||||
for r in rows.flatten() {
|
||||
result.push(r);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if result.is_empty() {
|
||||
// fallback to json clues
|
||||
for (word, entries) in self.json_clues.iter().take(count) {
|
||||
if let Some(e) = entries.first() {
|
||||
result.push((e.text.clone(), word.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn random_obscure_clues(&self, count: usize) -> Vec<(String, String)> {
|
||||
let mut result = Vec::new();
|
||||
if let Some(db) = &self.db {
|
||||
let conn = db.lock().unwrap();
|
||||
if let Ok(mut stmt) = conn.prepare(
|
||||
"SELECT word, clue FROM clues WHERE length(word) >= 7 AND source != 'cryptic' ORDER BY RANDOM() LIMIT ?1"
|
||||
) {
|
||||
if let Ok(rows) = stmt.query_map(rusqlite::params![count as i64], |row| {
|
||||
Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?))
|
||||
}) {
|
||||
for r in rows.flatten() {
|
||||
result.push(r);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if result.is_empty() {
|
||||
for (word, entries) in self
|
||||
.json_clues
|
||||
.iter()
|
||||
.filter(|(w, _)| w.len() >= 7)
|
||||
.take(count)
|
||||
{
|
||||
if let Some(e) = entries.first() {
|
||||
result.push((e.text.clone(), word.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.is_empty() {
|
||||
return self.random_clues(count);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Returns (clue_text, real_answer, vec_of_fake_clues)
|
||||
pub fn random_clues_with_fakes(
|
||||
&self,
|
||||
count: usize,
|
||||
fakes: usize,
|
||||
) -> Vec<(String, String, Vec<String>)> {
|
||||
let mut result = Vec::new();
|
||||
let clues = self.random_clues(count + count * fakes);
|
||||
if clues.len() < count + fakes {
|
||||
// not enough data, just return what we have with empty fakes
|
||||
for (clue, word) in clues.into_iter().take(count) {
|
||||
result.push((clue, word, Vec::new()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let main: Vec<_> = clues[..count].to_vec();
|
||||
let extra: Vec<_> = clues[count..].to_vec();
|
||||
for (i, (clue, word)) in main.into_iter().enumerate() {
|
||||
let fake_clues: Vec<String> = extra
|
||||
.iter()
|
||||
.filter(|(_, w)| w != &word)
|
||||
.skip(i * fakes)
|
||||
.take(fakes)
|
||||
.map(|(c, _)| c.clone())
|
||||
.collect();
|
||||
result.push((clue, word, fake_clues));
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// cryptic clue database (separate schema: answer/clue/definition)
|
||||
/// Cryptic clue access - reads from the same clues.db using source='cryptic'
|
||||
pub struct CrypticClueDb {
|
||||
db: std::sync::Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl CrypticClueDb {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let db = Connection::open(path)
|
||||
.map_err(|e| crate::error::CrucivError::Dictionary(e.to_string()))?;
|
||||
Ok(Self {
|
||||
db: std::sync::Mutex::new(db),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_clue(&self, word: &str, _difficulty: u8) -> Result<String> {
|
||||
let upper = word.to_uppercase();
|
||||
let conn = self.db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT clue FROM clues WHERE word = ?1 AND source = 'cryptic' ORDER BY RANDOM() LIMIT 1",
|
||||
[&upper],
|
||||
|row| row.get::<_, String>(0),
|
||||
).map_err(|_| crate::error::CrucivError::MissingClue(word.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_clue_with_meta(
|
||||
&self,
|
||||
word: &str,
|
||||
) -> Option<(String, Option<String>, Option<String>)> {
|
||||
let upper = word.to_uppercase();
|
||||
let conn = self.db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT clue, definition, wordplay_type FROM clues WHERE word = ?1 AND source = 'cryptic' ORDER BY RANDOM() LIMIT 1",
|
||||
[&upper],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<String>>(2)?)),
|
||||
).ok()
|
||||
}
|
||||
|
||||
pub fn suggestions(&self, word: &str, limit: usize) -> Vec<String> {
|
||||
let upper = word.to_uppercase();
|
||||
let mut result = Vec::new();
|
||||
let conn = self.db.lock().unwrap();
|
||||
if let Ok(mut stmt) = conn.prepare(
|
||||
"SELECT DISTINCT clue FROM clues WHERE word = ?1 AND source = 'cryptic' LIMIT ?2",
|
||||
) {
|
||||
if let Ok(rows) = stmt.query_map(rusqlite::params![upper, limit as i64], |row| {
|
||||
row.get::<_, String>(0)
|
||||
}) {
|
||||
for r in rows.flatten() {
|
||||
result.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn word_count(&self) -> usize {
|
||||
let conn = self.db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(DISTINCT word) FROM clues WHERE source = 'cryptic'",
|
||||
[],
|
||||
|row| row.get::<_, usize>(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn all_words(&self) -> std::collections::HashSet<String> {
|
||||
let mut words = std::collections::HashSet::new();
|
||||
let conn = self.db.lock().unwrap();
|
||||
if let Ok(mut stmt) = conn.prepare(
|
||||
"SELECT DISTINCT word FROM clues WHERE source = 'cryptic' AND LENGTH(word) >= 3",
|
||||
) {
|
||||
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) {
|
||||
for w in rows.flatten() {
|
||||
if w.chars().all(|c| c.is_alphabetic()) {
|
||||
words.insert(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
words
|
||||
}
|
||||
}
|
||||
|
||||
fn query_sqlite(db: &Connection, word: &str, difficulty: u8) -> Option<String> {
|
||||
if difficulty <= 1 {
|
||||
// very easy / easy: pick shortest straightforward non-cryptic clue
|
||||
// exclude fill-in-blanks (___), abbreviation markers, and foreign clues
|
||||
let easy_filter = "AND source != 'cryptic' AND clue NOT LIKE '%\\_\\_\\_%' ESCAPE '\\' AND clue NOT LIKE '%bbr%' AND clue NOT LIKE '%org.%' AND clue NOT LIKE '%Var.%' AND clue NOT LIKE '%: %' AND clue NOT LIKE '%Sp.%' AND clue NOT LIKE '%Fr.%' AND clue NOT LIKE '%Lat.%' AND clue NOT LIKE '%Ger.%'";
|
||||
return db.query_row(
|
||||
&format!("SELECT clue FROM clues WHERE word = ?1 AND difficulty <= ?2 {} ORDER BY LENGTH(clue) ASC LIMIT 1", easy_filter),
|
||||
rusqlite::params![word, difficulty],
|
||||
|row| row.get::<_, String>(0),
|
||||
).ok().or_else(|| {
|
||||
// widen to medium, still filtering junk
|
||||
db.query_row(
|
||||
&format!("SELECT clue FROM clues WHERE word = ?1 AND difficulty <= 2 {} ORDER BY LENGTH(clue) ASC LIMIT 1", easy_filter),
|
||||
[word],
|
||||
|row| row.get::<_, String>(0),
|
||||
).ok()
|
||||
}).or_else(|| {
|
||||
// any non-cryptic clue, prefer easier
|
||||
db.query_row(
|
||||
"SELECT clue FROM clues WHERE word = ?1 AND source != 'cryptic' ORDER BY difficulty ASC, LENGTH(clue) ASC LIMIT 1",
|
||||
[word],
|
||||
|row| row.get::<_, String>(0),
|
||||
).ok()
|
||||
});
|
||||
}
|
||||
|
||||
// medium/hard: random non-cryptic clue at matching difficulty, fall back to closest
|
||||
match db.query_row(
|
||||
"SELECT clue FROM clues WHERE word = ?1 AND difficulty = ?2 AND source != 'cryptic' ORDER BY RANDOM() LIMIT 1",
|
||||
rusqlite::params![word, difficulty],
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(clue) => Some(clue),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
match db.query_row(
|
||||
"SELECT clue FROM clues WHERE word = ?1 AND source != 'cryptic' ORDER BY ABS(difficulty - ?2), RANDOM() LIMIT 1",
|
||||
rusqlite::params![word, difficulty],
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(clue) => Some(clue),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => None,
|
||||
Err(e) => {
|
||||
tracing::warn!("clue query error for {}: {}", word, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("clue query error for {}: {}", word, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_db() -> ClueDatabase {
|
||||
let mut clues = HashMap::new();
|
||||
clues.insert(
|
||||
"CAT".to_string(),
|
||||
vec![
|
||||
ClueEntry {
|
||||
text: "A furry pet".into(),
|
||||
difficulty: 1,
|
||||
},
|
||||
ClueEntry {
|
||||
text: "Feline friend".into(),
|
||||
difficulty: 2,
|
||||
},
|
||||
ClueEntry {
|
||||
text: "Felix, for one".into(),
|
||||
difficulty: 3,
|
||||
},
|
||||
],
|
||||
);
|
||||
clues.insert(
|
||||
"DOG".to_string(),
|
||||
vec![ClueEntry {
|
||||
text: "Man's best friend".into(),
|
||||
difficulty: 1,
|
||||
}],
|
||||
);
|
||||
ClueDatabase {
|
||||
json_clues: clues,
|
||||
db: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_difficulty_match() {
|
||||
let db = test_db();
|
||||
let clue = db.lookup("CAT", 2).unwrap();
|
||||
assert_eq!(clue.text, "Feline friend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_to_adjacent() {
|
||||
let db = test_db();
|
||||
let clue = db.lookup("DOG", 2).unwrap();
|
||||
assert_eq!(clue.text, "Man's best friend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_word_gets_fallback() {
|
||||
let db = test_db();
|
||||
let clue = db.get_clue("XYZ", 1).unwrap();
|
||||
assert!(clue.contains("letter word"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
use super::ClueDatabase;
|
||||
|
||||
pub fn load_clue_database(data_dir: &Path) -> Result<ClueDatabase> {
|
||||
ClueDatabase::load(data_dir)
|
||||
}
|
||||
Reference in New Issue
Block a user