diff --git a/youmubot-cf/src/lib.rs b/youmubot-cf/src/lib.rs index 3abb68e..7823653 100644 --- a/youmubot-cf/src/lib.rs +++ b/youmubot-cf/src/lib.rs @@ -13,8 +13,8 @@ mod db; mod embed; mod hook; -// /// Live-commentating a Codeforces round. -// pub mod live; +/// Live-commentating a Codeforces round. +mod live; use db::CfSavedUsers; @@ -30,7 +30,7 @@ pub fn setup(path: &std::path::Path, data: &mut ShareMap, announcers: &mut Annou #[group] #[prefix = "cf"] #[description = "Codeforces-related commands"] -#[commands(profile, save, ranks)] +#[commands(profile, save, ranks, watch)] #[default_command(profile)] pub struct Codeforces; @@ -205,3 +205,17 @@ pub fn ranks(ctx: &mut Context, m: &Message) -> CommandResult { Ok(()) } + +#[command] +#[description = "Watch a contest and announce any change on the members of the server assigned to the contest."] +#[usage = "[the contest id]"] +#[num_args(1)] +#[required_permissions(MANAGE_CHANNELS)] +#[only_in(guilds)] +pub fn watch(ctx: &mut Context, m: &Message, mut args: Args) -> CommandResult { + let contest_id: u64 = args.single()?; + + live::watch_contest(ctx, m.guild_id.unwrap(), m.channel_id, contest_id)?; + + Ok(()) +} diff --git a/youmubot-cf/src/live.rs b/youmubot-cf/src/live.rs index 745cf93..4aacd84 100644 --- a/youmubot-cf/src/live.rs +++ b/youmubot-cf/src/live.rs @@ -1,14 +1,23 @@ -use codeforces::{Problem, ProblemResult, ProblemResultType, RanklistRow}; +use crate::db::CfSavedUsers; +use codeforces::{Contest, ContestPhase, Problem, ProblemResult, ProblemResultType, RanklistRow}; +use rayon::prelude::*; use serenity::{ - framework::standard::CommandResult, + framework::standard::{CommandError, CommandResult}, model::{ guild::Member, - id::{ChannelId, GuildId}, + id::{ChannelId, GuildId, UserId}, }, utils::MessageBuilder, }; +use std::collections::HashMap; use youmubot_prelude::*; +struct MemberResult { + member: Member, + handle: String, + row: Option, +} + /// Watch and commentate a contest. /// /// Does the thing on a channel, block until the contest ends. @@ -18,14 +27,228 @@ pub fn watch_contest( channel: ChannelId, contest_id: u64, ) -> CommandResult { - unimplemented!() + let db = CfSavedUsers::open(&*ctx.data.read()).borrow()?.clone(); + let http = ctx.http.clone(); + // Collect an initial member list. + // This never changes during the scan. + let mut member_results: HashMap = db + .into_par_iter() + .filter_map(|(user_id, cfu)| { + let member = guild.member(http.clone().as_ref(), user_id).ok(); + match member { + Some(m) => Some(( + user_id, + MemberResult { + member: m, + handle: cfu.handle, + row: None, + }, + )), + None => None, + } + }) + .collect(); + + let http = ctx.data.get_cloned::(); + let (mut contest, _, _) = Contest::standings(&http, contest_id, |f| f.limit(1, 1))?; + + channel.send_message(&ctx, |e| { + e.content(format!( + "Youmu is watching contest **{}**, with the following members:\n{}", + contest.name, + member_results + .iter() + .map(|(u, m)| format!("- {} as **{}**", m.member.distinct(), m.handle)) + .collect::>() + .join("\n"), + )) + })?; + + loop { + if let Ok(messages) = scan_changes(http.clone(), &mut member_results, &mut contest) { + for message in messages { + channel + .send_message(&ctx, |e| { + e.content(format!("**{}**: {}", contest.name, message)) + }) + .ok(); + } + } + if contest.phase == ContestPhase::Finished { + break; + } + // Sleep for a minute + std::thread::sleep(std::time::Duration::from_secs(60)); + } + + // Announce the final results + let mut ranks = member_results + .into_iter() + .filter_map(|(_, m)| { + let member = m.member; + let handle = m.handle; + m.row.map(|row| ((handle, member), row)) + }) + .collect::>(); + ranks.sort_by(|(_, a), (_, b)| a.rank.cmp(&b.rank)); + + if ranks.is_empty() { + channel.send_message(&ctx, |e| { + e.content(format!( + "**{}** has ended, but I can't find anyone in this server on the scoreboard...", + contest.name + )) + })?; + return Ok(()); + } + + channel.send_message( + &ctx, |e| + e.content(format!( + "**{}** has ended, and the rankings in the server is:\n{}", contest.name, + ranks.into_iter().map(|((handle, mem), row)| format!( + "- **#{}**: {} (**{}**) with **{:.0}** points [{}] and ({} succeeded, {} failed) hacks!", + row.rank, + mem.mention(), + handle, + row.points, + row.problem_results.iter().map(|p| format!("{:.0}", p.points)).collect::>().join("/"), + row.successful_hack_count, + row.unsuccessful_hack_count, + )).collect::>().join("\n"))))?; + + Ok(()) } fn scan_changes( http: ::Value, - members: &[(Member, &str)], + members: &mut HashMap, + contest: &mut Contest, +) -> Result, CommandError> { + let mut messages: Vec = vec![]; + let (updated_contest, problems, ranks) = { + let handles = members + .iter() + .map(|(_, h)| h.handle.clone()) + .collect::>(); + Contest::standings(&http, contest.id, |f| f.handles(handles))? + }; + // Change of phase. + if contest.phase != updated_contest.phase { + messages.push(updated_contest.phase.to_string()); + } + let mut handle_to_user_id = members + .iter_mut() + .map(|(_, v)| (v.handle.clone(), v)) + .collect::>(); + // Change of ProblemResult... + for row in ranks { + // Gotta find an user id? + let user_ids = row + .party + .members + .iter() + .filter_map(|v| handle_to_user_id.get(&v.handle)); + // Scan for changes immutably + for member_result in user_ids { + let old_row = member_result.row.as_ref().cloned().unwrap_or(RanklistRow { + party: row.party.clone(), + rank: 0, + points: 0.0, + penalty: 0, + successful_hack_count: 0, + unsuccessful_hack_count: 0, + problem_results: vec![ + ProblemResult { + points: 0.0, + penalty: None, + rejected_attempt_count: 0, + result_type: ProblemResultType::Preliminary, + best_submission_time_seconds: None + }; + row.problem_results.len() + ], + last_submission_time_seconds: None, + }); + messages.extend(translate_overall_result( + member_result.handle.as_str(), + &old_row, + &row, + &member_result.member, + )); + for (problem, (old, new)) in problems.iter().zip( + old_row + .problem_results + .iter() + .zip(row.problem_results.iter()), + ) { + if let Some(message) = analyze_change(old, new).map(|c| { + translate_change( + member_result.handle.as_str(), + &row, + &member_result.member, + problem, + new, + c, + ) + }) { + messages.push(message); + } + } + } + // Update list mutably + for handle in row.party.members.iter().map(|v| v.handle.as_str()) { + if let Some(mut u) = handle_to_user_id.get_mut(handle) { + u.row = Some(row.clone()); + } + } + } + + // Update entire contest + *contest = updated_contest; + + Ok(messages) +} + +fn translate_overall_result( + handle: &str, + old_row: &RanklistRow, + new_row: &RanklistRow, + member: &Member, ) -> Vec { - unimplemented!() + let mention = || -> MessageBuilder { + let mut m = MessageBuilder::new(); + m.push_bold_safe(handle) + .push(" (") + .push_safe(member.distinct()) + .push(")"); + m + }; + let mut res = vec![]; + + // Hack counts + if new_row.successful_hack_count > old_row.successful_hack_count { + res.push( + mention() + .push(format!( + " attempted a hack and **succeeded**! 🕵️ : **{:.0}** points placing #**{}**!", + new_row.points, new_row.rank + )) + .build(), + ); + } + if new_row.unsuccessful_hack_count > old_row.unsuccessful_hack_count { + res.push( + mention() + .push(format!( + " attempted a hack but **did not succeed** 😣: **{}** points placing #**{}**.", + new_row.points, new_row.rank + )) + .build(), + ); + } + + res } fn translate_change( @@ -95,9 +318,13 @@ fn analyze_change(old: &ProblemResult, new: &ProblemResult) -> Option { use Change::*; if old.points == new.points { if new.rejected_attempt_count > old.rejected_attempt_count { - return Some(Attempted); + if new.result_type == ProblemResultType::Preliminary { + return Some(Attempted); + } else { + return Some(TestFailed); + } } - if old.result_type != new.result_type { + if old.result_type != new.result_type && new.points > 0.0 { return Some(Accepted); } None