use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use rusqlite::Connection; use tokio::sync::{Mutex, RwLock, Semaphore}; use uuid::Uuid; use cruciverb_core::clue::{ClueDatabase, CrypticClueDb}; use crate::activitypub::ApState; use cruciverb_core::dict::Dictionary; use cruciverb_core::puzzle::Puzzle; const STALENESS_WINDOW: u64 = 50; const PUZZLE_CACHE_MAX: usize = 500; const PUZZLE_CACHE_EXPIRY_SECS: u64 = 3600; pub struct AppStateInner { pub db: Connection, } pub struct AppState { pub dict: Arc, pub clue_db: Arc, pub cryptic_db: Option>, pub word_diffs: Arc>, pub puzzles: RwLock>, pub db: Mutex, pub db_path: String, pub word_quality: RwLock>, pub clue_quality: RwLock>, pub rp_id: String, pub rp_origin: String, // maps challenge -> (session_token, timestamp) pub reg_challenges: Mutex>, // maps challenge -> timestamp (no session needed for auth) pub auth_challenges: Mutex>, pub generation_semaphore: Semaphore, pub ap: Option>, pub daily_secret: String, pub rooms: crate::rooms::Rooms, pub blocklist: std::sync::RwLock, pub definitions: Arc>, pub wotd_words: Arc>, /// Per-(date, preset) locks so the pre-generator and the API cannot both /// generate the same daily puzzle at once. pub daily_gen_locks: Mutex>>>, /// Tracks the generation number when each word was last used in a puzzle. pub word_staleness: RwLock>, /// Monotonic counter incremented after each puzzle generation. pub generation_counter: AtomicU64, } impl AppState { pub async fn cache_puzzle(&self, id: Uuid, puzzle: Puzzle) { let mut cache = self.puzzles.write().await; cache.insert(id, (puzzle, Instant::now())); if cache.len() > PUZZLE_CACHE_MAX { let cutoff = Instant::now() - std::time::Duration::from_secs(PUZZLE_CACHE_EXPIRY_SECS); cache.retain(|_, (_, ts)| *ts > cutoff); } if cache.len() > PUZZLE_CACHE_MAX { let mut entries: Vec<(Uuid, Instant)> = cache.iter().map(|(k, (_, ts))| (*k, *ts)).collect(); entries.sort_by_key(|(_, ts)| *ts); for (k, _) in entries.iter().take(100) { cache.remove(k); } } } /// Build a staleness penalty map for the solver. /// Returns word -> penalty where penalty is 0.3 (just used) to 1.0 (old/unused). pub async fn build_staleness_penalties(&self) -> Option>> { let gen = self.generation_counter.load(Ordering::Relaxed); let staleness = self.word_staleness.read().await; if staleness.is_empty() { return None; } let mut penalties: HashMap = HashMap::new(); for (word, &last_gen) in staleness.iter() { let age = gen.saturating_sub(last_gen); if age < STALENESS_WINDOW { // 0.3 for just-used words, linearly decaying to 1.0 let decay = age as f64 / STALENESS_WINDOW as f64; penalties.insert(word.clone(), 0.3 + 0.7 * decay); } } if penalties.is_empty() { None } else { Some(Arc::new(penalties)) } } /// Record words from a generated puzzle so future puzzles deprioritize them. pub async fn record_puzzle_words(&self, words: &[String]) { let gen = self.generation_counter.fetch_add(1, Ordering::Relaxed); let mut staleness = self.word_staleness.write().await; for word in words { staleness.insert(word.clone(), gen); } // prune old entries periodically if staleness.len() > 10_000 { let cutoff = gen.saturating_sub(STALENESS_WINDOW); staleness.retain(|_, v| *v >= cutoff); } } /// Returns a per-(date, preset) async lock that serializes daily /// generation between the pre-generator and on-demand API requests. pub async fn daily_gen_lock(&self, date: &str, preset: &str) -> Arc> { let mut locks = self.daily_gen_locks.lock().await; locks .entry(format!("{}:{}", date, preset)) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() } pub fn new( dict: Dictionary, clue_db: ClueDatabase, cryptic_db: Option, word_diffs: HashMap, db: Connection, db_path: String, rp_id: String, rp_origin: String, ap: Option, daily_secret: String, blocklist: cruciverb_core::dict::blocklist::Blocklist, definitions: HashMap, wotd_words: Vec, ) -> Arc { Arc::new(Self { dict: Arc::new(dict), clue_db: Arc::new(clue_db), cryptic_db: cryptic_db.map(Arc::new), word_diffs: Arc::new(word_diffs), puzzles: RwLock::new(HashMap::new()), db: Mutex::new(AppStateInner { db }), db_path, word_quality: RwLock::new(HashMap::new()), clue_quality: RwLock::new(HashMap::new()), rp_id, rp_origin, reg_challenges: Mutex::new(HashMap::new()), auth_challenges: Mutex::new(HashMap::new()), generation_semaphore: Semaphore::new(3), ap: ap.map(Arc::new), daily_secret, rooms: crate::rooms::new_rooms(), blocklist: std::sync::RwLock::new(blocklist), definitions: Arc::new(definitions), wotd_words: Arc::new(wotd_words), daily_gen_locks: Mutex::new(HashMap::new()), word_staleness: RwLock::new(HashMap::new()), generation_counter: AtomicU64::new(0), }) } }