diff --git a/cruciverb-server/src/rooms.rs b/cruciverb-server/src/rooms.rs new file mode 100644 index 0000000..169bbb1 --- /dev/null +++ b/cruciverb-server/src/rooms.rs @@ -0,0 +1,1449 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::{Path, State, WebSocketUpgrade}; +use axum::extract::ws::{Message, WebSocket}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::{Deserialize, Serialize}; +use tokio::sync::{broadcast, RwLock}; + +use crate::db; +use crate::routes::AppError; +use crate::session::Session; +use crate::state::AppState; + +// -- room state -- + +#[derive(Clone, Serialize, Deserialize)] +pub enum RoomMode { + Coop, + Competitive, + Party, +} + +// -- party mode -- + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +pub enum PartyPhase { + Lobby, + Submit, + Vote, + Choose, + Race, + Results, + Finished, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +pub enum PartySubMode { + WrongAnswers, + Bluff, + ReverseClue, + SpeedRound, + DefinitionRoulette, +} + +impl PartySubMode { + fn from_str(s: &str) -> Self { + match s { + "bluff" => Self::Bluff, + "reverse" => Self::ReverseClue, + "speed" => Self::SpeedRound, + "roulette" => Self::DefinitionRoulette, + _ => Self::WrongAnswers, + } + } + fn as_str(&self) -> &str { + match self { + Self::WrongAnswers => "wrong", + Self::Bluff => "bluff", + Self::ReverseClue => "reverse", + Self::SpeedRound => "speed", + Self::DefinitionRoulette => "roulette", + } + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PartyChoiceInfo { + pub id: String, + pub label: String, + pub text: String, +} + +pub struct PartyState { + pub sub_mode: PartySubMode, + pub clues: Vec<(String, String)>, // (clue_text, real_answer) + pub roulette_fakes: Vec>, // per-round fake clues for roulette + pub current_round: usize, + pub max_rounds: usize, + pub phase: PartyPhase, + pub submissions: HashMap, // player_id -> their answer + pub answer_ids: HashMap, // answer_id -> player_id (or "__real__") + pub votes: HashMap, // voter_id -> answer_id + pub scores: HashMap, // player_id -> total score + pub speed_correct: Vec<(String, String, String)>, // (player_id, name, color) in order + pub roulette_choices: Vec, // current round options + pub roulette_correct_id: String, // correct choice id + pub roulette_picks: HashMap, // player_id -> choice_id +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PartyAnswerInfo { + pub id: String, + pub text: String, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PartyResultInfo { + pub id: String, + pub text: String, + pub author: String, + pub author_color: String, + pub votes: u32, + pub is_real: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PartyScoreInfo { + pub id: String, + pub name: String, + pub color: String, + pub score: i32, +} + +fn random_id() -> String { + use rand::Rng; + let mut rng = rand::rng(); + let chars: Vec = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect(); + (0..6).map(|_| chars[rng.random_range(0..chars.len())]).collect() +} + +fn normalize_case(s: &str) -> String { + let lower = s.to_lowercase(); + let mut chars = lower.chars(); + match chars.next() { + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + None => String::new(), + } +} + +fn extract_clues(puzzle_json: &str) -> Vec<(String, String)> { + let v: serde_json::Value = serde_json::from_str(puzzle_json).unwrap_or_default(); + let mut clues = Vec::new(); + for key in ["clues_across", "clues_down"] { + if let Some(arr) = v.get(key).and_then(|v| v.as_array()) { + for c in arr { + let text = c.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let answer = c.get("answer").and_then(|v| v.as_str()).unwrap_or(""); + if !text.is_empty() && !answer.is_empty() { + clues.push((text.to_string(), answer.to_uppercase())); + } + } + } + } + clues +} + +fn start_party_game(rooms: Rooms, code: String) { + tokio::spawn(async move { + let (max_rounds, sub_mode) = { + let rooms = rooms.read().await; + let room = match rooms.get(&code) { + Some(r) => r, + None => return, + }; + let party = match &room.party { + Some(p) => p, + None => return, + }; + (party.max_rounds, party.sub_mode.clone()) + }; + + match sub_mode { + PartySubMode::WrongAnswers => run_wrong_answers(rooms.clone(), code.clone(), max_rounds).await, + PartySubMode::Bluff => run_bluff(rooms.clone(), code.clone(), max_rounds).await, + PartySubMode::ReverseClue => run_reverse_clue(rooms.clone(), code.clone(), max_rounds).await, + PartySubMode::SpeedRound => run_speed_round(rooms.clone(), code.clone(), max_rounds).await, + PartySubMode::DefinitionRoulette => run_roulette(rooms.clone(), code.clone(), max_rounds).await, + } + + // final scores + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { + Some(r) => r, + None => return, + }; + let party = match room.party.as_mut() { + Some(p) => p, + None => return, + }; + party.phase = PartyPhase::Finished; + + let mut score_list: Vec = room.players.values().map(|p| { + PartyScoreInfo { + id: p.id.clone(), + name: p.name.clone(), + color: p.color.clone(), + score: party.scores.get(&p.id).copied().unwrap_or(0), + } + }).collect(); + score_list.sort_by(|a, b| b.score.cmp(&a.score)); + + let msg = serde_json::to_string(&WsMessage::PartyFinal { + scores: score_list, + }).unwrap(); + let _ = room.tx.send(msg); + } + }); +} + +fn build_score_list(players: &HashMap, scores: &HashMap) -> Vec { + let mut list: Vec = players.values().map(|p| { + PartyScoreInfo { + id: p.id.clone(), + name: p.name.clone(), + color: p.color.clone(), + score: scores.get(&p.id).copied().unwrap_or(0), + } + }).collect(); + list.sort_by(|a, b| b.score.cmp(&a.score)); + list +} + +async fn wait_or_all_done( + rooms: &Rooms, + code: &str, + max_secs: u64, + check: impl Fn(&Room, &PartyState) -> bool, +) { + let deadline = std::time::Instant::now() + Duration::from_secs(max_secs); + loop { + if std::time::Instant::now() >= deadline { break; } + { + let rooms = rooms.read().await; + if let Some(room) = rooms.get(code) { + if room.players.len() < 2 { break; } + if let Some(party) = &room.party { + if matches!(party.phase, PartyPhase::Finished) { break; } + if check(room, party) { break; } + } + } else { + break; + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +async fn should_stop(rooms: &Rooms, code: &str) -> bool { + let rooms = rooms.read().await; + match rooms.get(code) { + Some(room) => { + if room.players.len() < 2 { return true; } + if let Some(party) = &room.party { + matches!(party.phase, PartyPhase::Finished) + } else { true } + } + None => true, + } +} + +async fn run_wrong_answers(rooms: Rooms, code: String, max_rounds: usize) { + for round in 0..max_rounds { + if should_stop(&rooms, &code).await { return; } + // submit phase + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.current_round = round; + party.phase = PartyPhase::Submit; + party.submissions.clear(); + party.answer_ids.clear(); + party.votes.clear(); + + let (clue_text, _) = &party.clues[round]; + let msg = serde_json::to_string(&WsMessage::PartyRound { + round: round + 1, total: party.max_rounds, + clue: clue_text.clone(), phase: "submit".into(), deadline_secs: 30, + }).unwrap(); + let _ = room.tx.send(msg); + } + wait_or_all_done(&rooms, &code, 30, |room, party| { + room.players.len() > 0 && party.submissions.len() >= room.players.len() + }).await; + if should_stop(&rooms, &code).await { return; } + + // vote phase + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.phase = PartyPhase::Vote; + + let (_, real_answer) = &party.clues[round]; + let mut answers: Vec = Vec::new(); + for (pid, text) in &party.submissions { + let aid = random_id(); + party.answer_ids.insert(aid.clone(), pid.clone()); + answers.push(PartyAnswerInfo { id: aid, text: text.to_uppercase() }); + } + let real_id = random_id(); + party.answer_ids.insert(real_id.clone(), "__real__".into()); + answers.push(PartyAnswerInfo { id: real_id, text: real_answer.to_uppercase() }); + + use rand::seq::SliceRandom; + let mut rng = rand::rng(); + answers.shuffle(&mut rng); + + let round_msg = serde_json::to_string(&WsMessage::PartyRound { + round: round + 1, total: party.max_rounds, + clue: party.clues[round].0.clone(), phase: "vote".into(), deadline_secs: 20, + }).unwrap(); + let _ = room.tx.send(round_msg); + + let ans_msg = serde_json::to_string(&WsMessage::PartyAnswers { answers }).unwrap(); + let _ = room.tx.send(ans_msg); + } + wait_or_all_done(&rooms, &code, 20, |room, party| { + room.players.len() > 0 && party.votes.len() >= room.players.len() + }).await; + if should_stop(&rooms, &code).await { return; } + + // results + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.phase = PartyPhase::Results; + + let (_, real_answer) = party.clues[round].clone(); + let mut vote_counts: HashMap = HashMap::new(); + for (_, aid) in &party.votes { + *vote_counts.entry(aid.clone()).or_insert(0) += 1; + } + for (aid, author_id) in &party.answer_ids { + if author_id == "__real__" { continue; } + let v = vote_counts.get(aid).copied().unwrap_or(0); + if v > 0 { + *party.scores.entry(author_id.clone()).or_insert(0) += v as i32; + } + } + + let mut results: Vec = Vec::new(); + for (aid, author_id) in &party.answer_ids { + let is_real = author_id == "__real__"; + let text = if is_real { real_answer.to_uppercase() } else { + party.submissions.get(author_id).map(|s| s.to_uppercase()).unwrap_or_default() + }; + let (aname, acolor) = if is_real { + ("the real answer".to_string(), "#888888".to_string()) + } else { + let p = room.players.get(author_id); + (p.map(|p| p.name.clone()).unwrap_or("unknown".into()), + p.map(|p| p.color.clone()).unwrap_or("#888888".into())) + }; + results.push(PartyResultInfo { + id: aid.clone(), text, author: aname, author_color: acolor, + votes: vote_counts.get(aid).copied().unwrap_or(0), is_real, + }); + } + results.sort_by(|a, b| b.votes.cmp(&a.votes)); + let score_list = build_score_list(&room.players, &party.scores); + + let msg = serde_json::to_string(&WsMessage::PartyResults { + answers: results, real_answer, scores: score_list, + }).unwrap(); + let _ = room.tx.send(msg); + } + tokio::time::sleep(Duration::from_secs(8)).await; + } +} + +async fn run_bluff(rooms: Rooms, code: String, max_rounds: usize) { + for round in 0..max_rounds { + if should_stop(&rooms, &code).await { return; } + // submit phase - show the WORD, players write fake definitions + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.current_round = round; + party.phase = PartyPhase::Submit; + party.submissions.clear(); + party.answer_ids.clear(); + party.votes.clear(); + + let (_, word) = &party.clues[round]; + let msg = serde_json::to_string(&WsMessage::PartyRound { + round: round + 1, total: party.max_rounds, + clue: word.clone(), phase: "submit".into(), deadline_secs: 40, + }).unwrap(); + let _ = room.tx.send(msg); + } + wait_or_all_done(&rooms, &code, 40, |room, party| { + room.players.len() > 0 && party.submissions.len() >= room.players.len() + }).await; + if should_stop(&rooms, &code).await { return; } + + // vote phase - fake defs + real definition shuffled + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.phase = PartyPhase::Vote; + + let (real_def, word) = &party.clues[round]; + let word_clone = word.clone(); + let mut answers: Vec = Vec::new(); + for (pid, text) in &party.submissions { + let aid = random_id(); + party.answer_ids.insert(aid.clone(), pid.clone()); + answers.push(PartyAnswerInfo { id: aid, text: normalize_case(text) }); + } + let real_id = random_id(); + party.answer_ids.insert(real_id.clone(), "__real__".into()); + answers.push(PartyAnswerInfo { id: real_id, text: normalize_case(&real_def) }); + + use rand::seq::SliceRandom; + let mut rng = rand::rng(); + answers.shuffle(&mut rng); + + let round_msg = serde_json::to_string(&WsMessage::PartyRound { + round: round + 1, total: party.max_rounds, + clue: word_clone, phase: "vote".into(), deadline_secs: 25, + }).unwrap(); + let _ = room.tx.send(round_msg); + let ans_msg = serde_json::to_string(&WsMessage::PartyAnswers { answers }).unwrap(); + let _ = room.tx.send(ans_msg); + } + wait_or_all_done(&rooms, &code, 25, |room, party| { + room.players.len() > 0 && party.votes.len() >= room.players.len() + }).await; + if should_stop(&rooms, &code).await { return; } + + // results - 2 pts per fool, 3 pts for spotting real + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.phase = PartyPhase::Results; + + let (real_def, _) = party.clues[round].clone(); + let mut vote_counts: HashMap = HashMap::new(); + for (_, aid) in &party.votes { + *vote_counts.entry(aid.clone()).or_insert(0) += 1; + } + + // 2 pts per vote on your fake def (fooling people) + for (aid, author_id) in &party.answer_ids { + if author_id == "__real__" { continue; } + let v = vote_counts.get(aid).copied().unwrap_or(0); + if v > 0 { + *party.scores.entry(author_id.clone()).or_insert(0) += v as i32 * 2; + } + } + // 3 pts for voting on the real definition + let real_aid = party.answer_ids.iter() + .find(|(_, v)| v.as_str() == "__real__") + .map(|(k, _)| k.clone()); + if let Some(ref real_aid) = real_aid { + for (voter, voted_aid) in &party.votes { + if voted_aid == real_aid { + *party.scores.entry(voter.clone()).or_insert(0) += 3; + } + } + } + + let mut results: Vec = Vec::new(); + for (aid, author_id) in &party.answer_ids { + let is_real = author_id == "__real__"; + let text = if is_real { normalize_case(&real_def) } else { + party.submissions.get(author_id).map(|s| normalize_case(s)).unwrap_or_default() + }; + let (aname, acolor) = if is_real { + ("real definition".to_string(), "#22c55e".to_string()) + } else { + let p = room.players.get(author_id); + (p.map(|p| p.name.clone()).unwrap_or("unknown".into()), + p.map(|p| p.color.clone()).unwrap_or("#888888".into())) + }; + results.push(PartyResultInfo { + id: aid.clone(), text, author: aname, author_color: acolor, + votes: vote_counts.get(aid).copied().unwrap_or(0), is_real, + }); + } + results.sort_by(|a, b| b.votes.cmp(&a.votes)); + let score_list = build_score_list(&room.players, &party.scores); + + let msg = serde_json::to_string(&WsMessage::PartyResults { + answers: results, real_answer: real_def, scores: score_list, + }).unwrap(); + let _ = room.tx.send(msg); + } + tokio::time::sleep(Duration::from_secs(8)).await; + } +} + +async fn run_reverse_clue(rooms: Rooms, code: String, max_rounds: usize) { + for round in 0..max_rounds { + if should_stop(&rooms, &code).await { return; } + // submit phase - show the WORD, players write clues + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.current_round = round; + party.phase = PartyPhase::Submit; + party.submissions.clear(); + party.answer_ids.clear(); + party.votes.clear(); + + let (_, word) = &party.clues[round]; + let msg = serde_json::to_string(&WsMessage::PartyRound { + round: round + 1, total: party.max_rounds, + clue: word.clone(), phase: "submit".into(), deadline_secs: 30, + }).unwrap(); + let _ = room.tx.send(msg); + } + wait_or_all_done(&rooms, &code, 30, |room, party| { + room.players.len() > 0 && party.submissions.len() >= room.players.len() + }).await; + if should_stop(&rooms, &code).await { return; } + + // vote phase - all submitted clues, no real answer mixed in + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.phase = PartyPhase::Vote; + + let (_, word) = &party.clues[round]; + let word_clone = word.clone(); + let mut answers: Vec = Vec::new(); + for (pid, text) in &party.submissions { + let aid = random_id(); + party.answer_ids.insert(aid.clone(), pid.clone()); + answers.push(PartyAnswerInfo { id: aid, text: normalize_case(text) }); + } + + use rand::seq::SliceRandom; + let mut rng = rand::rng(); + answers.shuffle(&mut rng); + + let round_msg = serde_json::to_string(&WsMessage::PartyRound { + round: round + 1, total: party.max_rounds, + clue: word_clone, phase: "vote".into(), deadline_secs: 20, + }).unwrap(); + let _ = room.tx.send(round_msg); + let ans_msg = serde_json::to_string(&WsMessage::PartyAnswers { answers }).unwrap(); + let _ = room.tx.send(ans_msg); + } + wait_or_all_done(&rooms, &code, 20, |room, party| { + room.players.len() > 0 && party.votes.len() >= room.players.len() + }).await; + if should_stop(&rooms, &code).await { return; } + + // results - 1 pt per vote received + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.phase = PartyPhase::Results; + + let (real_clue, _) = party.clues[round].clone(); + let mut vote_counts: HashMap = HashMap::new(); + for (_, aid) in &party.votes { + *vote_counts.entry(aid.clone()).or_insert(0) += 1; + } + for (aid, author_id) in &party.answer_ids { + let v = vote_counts.get(aid).copied().unwrap_or(0); + if v > 0 { + *party.scores.entry(author_id.clone()).or_insert(0) += v as i32; + } + } + + let mut results: Vec = Vec::new(); + for (aid, author_id) in &party.answer_ids { + let text = party.submissions.get(author_id).map(|s| normalize_case(s)).unwrap_or_default(); + let p = room.players.get(author_id); + results.push(PartyResultInfo { + id: aid.clone(), text, + author: p.map(|p| p.name.clone()).unwrap_or("unknown".into()), + author_color: p.map(|p| p.color.clone()).unwrap_or("#888888".into()), + votes: vote_counts.get(aid).copied().unwrap_or(0), + is_real: false, + }); + } + results.sort_by(|a, b| b.votes.cmp(&a.votes)); + let score_list = build_score_list(&room.players, &party.scores); + + let msg = serde_json::to_string(&WsMessage::PartyResults { + answers: results, real_answer: real_clue, scores: score_list, + }).unwrap(); + let _ = room.tx.send(msg); + } + tokio::time::sleep(Duration::from_secs(8)).await; + } +} + +async fn run_speed_round(rooms: Rooms, code: String, max_rounds: usize) { + for round in 0..max_rounds { + if should_stop(&rooms, &code).await { return; } + // race phase - show clue, players race to answer + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.current_round = round; + party.phase = PartyPhase::Race; + party.speed_correct.clear(); + + let (clue_text, _) = &party.clues[round]; + let msg = serde_json::to_string(&WsMessage::PartyRound { + round: round + 1, total: party.max_rounds, + clue: clue_text.clone(), phase: "race".into(), deadline_secs: 10, + }).unwrap(); + let _ = room.tx.send(msg); + } + wait_or_all_done(&rooms, &code, 10, |room, party| { + room.players.len() > 0 && party.speed_correct.len() >= room.players.len() + }).await; + if should_stop(&rooms, &code).await { return; } + + // results + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.phase = PartyPhase::Results; + + let (_, real_answer) = party.clues[round].clone(); + let correct = party.speed_correct.clone(); + + let mut results: Vec = Vec::new(); + for (i, (pid, name, color)) in correct.iter().enumerate() { + let pts = match i { 0 => 3, 1 => 2, 2 => 1, _ => 0 }; + results.push(PartyResultInfo { + id: pid.clone(), text: format!("#{} - {} pts", i + 1, pts), + author: name.clone(), author_color: color.clone(), + votes: pts as u32, is_real: false, + }); + } + let score_list = build_score_list(&room.players, &party.scores); + + let msg = serde_json::to_string(&WsMessage::PartyResults { + answers: results, real_answer, scores: score_list, + }).unwrap(); + let _ = room.tx.send(msg); + } + tokio::time::sleep(Duration::from_secs(5)).await; + } +} + +async fn run_roulette(rooms: Rooms, code: String, max_rounds: usize) { + for round in 0..max_rounds { + if should_stop(&rooms, &code).await { return; } + // choose phase - show word + 4 options + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.current_round = round; + party.phase = PartyPhase::Choose; + party.roulette_picks.clear(); + + let (real_clue, word) = &party.clues[round]; + let labels = ["A", "B", "C", "D"]; + + let mut options: Vec = Vec::new(); + // real option + let real_id = random_id(); + options.push(PartyChoiceInfo { id: real_id.clone(), label: String::new(), text: normalize_case(real_clue) }); + party.roulette_correct_id = real_id; + + // fake options + let fakes = if round < party.roulette_fakes.len() { + party.roulette_fakes[round].clone() + } else { + Vec::new() + }; + for fake in fakes.into_iter().take(3) { + options.push(PartyChoiceInfo { id: random_id(), label: String::new(), text: normalize_case(&fake) }); + } + + // shuffle and assign labels + use rand::seq::SliceRandom; + let mut rng = rand::rng(); + options.shuffle(&mut rng); + for (i, opt) in options.iter_mut().enumerate() { + opt.label = labels.get(i).unwrap_or(&"?").to_string(); + } + party.roulette_choices = options.clone(); + + let round_msg = serde_json::to_string(&WsMessage::PartyRound { + round: round + 1, total: party.max_rounds, + clue: word.clone(), phase: "choose".into(), deadline_secs: 15, + }).unwrap(); + let _ = room.tx.send(round_msg); + let choices_msg = serde_json::to_string(&WsMessage::PartyChoices { + word: word.clone(), options, + }).unwrap(); + let _ = room.tx.send(choices_msg); + } + wait_or_all_done(&rooms, &code, 15, |room, party| { + room.players.len() > 0 && party.roulette_picks.len() >= room.players.len() + }).await; + if should_stop(&rooms, &code).await { return; } + + // results + { + let mut rooms = rooms.write().await; + let room = match rooms.get_mut(&code) { Some(r) => r, None => return }; + let party = match room.party.as_mut() { Some(p) => p, None => return }; + party.phase = PartyPhase::Results; + + let (real_clue, _) = party.clues[round].clone(); + let correct_id = party.roulette_correct_id.clone(); + + // award 1 pt per correct pick + for (pid, cid) in &party.roulette_picks { + if cid == &correct_id { + *party.scores.entry(pid.clone()).or_insert(0) += 1; + } + } + + // build results showing each choice and who picked it + let mut results: Vec = Vec::new(); + for opt in &party.roulette_choices { + let is_correct = opt.id == correct_id; + let picks: u32 = party.roulette_picks.values().filter(|v| **v == opt.id).count() as u32; + results.push(PartyResultInfo { + id: opt.id.clone(), + text: format!("{}: {}", opt.label, opt.text), + author: if is_correct { "correct".into() } else { String::new() }, + author_color: if is_correct { "#22c55e".into() } else { "#888888".into() }, + votes: picks, + is_real: is_correct, + }); + } + let score_list = build_score_list(&room.players, &party.scores); + + let msg = serde_json::to_string(&WsMessage::PartyResults { + answers: results, real_answer: real_clue, scores: score_list, + }).unwrap(); + let _ = room.tx.send(msg); + } + tokio::time::sleep(Duration::from_secs(6)).await; + } +} + +#[derive(Clone)] +pub struct Player { + pub id: String, + pub name: String, + pub color: String, + pub cursor: Option<(usize, usize)>, + pub direction: String, + pub order: usize, +} + +pub struct Room { + pub code: String, + pub mode: RoomMode, + pub puzzle_id: String, + pub width: usize, + pub height: usize, + pub grid: Vec>, + pub players: HashMap, + pub turn_order: Vec, + pub current_turn: usize, + pub started: bool, + pub last_activity: std::time::Instant, + pub tx: broadcast::Sender, + pub max_players: usize, + pub creator: String, + pub party: Option, +} + +impl Room { + fn current_player_id(&self) -> Option<&str> { + if !self.started || self.turn_order.is_empty() { + return None; + } + let idx = self.current_turn % self.turn_order.len(); + Some(&self.turn_order[idx]) + } + + fn advance_turn(&mut self) { + if self.turn_order.is_empty() { return; } + self.current_turn += 1; + let idx = self.current_turn % self.turn_order.len(); + let id = self.turn_order[idx].clone(); + let name = self.players.get(&id).map(|p| p.name.clone()).unwrap_or_default(); + let color = self.players.get(&id).map(|p| p.color.clone()).unwrap_or_default(); + let msg = serde_json::to_string(&WsMessage::TurnChange { + id: id.clone(), + name, + color, + turn_number: self.current_turn, + }).unwrap(); + let _ = self.tx.send(msg); + } + + fn remove_from_turn_order(&mut self, token: &str) { + if let Some(pos) = self.turn_order.iter().position(|id| id == token) { + self.turn_order.remove(pos); + if !self.turn_order.is_empty() && self.current_turn >= self.turn_order.len() { + self.current_turn = self.current_turn % self.turn_order.len(); + } + } + } +} + +pub type Rooms = Arc>>; + +pub fn new_rooms() -> Rooms { + Arc::new(RwLock::new(HashMap::new())) +} + +// -- code gen -- + +const PLAYER_COLORS: &[&str] = &[ + "#e8594a", "#3b82f6", "#22c55e", "#f59e0b", + "#8b5cf6", "#ec4899", "#14b8a6", "#f97316", +]; + +pub fn generate_code() -> String { + use rand::Rng; + let mut rng = rand::rng(); + let chars: Vec = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars().collect(); + (0..6).map(|_| chars[rng.random_range(0..chars.len())]).collect() +} + +// -- ws protocol -- + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum WsMessage { + // client -> server + #[serde(rename = "cursor")] + Cursor { row: usize, col: usize, direction: String }, + #[serde(rename = "cell")] + CellUpdate { row: usize, col: usize, letter: String }, + #[serde(rename = "clear")] + CellClear { row: usize, col: usize }, + #[serde(rename = "pass")] + Pass, + #[serde(rename = "start")] + Start, + #[serde(rename = "party_submit")] + PartySubmit { answer: String }, + #[serde(rename = "party_vote")] + PartyVote { answer_id: String }, + #[serde(rename = "party_answer")] + PartyAnswer { answer: String }, + #[serde(rename = "party_pick")] + PartyPick { choice_id: String }, + + // server -> client + #[serde(rename = "player_join")] + PlayerJoin { id: String, name: String, color: String }, + #[serde(rename = "player_leave")] + PlayerLeave { id: String }, + #[serde(rename = "player_cursor")] + PlayerCursor { id: String, row: usize, col: usize, direction: String }, + #[serde(rename = "cell_update")] + ServerCellUpdate { id: String, row: usize, col: usize, letter: String }, + #[serde(rename = "cell_clear")] + ServerCellClear { id: String, row: usize, col: usize }, + #[serde(rename = "state")] + FullState { + grid: Vec>, + players: Vec, + current_turn: Option, + started: bool, + your_id: String, + }, + #[serde(rename = "turn_change")] + TurnChange { id: String, name: String, color: String, turn_number: usize }, + #[serde(rename = "game_started")] + GameStarted { first_player: String }, + #[serde(rename = "complete")] + Complete { id: String, time: i64 }, + #[serde(rename = "error")] + Error { message: String }, + + // party mode server -> client + #[serde(rename = "party_round")] + PartyRound { round: usize, total: usize, clue: String, phase: String, deadline_secs: u32 }, + #[serde(rename = "party_answers")] + PartyAnswers { answers: Vec }, + #[serde(rename = "party_results")] + PartyResults { answers: Vec, real_answer: String, scores: Vec }, + #[serde(rename = "party_final")] + PartyFinal { scores: Vec }, + #[serde(rename = "party_correct")] + PartyCorrect { id: String, name: String, color: String, rank: usize, points: i32 }, + #[serde(rename = "party_choices")] + PartyChoices { word: String, options: Vec }, +} + +#[derive(Serialize, Deserialize)] +pub struct PlayerInfo { + pub id: String, + pub name: String, + pub color: String, + pub cursor: Option<(usize, usize)>, + pub direction: String, + pub order: usize, +} + +// -- REST endpoints -- + +pub async fn create_room( + State(state): State>, + session: Session, + Json(req): Json, +) -> Result { + { + let dbi = state.db.lock().await; + if db::is_banned(&dbi.db, &session.token) { + return Err(AppError::with_status( + axum::http::StatusCode::FORBIDDEN, "account suspended", + )); + } + } + + crate::security::check_rate_limit(&session.token, "create_room", 5, 300) + .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?; + + let puzzle_id = req.get("puzzle_id").and_then(|v| v.as_str()).unwrap_or(""); + let mode = match req.get("mode").and_then(|v| v.as_str()) { + Some("competitive") => RoomMode::Competitive, + Some("party") => RoomMode::Party, + _ => RoomMode::Coop, + }; + + // party mode doesn't need a puzzle - pull random clues from the clue DB + let (puzzle_json, width, height) = if matches!(mode, RoomMode::Party) { + (String::new(), 0usize, 0usize) + } else { + let pj = { + let dbi = state.db.lock().await; + db::get_puzzle_json(&dbi.db, puzzle_id).map(|(json, _)| json) + }; + let pj = match pj { + Some(j) => j, + None => return Err(AppError::with_status( + axum::http::StatusCode::NOT_FOUND, "puzzle not found", + )), + }; + let pdata: serde_json::Value = serde_json::from_str(&pj).unwrap_or_default(); + let w = pdata.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let h = pdata.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + if w == 0 || h == 0 { + return Err(AppError::with_status( + axum::http::StatusCode::BAD_REQUEST, "invalid puzzle dimensions", + )); + } + (pj, w, h) + }; + + let code = generate_code(); + let max_players = match mode { + RoomMode::Coop => 8, + RoomMode::Competitive => 20, + RoomMode::Party => 20, + }; + + let (tx, _) = broadcast::channel(256); + let grid = vec![vec![String::new(); width]; height]; + + let party_sub_str = req.get("party_sub").and_then(|v| v.as_str()).unwrap_or("wrong"); + let party_sub = PartySubMode::from_str(party_sub_str); + + let party = if matches!(mode, RoomMode::Party) { + let max_rounds = 10; + let (clues, roulette_fakes) = match &party_sub { + PartySubMode::Bluff => { + let mut c = state.clue_db.random_obscure_clues(max_rounds); + use rand::seq::SliceRandom; + let mut rng = rand::rng(); + c.shuffle(&mut rng); + c.truncate(max_rounds); + (c, Vec::new()) + } + PartySubMode::DefinitionRoulette => { + let raw = state.clue_db.random_clues_with_fakes(max_rounds, 3); + let mut clues = Vec::new(); + let mut fakes = Vec::new(); + for (clue, word, f) in raw { + clues.push((clue, word)); + fakes.push(f); + } + (clues, fakes) + } + _ => { + let mut c = if puzzle_json.is_empty() { + state.clue_db.random_clues(max_rounds) + } else { + extract_clues(&puzzle_json) + }; + use rand::seq::SliceRandom; + let mut rng = rand::rng(); + c.shuffle(&mut rng); + c.truncate(max_rounds); + (c, Vec::new()) + } + }; + let max_rounds = clues.len().min(10); + Some(PartyState { + sub_mode: party_sub, + clues, + roulette_fakes, + current_round: 0, + max_rounds, + phase: PartyPhase::Lobby, + submissions: HashMap::new(), + answer_ids: HashMap::new(), + votes: HashMap::new(), + scores: HashMap::new(), + speed_correct: Vec::new(), + roulette_choices: Vec::new(), + roulette_correct_id: String::new(), + roulette_picks: HashMap::new(), + }) + } else { + None + }; + + let room = Room { + code: code.clone(), + mode, + puzzle_id: puzzle_id.to_string(), + width, + height, + grid, + players: HashMap::new(), + turn_order: Vec::new(), + current_turn: 0, + started: false, + last_activity: std::time::Instant::now(), + tx, + max_players, + creator: session.token.clone(), + party, + }; + + let mut rooms = state.rooms.write().await; + rooms.insert(code.clone(), room); + + Ok(Json(serde_json::json!({ "code": code })).into_response()) +} + +pub async fn get_room( + State(state): State>, + Path(code): Path, +) -> Result { + let rooms = state.rooms.read().await; + let room = rooms.get(&code.to_uppercase()) + .ok_or(AppError::with_status( + axum::http::StatusCode::NOT_FOUND, "room not found", + ))?; + + let players: Vec = room.players.values().map(|p| PlayerInfo { + id: p.id.clone(), + name: p.name.clone(), + color: p.color.clone(), + cursor: p.cursor, + direction: p.direction.clone(), + order: p.order, + }).collect(); + + let mode_str = match room.mode { + RoomMode::Coop => "coop", + RoomMode::Competitive => "competitive", + RoomMode::Party => "party", + }; + + let mut resp = serde_json::json!({ + "code": room.code, + "mode": mode_str, + "puzzle_id": room.puzzle_id, + "players": players, + "player_count": room.players.len(), + "max_players": room.max_players, + "started": room.started, + "current_turn": room.current_player_id(), + }); + + if let Some(party) = &room.party { + let phase_str = match &party.phase { + PartyPhase::Lobby => "lobby", + PartyPhase::Submit => "submit", + PartyPhase::Vote => "vote", + PartyPhase::Choose => "choose", + PartyPhase::Race => "race", + PartyPhase::Results => "results", + PartyPhase::Finished => "finished", + }; + let mut score_list: Vec = room.players.values().map(|p| { + PartyScoreInfo { + id: p.id.clone(), + name: p.name.clone(), + color: p.color.clone(), + score: party.scores.get(&p.id).copied().unwrap_or(0), + } + }).collect(); + score_list.sort_by(|a, b| b.score.cmp(&a.score)); + + resp["party"] = serde_json::json!({ + "phase": phase_str, + "round": party.current_round + 1, + "total_rounds": party.max_rounds, + "scores": score_list, + "sub_mode": party.sub_mode.as_str(), + }); + } + + Ok(Json(resp).into_response()) +} + +// -- WebSocket -- + +pub async fn ws_upgrade( + State(state): State>, + Path(code): Path, + session: Session, + ws: WebSocketUpgrade, +) -> Response { + let code = code.to_uppercase(); + let token = session.token.clone(); + let name = session.display_name.unwrap_or_else(|| "Anonymous".into()); + ws.on_upgrade(move |socket| handle_ws(socket, state, code, token, name)) +} + +async fn send_text(socket: &mut WebSocket, text: String) -> Result<(), axum::Error> { + socket.send(Message::Text(text.into())).await +} + +async fn handle_ws( + mut socket: WebSocket, + state: Arc, + code: String, + token: String, + name: String, +) { + // ban check + { + let dbi = state.db.lock().await; + if db::is_banned(&dbi.db, &token) { + let msg = serde_json::to_string(&WsMessage::Error { + message: "account suspended".into(), + }).unwrap(); + let _ = send_text(&mut socket, msg).await; + return; + } + } + + // join room + let mut rx = { + let mut rooms = state.rooms.write().await; + let room = match rooms.get_mut(&code) { + Some(r) => r, + None => { + let msg = serde_json::to_string(&WsMessage::Error { + message: "room not found".into(), + }).unwrap(); + let _ = send_text(&mut socket, msg).await; + return; + } + }; + + if room.players.len() >= room.max_players { + let msg = serde_json::to_string(&WsMessage::Error { + message: "room is full".into(), + }).unwrap(); + let _ = send_text(&mut socket, msg).await; + return; + } + + let order = room.turn_order.len(); + let color_idx = order % PLAYER_COLORS.len(); + let color = PLAYER_COLORS[color_idx].to_string(); + + let player = Player { + id: token.clone(), + name: name.clone(), + color: color.clone(), + cursor: None, + direction: "across".into(), + order, + }; + room.players.insert(token.clone(), player); + room.turn_order.push(token.clone()); + room.last_activity = std::time::Instant::now(); + + let join_msg = serde_json::to_string(&WsMessage::PlayerJoin { + id: token.clone(), + name: name.clone(), + color: color.clone(), + }).unwrap(); + let _ = room.tx.send(join_msg); + + let rx = room.tx.subscribe(); + + let state_msg = serde_json::to_string(&WsMessage::FullState { + grid: room.grid.clone(), + players: room.players.values().map(|p| PlayerInfo { + id: p.id.clone(), + name: p.name.clone(), + color: p.color.clone(), + cursor: p.cursor, + direction: p.direction.clone(), + order: p.order, + }).collect(), + current_turn: room.current_player_id().map(|s| s.to_string()), + started: room.started, + your_id: token.clone(), + }).unwrap(); + let _ = send_text(&mut socket, state_msg).await; + + rx + }; + + // main loop + loop { + tokio::select! { + msg = socket.recv() => { + match msg { + Some(Ok(Message::Text(text))) => { + let text: &str = &text; + if let Ok(ws_msg) = serde_json::from_str::(text) { + let mut rooms = state.rooms.write().await; + if let Some(room) = rooms.get_mut(&code) { + room.last_activity = std::time::Instant::now(); + match ws_msg { + WsMessage::Start => { + if token == room.creator && !room.started && room.players.len() >= 2 { + room.started = true; + + if matches!(room.mode, RoomMode::Party) { + // party mode - spawn the game loop + let msg = serde_json::to_string(&WsMessage::GameStarted { + first_player: String::new(), + }).unwrap(); + let _ = room.tx.send(msg); + start_party_game(state.rooms.clone(), code.clone()); + } else { + // coop/competitive + room.current_turn = 0; + let first = room.turn_order[0].clone(); + let msg = serde_json::to_string(&WsMessage::GameStarted { + first_player: first, + }).unwrap(); + let _ = room.tx.send(msg); + room.advance_turn(); + room.current_turn = 0; + let id = room.turn_order[0].clone(); + let name = room.players.get(&id).map(|p| p.name.clone()).unwrap_or_default(); + let color = room.players.get(&id).map(|p| p.color.clone()).unwrap_or_default(); + let turn_msg = serde_json::to_string(&WsMessage::TurnChange { + id, name, color, turn_number: 0, + }).unwrap(); + let _ = room.tx.send(turn_msg); + } + } + } + WsMessage::PartySubmit { answer } => { + if let Some(party) = room.party.as_mut() { + if party.phase == PartyPhase::Submit { + let clean = answer.trim().chars().take(50).collect::(); + if !clean.is_empty() && !state.blocklist.read().unwrap().contains_blocked(&clean) { + party.submissions.insert(token.clone(), clean); + } + } + } + } + WsMessage::PartyVote { answer_id } => { + if let Some(party) = room.party.as_mut() { + if party.phase == PartyPhase::Vote { + let is_own = party.answer_ids.get(&answer_id) + .map(|author| author == &token) + .unwrap_or(false); + if !is_own && party.answer_ids.contains_key(&answer_id) { + party.votes.insert(token.clone(), answer_id); + } + } + } + } + WsMessage::PartyAnswer { answer } => { + if let Some(party) = room.party.as_mut() { + if party.phase == PartyPhase::Race && party.sub_mode == PartySubMode::SpeedRound { + // already answered correctly? + if party.speed_correct.iter().any(|(id, _, _)| id == &token) { + continue; + } + let (_, real_answer) = &party.clues[party.current_round]; + if answer.trim().eq_ignore_ascii_case(real_answer.trim()) { + let rank = party.speed_correct.len(); + let pts = match rank { 0 => 3, 1 => 2, 2 => 1, _ => 0 }; + let pname = room.players.get(&token).map(|p| p.name.clone()).unwrap_or("unknown".into()); + let pcolor = room.players.get(&token).map(|p| p.color.clone()).unwrap_or("#888888".into()); + party.speed_correct.push((token.clone(), pname.clone(), pcolor.clone())); + *party.scores.entry(token.clone()).or_insert(0) += pts; + + let msg = serde_json::to_string(&WsMessage::PartyCorrect { + id: token.clone(), name: pname, color: pcolor, + rank: rank + 1, points: pts, + }).unwrap(); + let _ = room.tx.send(msg); + } + } + } + } + WsMessage::PartyPick { choice_id } => { + if let Some(party) = room.party.as_mut() { + if party.phase == PartyPhase::Choose && party.sub_mode == PartySubMode::DefinitionRoulette { + if !party.roulette_picks.contains_key(&token) { + if party.roulette_choices.iter().any(|c| c.id == choice_id) { + party.roulette_picks.insert(token.clone(), choice_id); + } + } + } + } + } + WsMessage::Pass => { + if room.started && room.current_player_id() == Some(&token) { + room.advance_turn(); + } + } + WsMessage::Cursor { row, col, direction } => { + if let Some(p) = room.players.get_mut(&token) { + p.cursor = Some((row, col)); + p.direction = direction.clone(); + } + let out = serde_json::to_string(&WsMessage::PlayerCursor { + id: token.clone(), row, col, direction, + }).unwrap(); + let _ = room.tx.send(out); + } + WsMessage::CellUpdate { row, col, letter } => { + // only current player can update cells + if !room.started || room.current_player_id() != Some(&token) { + continue; + } + if row < room.height && col < room.width { + room.grid[row][col] = letter.clone(); + let out = serde_json::to_string(&WsMessage::ServerCellUpdate { + id: token.clone(), row, col, letter, + }).unwrap(); + let _ = room.tx.send(out); + } + } + WsMessage::CellClear { row, col } => { + if !room.started || room.current_player_id() != Some(&token) { + continue; + } + if row < room.height && col < room.width { + room.grid[row][col] = String::new(); + let out = serde_json::to_string(&WsMessage::ServerCellClear { + id: token.clone(), row, col, + }).unwrap(); + let _ = room.tx.send(out); + } + } + _ => {} + } + } + } + } + Some(Ok(Message::Close(_))) | None => break, + _ => {} + } + } + msg = rx.recv() => { + match msg { + Ok(text) => { + if send_text(&mut socket, text).await.is_err() { + break; + } + } + Err(_) => break, + } + } + } + } + + // leave room + { + let mut rooms = state.rooms.write().await; + if let Some(room) = rooms.get_mut(&code) { + let is_creator = room.creator == token; + let was_current = room.current_player_id() == Some(&token); + room.players.remove(&token); + room.remove_from_turn_order(&token); + + let leave_msg = serde_json::to_string(&WsMessage::PlayerLeave { + id: token.clone(), + }).unwrap(); + let _ = room.tx.send(leave_msg); + + // if host leaves or <2 players remain in a party game, end it + let should_end_party = matches!(room.mode, RoomMode::Party) + && room.started + && (is_creator || room.players.len() < 2); + if should_end_party { + if let Some(party) = room.party.as_mut() { + if !matches!(party.phase, PartyPhase::Finished) { + party.phase = PartyPhase::Finished; + let score_list: Vec = room.players.values().map(|p| { + PartyScoreInfo { + id: p.id.clone(), name: p.name.clone(), color: p.color.clone(), + score: party.scores.get(&p.id).copied().unwrap_or(0), + } + }).collect(); + let msg = serde_json::to_string(&WsMessage::PartyFinal { scores: score_list }).unwrap(); + let _ = room.tx.send(msg); + } + } + } + + // if the leaving player had the turn, advance + if was_current && room.started && !room.turn_order.is_empty() && !matches!(room.mode, RoomMode::Party) { + let idx = room.current_turn % room.turn_order.len(); + let id = room.turn_order[idx].clone(); + let name = room.players.get(&id).map(|p| p.name.clone()).unwrap_or_default(); + let color = room.players.get(&id).map(|p| p.color.clone()).unwrap_or_default(); + let turn_msg = serde_json::to_string(&WsMessage::TurnChange { + id, name, color, turn_number: room.current_turn, + }).unwrap(); + let _ = room.tx.send(turn_msg); + } + + if room.players.is_empty() { + rooms.remove(&code); + } + } + } +} + +// -- cleanup -- + +pub fn start_room_cleanup(rooms: Rooms) { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(300)).await; + let mut rooms = rooms.write().await; + let now = std::time::Instant::now(); + rooms.retain(|_, room| { + now.duration_since(room.last_activity).as_secs() < 7200 + }); + } + }); +}