clue blocklist and seeds

This commit is contained in:
2025-03-27 21:33:16 +03:00
parent 2e08e3c730
commit 00825c6618
2 changed files with 536 additions and 0 deletions
+527
View File
@@ -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"));
}
}
+9
View File
@@ -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)
}