159 lines
6.1 KiB
Rust
159 lines
6.1 KiB
Rust
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<Dictionary>,
|
|
pub clue_db: Arc<ClueDatabase>,
|
|
pub cryptic_db: Option<Arc<CrypticClueDb>>,
|
|
pub word_diffs: Arc<HashMap<String, f64>>,
|
|
pub puzzles: RwLock<HashMap<Uuid, (Puzzle, Instant)>>,
|
|
pub db: Mutex<AppStateInner>,
|
|
pub db_path: String,
|
|
pub word_quality: RwLock<HashMap<String, f64>>,
|
|
pub clue_quality: RwLock<HashMap<(String, String), f64>>,
|
|
pub rp_id: String,
|
|
pub rp_origin: String,
|
|
// maps challenge -> (session_token, timestamp)
|
|
pub reg_challenges: Mutex<HashMap<String, (String, i64)>>,
|
|
// maps challenge -> timestamp (no session needed for auth)
|
|
pub auth_challenges: Mutex<HashMap<String, i64>>,
|
|
pub generation_semaphore: Semaphore,
|
|
pub ap: Option<Arc<ApState>>,
|
|
pub daily_secret: String,
|
|
pub rooms: crate::rooms::Rooms,
|
|
pub blocklist: std::sync::RwLock<cruciverb_core::dict::blocklist::Blocklist>,
|
|
pub definitions: Arc<HashMap<String, String>>,
|
|
pub wotd_words: Arc<Vec<String>>,
|
|
/// 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<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
|
/// Tracks the generation number when each word was last used in a puzzle.
|
|
pub word_staleness: RwLock<HashMap<String, u64>>,
|
|
/// 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<Arc<HashMap<String, f64>>> {
|
|
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<String, f64> = 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<tokio::sync::Mutex<()>> {
|
|
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<CrypticClueDb>,
|
|
word_diffs: HashMap<String, f64>,
|
|
db: Connection,
|
|
db_path: String,
|
|
rp_id: String,
|
|
rp_origin: String,
|
|
ap: Option<ApState>,
|
|
daily_secret: String,
|
|
blocklist: cruciverb_core::dict::blocklist::Blocklist,
|
|
definitions: HashMap<String, String>,
|
|
wotd_words: Vec<String>,
|
|
) -> Arc<Self> {
|
|
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),
|
|
})
|
|
}
|
|
}
|